Give terrain, sky, retained UI, portal preparation, and the update/render frame pair explicit single owners. Make shader, texture, text, bindless, and GL construction prefixes checked and retryable so partial failure cannot lose or replay resource ownership. Co-authored-by: Codex <codex@openai.com>
538 lines
19 KiB
C#
538 lines
19 KiB
C#
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 World_scene_uses_the_typed_builder_and_orchestrator_owns_its_local_composition()
|
|
{
|
|
string gameWindow = GameWindowSource();
|
|
string worldScene = File.ReadAllText(Path.Combine(
|
|
FindRepoRoot(),
|
|
"src",
|
|
"AcDream.App",
|
|
"Rendering",
|
|
"WorldSceneRenderer.cs"));
|
|
|
|
Assert.Equal(
|
|
1,
|
|
CountOccurrences(worldScene, "_frames.Build("));
|
|
Assert.DoesNotContain("private void UpdateSunFromSky(", gameWindow, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("private void UpdateSkyPes(", gameWindow, StringComparison.Ordinal);
|
|
Assert.DoesNotContain("private static float ParseEnvFloat(", gameWindow, StringComparison.Ordinal);
|
|
Assert.DoesNotContain(
|
|
"private AcDream.App.Rendering.WorldRenderFrameBuilder?",
|
|
gameWindow,
|
|
StringComparison.Ordinal);
|
|
Assert.DoesNotContain(
|
|
"private AcDream.App.Rendering.SkyPesFrameController?",
|
|
gameWindow,
|
|
StringComparison.Ordinal);
|
|
AssertAppearsInOrder(
|
|
worldScene,
|
|
"_alpha.BeginFrame();",
|
|
"_frames.Build(",
|
|
"if (_login.IsWaitingForLogin)",
|
|
"_passes.DrawFlatTerrain(",
|
|
"NormalWorldDrawn: true");
|
|
AssertAppearsInOrder(
|
|
gameWindow,
|
|
"var skyPesFrame = new AcDream.App.Rendering.SkyPesFrameController(",
|
|
"var worldRenderFrameBuilder =",
|
|
"new AcDream.App.Rendering.RenderFrameOrchestrator(",
|
|
"_frameGraphs.Withdraw();",
|
|
"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;
|
|
}
|
|
}
|
|
}
|