537 lines
20 KiB
C#
537 lines
20 KiB
C#
using System.Numerics;
|
|
using System.Reflection;
|
|
using AcDream.App.Composition;
|
|
using AcDream.App.Input;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.Rendering.Wb;
|
|
using AcDream.App.Streaming;
|
|
using AcDream.App.Tests.Architecture;
|
|
using AcDream.App.World;
|
|
using AcDream.Core.Lighting;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.Rendering;
|
|
using AcDream.Core.World;
|
|
using AcDream.Core.World.Cells;
|
|
|
|
namespace AcDream.App.Tests.Rendering;
|
|
|
|
[Collection(CameraDiagnosticsCollection.Name)]
|
|
public sealed class WorldRenderFrameBuilderTests
|
|
{
|
|
[Fact]
|
|
public void Build_orders_world_preparation_and_combines_borrowed_results()
|
|
{
|
|
List<string> calls = [];
|
|
var camera = new FlyCamera { Position = new Vector3(11f, 22f, 33f) };
|
|
var cameraFrame = new WorldCameraFrame(
|
|
camera,
|
|
camera.Projection,
|
|
camera.View * camera.Projection,
|
|
default,
|
|
Matrix4x4.Identity,
|
|
camera.Position);
|
|
WorldRootFrame roots = default;
|
|
var animated = new HashSet<uint> { 41u, 42u };
|
|
var buildings = new List<LoadedCell>();
|
|
var foundation = new RenderFrameFoundation(
|
|
PortalViewportVisible: false,
|
|
Sky: default,
|
|
Atmosphere: default);
|
|
var visibility = new RecordingVisibility(calls);
|
|
var environment = new RecordingEnvironment(calls);
|
|
var builder = new WorldRenderFrameBuilder(
|
|
new RecordingCamera(calls, cameraFrame),
|
|
visibility,
|
|
new RecordingSettings(calls),
|
|
new RecordingRoots(calls, roots),
|
|
environment,
|
|
new RecordingAnimated(calls, animated),
|
|
new RecordingBuildings(calls, new WorldBuildingFrame(null, buildings)));
|
|
|
|
WorldRenderFrame result = builder.Build(
|
|
in foundation,
|
|
waitingForLogin: true,
|
|
activeDayGroup: null);
|
|
var drawableCells = new HashSet<uint> { 0x01010001u };
|
|
builder.ObserveDrawableCells(drawableCells);
|
|
builder.ClearDrawableCells();
|
|
|
|
Assert.Equal(
|
|
[
|
|
"camera",
|
|
"visibility:begin",
|
|
"settings",
|
|
"roots",
|
|
"environment",
|
|
"visibility:projection",
|
|
"animated",
|
|
"buildings",
|
|
],
|
|
calls);
|
|
Assert.Same(camera, result.Camera.Camera);
|
|
Assert.Same(animated, result.AnimatedEntityIds);
|
|
Assert.Same(buildings, result.Buildings.NearbyBuildingCells);
|
|
Assert.Null(result.ClipRoot);
|
|
Assert.True(visibility.WaitingForLogin);
|
|
Assert.Equal(foundation, environment.Foundation);
|
|
Assert.Same(drawableCells, environment.DrawableCells);
|
|
Assert.Equal(1, environment.ClearCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void Required_sources_fail_fast()
|
|
{
|
|
var camera = new RecordingCamera([], default);
|
|
var visibility = new RecordingVisibility([]);
|
|
var settings = new RecordingSettings([]);
|
|
var roots = new RecordingRoots([], default);
|
|
var environment = new RecordingEnvironment([]);
|
|
var animated = new RecordingAnimated([], []);
|
|
var buildings = new RecordingBuildings([], default);
|
|
|
|
Assert.Throws<ArgumentNullException>(() =>
|
|
new WorldRenderFrameBuilder(null!, visibility, settings, roots, environment, animated, buildings));
|
|
Assert.Throws<ArgumentNullException>(() =>
|
|
new WorldRenderFrameBuilder(camera, null!, settings, roots, environment, animated, buildings));
|
|
Assert.Throws<ArgumentNullException>(() =>
|
|
new WorldRenderFrameBuilder(camera, visibility, null!, roots, environment, animated, buildings));
|
|
Assert.Throws<ArgumentNullException>(() =>
|
|
new WorldRenderFrameBuilder(camera, visibility, settings, null!, environment, animated, buildings));
|
|
Assert.Throws<ArgumentNullException>(() =>
|
|
new WorldRenderFrameBuilder(camera, visibility, settings, roots, null!, animated, buildings));
|
|
Assert.Throws<ArgumentNullException>(() =>
|
|
new WorldRenderFrameBuilder(camera, visibility, settings, roots, environment, null!, buildings));
|
|
Assert.Throws<ArgumentNullException>(() =>
|
|
new WorldRenderFrameBuilder(camera, visibility, settings, roots, environment, animated, null!));
|
|
}
|
|
|
|
[Fact]
|
|
public void Render_range_state_updates_both_tiers_without_replacing_the_source()
|
|
{
|
|
IWorldRenderRangeSource source = new WorldRenderRangeState(4, 12);
|
|
var mutable = Assert.IsType<WorldRenderRangeState>(source);
|
|
|
|
mutable.NearRadius = 7;
|
|
mutable.FarRadius = 19;
|
|
|
|
Assert.Equal(7, source.NearRadius);
|
|
Assert.Equal(19, source.FarRadius);
|
|
}
|
|
|
|
[Fact]
|
|
public void Runtime_animated_source_reuses_scratch_and_removes_stale_ids()
|
|
{
|
|
var slot = new LiveEntityRuntimeSlot();
|
|
var live = new LiveEntityAnimationRuntimeView<LiveEntityAnimationState>(slot);
|
|
var source = new RuntimeWorldFrameAnimatedEntitySource(
|
|
live,
|
|
statics: null,
|
|
equipped: null);
|
|
|
|
HashSet<uint> first = source.Capture();
|
|
first.Add(0xDEADBEEFu);
|
|
HashSet<uint> second = source.Capture();
|
|
|
|
Assert.Same(first, second);
|
|
Assert.Empty(second);
|
|
}
|
|
|
|
[Fact]
|
|
public void Runtime_building_source_reuses_scratch_and_rebuilds_outdoor_root()
|
|
{
|
|
var pipeline = new LandblockPresentationPipeline(
|
|
publishBeforeSpatialCommit: (_, _) => { },
|
|
state: new GpuWorldState());
|
|
var source = new RuntimeWorldFrameBuildingSource(
|
|
pipeline,
|
|
new CellVisibility());
|
|
FrustumPlanes frustum = default;
|
|
|
|
WorldBuildingFrame first = source.Gather(
|
|
viewerRoot: null,
|
|
viewerCellId: 0x01010001u,
|
|
in frustum);
|
|
var scratch = Assert.IsType<List<LoadedCell>>(first.NearbyBuildingCells);
|
|
scratch.Add(new LoadedCell { CellId = 0x01010100u });
|
|
WorldBuildingFrame second = source.Gather(
|
|
viewerRoot: null,
|
|
viewerCellId: 0x02020001u,
|
|
in frustum);
|
|
|
|
Assert.Same(scratch, second.NearbyBuildingCells);
|
|
Assert.Empty(second.NearbyBuildingCells);
|
|
Assert.Equal(0x01010001u, first.OutdoorNode?.CellId);
|
|
Assert.Equal(0x02020001u, second.OutdoorNode?.CellId);
|
|
}
|
|
|
|
[Fact]
|
|
public void Runtime_root_source_keeps_player_lighting_and_viewer_render_cells_distinct()
|
|
{
|
|
const uint playerCellId = 0x01010100u;
|
|
const uint viewerCellId = 0x01010101u;
|
|
var physics = new PhysicsEngine { DataCache = new PhysicsDataCache() };
|
|
physics.DataCache.CellGraph.Add(CoreCell(playerCellId, seenOutside: false));
|
|
physics.UpdatePlayerCurrCell(playerCellId);
|
|
var cells = new CellVisibility();
|
|
var playerCell = new LoadedCell { CellId = playerCellId, SeenOutside = false };
|
|
var viewerCell = new LoadedCell { CellId = viewerCellId, SeenOutside = true };
|
|
cells.AddCell(playerCell);
|
|
cells.AddCell(viewerCell);
|
|
var retailChase = new RetailChaseCamera();
|
|
retailChase.Update(
|
|
playerPosition: Vector3.Zero,
|
|
playerYaw: 0f,
|
|
playerVelocity: Vector3.Zero,
|
|
isOnGround: true,
|
|
contactPlaneNormal: Vector3.UnitZ,
|
|
dt: 1f / 60f,
|
|
cellId: viewerCellId);
|
|
var mode = new LocalPlayerModeState { IsPlayerMode = true };
|
|
var chase = new ChaseCameraInputState { Retail = retailChase };
|
|
var origin = new LiveWorldOriginState();
|
|
origin.SetPlaceholder(0x01, 0x01);
|
|
var source = new RuntimeWorldFrameRootSource(
|
|
physics,
|
|
cells,
|
|
mode,
|
|
chase,
|
|
new RuntimeLocalPlayerMovementState(),
|
|
origin);
|
|
var camera = new FlyCamera { Position = new Vector3(5f, 6f, 7f) };
|
|
WorldCameraFrame cameraFrame = CameraFrame(camera);
|
|
bool previousRetailCamera = CameraDiagnostics.UseRetailChaseCamera;
|
|
|
|
try
|
|
{
|
|
CameraDiagnostics.UseRetailChaseCamera = true;
|
|
WorldRootFrame result = source.Resolve(in cameraFrame);
|
|
|
|
Assert.Same(playerCell, result.PlayerRoot);
|
|
Assert.Same(viewerCell, result.ViewerRoot);
|
|
Assert.True(result.PlayerInsideCell);
|
|
Assert.True(result.CameraInsideCell);
|
|
Assert.False(result.PlayerSeenOutside);
|
|
Assert.True(result.RootSeenOutside);
|
|
Assert.True(result.RenderSky);
|
|
Assert.Equal(playerCellId, result.PlayerCellId);
|
|
Assert.Equal(viewerCellId, result.ViewerCellId);
|
|
Assert.Equal(camera.Position, result.ViewerEyePosition);
|
|
}
|
|
finally
|
|
{
|
|
CameraDiagnostics.UseRetailChaseCamera = previousRetailCamera;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Runtime_root_source_preserves_null_root_fallback_before_player_membership()
|
|
{
|
|
var physics = new PhysicsEngine();
|
|
var source = new RuntimeWorldFrameRootSource(
|
|
physics,
|
|
new CellVisibility(),
|
|
new LocalPlayerModeState(),
|
|
new ChaseCameraInputState(),
|
|
new RuntimeLocalPlayerMovementState(),
|
|
new LiveWorldOriginState());
|
|
WorldCameraFrame camera = CameraFrame(new FlyCamera());
|
|
|
|
WorldRootFrame result = source.Resolve(in camera);
|
|
|
|
Assert.Null(result.PlayerRoot);
|
|
Assert.Null(result.ViewerRoot);
|
|
Assert.Equal(0u, result.PlayerCellId);
|
|
Assert.Equal(0u, result.ViewerCellId);
|
|
Assert.False(result.PlayerInsideCell);
|
|
Assert.False(result.CameraInsideCell);
|
|
Assert.True(result.RenderSky);
|
|
}
|
|
|
|
[Fact]
|
|
public void Runtime_environment_copies_visible_cells_and_clear_restores_unscoped_lights()
|
|
{
|
|
const uint visibleCell = 0x01010100u;
|
|
const uint hiddenCell = 0x01010101u;
|
|
var lighting = new LightManager();
|
|
var visibleLight = new LightSource
|
|
{
|
|
Kind = LightKind.Point,
|
|
CellId = visibleCell,
|
|
};
|
|
var hiddenLight = new LightSource
|
|
{
|
|
Kind = LightKind.Point,
|
|
CellId = hiddenCell,
|
|
};
|
|
lighting.Register(visibleLight);
|
|
lighting.Register(hiddenLight);
|
|
var environment = new RuntimeWorldFrameEnvironmentPreparation(
|
|
RuntimeOptions.Parse("test-dat", _ => null),
|
|
new WorldTimeService(SkyStateProvider.Default()),
|
|
lighting,
|
|
dispatcher: null,
|
|
environmentCells: null,
|
|
lightingUbo: null,
|
|
new WorldRenderRangeState(4, 12),
|
|
skyPes: null);
|
|
var borrowed = new HashSet<uint> { visibleCell };
|
|
environment.ObserveDrawableCells(borrowed);
|
|
borrowed.Clear();
|
|
borrowed.Add(hiddenCell);
|
|
WorldCameraFrame camera = CameraFrame(new FlyCamera());
|
|
WorldRootFrame roots = default;
|
|
RenderFrameFoundation foundation = default;
|
|
|
|
environment.Prepare(in camera, in roots, in foundation, activeDayGroup: null);
|
|
|
|
Assert.Contains(visibleLight, lighting.PointSnapshot);
|
|
Assert.DoesNotContain(hiddenLight, lighting.PointSnapshot);
|
|
|
|
environment.ClearDrawableCells();
|
|
environment.Prepare(in camera, in roots, in foundation, activeDayGroup: null);
|
|
|
|
Assert.Contains(visibleLight, lighting.PointSnapshot);
|
|
Assert.Contains(hiddenLight, lighting.PointSnapshot);
|
|
}
|
|
|
|
[Fact]
|
|
public void Environment_preparation_keeps_lighting_snapshot_before_ubo_upload()
|
|
{
|
|
MethodInfo prepare = typeof(RuntimeWorldFrameEnvironmentPreparation).GetMethod(
|
|
nameof(RuntimeWorldFrameEnvironmentPreparation.Prepare))!;
|
|
IReadOnlyList<CompiledCall> calls = CompiledCallGraph.Read(prepare);
|
|
|
|
Type[] ownerOrder =
|
|
[
|
|
typeof(RuntimeWorldFrameEnvironmentPreparation),
|
|
typeof(LightManager),
|
|
typeof(LightManager),
|
|
typeof(LightManager),
|
|
typeof(WbDrawDispatcher),
|
|
typeof(EnvCellRenderer),
|
|
typeof(SceneLightingUbo),
|
|
typeof(SceneLightingUboBinding),
|
|
typeof(RenderingDiagnostics),
|
|
];
|
|
string[] methodOrder =
|
|
[
|
|
"UpdateSunFromSky",
|
|
nameof(LightManager.UpdateViewerLight),
|
|
nameof(LightManager.Tick),
|
|
nameof(LightManager.BuildPointLightSnapshot),
|
|
nameof(WbDrawDispatcher.SetSceneLights),
|
|
nameof(EnvCellRenderer.SetPointSnapshot),
|
|
nameof(SceneLightingUbo.Build),
|
|
nameof(SceneLightingUboBinding.Upload),
|
|
nameof(RenderingDiagnostics.EmitLight),
|
|
];
|
|
|
|
AssertCompiledCallOrder(calls, ownerOrder, methodOrder);
|
|
}
|
|
|
|
[Fact]
|
|
public void World_scene_uses_the_typed_builder_and_orchestrator_owns_its_local_composition()
|
|
{
|
|
MethodInfo[] windowMethods = typeof(GameWindow).GetMethods(
|
|
BindingFlags.Instance
|
|
| BindingFlags.Static
|
|
| BindingFlags.Public
|
|
| BindingFlags.NonPublic
|
|
| BindingFlags.DeclaredOnly);
|
|
Assert.DoesNotContain(
|
|
windowMethods,
|
|
method => method.Name is "UpdateSunFromSky" or "UpdateSkyPes" or "ParseEnvFloat");
|
|
|
|
FieldInfo[] windowFields = typeof(GameWindow).GetFields(
|
|
BindingFlags.Instance
|
|
| BindingFlags.Static
|
|
| BindingFlags.Public
|
|
| BindingFlags.NonPublic
|
|
| BindingFlags.DeclaredOnly);
|
|
Assert.DoesNotContain(
|
|
windowFields,
|
|
field => field.FieldType == typeof(WorldRenderFrameBuilder)
|
|
|| field.FieldType == typeof(SkyPesFrameController));
|
|
|
|
// Gate-fix round (2026-08-17): the renderer's own IsWaitingForLogin
|
|
// short-circuit is deleted — the frame gate
|
|
// (LocalPlayerTeleportRenderStateSource) folds the waiting state
|
|
// into PortalViewportVisible, so the portal-visible return above
|
|
// covers every waiting frame (one gate computes, this phase
|
|
// enforces).
|
|
MethodInfo render = typeof(WorldSceneRenderer).GetMethod(
|
|
nameof(WorldSceneRenderer.Render))!;
|
|
IReadOnlyList<CompiledCall> renderCalls = CompiledCallGraph.Read(render);
|
|
AssertCompiledCallOrder(
|
|
renderCalls,
|
|
[
|
|
typeof(IWorldSceneAlphaFrame),
|
|
typeof(IWorldRenderFrameBuilder),
|
|
typeof(IWorldScenePassExecutor),
|
|
typeof(WorldRenderFrameOutcome),
|
|
],
|
|
[
|
|
nameof(IWorldSceneAlphaFrame.BeginFrame),
|
|
nameof(IWorldRenderFrameBuilder.Build),
|
|
nameof(IWorldScenePassExecutor.DrawFlatTerrain),
|
|
".ctor",
|
|
]);
|
|
Assert.Single(
|
|
renderCalls,
|
|
call => call.Target.DeclaringType == typeof(IWorldRenderFrameBuilder)
|
|
&& call.Target.Name == nameof(IWorldRenderFrameBuilder.Build));
|
|
|
|
MethodInfo compose = typeof(FrameRootCompositionPhase).GetMethod(
|
|
"ComposeCore",
|
|
BindingFlags.Instance | BindingFlags.NonPublic)!;
|
|
IReadOnlyList<CompiledCall> composeCalls = CompiledCallGraph.Read(compose);
|
|
AssertCompiledCallOrder(
|
|
composeCalls,
|
|
[
|
|
typeof(SkyPesFrameController),
|
|
typeof(WorldRenderFrameBuilder),
|
|
typeof(RenderFrameOrchestrator),
|
|
typeof(GameFrameGraphSlot),
|
|
],
|
|
[".ctor", ".ctor", ".ctor", nameof(GameFrameGraphSlot.PublishOwned)]);
|
|
|
|
// The former source assertion also mixed in teardown ordering. That
|
|
// distinct lifetime contract remains covered by the dedicated lifetime
|
|
// suite and is reconciled with the later host/lifetime R3 batch.
|
|
}
|
|
|
|
private static WorldCameraFrame CameraFrame(FlyCamera camera) => new(
|
|
camera,
|
|
camera.Projection,
|
|
camera.View * camera.Projection,
|
|
default,
|
|
Matrix4x4.Identity,
|
|
camera.Position);
|
|
|
|
private static AcDream.Core.World.Cells.EnvCell CoreCell(
|
|
uint id,
|
|
bool seenOutside) => new(
|
|
id,
|
|
Matrix4x4.Identity,
|
|
Matrix4x4.Identity,
|
|
Vector3.Zero,
|
|
Vector3.One,
|
|
Array.Empty<CellPortal>(),
|
|
Array.Empty<uint>(),
|
|
seenOutside,
|
|
containmentBsp: null);
|
|
|
|
private static void AssertCompiledCallOrder(
|
|
IReadOnlyList<CompiledCall> calls,
|
|
IReadOnlyList<Type> declaringTypes,
|
|
IReadOnlyList<string> methodNames)
|
|
{
|
|
Assert.Equal(declaringTypes.Count, methodNames.Count);
|
|
int cursor = -1;
|
|
for (int index = 0; index < declaringTypes.Count; index++)
|
|
{
|
|
int next = CompiledCallGraph.IndexOf(
|
|
calls,
|
|
declaringTypes[index],
|
|
methodNames[index],
|
|
cursor + 1);
|
|
Assert.True(
|
|
next > cursor,
|
|
$"Missing or out-of-order compiled call: {declaringTypes[index].Name}.{methodNames[index]}");
|
|
cursor = next;
|
|
}
|
|
}
|
|
|
|
private sealed class RecordingCamera(
|
|
List<string> calls,
|
|
WorldCameraFrame result) : IWorldFrameCameraSource
|
|
{
|
|
public WorldCameraFrame Resolve()
|
|
{
|
|
calls.Add("camera");
|
|
return result;
|
|
}
|
|
}
|
|
|
|
private sealed class RecordingVisibility(List<string> calls)
|
|
: IWorldFrameVisibilityPreparation
|
|
{
|
|
public bool WaitingForLogin { get; private set; }
|
|
|
|
public void Begin(in WorldCameraFrame camera, bool waitingForLogin)
|
|
{
|
|
calls.Add("visibility:begin");
|
|
WaitingForLogin = waitingForLogin;
|
|
}
|
|
|
|
public void PublishViewProjection(in WorldCameraFrame camera) =>
|
|
calls.Add("visibility:projection");
|
|
}
|
|
|
|
private sealed class RecordingSettings(List<string> calls)
|
|
: IWorldFrameSettingsPreview
|
|
{
|
|
public void Apply(in WorldCameraFrame camera) => calls.Add("settings");
|
|
}
|
|
|
|
private sealed class RecordingRoots(
|
|
List<string> calls,
|
|
WorldRootFrame result) : IWorldFrameRootSource
|
|
{
|
|
public WorldRootFrame Resolve(in WorldCameraFrame camera)
|
|
{
|
|
calls.Add("roots");
|
|
return result;
|
|
}
|
|
}
|
|
|
|
private sealed class RecordingEnvironment(List<string> calls)
|
|
: IWorldFrameEnvironmentPreparation
|
|
{
|
|
public RenderFrameFoundation Foundation { get; private set; }
|
|
|
|
public IReadOnlySet<uint>? DrawableCells { get; private set; }
|
|
|
|
public int ClearCount { get; private set; }
|
|
|
|
public void Prepare(
|
|
in WorldCameraFrame camera,
|
|
in WorldRootFrame roots,
|
|
in RenderFrameFoundation foundation,
|
|
DayGroupData? activeDayGroup)
|
|
{
|
|
calls.Add("environment");
|
|
Foundation = foundation;
|
|
}
|
|
|
|
public void ObserveDrawableCells(IReadOnlySet<uint> drawableCells) =>
|
|
DrawableCells = drawableCells;
|
|
|
|
public void ClearDrawableCells() => ClearCount++;
|
|
}
|
|
|
|
private sealed class RecordingAnimated(
|
|
List<string> calls,
|
|
HashSet<uint> result) : IWorldFrameAnimatedEntitySource
|
|
{
|
|
public HashSet<uint> Capture()
|
|
{
|
|
calls.Add("animated");
|
|
return result;
|
|
}
|
|
}
|
|
|
|
private sealed class RecordingBuildings(
|
|
List<string> calls,
|
|
WorldBuildingFrame result) : IWorldFrameBuildingSource
|
|
{
|
|
public WorldBuildingFrame Gather(
|
|
LoadedCell? viewerRoot,
|
|
uint viewerCellId,
|
|
in FrustumPlanes frustum)
|
|
{
|
|
calls.Add("buildings");
|
|
return result;
|
|
}
|
|
}
|
|
}
|