From 11e68aad82cabce37ae8080fd506833e5e4010b2 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 31 Aug 2026 06:15:12 +0200 Subject: [PATCH] fix(render): clip walk content to authored portal views --- .../RetailPViewPassExecutor.WalkLeaf.cs | 27 ++- .../Rendering/RetailPViewPassExecutor.cs | 3 + .../Rendering/RetailPViewRenderer.cs | 3 +- .../Rendering/Walk/WalkFrameDriver.cs | 174 ++++++++++++++++-- .../Walk/WalkStaticStreamPopulator.cs | 7 +- .../Wb/WbDrawDispatcher.WalkClassify.cs | 98 +++++----- .../Rendering/Walk/WalkFrameDriverTests.cs | 22 ++- .../WalkProductionWorldConformanceTests.cs | 95 +++++++++- .../Walk/WalkStaticStreamPopulatorTests.cs | 45 +++++ .../Wb/WbDrawDispatcherLookInConeTests.cs | 12 ++ 10 files changed, 408 insertions(+), 78 deletions(-) diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs index b082960b..a9534dd9 100644 --- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs +++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.WalkLeaf.cs @@ -209,6 +209,7 @@ internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer private readonly Action _drawExitSeals; private readonly HashSet _singleCellScratch = new(); private readonly List _singleCellListScratch = new(); + private readonly Dictionary _singleCellClipScratch = new(1); internal WalkProductionLeafRenderer( RetailPViewPassExecutor passes, @@ -231,16 +232,26 @@ internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer public void DrawTerrainSlice(int sliceIndex) => _passes.DrawWalkTerrainSlice(_frame, _clipAssembly, sliceIndex); - public void DrawCellShell(uint cellId) + public void DrawCellShell(uint cellId, uint clipSlot) { - _singleCellScratch.Clear(); - _singleCellScratch.Add(cellId); - _passes.DrawOpaqueCellShells(_singleCellScratch); - if (_passes.CellHasTransparentShell(cellId)) + _singleCellClipScratch.Clear(); + _singleCellClipScratch.Add(cellId, checked((int)clipSlot)); + _passes.SetCellShellClipRouting(_singleCellClipScratch); + try { - _singleCellListScratch.Clear(); - _singleCellListScratch.Add(cellId); - _passes.DrawTransparentCellShellsOrdered(_singleCellListScratch); + _singleCellScratch.Clear(); + _singleCellScratch.Add(cellId); + _passes.DrawOpaqueCellShells(_singleCellScratch); + if (_passes.CellHasTransparentShell(cellId)) + { + _singleCellListScratch.Clear(); + _singleCellListScratch.Add(cellId); + _passes.DrawTransparentCellShellsOrdered(_singleCellListScratch); + } + } + finally + { + _passes.SetCellShellClipRouting(null); } } diff --git a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs index b2b246c0..4a8f35ec 100644 --- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs +++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs @@ -240,6 +240,9 @@ internal sealed partial class RetailPViewPassExecutor : public void DrawOpaqueCellShells(HashSet cellIds) => _envCells.Render(WbRenderPass.Opaque, cellIds); + public void SetCellShellClipRouting(IReadOnlyDictionary? routing) => + _envCells.SetClipRouting(routing); + public bool CellHasTransparentShell(uint cellId) => _envCells.CellHasTransparent(cellId); diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index 6ff79d4a..03c9fb87 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -215,7 +215,8 @@ internal sealed class RetailPViewRenderer walkDriver = new Walk.WalkFrameDriver( walkExecutor!.Dispatcher, walkLeafRenderer, - _walkWorldData); + _walkWorldData, + clipFrame: clipAssembly.Frame); if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeWalkRootEnabled) { diff --git a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs index a1bcb3ba..c87f54fe 100644 --- a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs +++ b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs @@ -120,7 +120,7 @@ internal interface IWalkFrameLeafRenderer /// second reverse loop for DrawObjCellForDummies @0x005a4b0d. /// The ordinary interior-root flood and every building look-in flood use /// this same two-pass discipline. - void DrawCellShell(uint cellId); + void DrawCellShell(uint cellId, uint clipSlot); /// One landscape cell's or building shell's static-owner /// particle submission, at its own walk turn — see @@ -231,7 +231,9 @@ internal enum WalkFrameEventKind : byte TerrainSlice, /// — - /// is the cell. + /// is the cell and + /// is the exact portal-view GPU clip + /// slot for this retail DrawEnvCell turn. CellShell, /// — @@ -288,9 +290,23 @@ internal interface IWalkLookInViewSource int routeIndex, in Vector3 center, float radius); + + /// Returns the GPU clip slots for every view slice in this + /// DrawObjCell turn which admits the part's retail drawing sphere. Retail + /// runs DrawMeshInternal once per surviving portal_view slice; a boolean + /// sphere gate alone is insufficient because the mesh polygons must remain + /// clipped to that aperture. + IReadOnlyList VisibleClipSlotsInLookInTurn( + int routeIndex, + in Vector3 center, + float radius, + bool testSphere); } -internal readonly record struct WalkLookInSlice(int PlaneStart, int PlaneCount); +internal readonly record struct WalkLookInSlice( + int PlaneStart, + int PlaneCount, + uint ClipSlot); internal readonly record struct WalkLookInTurn( uint CellId, @@ -341,8 +357,8 @@ internal readonly struct WalkFrameEvent internal static WalkFrameEvent TerrainSlice(int sliceIndex) => new(WalkFrameEventKind.TerrainSlice, sliceIndex, 0, 0f, null); - internal static WalkFrameEvent CellShell(uint cellId) => - new(WalkFrameEventKind.CellShell, 0, cellId, 0f, null); + internal static WalkFrameEvent CellShell(uint cellId, uint clipSlot) => + new(WalkFrameEventKind.CellShell, checked((int)clipSlot), cellId, 0f, null); internal static WalkFrameEvent PunchFan(WalkPolygon worldPolygon, int activeViewIndex) => new(WalkFrameEventKind.PunchFan, activeViewIndex, 0, 0f, worldPolygon); @@ -452,6 +468,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource private readonly IWalkFrameLeafRenderer _leafRenderer; private readonly IWalkFrameWorldData _worldData; private readonly IWalkFrameDriverTrace? _trace; + private readonly ClipFrame? _clipFrame; private readonly OrderedDrawStream _stream = new(); private readonly List _events = new(); private readonly List _markPositions = new(); @@ -472,6 +489,8 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource private readonly List _lookInTurns = new(); private readonly List _lookInSlices = new(); private readonly List _lookInPlanes = new(); + private readonly List _visibleClipSlotScratch = new(); + private readonly List _floodViewRouteScratch = new(); private WalkPlane _lookInCyPlane; IReadOnlyList IWalkLookInViewSource.LookInCellTurns => LookInCellTurns; @@ -524,12 +543,14 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource WbDrawDispatcher dispatcher, IWalkFrameLeafRenderer leafRenderer, IWalkFrameWorldData worldData, - IWalkFrameDriverTrace? trace = null) + IWalkFrameDriverTrace? trace = null, + ClipFrame? clipFrame = null) { _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); _leafRenderer = leafRenderer ?? throw new ArgumentNullException(nameof(leafRenderer)); _worldData = worldData ?? throw new ArgumentNullException(nameof(worldData)); _trace = trace; + _clipFrame = clipFrame; _populator = new WalkStaticStreamPopulator(dispatcher); } @@ -621,6 +642,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource _lookInTurns.Clear(); _lookInSlices.Clear(); _lookInPlanes.Clear(); + _visibleClipSlotScratch.Clear(); _lookInCyPlane = ctx.CyPlane; LookInCells.Clear(); VisitedBuildings.Clear(); @@ -699,7 +721,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource _leafRenderer.DrawTerrainSlice(e.IntArg); break; case WalkFrameEventKind.CellShell: - _leafRenderer.DrawCellShell(e.CellId); + _leafRenderer.DrawCellShell(e.CellId, checked((uint)e.IntArg)); break; case WalkFrameEventKind.PunchFan: _leafRenderer.DrawPunchFan(e.Polygon!, e.IntArg); @@ -985,12 +1007,23 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource private void EmitFloodTurns(WalkDrawStage stage, IReadOnlyList cells) { + _floodViewRouteScratch.Clear(); + for (int i = 0; i < cells.Count; i++) + _floodViewRouteScratch.Add(-1); + // PView::DrawCells @0x005A4840, loop 2 (005A4A00–005A4ADE): - // cell_draw_list[count - 1] down to zero, DrawEnvCell only. + // cell_draw_list[count - 1] down to zero. DrawEnvCell loops the + // cell's live portal_view slices, so capture those slices here and + // replay the shell once per exact GPU clip slot. for (int i = cells.Count - 1; i >= 0; i--) { + int viewRouteIndex = CaptureCellViewRoute(cells[i]); + _floodViewRouteScratch[i] = viewRouteIndex; + IReadOnlyList clipSlots = VisibleClipSlotsInLookInTurn( + viewRouteIndex, default, 0f, testSphere: false); MarkIfGrown(); - _events.Add(WalkFrameEvent.CellShell(cells[i])); + for (int slotIndex = 0; slotIndex < clipSlots.Count; slotIndex++) + _events.Add(WalkFrameEvent.CellShell(cells[i], clipSlots[slotIndex])); } // Loop 3 (005A4ADE–005A4B2D): restart at count - 1 and draw each @@ -998,23 +1031,31 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource // static records and its dynamic/particle tail stay at that exact // retail turn. for (int i = cells.Count - 1; i >= 0; i--) - EmitCellContentsTurn(stage, cells[i]); + EmitCellContentsTurn(stage, cells[i], _floodViewRouteScratch[i]); } - private void EmitCellContentsTurn(WalkDrawStage stage, uint cellId) + private int CaptureCellViewRoute(uint cellId) { + int viewRouteIndex = _cellViewRouteIndex++; + CaptureCellViews(cellId); + return viewRouteIndex; + } + + private void EmitCellContentsTurn( + WalkDrawStage stage, + uint cellId, + int viewRouteIndex) + { + // The shell loop captured the exact live portal_view for this cell. + // Static and dynamic object lists reuse that same route: retail's + // DrawEnvCell and DrawObjCell consume the same PortalList. WalkFrameStaticRecords records = _worldData.GetCellStatics(cellId); _populator.PopulateCell( _stream, stage, cellId, records.Records, records.TupleLandblockId, - _cameraWorldPosition, _viewProjection); + _cameraWorldPosition, _viewProjection, + this, viewRouteIndex); MarkIfGrown(); - // PView::DrawObjCellForDummies draws this cell's live objects at the - // same turn as its statics. Capture the currently installed portal - // views before the walk pops them, then apply retail's per-GfxObj - // drawing-sphere viewcone check in the walk classifier. - int viewRouteIndex = _cellViewRouteIndex++; - CaptureCellViews(cellId); if (stage == WalkDrawStage.LookInStatic) { LookInCellTurns.Add(cellId); @@ -1062,8 +1103,9 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource _lookInPlanes.Add( portalView.View.Vertices[poly.VertexIndex + edge].Plane); } + uint clipSlot = AppendClipSlot(portalView, poly); _lookInSlices.Add(new WalkLookInSlice( - planeStart, poly.VertexCount)); + planeStart, poly.VertexCount, clipSlot)); } _lookInTurns.Add(new WalkLookInTurn( cellId, sliceStart, _lookInSlices.Count - sliceStart)); @@ -1095,6 +1137,100 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource return false; } + public IReadOnlyList VisibleClipSlotsInLookInTurn( + int routeIndex, + in Vector3 center, + float radius, + bool testSphere) + { + _visibleClipSlotScratch.Clear(); + if ((uint)routeIndex >= (uint)_lookInTurns.Count) + return _visibleClipSlotScratch; + + WalkLookInTurn turn = _lookInTurns[routeIndex]; + for (int sliceOffset = 0; sliceOffset < turn.SliceCount; sliceOffset++) + { + WalkLookInSlice slice = _lookInSlices[turn.SliceStart + sliceOffset]; + if (!testSphere + || WalkVisibilityMath.ViewconeCheck( + center, + radius, + _lookInCyPlane, + CollectionsMarshal.AsSpan(_lookInPlanes).Slice( + slice.PlaneStart, + slice.PlaneCount)) != WalkBoundingType.Outside) + { + _visibleClipSlotScratch.Add(slice.ClipSlot); + } + } + return _visibleClipSlotScratch; + } + + /// Converts one retail pixel-space portal_view polygon into the + /// clip-space half-planes consumed by mesh_modern.vert. copy_view already + /// deduplicates and removes collinear vertices, so 3..8 points map + /// directly. The rare >8-edge case uses its convex AABB as a conservative + /// four-plane gate rather than falling back to slot 0 (which would erase + /// the aperture entirely). + private uint AppendClipSlot(WalkPortalView portalView, WalkViewPoly poly) + { + if (_clipFrame is null) + return 0; + + IRetailFrameWalkContext ctx = (IRetailFrameWalkContext)RequireOpenFrame(); + int count = poly.VertexCount; + if (count < 3) + return 0; + + Span ndc = stackalloc Vector2[Math.Min(count, WalkCopyView.MaxVertices)]; + float minX = float.MaxValue, minY = float.MaxValue; + float maxX = float.MinValue, maxY = float.MinValue; + for (int i = 0; i < ndc.Length; i++) + { + Vector2 px = portalView.View.Vertices[poly.VertexIndex + i].Point; + Vector2 point = new( + px.X / ctx.ViewportWidth * 2f - 1f, + 1f - px.Y / ctx.ViewportHeight * 2f); + ndc[i] = point; + minX = MathF.Min(minX, point.X); + minY = MathF.Min(minY, point.Y); + maxX = MathF.Max(maxX, point.X); + maxY = MathF.Max(maxY, point.Y); + } + + if (ndc.Length > ClipFrame.MaxPlanes) + { + Span aabbPlanes = stackalloc Vector4[4] + { + new(1f, 0f, 0f, -minX), + new(-1f, 0f, 0f, maxX), + new(0f, 1f, 0f, -minY), + new(0f, -1f, 0f, maxY), + }; + return checked((uint)_clipFrame.AppendSlot(aabbPlanes)); + } + + float area2 = 0f; + for (int i = 0; i < ndc.Length; i++) + area2 += ndc[i].X * ndc[(i + 1) % ndc.Length].Y + - ndc[(i + 1) % ndc.Length].X * ndc[i].Y; + bool ccw = area2 >= 0f; + Span planes = stackalloc Vector4[ClipFrame.MaxPlanes]; + for (int i = 0; i < ndc.Length; i++) + { + int current = ccw ? i : ndc.Length - 1 - i; + int next = ccw + ? (i + 1) % ndc.Length + : (ndc.Length - 2 - i + ndc.Length) % ndc.Length; + Vector2 p = ndc[current]; + Vector2 q = ndc[next]; + Vector2 dir = q - p; + Vector2 normal = Vector2.Normalize(new Vector2(-dir.Y, dir.X)); + planes[i] = new Vector4(normal.X, normal.Y, 0f, -Vector2.Dot(normal, p)); + } + return checked((uint)_clipFrame.AppendSlot(planes[..ndc.Length])); + } + /// Campaign FW3.4a: the collect-time analogue of the old /// immediate driver's FlushIfNonEmpty — records a /// at the stream's current diff --git a/src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs b/src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs index 4e0e9498..b44f0a1d 100644 --- a/src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs +++ b/src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs @@ -64,14 +64,17 @@ internal sealed class WalkStaticStreamPopulator ReadOnlySpan records, uint tupleLandblockId, Vector3 cameraWorldPosition, - Matrix4x4 viewProjection) + Matrix4x4 viewProjection, + IWalkLookInViewSource? views = null, + int viewRouteIndex = -1) { ArgumentNullException.ThrowIfNull(stream); for (int i = 0; i < records.Length; i++) { ClassifyAndAppend( stream, stage, cellId, in records[i], tupleLandblockId, - cameraWorldPosition, viewProjection); + cameraWorldPosition, viewProjection, + liveDynamic: false, views, viewRouteIndex); } } diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs index 574386e1..10bfa8a5 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs @@ -274,23 +274,15 @@ public sealed partial class WbDrawDispatcher Matrix4x4 model = restPose * entity.RootWorld; int selectionPartIndex = unchecked((partIndex << 16) | (setupPartIndex & 0xFFFF)); - if (!PartVisibleInLookInTurn( - lookInViews, - lookInRouteIndex, - lookInCellId, - in entity, - selectionPartIndex, - (uint)gfxObjId, - partData, - model)) - { + IReadOnlyList? partClipSlots = ResolvePartClipSlots( + lookInViews, lookInRouteIndex, partData, model); + if (partClipSlots is { Count: 0 }) continue; - } EmitClassifiedBatches( partData, model, in entity, meshRef, paletteIdentity, entityHasCutoutSubset, slot, lights, indoor, selectionLighting, - detailCategory, opacity, batches); + detailCategory, opacity, partClipSlots, batches); selectionParts.Add(new WalkClassifiedSelectionPart( entity.ServerGuid, entity.LocalEntityId, selectionPartIndex, (uint)gfxObjId, model)); @@ -308,22 +300,14 @@ public sealed partial class WbDrawDispatcher continue; Matrix4x4 model = meshRef.PartTransform * entity.RootWorld; - if (!PartVisibleInLookInTurn( - lookInViews, - lookInRouteIndex, - lookInCellId, - in entity, - partIndex, - (uint)meshRef.GfxObjId, - renderData, - model)) - { + IReadOnlyList? partClipSlots = ResolvePartClipSlots( + lookInViews, lookInRouteIndex, renderData, model); + if (partClipSlots is { Count: 0 }) continue; - } EmitClassifiedBatches( renderData, model, in entity, meshRef, paletteIdentity, entityHasCutoutSubsetOverride: null, slot, lights, indoor, - selectionLighting, detailCategory, opacity, batches); + selectionLighting, detailCategory, opacity, partClipSlots, batches); selectionParts.Add(new WalkClassifiedSelectionPart( entity.ServerGuid, entity.LocalEntityId, partIndex, (uint)meshRef.GfxObjId, model)); @@ -379,28 +363,29 @@ public sealed partial class WbDrawDispatcher lights = InstanceLightSet.From(selected); } - private static bool PartVisibleInLookInTurn( + private static IReadOnlyList? ResolvePartClipSlots( IWalkLookInViewSource? lookInViews, int routeIndex, - uint cellId, - in RenderInstanceCandidate entity, - int partIndex, - uint gfxObjId, ObjectRenderData renderData, Matrix4x4 localToWorld) { if (lookInViews is null) - return true; + return null; if (renderData.SelectionSphere is not { Radius: > 0f } sphere) - return true; + { + return lookInViews.VisibleClipSlotsInLookInTurn( + routeIndex, + Vector3.Zero, + radius: 0f, + testSphere: false); + } - return LookInDrawingSphereVisible( - lookInViews, + TransformDrawingSphere(sphere, localToWorld, out Vector3 center, out float radius); + return lookInViews.VisibleClipSlotsInLookInTurn( routeIndex, - sphere, - localToWorld, - out _, - out _); + in center, + radius, + testSphere: true); } internal static bool LookInDrawingSphereVisible( @@ -414,6 +399,19 @@ public sealed partial class WbDrawDispatcher ArgumentNullException.ThrowIfNull(lookInViews); ArgumentNullException.ThrowIfNull(sphere); + TransformDrawingSphere(sphere, localToWorld, out center, out radius); + return lookInViews.SphereVisibleInLookInTurn( + routeIndex, + in center, + radius); + } + + private static void TransformDrawingSphere( + DatReaderWriter.Types.Sphere sphere, + Matrix4x4 localToWorld, + out Vector3 center, + out float radius) + { center = Vector3.Transform(sphere.Origin, localToWorld); float scaleX = new Vector3( localToWorld.M11, @@ -429,10 +427,6 @@ public sealed partial class WbDrawDispatcher localToWorld.M33).Length(); radius = sphere.Radius * MathF.Max(scaleX, MathF.Max(scaleY, scaleZ)); - return lookInViews.SphereVisibleInLookInTurn( - routeIndex, - in center, - radius); } /// @@ -454,6 +448,7 @@ public sealed partial class WbDrawDispatcher Vector2 selectionLighting, uint detailCategory, float opacity, + IReadOnlyList? clipSlots, List sink) { bool entityHasCutoutSubset = entityHasCutoutSubsetOverride ?? renderData.HasCutoutSubset; @@ -466,10 +461,23 @@ public sealed partial class WbDrawDispatcher if (!survives) continue; - sink.Add(new WalkClassifiedBatch( - key, model, slot, lights, indoor ? 1u : 0u, Alpha: opacity, - selectionLighting, detailCategory, IsOpaque: IsOpaque(key.Translucency), - LocalSortCenter: renderData.SortCenter)); + if (clipSlots is null) + { + sink.Add(new WalkClassifiedBatch( + key, model, slot, lights, indoor ? 1u : 0u, Alpha: opacity, + selectionLighting, detailCategory, IsOpaque: IsOpaque(key.Translucency), + LocalSortCenter: renderData.SortCenter)); + continue; + } + + for (int clipIndex = 0; clipIndex < clipSlots.Count; clipIndex++) + { + sink.Add(new WalkClassifiedBatch( + key, model, clipSlots[clipIndex], lights, indoor ? 1u : 0u, + Alpha: opacity, selectionLighting, detailCategory, + IsOpaque: IsOpaque(key.Translucency), + LocalSortCenter: renderData.SortCenter)); + } } } diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs index 1d03cff3..eb7102b3 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs @@ -43,12 +43,17 @@ public sealed class WalkFrameDriverTests private sealed class RecordingLeafRenderer(List log) : IWalkFrameLeafRenderer { public readonly List Punches = new(); + public readonly List<(uint CellId, uint ClipSlot)> Shells = new(); public void DrawSky() => log.Add("SKY"); public void DrawTerrainSlice(int sliceIndex) => log.Add($"TERRAIN:{sliceIndex}"); - public void DrawCellShell(uint cellId) => log.Add($"SHELL:{cellId:x8}"); + public void DrawCellShell(uint cellId, uint clipSlot) + { + Shells.Add((cellId, clipSlot)); + log.Add($"SHELL:{cellId:x8}"); + } public void ClearInteriorDepth() => log.Add("CLEAR"); @@ -427,7 +432,9 @@ public sealed class WalkFrameDriverTests var leaf = new RecordingLeafRenderer(log); var trace = new RecordingTrace(log); - var driver = new WalkFrameDriver(fx.Dispatcher, leaf, worldData, trace); + using ClipFrame clipFrame = ClipFrame.NoClip(); + var driver = new WalkFrameDriver( + fx.Dispatcher, leaf, worldData, trace, clipFrame); var walk = new RetailFrameWalk(); var activeView = new WalkPortalView(); @@ -456,6 +463,13 @@ public sealed class WalkFrameDriverTests Assert.Equal([0x104u], driver.LookInCellTurns); Assert.Equal([0x104u], driver.LookInCells); + Assert.Collection( + leaf.Shells, + shell => + { + Assert.Equal(0x104u, shell.CellId); + Assert.NotEqual(0u, shell.ClipSlot); + }); WalkPortalView capturedView = ctx.Cells[0x104].PortalViews[0]; WalkViewPoly capturedPoly = Assert.Single(capturedView.View.Polys); Vector2 capturedCenter = Vector2.Zero; @@ -470,6 +484,10 @@ public sealed class WalkFrameDriverTests 0, in insideCone, 0.1f)); Assert.False(driver.SphereVisibleInLookInTurn( 0, new Vector3(10_000f, 0f, 10f), 0.1f)); + uint clipSlot = Assert.Single(driver.VisibleClipSlotsInLookInTurn( + 0, in insideCone, 0.1f, testSphere: true)); + Assert.NotEqual(0u, clipSlot); + Assert.Equal(2, clipFrame.SlotCount); // The punch polygon reached the leaf renderer in WORLD space: the // building-local Quad(-2f) vertex (-0.5,-0.5,-2) translates by diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldConformanceTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldConformanceTests.cs index b78307fc..27fecc03 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldConformanceTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldConformanceTests.cs @@ -48,7 +48,13 @@ public sealed class WalkProductionWorldConformanceTests private sealed class Recorder : IWalkEventSink { public readonly List Events = new(); - public void Emit(in WalkEvent walkEvent) => Events.Add(walkEvent); + public Action? OnEmit { get; init; } + + public void Emit(in WalkEvent walkEvent) + { + Events.Add(walkEvent); + OnEmit?.Invoke(walkEvent); + } } private static DatCollection OpenDats() @@ -227,6 +233,93 @@ public sealed class WalkProductionWorldConformanceTests $"production walk diverged from the FW1 test adapter ({fixture})\nEXPECTED: {expected}\nACTUAL: {actual}"); } + [Theory] + [InlineData(0xF4180100u, 36.166267f, 79.828407f)] + [InlineData(0xF4180101u, 36.391270f, 72.167931f)] + public void Cathedral_transition_keeps_south_hall_draws_inside_the_authored_aperture( + uint cameraCellId, + float x, + float y) + { + // Owner's exact 2026-08-31 repro: the remote player and special NPC + // are parented in 0xF4180112, behind opaque cathedral walls. Retail + // hides them on BOTH sides of the 0x100 <-> 0x101 transition. The walk + // legitimately reaches 0x112 through one authored building aperture; + // the regression was submitting each admitted mesh with slot 0, so the + // whole player/NPC escaped that aperture. Preserve the installed-DAT + // fact this fix depends on: every admitted route is a real, bounded + // portal polygon, never a pass-all zero-plane route. + var pose = new WalkOraclePose( + cameraCellId, + new Vector3(x, y, 169.804993f), + Q0: -0.004591f, + Q1: 0f, + Q2: 0f, + Q3: 0.999989f); + using DatCollection dats = OpenDats(); + using var adapter = new DatCollectionAdapter(dats); + (WalkLandscapeAssembler assembler, Dictionary cells, + Dictionary buildings) = + BuildProductionWorld(adapter, pose.CellId, pose.Origin); + var ctx = new WalkTraceReplayContext(pose, cells) { Buildings = buildings }; + WalkCell camera = Assert.Contains(cameraCellId, cells); + var remotePlayer = new Vector3(36.299465f, 18.594580f, 169.804993f); + var southHallAdmission = new List(); + var recorder = new Recorder + { + OnEmit = e => + { + if (e.Kind != WalkEventKind.DrawCells + || !e.Cells.Contains(0xF4180112u)) + { + return; + } + + WalkPortalView views = cells[0xF4180112u].TopView; + for (int viewIndex = 0; viewIndex < views.ViewCount; viewIndex++) + { + WalkViewPoly poly = views.View.Polys[viewIndex]; + var planes = new WalkPlane[poly.VertexCount]; + for (int edge = 0; edge < poly.VertexCount; edge++) + { + planes[edge] = views.View.Vertices[ + poly.VertexIndex + edge].Plane; + } + + WalkBoundingType verdict = WalkVisibilityMath.ViewconeCheck( + remotePlayer, + radius: 1.5f, + ctx.CyPlane, + planes); + float minDistance = planes + .Select(plane => Vector3.Dot(plane.Normal, remotePlayer) + plane.D) + .Append(Vector3.Dot(ctx.CyPlane.Normal, remotePlayer) + ctx.CyPlane.D) + .Min(); + southHallAdmission.Add( + $"view={viewIndex} planes={poly.VertexCount} verdict={verdict} min={minDistance:F4}"); + } + }, + }; + + new RetailFrameWalk().WalkFrame( + cameraCellId, + camera, + assembler.Landscape, + ctx, + recorder); + + WalkEvent[] drawCells = recorder.Events + .Where(static e => e.Kind == WalkEventKind.DrawCells) + .ToArray(); + Assert.NotEmpty(drawCells); + Assert.Contains(cameraCellId, drawCells[0].Cells); + Assert.NotEmpty(southHallAdmission); + Assert.Contains( + southHallAdmission, + static verdict => verdict.Contains("verdict=PartiallyInside") + && !verdict.Contains("planes=0")); + } + [Fact] public void Foundry_entry_reproduces_every_frame_before_the_f67_order_segment() { diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs index 4bd0f689..62b29a58 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs @@ -45,6 +45,22 @@ public sealed class WalkStaticStreamPopulatorTests Calls.Add((serverGuid, localEntityId, partIndex, gfxObjId, partWorld)); } + private sealed class FixedWalkViews(params uint[] slots) : IWalkLookInViewSource + { + public IReadOnlyList LookInCellTurns { get; } = [0x8C040112u]; + + public bool SphereVisibleInLookInTurn( + int routeIndex, + in Vector3 center, + float radius) => slots.Length != 0; + + public IReadOnlyList VisibleClipSlotsInLookInTurn( + int routeIndex, + in Vector3 center, + float radius, + bool testSphere) => slots; + } + // ── Synthetic RenderProjectionRecord construction ────────────────────── private static RenderProjectionRecord MakeRecord( @@ -212,6 +228,35 @@ public sealed class WalkStaticStreamPopulatorTests Assert.Equal((uint)leavesGfxObj, selectionParts[1].GfxObjId); } + [Fact] + public void ClassifyEntityForWalk_EmitsOneGpuClippedInstancePerPortalViewSlice() + { + using var fx = new DispatcherFixture(); + const ulong gfxObj = 0x0100_0013UL; + InjectRenderData(fx.Manager, gfxObj, MakeFlatMesh( + MakeBatch(0x08000013u, TranslucencyKind.Opaque, 0, 0, 3, 1))); + RenderProjectionRecord record = MakeRecord( + 201, 0, Vector3.Zero, + [new MeshRef((uint)gfxObj, Matrix4x4.Identity)], + parentCellId: 0x8C040112u); + var batches = new List(); + var selectionParts = new List(); + var views = new FixedWalkViews(7u, 9u); + + fx.Dispatcher.ClassifyEntityForWalk( + in record, + 0x8C04u, + batches, + selectionParts, + liveDynamic: true, + views, + lookInRouteIndex: 0, + lookInCellId: 0x8C040112u); + + Assert.Equal([7u, 9u], batches.Select(static batch => batch.ClipSlot)); + Assert.Single(selectionParts); + } + // ── Deliverable 2: WalkStaticStreamPopulator routing ─────────────────── [Fact] diff --git a/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherLookInConeTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherLookInConeTests.cs index 993df47f..c8adf719 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherLookInConeTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherLookInConeTests.cs @@ -56,5 +56,17 @@ public sealed class WbDrawDispatcherLookInConeTests Radius = radius; return true; } + + public IReadOnlyList VisibleClipSlotsInLookInTurn( + int routeIndex, + in Vector3 center, + float radius, + bool testSphere) + { + RouteIndex = routeIndex; + Center = center; + Radius = radius; + return [7u]; + } } }