refactor(render): extract world frame preparation
Move camera/root resolution, live settings and listener preview, sky/lighting/fog preparation, animated classification, and building visibility scratch behind a typed WorldRenderFrameBuilder while preserving retail frame order and borrowed lifetimes. Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
parent
bc6f09f987
commit
6d6e5b5fa5
8 changed files with 1463 additions and 583 deletions
|
|
@ -0,0 +1,522 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Streaming;
|
||||
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;
|
||||
|
||||
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 LocalPlayerControllerSlot(),
|
||||
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 LocalPlayerControllerSlot(),
|
||||
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 Production_builder_preserves_the_frame_preparation_order()
|
||||
{
|
||||
string source = BuilderSource();
|
||||
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"WorldCameraFrame camera = _camera.Resolve();",
|
||||
"_visibility.Begin(in camera, waitingForLogin);",
|
||||
"_settings.Apply(in camera);",
|
||||
"WorldRootFrame roots = _roots.Resolve(in camera);",
|
||||
"_environment.Prepare(in camera, in roots, in foundation, activeDayGroup);",
|
||||
"_visibility.PublishViewProjection(in camera);",
|
||||
"HashSet<uint> animated = _animated.Capture();",
|
||||
"WorldBuildingFrame buildings = _buildings.Gather(");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Environment_preparation_keeps_lighting_snapshot_before_ubo_upload()
|
||||
{
|
||||
string source = BuilderSource();
|
||||
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"UpdateSunFromSky(foundation.Sky, roots.PlayerInsideCell);",
|
||||
"_lighting.UpdateViewerLight(roots.PlayerViewPosition);",
|
||||
"_lighting.Tick(camera.Position);",
|
||||
"_lighting.BuildPointLightSnapshot(",
|
||||
"_dispatcher?.SetSceneLights(_lighting.PointSnapshot);",
|
||||
"_environmentCells?.SetPointSnapshot(_lighting.PointSnapshot);",
|
||||
"SceneLightingUbo ubo = SceneLightingUbo.Build(",
|
||||
"_lightingUbo?.Upload(ubo);",
|
||||
"RenderingDiagnostics.EmitLight(");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Game_window_uses_the_typed_builder_and_releases_it_before_render_owners()
|
||||
{
|
||||
string source = GameWindowSource();
|
||||
|
||||
Assert.Equal(
|
||||
1,
|
||||
CountOccurrences(source, "_worldRenderFrameBuilder!.Build("));
|
||||
Assert.DoesNotContain("private void UpdateSunFromSky(", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("private void UpdateSkyPes(", source, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("private static float ParseEnvFloat(", source, StringComparison.Ordinal);
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"if (_cameraController is not null && !portalViewportVisible)",
|
||||
"_retailAlphaQueue.BeginFrame();",
|
||||
"_worldRenderFrameBuilder!.Build(",
|
||||
"if (IsLiveModeWaitingForLogin)",
|
||||
"normalWorldDrawn = true;");
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"_worldRenderFrameBuilder = null;",
|
||||
"_skyPesFrame = null;",
|
||||
"new(\"equipped children\"",
|
||||
"new(\"effect network state\"",
|
||||
"new(\"audio\"",
|
||||
"new(\"mesh draw dispatcher\"",
|
||||
"new(\"environment cells\"",
|
||||
"new(\"scene lighting\"");
|
||||
}
|
||||
|
||||
private static string BuilderSource() => File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Rendering",
|
||||
"WorldRenderFrameBuilder.cs"));
|
||||
|
||||
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 string GameWindowSource() => File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Rendering",
|
||||
"GameWindow.cs"));
|
||||
|
||||
private static int CountOccurrences(string source, string needle)
|
||||
{
|
||||
int count = 0;
|
||||
int cursor = 0;
|
||||
while ((cursor = source.IndexOf(needle, cursor, StringComparison.Ordinal)) >= 0)
|
||||
{
|
||||
count++;
|
||||
cursor += needle.Length;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static void AssertAppearsInOrder(string source, params string[] needles)
|
||||
{
|
||||
int cursor = -1;
|
||||
foreach (string needle in needles)
|
||||
{
|
||||
int next = source.IndexOf(needle, cursor + 1, StringComparison.Ordinal);
|
||||
Assert.True(next >= 0, $"Missing expected source fragment: {needle}");
|
||||
Assert.True(next > cursor, $"Out-of-order source fragment: {needle}");
|
||||
cursor = next;
|
||||
}
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||
return directory.FullName;
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not find AcDream.slnx.");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue