acdream/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs
Erik 3628aeb520 refactor(app): compose atomic frame roots
Move the complete update/render construction graph into a typed Phase-8 owner, explicitly carry the content dependencies it consumes, and publish both roots through one exact lease. Extract lifecycle resource sampling and frame-owned late bindings so partial startup and shutdown withdraw the same generation without window callbacks.

Co-authored-by: Codex <codex@openai.com>
2026-07-22 19:02:08 +02:00

745 lines
25 KiB
C#

using System.Numerics;
using System.Reflection;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Selection;
using AcDream.App.Rendering.Vfx;
using AcDream.Core.Rendering;
using AcDream.Core.World;
namespace AcDream.App.Tests.Rendering;
public sealed class WorldSceneRendererTests
{
[Fact]
public void PortalViewport_PublishesEmptySelectionFrameAndSkipsWorldOwners()
{
var rig = new Rig(portalVisible: true, waitingForLogin: false, clipRoot: null);
WorldRenderFrameOutcome result = rig.Renderer.Render(default);
Assert.Equal(default, result);
Assert.Equal(["selection:begin", "selection:complete"], rig.Calls);
}
[Fact]
public void LoginWait_DrawsFlatSkyThenCompletesFrameWithoutWorldGeometry()
{
var rig = new Rig(portalVisible: false, waitingForLogin: true, clipRoot: null);
WorldRenderFrameOutcome result = rig.Renderer.Render(default);
Assert.False(result.NormalWorldDrawn);
Assert.Equal(0, result.VisibleLandblocks);
Assert.Equal(0, result.TotalLandblocks);
Assert.Equal(
[
"selection:begin",
"alpha:begin",
"frame:build",
"passes:begin",
"flat:clip",
"flat:sky",
"alpha:end",
"visibility:mark",
"visibility:complete",
"selection:complete",
],
rig.Calls);
}
[Fact]
public void FlatWorld_PreservesTerrainEntityParticleWeatherAndCompletionOrder()
{
var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: null);
WorldRenderFrameOutcome result = rig.Renderer.Render(default);
Assert.Equal(new WorldRenderFrameOutcome(3, 8, true), result);
Assert.Equal(
[
"selection:begin",
"alpha:begin",
"frame:build",
"passes:begin",
"flat:clip",
"flat:sky",
"flat:terrain",
"flat:entities",
"frame:clear-cells",
"passes:disable-clip",
"particles:global",
"flat:weather",
"diagnostics:signature:OutdoorRoot",
"diagnostics:draw",
"alpha:end",
"visibility:mark",
"visibility:complete",
"selection:complete",
],
rig.Calls);
Assert.Equal(rig.Foundation, rig.Frames.Foundation);
Assert.False(rig.Frames.WaitingForLogin);
Assert.Same(rig.DayGroup, rig.Frames.ActiveDayGroup);
Assert.Equal(rig.Foundation, rig.Passes.SkyFoundation);
Assert.Equal(rig.Foundation, rig.Passes.WeatherFoundation);
Assert.Same(rig.DayGroup, rig.Passes.SkyDayGroup);
Assert.Same(rig.DayGroup, rig.Passes.WeatherDayGroup);
Assert.Equal(rig.DayFraction, rig.Passes.SkyDayFraction);
Assert.Equal(rig.DayFraction, rig.Passes.WeatherDayFraction);
}
[Fact]
public void PViewWorld_UsesOnePViewProductAndPublishesItsDrawableCells()
{
var root = new LoadedCell
{
CellId = 0x01010001u,
IsOutdoorNode = false,
};
var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: root);
WorldRenderFrameOutcome result = rig.Renderer.Render(default);
Assert.True(result.NormalWorldDrawn);
Assert.Equal(
[
"selection:begin",
"alpha:begin",
"frame:build",
"passes:begin",
"pview:draw",
"visibility:mark",
"frame:observe-cells",
"diagnostics:pview",
"passes:disable-clip",
"particles:none",
"diagnostics:signature:RetailPViewInside",
"diagnostics:draw",
"alpha:end",
"visibility:mark",
"visibility:complete",
"selection:complete",
],
rig.Calls);
Assert.DoesNotContain("flat:clip", rig.Calls);
Assert.DoesNotContain("flat:terrain", rig.Calls);
Assert.DoesNotContain("flat:entities", rig.Calls);
Assert.NotNull(rig.PView.LastInput);
Assert.Same(rig.DayGroup, rig.PView.LastInput!.ActiveDayGroup);
Assert.Equal(rig.DayFraction, rig.PView.LastInput.DayFraction);
Assert.Equal(rig.Foundation.Sky, rig.PView.LastInput.SkyKeyframe);
Assert.True(rig.PView.LastInput.RenderWeather);
Assert.Equal(root.CellId, rig.PView.LastInput.ViewerCellId);
Assert.Equal(0x01010002u, rig.PView.LastInput.PlayerCellId);
Assert.Equal(4, rig.PView.LastInput.RenderRadius);
}
[Fact]
public void OutdoorPView_UsesPViewOwnersForPostWorldParticlesWithoutFlatWeather()
{
var root = new LoadedCell
{
CellId = 0x01010001u,
IsOutdoorNode = true,
};
var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: root);
WorldRenderFrameOutcome result = rig.Renderer.Render(default);
Assert.True(result.NormalWorldDrawn);
Assert.Contains("particles:pviewScoped+unattached", rig.Calls);
Assert.Same(rig.PView.OutdoorSceneParticleEntityIds, rig.Passes.ParticleOwners);
Assert.Equal([0xCAFEu], rig.Passes.ParticleOwners);
Assert.DoesNotContain("flat:weather", rig.Calls);
}
[Fact]
public void SealedInteriorPView_DisablesWeatherAndPropagatesEnvironmentOverride()
{
var root = new LoadedCell
{
CellId = 0x01010001u,
IsOutdoorNode = false,
};
var rig = new Rig(
portalVisible: false,
waitingForLogin: false,
clipRoot: root,
playerSeenOutside: false,
environOverride: EnvironOverride.BlueFog);
rig.Renderer.Render(default);
Assert.NotNull(rig.PView.LastInput);
Assert.False(rig.PView.LastInput!.RenderWeather);
Assert.True(rig.PView.LastInput.EnvironOverrideActive);
}
[Fact]
public void PViewFailure_AbortsPortalStateAndRecoversOnTheNextFrame()
{
var root = new LoadedCell
{
CellId = 0x01010001u,
IsOutdoorNode = false,
};
var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: root);
rig.PView.ThrowOnDraw = true;
Assert.Throws<InvalidOperationException>(() => rig.Renderer.Render(default));
Assert.Equal(
[
"selection:begin",
"alpha:begin",
"frame:build",
"passes:begin",
"pview:draw",
"pview:abort",
"passes:abort",
"visibility:abort",
"alpha:abort",
"selection:abort",
],
rig.Calls);
rig.Calls.Clear();
rig.PView.ThrowOnDraw = false;
Assert.True(rig.Renderer.Render(default).NormalWorldDrawn);
Assert.Contains("selection:complete", rig.Calls);
}
[Fact]
public void DrawFailure_DoesNotPublishPartialAlphaSelectionOrParticleFrames()
{
var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: null);
rig.Passes.ThrowOnTerrain = true;
Assert.Throws<InvalidOperationException>(() => rig.Renderer.Render(default));
Assert.Equal(
[
"selection:begin",
"alpha:begin",
"frame:build",
"passes:begin",
"flat:clip",
"flat:sky",
"flat:terrain",
"passes:abort",
"visibility:abort",
"alpha:abort",
"selection:abort",
],
rig.Calls);
rig.Calls.Clear();
rig.Passes.ThrowOnTerrain = false;
WorldRenderFrameOutcome recovered = rig.Renderer.Render(default);
Assert.True(recovered.NormalWorldDrawn);
Assert.Contains("alpha:end", rig.Calls);
Assert.Contains("selection:complete", rig.Calls);
}
[Fact]
public void AbortFailure_IsAggregatedAfterEveryOtherFrameOwnerIsAborted()
{
var rig = new Rig(portalVisible: false, waitingForLogin: false, clipRoot: null);
rig.Passes.ThrowOnTerrain = true;
rig.Passes.ThrowOnAbort = true;
AggregateException failure = Assert.Throws<AggregateException>(
() => rig.Renderer.Render(default));
Assert.Contains(
failure.InnerExceptions,
error => error is InvalidOperationException
&& error.Message == "terrain failed");
Assert.Contains(
failure.InnerExceptions,
error => error is InvalidOperationException
&& error.Message == "pass abort failed");
Assert.Contains("visibility:abort", rig.Calls);
Assert.Contains("alpha:abort", rig.Calls);
Assert.Contains("selection:abort", rig.Calls);
rig.Calls.Clear();
rig.Passes.ThrowOnTerrain = false;
rig.Passes.ThrowOnAbort = false;
Assert.True(rig.Renderer.Render(default).NormalWorldDrawn);
}
[Fact]
public void GameWindow_DelegatesWorldRenderingWithoutOwningDrawBranches()
{
string source = File.ReadAllText(Path.Combine(
FindRepoRoot(),
"src",
"AcDream.App",
"Composition",
"FrameRootComposition.cs"));
Assert.Contains("new WorldSceneRenderer(", source);
Assert.Contains("new RenderFrameOrchestrator(", source);
Assert.DoesNotContain("_worldSceneRenderer", source);
Assert.DoesNotContain("_retailPViewRenderer.DrawInside(", source);
Assert.DoesNotContain("_retailAlphaQueue.BeginFrame();", source);
Assert.DoesNotContain("_terrainDrawDiagnostics!.Begin();", source);
Assert.DoesNotContain("SkipWorldGeometry:", source);
}
[Fact]
public void ExtractedWorldOwners_RetainNoWindowDelegateOrBorrowedPViewProduct()
{
Type[] ownerTypes =
[
typeof(WorldSceneRenderer),
typeof(WorldScenePassExecutor),
typeof(WorldSceneDiagnosticsController),
];
Type[] borrowedTypes =
[
typeof(RetailPViewFrameResult),
typeof(PortalVisibilityFrame),
typeof(ClipFrameAssembly),
];
foreach (Type owner in ownerTypes)
{
foreach (FieldInfo field in owner.GetFields(
BindingFlags.Instance
| BindingFlags.Public
| BindingFlags.NonPublic))
{
Assert.False(
typeof(GameWindow).IsAssignableFrom(field.FieldType),
$"{owner.Name}.{field.Name} retains GameWindow.");
Assert.False(
typeof(Delegate).IsAssignableFrom(field.FieldType),
$"{owner.Name}.{field.Name} retains a delegate callback.");
Assert.DoesNotContain(field.FieldType, borrowedTypes);
}
}
}
private sealed class Rig
{
public Rig(
bool portalVisible,
bool waitingForLogin,
LoadedCell? clipRoot,
bool? playerSeenOutside = null,
EnvironOverride environOverride = EnvironOverride.None)
{
Calls = [];
DayGroup = new DayGroupData { Name = "sentinel-day-group" };
DayFraction = 0.375f;
Foundation = new RenderFrameFoundation(
portalVisible,
new SkyKeyframe(
0.25f,
35f,
45f,
new Vector3(0.1f, 0.2f, 0.3f),
0.8f,
new Vector3(0.4f, 0.5f, 0.6f),
0.7f,
new Vector3(0.7f, 0.8f, 0.9f),
0.2f),
new AtmosphereSnapshot(
WeatherKind.Clear,
1f,
new Vector3(0.1f, 0.2f, 0.3f),
1f,
100f,
FogMode.Linear,
0f,
environOverride));
var foundation = new FoundationSource(Foundation);
var login = new LoginSource(waitingForLogin);
var sky = new SkySource(DayGroup, DayFraction);
var frame = CreateFrame(
clipRoot,
playerSeenOutside ?? clipRoot is not null);
Frames = new FrameBuilder(Calls, frame);
var selection = new SelectionFrame(Calls);
var alpha = new AlphaFrame(Calls);
var visibility = new ParticleVisibility(Calls);
PView = new PViewRenderer(Calls);
Passes = new PassExecutor(Calls);
var diagnostics = new Diagnostics(Calls);
Renderer = new WorldSceneRenderer(
foundation,
login,
sky,
Frames,
new EntitySource(),
selection,
alpha,
visibility,
PView,
new PViewCells(),
Passes,
new WorldRenderRangeState(4, 12),
diagnostics);
}
public List<string> Calls { get; }
public RenderFrameFoundation Foundation { get; }
public DayGroupData DayGroup { get; }
public float DayFraction { get; }
public FrameBuilder Frames { get; }
public PViewRenderer PView { get; }
public PassExecutor Passes { get; }
public WorldSceneRenderer Renderer { get; }
}
private sealed class FoundationSource(RenderFrameFoundation foundation) :
IRenderFrameFoundationSource
{
public RenderFrameFoundation Foundation { get; } = foundation;
}
private sealed class LoginSource(bool waiting) : IRenderLoginStateSource
{
public bool IsWaitingForLogin { get; } = waiting;
}
private sealed class SkySource(
DayGroupData activeDayGroup,
float dayFraction) : IWorldSceneSkyStateSource
{
public DayGroupData? ActiveDayGroup { get; } = activeDayGroup;
public float DayFraction { get; } = dayFraction;
}
private sealed class FrameBuilder(List<string> calls, WorldRenderFrame frame) :
IWorldRenderFrameBuilder
{
public RenderFrameFoundation Foundation { get; private set; }
public bool WaitingForLogin { get; private set; }
public DayGroupData? ActiveDayGroup { get; private set; }
public WorldRenderFrame Build(
in RenderFrameFoundation foundation,
bool waitingForLogin,
DayGroupData? activeDayGroup)
{
calls.Add("frame:build");
Foundation = foundation;
WaitingForLogin = waitingForLogin;
ActiveDayGroup = activeDayGroup;
return frame;
}
public void ObserveDrawableCells(IReadOnlySet<uint> drawableCells) =>
calls.Add("frame:observe-cells");
public void ClearDrawableCells() => calls.Add("frame:clear-cells");
}
private sealed class EntitySource : IWorldSceneEntitySource
{
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries =>
Array.Empty<(uint, Vector3, Vector3, IReadOnlyList<WorldEntity>,
IReadOnlyDictionary<uint, WorldEntity>?)>();
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> LandblockBounds =>
Array.Empty<(uint, Vector3, Vector3)>();
}
private sealed class SelectionFrame(List<string> calls) : IWorldSceneSelectionFrame
{
public void BeginFrame() => calls.Add("selection:begin");
public void CompleteFrame() => calls.Add("selection:complete");
public void AbortFrame() => calls.Add("selection:abort");
}
private sealed class AlphaFrame(List<string> calls) : IWorldSceneAlphaFrame
{
public void BeginFrame() => calls.Add("alpha:begin");
public void EndFrame() => calls.Add("alpha:end");
public void AbortFrame() => calls.Add("alpha:abort");
}
private sealed class ParticleVisibility(List<string> calls) :
IWorldSceneParticleVisibility
{
public void MarkVisibleCells(HashSet<uint> cellIds) =>
calls.Add("visibility:mark");
public void CompleteFrame() => calls.Add("visibility:complete");
public void AbortFrame() => calls.Add("visibility:abort");
}
private sealed class PViewRenderer : IWorldScenePViewRenderer
{
private readonly List<string> _calls;
private readonly RetailPViewFrameResult _interiorResult;
private readonly RetailPViewFrameResult _outdoorResult;
public PViewRenderer(List<string> calls)
{
_calls = calls;
_interiorResult = new RetailPViewFrameResult().Reset(
new PortalVisibilityFrame(),
new ClipFrameAssembly(),
[],
new InteriorEntityPartition.Result());
var outdoorPortalFrame = new PortalVisibilityFrame();
outdoorPortalFrame.OutsideView.Add(new ViewPolygon(
[
new Vector2(-1f, -1f),
new Vector2(1f, -1f),
new Vector2(1f, 1f),
new Vector2(-1f, 1f),
]));
_outdoorResult = new RetailPViewFrameResult().Reset(
outdoorPortalFrame,
ClipFrameAssembler.Assemble(ClipFrame.NoClip(), outdoorPortalFrame),
[],
new InteriorEntityPartition.Result());
}
public IReadOnlySet<uint> OutdoorSceneParticleEntityIds { get; } =
new HashSet<uint> { 0xCAFEu };
public RetailPViewFrameInput? LastInput { get; private set; }
public bool ThrowOnDraw { get; set; }
public RetailPViewFrameResult DrawInside(RetailPViewFrameInput input)
{
_calls.Add("pview:draw");
LastInput = input;
if (ThrowOnDraw)
throw new InvalidOperationException("pview failed");
return input.RootCell.IsOutdoorNode ? _outdoorResult : _interiorResult;
}
public void AbortFrame() => _calls.Add("pview:abort");
}
private sealed class PViewCells : IRetailPViewCellSource
{
public LoadedCell? Find(uint cellId) => null;
}
private sealed class PassExecutor(List<string> calls) : IWorldScenePassExecutor
{
public bool ThrowOnTerrain { get; set; }
public bool ThrowOnAbort { get; set; }
public HashSet<uint>? TerrainVisibleCellIds { get; } = [0x01010001u];
public RenderFrameFoundation? SkyFoundation { get; private set; }
public RenderFrameFoundation? WeatherFoundation { get; private set; }
public DayGroupData? SkyDayGroup { get; private set; }
public DayGroupData? WeatherDayGroup { get; private set; }
public float SkyDayFraction { get; private set; }
public float WeatherDayFraction { get; private set; }
public IReadOnlySet<uint>? ParticleOwners { get; private set; }
public void BeginFrame() => calls.Add("passes:begin");
public void PrepareFlatWorldClip() => calls.Add("flat:clip");
public void DrawFlatSky(
in WorldCameraFrame camera,
in RenderFrameFoundation foundation,
DayGroupData? activeDayGroup,
float dayFraction)
{
calls.Add("flat:sky");
SkyFoundation = foundation;
SkyDayGroup = activeDayGroup;
SkyDayFraction = dayFraction;
}
public void DrawFlatTerrain(in WorldCameraFrame camera, uint? playerLandblockId)
{
calls.Add("flat:terrain");
if (ThrowOnTerrain)
throw new InvalidOperationException("terrain failed");
}
public void DrawFlatEntities(
in WorldCameraFrame camera,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> entries,
uint? playerLandblockId,
HashSet<uint> animatedEntityIds) => calls.Add("flat:entities");
public string DrawPostWorldParticles(
LoadedCell? clipRoot,
ClipFrameAssembly? clipAssembly,
in WorldCameraFrame camera,
IReadOnlySet<uint> outdoorOwnerIds,
string currentSignature)
{
ParticleOwners = outdoorOwnerIds;
string kind = clipRoot switch
{
null => "global",
{ IsOutdoorNode: true } => currentSignature == "none"
? "unattached"
: currentSignature + "+unattached",
_ => currentSignature,
};
calls.Add($"particles:{kind}");
return kind;
}
public void DrawFlatWeather(
in WorldCameraFrame camera,
in RenderFrameFoundation foundation,
DayGroupData? activeDayGroup,
float dayFraction)
{
calls.Add("flat:weather");
WeatherFoundation = foundation;
WeatherDayGroup = activeDayGroup;
WeatherDayFraction = dayFraction;
}
public void DisableClipDistances() => calls.Add("passes:disable-clip");
public void AbortFrame()
{
calls.Add("passes:abort");
if (ThrowOnAbort)
throw new InvalidOperationException("pass abort failed");
}
}
private sealed class Diagnostics(List<string> calls) : IWorldSceneDiagnostics
{
public CameraCellResolution CameraCellResolution => CameraCellResolution.None;
public void EmitPViewInput(
PortalVisibilityFrame portalFrame,
Matrix4x4 viewProjection,
LoadedCell clipRoot,
Vector3 cameraPosition,
Vector3 playerPosition) => calls.Add("diagnostics:pview");
public void EmitRenderSignature(
string branch,
LoadedCell? clipRoot,
LoadedCell? viewerRoot,
LoadedCell? playerRoot,
uint viewerCellId,
uint playerCellId,
bool playerIndoorGate,
bool cameraInsideCell,
bool renderSky,
bool drawSkyThisFrame,
bool terrainDrawn,
TerrainClipMode terrainClipMode,
bool skyDrawn,
bool depthClear,
bool outdoorSceneryDrawn,
int liveDynamicDrawnCount,
string sceneParticles,
RetailPViewFrameResult? pviewResult,
Vector3 cameraPosition,
Vector3 playerPosition) =>
calls.Add($"diagnostics:signature:{branch}");
public WorldSceneDiagnosticOutcome DrawAndPublish(
in WorldCameraFrame camera,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax)> bounds)
{
calls.Add("diagnostics:draw");
return new WorldSceneDiagnosticOutcome(3, 8);
}
}
private static WorldRenderFrame CreateFrame(
LoadedCell? clipRoot,
bool playerSeenOutside)
{
var camera = new FlyCamera { Position = new Vector3(1f, 2f, 3f) };
var cameraFrame = new WorldCameraFrame(
camera,
camera.Projection,
camera.View * camera.Projection,
default,
Matrix4x4.Identity,
camera.Position);
var roots = new WorldRootFrame(
PlayerRoot: null,
PlayerSeenOutside: playerSeenOutside,
ViewerCellId: clipRoot?.CellId ?? 0u,
ViewerEyePosition: camera.Position,
PlayerViewPosition: camera.Position,
ViewerRoot: clipRoot,
CameraInsideCell: clipRoot is not null,
RootSeenOutside: true,
PlayerInsideCell: false,
PlayerLandblockId: null,
RenderCenterLandblockX: 0,
RenderCenterLandblockY: 0,
PlayerCellId: 0x01010002u,
PlayerIndoorGate: false);
return new WorldRenderFrame(
cameraFrame,
roots,
new WorldBuildingFrame(null, Array.Empty<LoadedCell>()),
[]);
}
private static int CountOccurrences(string source, string value)
{
int count = 0;
int cursor = 0;
while ((cursor = source.IndexOf(value, cursor, StringComparison.Ordinal)) >= 0)
{
count++;
cursor += value.Length;
}
return count;
}
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.");
}
}