refactor(render): extract typed retail pview passes
This commit is contained in:
parent
6d6e5b5fa5
commit
85239fb373
13 changed files with 1888 additions and 841 deletions
|
|
@ -106,6 +106,14 @@ public sealed class GameWindowRenderLeafCompositionTests
|
|||
Assert.Contains("_renderFrameResources = null;", source);
|
||||
Assert.Contains("_renderFrameLivePreparation = null;", source);
|
||||
Assert.Contains("_renderWeatherFrame = null;", source);
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"new ResourceShutdownStage(\"frame borrowers\"",
|
||||
"_retailPViewPassExecutor = null;",
|
||||
"_retailPViewCells = null;",
|
||||
"_terrainDrawDiagnostics = null;",
|
||||
"_retailPViewRenderer = null;",
|
||||
"new ResourceShutdownStage(\"session dependents\"");
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"new(\"frame pacing\", _displayFramePacing.Dispose)",
|
||||
|
|
@ -143,15 +151,15 @@ public sealed class GameWindowRenderLeafCompositionTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void TerrainAndFrameDiagnostics_CommitOneSharedCadenceAfterBothWrites()
|
||||
public void TerrainAndFrameDiagnostics_AreComposedAsOneFocusedOwner()
|
||||
{
|
||||
string source = GameWindowSource();
|
||||
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"_worldRenderDiagnostics?.PublishTerrainDiagnostics(",
|
||||
"PublishFrameDiagnostics();",
|
||||
"_frameDiagLastPublicationMilliseconds = now;");
|
||||
Assert.Contains(
|
||||
"new AcDream.App.Rendering.TerrainDrawDiagnosticsController(",
|
||||
source);
|
||||
Assert.Contains("_terrainDrawDiagnostics!.Begin();", source);
|
||||
Assert.Contains("_terrainDrawDiagnostics.Complete();", source);
|
||||
}
|
||||
|
||||
private static string GameWindowSource() => File.ReadAllText(Path.Combine(
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ namespace AcDream.App.Tests.Rendering;
|
|||
/// <see cref="ViewconeCuller.Build"/> → the exact DrawDynamicsLast visibility predicate
|
||||
/// (RetailPViewRenderer.cs:375-401), PLUS the depth relationship DrawDynamicsLast is
|
||||
/// subject to: under an INTERIOR root the exit-portal SEAL stamps the door fan at TRUE
|
||||
/// depth (GameWindow.DrawRetailPViewPortalDepthWrite, forceFarZ=false) after the full
|
||||
/// depth (RetailPViewPassExecutor.DrawPortalDepthWrite, forceFarZ=false) after the full
|
||||
/// depth clear, and dynamics draw depth-tested AFTER it.
|
||||
///
|
||||
/// The four candidates this pins (handoff §5 + this session's read):
|
||||
|
|
@ -261,7 +261,11 @@ public class HouseExitWalkReplayTests
|
|||
var drawable = new HashSet<uint>(pv.OrderedVisibleCells);
|
||||
bool outsideStage = rootCellId != 0u
|
||||
&& RetailPViewRenderer.DynamicDrawsInOutsideStage(
|
||||
playerCell, sphereC, PlayerSphereRadius, drawable, lookup);
|
||||
playerCell,
|
||||
sphereC,
|
||||
PlayerSphereRadius,
|
||||
drawable,
|
||||
new TestCellSource(lookup));
|
||||
|
||||
// Candidate 4: the seal depth check. Applies when the root is INTERIOR
|
||||
// (ClearDepthForInterior + the TRUE-depth seal run, GameWindow:7719-7729),
|
||||
|
|
@ -483,4 +487,10 @@ public class HouseExitWalkReplayTests
|
|||
$"eye {d * 100,5:F1} cm OUTSIDE + root still 0x{ExitCellId:X8}: outPolys={pv.OutsideView.Polygons.Count} flood={pv.OrderedVisibleCells.Count} playerConeVisible={playerVisible}"));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestCellSource(Func<uint, LoadedCell?> find) :
|
||||
IRetailPViewCellSource
|
||||
{
|
||||
public LoadedCell? Find(uint cellId) => find(cellId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,540 @@
|
|||
using System.Numerics;
|
||||
using System.Reflection;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class RetailPViewPassExecutorTests
|
||||
{
|
||||
[Fact]
|
||||
public void DrawInside_outdoor_executes_the_real_typed_sequence_without_interior_clear()
|
||||
{
|
||||
var renderer = new RetailPViewRenderer();
|
||||
using var executor = new RecordingExecutor();
|
||||
LoadedCell root = OutdoorCellNode.Build(0xA9B40000u);
|
||||
|
||||
renderer.DrawInside(Frame(root), executor);
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"begin",
|
||||
"assemble",
|
||||
"prepare-clip:3",
|
||||
"indoor-routing",
|
||||
"prepare-cells",
|
||||
"diagnostics",
|
||||
"terrain-clip",
|
||||
"clear-routing",
|
||||
"landscape-early",
|
||||
"terrain-clip",
|
||||
"clear-routing",
|
||||
"outstage-routing",
|
||||
"landscape-late",
|
||||
"landscape-alpha",
|
||||
"indoor-routing",
|
||||
"indoor-routing",
|
||||
"exit-mask",
|
||||
"indoor-routing",
|
||||
"opaque-shells",
|
||||
],
|
||||
executor.Operations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrawInside_interior_exit_flushes_before_clear_and_resets_borrowed_result()
|
||||
{
|
||||
var renderer = new RetailPViewRenderer();
|
||||
using var executor = new RecordingExecutor();
|
||||
LoadedCell outdoor = OutdoorCellNode.Build(0xA9B40000u);
|
||||
RetailPViewFrameResult borrowed = renderer.DrawInside(Frame(outdoor), executor);
|
||||
Assert.Contains(outdoor.CellId, borrowed.DrawableCells);
|
||||
|
||||
executor.Operations.Clear();
|
||||
LoadedCell interior = InteriorWithExit(0xA9B40100u);
|
||||
RetailPViewFrameResult reused = renderer.DrawInside(Frame(interior), executor);
|
||||
|
||||
Assert.Same(borrowed, reused);
|
||||
Assert.DoesNotContain(outdoor.CellId, reused.DrawableCells);
|
||||
Assert.Contains(interior.CellId, reused.DrawableCells);
|
||||
AssertAppearsInOrder(
|
||||
string.Join('|', executor.Operations),
|
||||
"landscape-early",
|
||||
"landscape-late",
|
||||
"unattached-particles",
|
||||
"landscape-alpha",
|
||||
"interior-depth-clear",
|
||||
"indoor-routing",
|
||||
"exit-mask");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrawInside_interior_without_an_outside_slice_skips_landscape_and_depth_clear()
|
||||
{
|
||||
var renderer = new RetailPViewRenderer();
|
||||
using var executor = new RecordingExecutor();
|
||||
var root = new LoadedCell
|
||||
{
|
||||
CellId = 0xA9B40100u,
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
};
|
||||
|
||||
renderer.DrawInside(Frame(root), executor);
|
||||
|
||||
Assert.DoesNotContain("terrain-clip", executor.Operations);
|
||||
Assert.DoesNotContain("landscape-early", executor.Operations);
|
||||
Assert.DoesNotContain("landscape-late", executor.Operations);
|
||||
Assert.DoesNotContain("landscape-alpha", executor.Operations);
|
||||
Assert.DoesNotContain("interior-depth-clear", executor.Operations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Particle_classifications_reset_before_an_empty_following_frame()
|
||||
{
|
||||
var classifications = new RetailPViewParticleClassifications();
|
||||
classifications.ReplaceOutdoor([Entity(7u)]);
|
||||
classifications.Visible.Add(8u);
|
||||
classifications.Dynamics.Add(9u);
|
||||
|
||||
classifications.BeginFrame();
|
||||
|
||||
Assert.Empty(classifications.Outdoor);
|
||||
Assert.Empty(classifications.Visible);
|
||||
Assert.Empty(classifications.Dynamics);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrawInside_real_fixtures_reach_entity_particle_and_transparent_shell_operations()
|
||||
{
|
||||
var renderer = new RetailPViewRenderer();
|
||||
using var executor = new RecordingExecutor { HasTransparentShells = true };
|
||||
LoadedCell interior = InteriorWithExit(0xA9B40100u);
|
||||
WorldEntity cellStatic = Entity(10u, parentCellId: interior.CellId);
|
||||
WorldEntity dynamic = Entity(
|
||||
11u,
|
||||
serverGuid: 0x80000011u,
|
||||
parentCellId: interior.CellId);
|
||||
|
||||
renderer.DrawInside(Frame(interior, [cellStatic, dynamic]), executor);
|
||||
|
||||
Assert.Contains("opaque-shells", executor.Operations);
|
||||
Assert.Contains("transparent-shells-ordered", executor.Operations);
|
||||
Assert.Contains("entity-bucket", executor.Operations);
|
||||
Assert.Contains("cell-particles", executor.Operations);
|
||||
Assert.Contains("dynamics-particles", executor.Operations);
|
||||
Assert.Contains("phantom-objects", executor.Operations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrawInside_interior_root_executes_the_nearby_building_look_in_punch()
|
||||
{
|
||||
var renderer = new RetailPViewRenderer();
|
||||
using var executor = new RecordingExecutor();
|
||||
LoadedCell root = InteriorWithExit(0xA9B40100u);
|
||||
root.BuildingId = 1u;
|
||||
LoadedCell[] building = NearbyTwoCellBuilding();
|
||||
|
||||
renderer.DrawInside(
|
||||
Frame(
|
||||
root,
|
||||
nearbyBuildingCells: building,
|
||||
additionalCells: building),
|
||||
executor);
|
||||
|
||||
Assert.Contains("look-in-punch", executor.Operations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Renderer_source_preserves_retail_stage_order_through_typed_operations()
|
||||
{
|
||||
string source = RendererSource();
|
||||
string drawInside = MethodBody(source, "public RetailPViewFrameResult DrawInside(");
|
||||
AssertAppearsInOrder(
|
||||
drawInside,
|
||||
"passes.BeginFrame();",
|
||||
"passes.EmitDiagnostics(ctx, result);",
|
||||
"DrawLandscapeThroughOutsideView(ctx, passes",
|
||||
"DrawExitPortalMasks(ctx, passes",
|
||||
"DrawEnvCellShells(passes, pvFrame);",
|
||||
"DrawCellObjectLists(ctx, passes",
|
||||
"DrawDynamicsLast(ctx, passes");
|
||||
|
||||
string landscape = MethodBody(
|
||||
source,
|
||||
"private void DrawLandscapeThroughOutsideView(");
|
||||
AssertAppearsInOrder(
|
||||
landscape,
|
||||
"passes.SetTerrainClip(slice.Planes);",
|
||||
"passes.DrawLandscapeSlice(ctx",
|
||||
"DrawBuildingLookIns(ctx, passes",
|
||||
"passes.DrawLandscapeSliceLate(ctx",
|
||||
"passes.DrawUnattachedSceneParticles(ctx);",
|
||||
"passes.FlushLandscapeAlpha();",
|
||||
"passes.ClearInteriorDepth();");
|
||||
|
||||
string lookIns = MethodBody(source, "private void DrawBuildingLookIns(");
|
||||
AssertAppearsInOrder(
|
||||
lookIns,
|
||||
"passes.DrawLookInPortalPunch(ctx",
|
||||
"passes.DrawOpaqueCellShells(_shellBatch);",
|
||||
"passes.DrawCellParticles(ctx");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extracted_contracts_retain_no_window_callbacks_or_visibility_owner()
|
||||
{
|
||||
Assert.DoesNotContain(
|
||||
typeof(RetailPViewFrameInput).GetProperties(),
|
||||
property => typeof(Delegate).IsAssignableFrom(property.PropertyType));
|
||||
|
||||
FieldInfo[] fields = typeof(RetailPViewPassExecutor).GetFields(
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
Assert.DoesNotContain(fields, field => field.FieldType == typeof(GameWindow));
|
||||
Assert.DoesNotContain(fields, field => field.FieldType == typeof(CellVisibility));
|
||||
Assert.DoesNotContain(fields, field => field.FieldType == typeof(RetailPViewFrameInput));
|
||||
Assert.DoesNotContain(fields, field => field.FieldType == typeof(RetailPViewFrameResult));
|
||||
Assert.DoesNotContain(fields, field => field.FieldType == typeof(ClipFrameAssembly));
|
||||
Assert.DoesNotContain(
|
||||
fields,
|
||||
field => typeof(Delegate).IsAssignableFrom(field.FieldType));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Concrete_executor_forwards_frame_reset_and_brackets_terrain_diagnostics()
|
||||
{
|
||||
string source = ExecutorClassSource();
|
||||
string begin = MethodBody(source, "public void BeginFrame()");
|
||||
Assert.Contains("_particleClassifications.BeginFrame();", begin);
|
||||
|
||||
string landscape = MethodBody(source, "public void DrawLandscapeSlice(");
|
||||
AssertAppearsInOrder(
|
||||
landscape,
|
||||
"_terrainDiagnostics.Begin();",
|
||||
"_terrain?.Draw(",
|
||||
"_terrainDiagnostics.Complete();");
|
||||
|
||||
Assert.DoesNotContain("Console.WriteLine", source);
|
||||
Assert.DoesNotContain("Console.WriteLine", RendererSource());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GameWindow_composes_one_executor_instead_of_a_draw_callback_bag()
|
||||
{
|
||||
string source = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Rendering",
|
||||
"GameWindow.cs"));
|
||||
|
||||
Assert.Contains("new AcDream.App.Rendering.RetailPViewPassExecutor(", source);
|
||||
Assert.Contains("new AcDream.App.Rendering.RetailPViewFrameInput", source);
|
||||
Assert.DoesNotContain("RetailPViewDrawContext", source);
|
||||
Assert.DoesNotContain("DrawLandscapeSlice =", source);
|
||||
Assert.DoesNotContain("DrawExitPortalMasks =", source);
|
||||
}
|
||||
|
||||
private static string RendererSource() => File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Rendering",
|
||||
"RetailPViewRenderer.cs"));
|
||||
|
||||
private static string ExecutorSource() => File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"src",
|
||||
"AcDream.App",
|
||||
"Rendering",
|
||||
"RetailPViewPassExecutor.cs"));
|
||||
|
||||
private static string ExecutorClassSource()
|
||||
{
|
||||
string source = ExecutorSource();
|
||||
int start = source.IndexOf(
|
||||
"internal sealed class RetailPViewPassExecutor",
|
||||
StringComparison.Ordinal);
|
||||
Assert.True(start >= 0, "Missing concrete RetailPViewPassExecutor.");
|
||||
return source[start..];
|
||||
}
|
||||
|
||||
private static RetailPViewFrameInput Frame(
|
||||
LoadedCell root,
|
||||
IReadOnlyList<WorldEntity>? entities = null,
|
||||
IReadOnlyList<LoadedCell>? nearbyBuildingCells = null,
|
||||
IReadOnlyList<LoadedCell>? additionalCells = null)
|
||||
{
|
||||
var cells = new Dictionary<uint, LoadedCell> { [root.CellId] = root };
|
||||
if (additionalCells is not null)
|
||||
{
|
||||
foreach (LoadedCell cell in additionalCells)
|
||||
cells[cell.CellId] = cell;
|
||||
}
|
||||
|
||||
var entries = new List<(
|
||||
uint LandblockId,
|
||||
Vector3 AabbMin,
|
||||
Vector3 AabbMax,
|
||||
IReadOnlyList<WorldEntity> Entities,
|
||||
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)>();
|
||||
if (entities is not null)
|
||||
{
|
||||
entries.Add((
|
||||
root.CellId & 0xFFFF0000u,
|
||||
Vector3.Zero,
|
||||
Vector3.Zero,
|
||||
entities,
|
||||
null));
|
||||
}
|
||||
|
||||
return new RetailPViewFrameInput
|
||||
{
|
||||
RootCell = root,
|
||||
NearbyBuildingCells = nearbyBuildingCells,
|
||||
ViewerEyePos = Vector3.Zero,
|
||||
ViewProjection = TestCamera.ViewProjection,
|
||||
Cells = new DictionaryCellSource(cells),
|
||||
Camera = new TestCamera(),
|
||||
CameraWorldPosition = Vector3.Zero,
|
||||
Frustum = null,
|
||||
PlayerLandblockId = root.CellId & 0xFFFF0000u,
|
||||
AnimatedEntityIds = null,
|
||||
RenderCenterLbX = 0,
|
||||
RenderCenterLbY = 0,
|
||||
RenderRadius = 1,
|
||||
LandblockEntries = entries,
|
||||
RenderSky = true,
|
||||
RenderWeather = true,
|
||||
DayFraction = 0f,
|
||||
ActiveDayGroup = null,
|
||||
SkyKeyframe = default,
|
||||
EnvironOverrideActive = false,
|
||||
ViewerCellId = root.CellId,
|
||||
PlayerCellId = root.CellId,
|
||||
PlayerViewPosition = Vector3.Zero,
|
||||
CameraView = TestCamera.ViewMatrix,
|
||||
CameraCellResolution = CameraCellResolution.None,
|
||||
};
|
||||
}
|
||||
|
||||
private static LoadedCell InteriorWithExit(uint cellId)
|
||||
{
|
||||
var cell = new LoadedCell
|
||||
{
|
||||
CellId = cellId,
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
Portals = [new CellPortalInfo(0xFFFF, 0, 0, 0)],
|
||||
};
|
||||
cell.PortalPolygons.Add(
|
||||
[
|
||||
new Vector3(-1f, -1f, -2f),
|
||||
new Vector3(1f, -1f, -2f),
|
||||
new Vector3(1f, 1f, -2f),
|
||||
new Vector3(-1f, 1f, -2f),
|
||||
]);
|
||||
return cell;
|
||||
}
|
||||
|
||||
private static LoadedCell[] NearbyTwoCellBuilding()
|
||||
{
|
||||
const uint vestibuleId = 0xA9B40170u;
|
||||
const uint roomId = 0xA9B40171u;
|
||||
var vestibule = new LoadedCell
|
||||
{
|
||||
CellId = vestibuleId,
|
||||
BuildingId = 2u,
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
Portals =
|
||||
[
|
||||
new CellPortalInfo(0xFFFF, 0, 0, 0),
|
||||
new CellPortalInfo(0x0171, 1, 0, 0),
|
||||
],
|
||||
ClipPlanes =
|
||||
[
|
||||
new PortalClipPlane
|
||||
{
|
||||
Normal = new Vector3(0, 0, 1),
|
||||
D = 3f,
|
||||
InsideSide = 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
vestibule.PortalPolygons.Add(
|
||||
[
|
||||
new Vector3(-0.5f, -0.5f, -2f),
|
||||
new Vector3(0.5f, -0.5f, -2f),
|
||||
new Vector3(0.5f, 0.5f, -2f),
|
||||
new Vector3(-0.5f, 0.5f, -2f),
|
||||
]);
|
||||
vestibule.PortalPolygons.Add(
|
||||
[
|
||||
new Vector3(-0.6f, -0.6f, -4f),
|
||||
new Vector3(0.6f, -0.6f, -4f),
|
||||
new Vector3(0.6f, 0.6f, -4f),
|
||||
new Vector3(-0.6f, 0.6f, -4f),
|
||||
]);
|
||||
|
||||
var room = new LoadedCell
|
||||
{
|
||||
CellId = roomId,
|
||||
BuildingId = 2u,
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
Portals = [new CellPortalInfo(0x0170, 0, 0, 1)],
|
||||
};
|
||||
room.PortalPolygons.Add(
|
||||
[
|
||||
new Vector3(-0.6f, -0.6f, -4f),
|
||||
new Vector3(0.6f, -0.6f, -4f),
|
||||
new Vector3(0.6f, 0.6f, -4f),
|
||||
new Vector3(-0.6f, 0.6f, -4f),
|
||||
]);
|
||||
return [vestibule, room];
|
||||
}
|
||||
|
||||
private static WorldEntity Entity(
|
||||
uint id,
|
||||
uint serverGuid = 0,
|
||||
uint? parentCellId = null) => new()
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = serverGuid,
|
||||
SourceGfxObjOrSetupId = 0,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = [new MeshRef(1u, Matrix4x4.Identity)],
|
||||
ParentCellId = parentCellId,
|
||||
};
|
||||
|
||||
private static string MethodBody(string source, string signature)
|
||||
{
|
||||
int start = source.IndexOf(signature, StringComparison.Ordinal);
|
||||
Assert.True(start >= 0, $"Missing method: {signature}");
|
||||
int open = source.IndexOf('{', start);
|
||||
int depth = 0;
|
||||
for (int index = open; index < source.Length; index++)
|
||||
{
|
||||
if (source[index] == '{')
|
||||
depth++;
|
||||
else if (source[index] == '}' && --depth == 0)
|
||||
return source[start..(index + 1)];
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Unterminated method: {signature}");
|
||||
}
|
||||
|
||||
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 > cursor, $"Missing or out-of-order 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 DictionaryCellSource(
|
||||
IReadOnlyDictionary<uint, LoadedCell> cells) : IRetailPViewCellSource
|
||||
{
|
||||
public LoadedCell? Find(uint cellId) =>
|
||||
cells.TryGetValue(cellId, out LoadedCell? cell) ? cell : null;
|
||||
}
|
||||
|
||||
private sealed class TestCamera : ICamera
|
||||
{
|
||||
public static Matrix4x4 ViewMatrix { get; } = Matrix4x4.CreateLookAt(
|
||||
Vector3.Zero,
|
||||
new Vector3(0, 0, -1),
|
||||
Vector3.UnitY);
|
||||
|
||||
public static Matrix4x4 ViewProjection { get; } = ViewMatrix
|
||||
* Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1f, 0.1f, 1000f);
|
||||
|
||||
public Matrix4x4 View => ViewMatrix;
|
||||
public Matrix4x4 Projection { get; } =
|
||||
Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1f, 0.1f, 1000f);
|
||||
public float Aspect { get; set; } = 1f;
|
||||
}
|
||||
|
||||
private sealed class RecordingExecutor : IRetailPViewPassExecutor, IDisposable
|
||||
{
|
||||
private readonly ClipFrame _clipFrame = ClipFrame.NoClip();
|
||||
|
||||
public List<string> Operations { get; } = [];
|
||||
public bool HasTransparentShells { get; init; }
|
||||
|
||||
public void BeginFrame() => Operations.Add("begin");
|
||||
public ClipFrameAssembly AssembleClipFrame(
|
||||
PortalVisibilityFrame portalFrame,
|
||||
ClipFrameAssembly reuseAssembly)
|
||||
{
|
||||
Operations.Add("assemble");
|
||||
return ClipFrameAssembler.Assemble(_clipFrame, portalFrame, reuseAssembly);
|
||||
}
|
||||
|
||||
public void PrepareClipFrame(int terrainUploadCount) =>
|
||||
Operations.Add($"prepare-clip:{terrainUploadCount}");
|
||||
|
||||
public void SetTerrainClip(ReadOnlySpan<Vector4> planes) =>
|
||||
Operations.Add("terrain-clip");
|
||||
|
||||
public void ClearClipRouting() => Operations.Add("clear-routing");
|
||||
public void UseIndoorMembershipOnlyRouting() => Operations.Add("indoor-routing");
|
||||
public void PrepareCellBatches(RetailPViewFrameInput frame, HashSet<uint> visibleCellIds) =>
|
||||
Operations.Add("prepare-cells");
|
||||
public void DrawOpaqueCellShells(HashSet<uint> cellIds) => Operations.Add("opaque-shells");
|
||||
public bool CellHasTransparentShell(uint cellId) => HasTransparentShells;
|
||||
public void DrawTransparentCellShells(HashSet<uint> cellIds) => Operations.Add("transparent-shells");
|
||||
public void DrawTransparentCellShellsOrdered(IReadOnlyList<uint> cellIds) =>
|
||||
Operations.Add("transparent-shells-ordered");
|
||||
public void DrawEntityBucket(
|
||||
RetailPViewFrameInput frame,
|
||||
IReadOnlyList<WorldEntity> entities,
|
||||
HashSet<uint>? visibleCellIds) => Operations.Add("entity-bucket");
|
||||
public void EmitClipRouteProbe(
|
||||
ClipFrameAssembly clipAssembly,
|
||||
ClipViewSlice slice,
|
||||
int sliceIndex) => Operations.Add("clip-probe");
|
||||
public void EmitOutStageOwner(
|
||||
WorldEntity entity,
|
||||
Vector3 sphereCenter,
|
||||
float sphereRadius,
|
||||
int sliceIndex,
|
||||
bool passed) => Operations.Add("outstage-owner");
|
||||
public void EmitOutStageRouting(
|
||||
int sliceIndex,
|
||||
IReadOnlyList<WorldEntity> entities,
|
||||
ViewconeCuller viewcone) => Operations.Add("outstage-routing");
|
||||
public void EmitPhantomObjects(uint cellId, int survivorCount) =>
|
||||
Operations.Add("phantom-objects");
|
||||
public void DrawLandscapeSlice(RetailPViewFrameInput frame, RetailPViewLandscapeSliceContext context) => Operations.Add("landscape-early");
|
||||
public void DrawLandscapeSliceLate(RetailPViewFrameInput frame, RetailPViewLandscapeLateSliceContext context) => Operations.Add("landscape-late");
|
||||
public void ClearInteriorDepth() => Operations.Add("interior-depth-clear");
|
||||
public void DrawExitPortalMask(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("exit-mask");
|
||||
public void DrawLookInPortalPunch(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("look-in-punch");
|
||||
public void DrawUnattachedSceneParticles(RetailPViewFrameInput frame) => Operations.Add("unattached-particles");
|
||||
public void FlushLandscapeAlpha() => Operations.Add("landscape-alpha");
|
||||
public void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context) => Operations.Add("cell-particles");
|
||||
public void DrawDynamicsParticles(RetailPViewFrameInput frame, IReadOnlyList<WorldEntity> survivors) => Operations.Add("dynamics-particles");
|
||||
public void EmitDiagnostics(RetailPViewFrameInput frame, RetailPViewFrameResult result) => Operations.Add("diagnostics");
|
||||
|
||||
public void Dispose() => _clipFrame.Dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
using AcDream.App.Rendering;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class TerrainDrawDiagnosticsControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Disabled_controller_still_closes_the_terrain_sample_without_publication()
|
||||
{
|
||||
var log = new RecordingLog();
|
||||
var facts = new RecordingFacts();
|
||||
var world = new WorldRenderDiagnostics(new NullGlStateReader(), log);
|
||||
var controller = new TerrainDrawDiagnosticsController(false, world, facts, log);
|
||||
|
||||
controller.Begin();
|
||||
controller.Complete(10_000);
|
||||
|
||||
Assert.Equal(0, facts.TerrainCaptureCount);
|
||||
Assert.Equal(0, facts.FrameCaptureCount);
|
||||
Assert.Empty(log.Messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Publication_failure_does_not_commit_the_shared_cadence()
|
||||
{
|
||||
var log = new ThrowOnceLog();
|
||||
var facts = new RecordingFacts();
|
||||
var world = new WorldRenderDiagnostics(new NullGlStateReader(), log);
|
||||
var controller = new TerrainDrawDiagnosticsController(true, world, facts, log);
|
||||
|
||||
controller.Begin();
|
||||
Assert.Throws<InvalidOperationException>(() => controller.Complete(10_000));
|
||||
|
||||
controller.Begin();
|
||||
controller.Complete(10_001);
|
||||
|
||||
Assert.Equal(2, facts.TerrainCaptureCount);
|
||||
Assert.Equal(1, facts.FrameCaptureCount);
|
||||
Assert.Collection(
|
||||
log.Messages,
|
||||
terrain => Assert.StartsWith("[TERRAIN-DIAG]", terrain),
|
||||
frame => Assert.StartsWith("[FRAME-DIAG]", frame));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Frame_log_failure_retries_both_shared_publications()
|
||||
{
|
||||
var log = new ThrowOnFirstFrameLog();
|
||||
var facts = new RecordingFacts();
|
||||
var world = new WorldRenderDiagnostics(new NullGlStateReader(), log);
|
||||
var controller = new TerrainDrawDiagnosticsController(true, world, facts, log);
|
||||
|
||||
controller.Begin();
|
||||
Assert.Throws<InvalidOperationException>(() => controller.Complete(10_000));
|
||||
|
||||
controller.Begin();
|
||||
controller.Complete(10_001);
|
||||
|
||||
Assert.Equal(2, facts.TerrainCaptureCount);
|
||||
Assert.Equal(2, facts.FrameCaptureCount);
|
||||
Assert.Collection(
|
||||
log.Messages,
|
||||
firstTerrain => Assert.StartsWith("[TERRAIN-DIAG]", firstTerrain),
|
||||
retryTerrain => Assert.StartsWith("[TERRAIN-DIAG]", retryTerrain),
|
||||
frame => Assert.StartsWith("[FRAME-DIAG]", frame));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Successful_publication_waits_for_the_next_interval()
|
||||
{
|
||||
var log = new RecordingLog();
|
||||
var facts = new RecordingFacts();
|
||||
var world = new WorldRenderDiagnostics(new NullGlStateReader(), log);
|
||||
var controller = new TerrainDrawDiagnosticsController(true, world, facts, log);
|
||||
|
||||
controller.Begin();
|
||||
controller.Complete(10_000);
|
||||
controller.Begin();
|
||||
controller.Complete(14_999);
|
||||
controller.Begin();
|
||||
controller.Complete(15_000);
|
||||
controller.Begin();
|
||||
controller.Complete(15_001);
|
||||
|
||||
Assert.Equal(2, facts.TerrainCaptureCount);
|
||||
Assert.Equal(2, facts.FrameCaptureCount);
|
||||
Assert.Equal(4, log.Messages.Count);
|
||||
}
|
||||
|
||||
private sealed class RecordingFacts : IFramePipelineDiagnosticFactsSource
|
||||
{
|
||||
public int TerrainCaptureCount { get; private set; }
|
||||
public int FrameCaptureCount { get; private set; }
|
||||
|
||||
public TerrainRenderDiagnosticFacts CaptureTerrain()
|
||||
{
|
||||
TerrainCaptureCount++;
|
||||
return new TerrainRenderDiagnosticFacts(1, 2, 3);
|
||||
}
|
||||
|
||||
public FramePipelineDiagnosticFacts CaptureFrame()
|
||||
{
|
||||
FrameCaptureCount++;
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NullGlStateReader : IRenderGlStateReader
|
||||
{
|
||||
public RenderGlStateSnapshot CaptureState() => default;
|
||||
public RenderGlScissorSnapshot CaptureScissor() => default;
|
||||
}
|
||||
|
||||
private sealed class RecordingLog : IRenderFrameDiagnosticLog
|
||||
{
|
||||
public List<string> Messages { get; } = [];
|
||||
public void WriteLine(string message) => Messages.Add(message);
|
||||
}
|
||||
|
||||
private sealed class ThrowOnceLog : IRenderFrameDiagnosticLog
|
||||
{
|
||||
private bool _throw = true;
|
||||
public List<string> Messages { get; } = [];
|
||||
|
||||
public void WriteLine(string message)
|
||||
{
|
||||
if (_throw)
|
||||
{
|
||||
_throw = false;
|
||||
throw new InvalidOperationException("diagnostic failure");
|
||||
}
|
||||
|
||||
Messages.Add(message);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThrowOnFirstFrameLog : IRenderFrameDiagnosticLog
|
||||
{
|
||||
private bool _throw = true;
|
||||
public List<string> Messages { get; } = [];
|
||||
|
||||
public void WriteLine(string message)
|
||||
{
|
||||
if (_throw && message.StartsWith("[FRAME-DIAG]", StringComparison.Ordinal))
|
||||
{
|
||||
_throw = false;
|
||||
throw new InvalidOperationException("frame diagnostic failure");
|
||||
}
|
||||
|
||||
Messages.Add(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue