From 2127c955f633066a9c54794df7fcc33e2388c1c4 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 31 Aug 2026 03:57:45 +0200 Subject: [PATCH] refactor(render): move outdoor dynamics onto landscape turns --- .../Scene/RenderScenePViewFrameProduct.cs | 13 ++++- .../Rendering/Walk/RetailFrameWalk.cs | 1 + src/AcDream.App/Rendering/Walk/WalkEvents.cs | 8 +++ .../Rendering/Walk/WalkFrameDriver.cs | 43 ++++++++++++++-- .../Rendering/Walk/WalkProductionWorldData.cs | 50 +++++++++++++++++++ .../Walk/WalkStaticStreamPopulator.cs | 17 +++++-- .../RenderScenePViewFrameProductTests.cs | 7 +++ .../Rendering/Walk/WalkFrameDriverTests.cs | 24 +++++++-- 8 files changed, 151 insertions(+), 12 deletions(-) diff --git a/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs index 135f7880..1a27aa51 100644 --- a/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs +++ b/src/AcDream.App/Rendering/Scene/RenderScenePViewFrameProduct.cs @@ -1211,8 +1211,17 @@ internal sealed class RenderScenePViewFrameBuilder // statics, live dynamics, fades, picking, and particles. Do not // advertise a packed LookInObject range the dispatcher cannot and // must not consume a second time. - BuildOutsideDynamicRoutes(writer, in input); - BuildDynamicLastRoute(writer, in input); + // FW4 cutover: once a walk view source is present, every live + // object is emitted at its retail cell turn (landscape, root + // flood, or building look-in). The packed dynamic ranges exist + // only for non-walk diagnostic callers during this atomic slice; + // advertising either range in production would draw the same + // object a second time after its owner cell. + if (input.WalkLookInViews is null) + { + BuildOutsideDynamicRoutes(writer, in input); + BuildDynamicLastRoute(writer, in input); + } writer.Publish(); AcknowledgeCachedDirtyRecords(); } diff --git a/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs b/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs index 52b5cdfe..ed20b5ac 100644 --- a/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs +++ b/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs @@ -148,6 +148,7 @@ public sealed class RetailFrameWalk IRetailFrameWalkContext ctx, IWalkEventSink sink) { sink.Emit(WalkEvent.Landscape(activeViews.ViewCount)); + sink.OnLandscapeViews(activeViews); landscape.CalcDrawOrder(); landscape.CheckBlocks(ctx.CyPlane, activeViews); diff --git a/src/AcDream.App/Rendering/Walk/WalkEvents.cs b/src/AcDream.App/Rendering/Walk/WalkEvents.cs index a50a9f47..cae598ab 100644 --- a/src/AcDream.App/Rendering/Walk/WalkEvents.cs +++ b/src/AcDream.App/Rendering/Walk/WalkEvents.cs @@ -77,6 +77,14 @@ public interface IWalkEventSink { void Emit(in WalkEvent walkEvent); + /// + /// Installs the exact view set LScape::draw is walking. The same + /// view set gates every landscape object's drawing sphere at its cell + /// turn (Render::viewconeCheck); consumers must copy any data they + /// retain because the walk owns and reuses this object. + /// + void OnLandscapeViews(WalkPortalView activeViews) { } + /// /// Fires once per visited landscape cell, AFTER that cell's building /// turn (if any) — RenderDeviceD3D::DrawSortCell @0x0059f140 diff --git a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs index 4f5528da..a082c65d 100644 --- a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs +++ b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs @@ -58,6 +58,11 @@ internal interface IWalkFrameWorldData /// computes. WalkFrameStaticRecords GetOutdoorStatics(uint cellId); + /// One visited landscape cell's live dynamic occupants. The + /// walk draws these at the same DrawObjCell turn as its statics, + /// through the landscape's currently installed view set. + WalkFrameStaticRecords GetOutdoorDynamics(uint cellId); + /// One building's own exterior shell content (IsBuildingShell /// records anchored at the building's position cell). WalkFrameStaticRecords GetBuildingShellStatics(WalkBuilding building); @@ -519,6 +524,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource private WalkDrawStage? _currentDcStage; private bool _readyToReplay; private int _cellViewRouteIndex; + private int _landscapeViewRouteIndex; internal WalkFrameDriver( WbDrawDispatcher dispatcher, @@ -627,6 +633,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource VisitedLandscapeCellIds.Clear(); InteriorFloodCells.Clear(); _cellViewRouteIndex = 0; + _landscapeViewRouteIndex = -1; } /// Records the final segment mark (plan §FW3.2b-1's "at frame @@ -719,6 +726,12 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource ? _worldData.GetBuildingShellStatics(shellOwner) : _worldData.GetOutdoorStatics(e.CellId), _staticParticleOwnerScratch); + if (e.Building is null) + { + UnionOwners( + _worldData.GetOutdoorDynamics(e.CellId), + _staticParticleOwnerScratch); + } if (_staticParticleOwnerScratch.Count > 0) _leafRenderer.DrawStaticParticles(_staticParticleOwnerScratch); break; @@ -776,24 +789,44 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource void IWalkEventSink.OnLandscapeCellTurn(uint cellId) { RequireOpenFrame(); + if (_landscapeViewRouteIndex < 0) + { + throw new InvalidOperationException( + "A landscape cell turn fired before RetailFrameWalk installed its active " + + "view set — the walk and draw driver are desynchronized."); + } VisitedLandscapeCellIds.Add(cellId); WalkFrameStaticRecords records = _worldData.GetOutdoorStatics(cellId); + WalkFrameStaticRecords dynamics = _worldData.GetOutdoorDynamics(cellId); _populator.PopulateOutdoorStatics( _stream, cellId, records.Records, records.TupleLandblockId, - _cameraWorldPosition, _viewProjection); + _cameraWorldPosition, _viewProjection, + this, _landscapeViewRouteIndex); + _populator.PopulateCellDynamics( + _stream, cellId, dynamics.Records, dynamics.TupleLandblockId, + _cameraWorldPosition, _viewProjection, + this, _landscapeViewRouteIndex); // FW4 (the #132 positional invariant): this cell's emitter owners // submit AT THIS TURN, so nearer buildings' pre-punch barriers // drain them against still-true depth — see // WalkFrameEventKind.StaticParticles. Mark first so the cell's own // meshes flush ahead of its particle submission (retail's // per-object DrawObjCell order). - if (HasAnyOwner(records)) + if (HasAnyOwner(records) || HasAnyOwner(dynamics)) { MarkIfGrown(); _events.Add(WalkFrameEvent.LandscapeCellParticles(cellId)); } } + void IWalkEventSink.OnLandscapeViews(WalkPortalView activeViews) + { + ArgumentNullException.ThrowIfNull(activeViews); + RequireOpenFrame(); + _landscapeViewRouteIndex = _cellViewRouteIndex++; + CaptureViews(0, activeViews); + } + private static bool HasAnyOwner(in WalkFrameStaticRecords records) { foreach (RenderProjectionRecord record in records.Records) @@ -1020,7 +1053,11 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource return; } - WalkPortalView portalView = cell.TopView; + CaptureViews(cellId, cell.TopView); + } + + private void CaptureViews(uint cellId, WalkPortalView portalView) + { int sliceStart = _lookInSlices.Count; for (int sliceIndex = 0; sliceIndex < portalView.ViewCount; sliceIndex++) { diff --git a/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs b/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs index 85de51ed..1d91e3ec 100644 --- a/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs +++ b/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs @@ -63,10 +63,13 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData private readonly Dictionary _cellCache = new(); private readonly Dictionary _cellDynamicCache = new(); private readonly Dictionary> _outdoorByCell = new(); + private readonly Dictionary> _outdoorDynamicsByCell = new(); private readonly Dictionary> _shellsByAnchor = new(); private readonly Dictionary _outdoorMaterialized = new(); + private readonly Dictionary _outdoorDynamicsMaterialized = new(); private readonly Dictionary _shellMaterialized = new(); private RenderProjectionRecord[] _sweepScratch = new RenderProjectionRecord[1024]; + private RenderProjectionRecord[] _dynamicSweepScratch = new RenderProjectionRecord[256]; private RenderProjectionRecord[] _cellScratch = new RenderProjectionRecord[256]; private RenderProjectionRecord[] _cellDynamicScratch = new RenderProjectionRecord[256]; @@ -102,10 +105,13 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData _cellCache.Clear(); _cellDynamicCache.Clear(); _outdoorMaterialized.Clear(); + _outdoorDynamicsMaterialized.Clear(); _shellMaterialized.Clear(); _arenaLength = 0; foreach (List bucket in _outdoorByCell.Values) bucket.Clear(); + foreach (List bucket in _outdoorDynamicsByCell.Values) + bucket.Clear(); foreach (List bucket in _shellsByAnchor.Values) bucket.Clear(); @@ -137,6 +143,29 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData _outdoorByCell[cellId] = bucket = new List(); bucket.Add(record); } + + required = _scene.IndexCounts.For(RenderSceneIndex.OutdoorDynamic); + if (required > _dynamicSweepScratch.Length) + { + _dynamicSweepScratch = new RenderProjectionRecord[ + Math.Max(required, _dynamicSweepScratch.Length * 2)]; + } + count = _scene.CopyIndexTo( + RenderSceneIndex.OutdoorDynamic, + _dynamicSweepScratch); + for (int i = 0; i < count; i++) + { + ref readonly RenderProjectionRecord record = ref _dynamicSweepScratch[i]; + uint cellId = LandscapeCellId( + record.Transform.Position, _renderCenterLbX, _renderCenterLbY); + if (!_outdoorDynamicsByCell.TryGetValue( + cellId, + out List? bucket)) + { + _outdoorDynamicsByCell[cellId] = bucket = new List(); + } + bucket.Add(record); + } } /// The landscape cell owning a RENDER-ORIGIN-RELATIVE position @@ -216,6 +245,27 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData return records; } + public WalkFrameStaticRecords GetOutdoorDynamics(uint cellId) + { + if (_outdoorDynamicsMaterialized.TryGetValue( + cellId, + out WalkFrameStaticRecords cached)) + { + return cached; + } + + WalkFrameStaticRecords records = + _outdoorDynamicsByCell.TryGetValue( + cellId, + out List? bucket) + && bucket.Count > 0 + ? new WalkFrameStaticRecords( + AppendToArena(CollectionsMarshal.AsSpan(bucket)), _tupleLandblockId) + : WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId }; + _outdoorDynamicsMaterialized[cellId] = records; + return records; + } + public WalkFrameStaticRecords GetBuildingShellStatics(WalkBuilding building) { uint anchor = AnchorCellId(building); diff --git a/src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs b/src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs index d4079364..4e0e9498 100644 --- a/src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs +++ b/src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs @@ -92,10 +92,19 @@ internal sealed class WalkStaticStreamPopulator ReadOnlySpan records, uint tupleLandblockId, Vector3 cameraWorldPosition, - Matrix4x4 viewProjection) => - PopulateCell( - stream, WalkDrawStage.OutdoorStatic, cellId, records, - tupleLandblockId, cameraWorldPosition, viewProjection); + Matrix4x4 viewProjection, + IWalkLookInViewSource? views = null, + int viewRouteIndex = -1) + { + ArgumentNullException.ThrowIfNull(stream); + for (int i = 0; i < records.Length; i++) + { + ClassifyAndAppend( + stream, WalkDrawStage.OutdoorStatic, cellId, in records[i], + tupleLandblockId, cameraWorldPosition, viewProjection, + liveDynamic: false, views, viewRouteIndex); + } + } internal void PopulateCellDynamics( OrderedDrawStream stream, diff --git a/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs b/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs index 6ac865a6..64392224 100644 --- a/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RenderScenePViewFrameProductTests.cs @@ -273,10 +273,14 @@ public sealed class RenderScenePViewFrameProductTests 0x0100_0000_0000_0023, RenderProjectionClass.LiveDynamicRoot, parentCell: Cell); + RenderProjectionRecord outdoorDynamic = Record( + 0x0100_0000_0000_0024, + RenderProjectionClass.LiveDynamicRoot); scene.Apply( [ RenderProjectionDelta.Register(Generation, 1, lookInDynamic), RenderProjectionDelta.Register(Generation, 2, rootDynamic), + RenderProjectionDelta.Register(Generation, 3, outdoorDynamic), ]); PortalVisibilityFrame portal = Portal(Cell); @@ -309,6 +313,9 @@ public sealed class RenderScenePViewFrameProductTests Assert.DoesNotContain( frame.RouteCandidates.ToArray(), candidate => candidate.Id == rootDynamic.Id); + Assert.DoesNotContain( + frame.RouteCandidates.ToArray(), + candidate => candidate.Id == outdoorDynamic.Id); Assert.NotNull(frame.WalkLookInViews); Assert.False(frame.WalkLookInViews!.SphereVisibleInLookInTurn( routeIndex: 0, diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs index ceaab8d9..6ee9c6f8 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs @@ -92,6 +92,7 @@ public sealed class WalkFrameDriverTests public readonly Dictionary CellStaticsByCell = new(); public readonly Dictionary CellDynamicsByCell = new(); public readonly Dictionary OutdoorStaticsByCell = new(); + public readonly Dictionary OutdoorDynamicsByCell = new(); public readonly Dictionary ShellByBuilding = new(); public readonly Dictionary WorldTransformByBuilding = new(); @@ -104,6 +105,9 @@ public sealed class WalkFrameDriverTests public WalkFrameStaticRecords GetOutdoorStatics(uint cellId) => OutdoorStaticsByCell.GetValueOrDefault(cellId, WalkFrameStaticRecords.Empty); + public WalkFrameStaticRecords GetOutdoorDynamics(uint cellId) => + OutdoorDynamicsByCell.GetValueOrDefault(cellId, WalkFrameStaticRecords.Empty); + public WalkFrameStaticRecords GetBuildingShellStatics(WalkBuilding building) => ShellByBuilding.GetValueOrDefault(building, WalkFrameStaticRecords.Empty); @@ -601,12 +605,22 @@ public sealed class WalkFrameDriverTests var worldData = new FakeWorldData(); worldData.OutdoorStaticsByCell[0x8C040005u] = new WalkFrameStaticRecords( new[] { MakeRecord(301, 0, Vector3.Zero, [new MeshRef((uint)gfxObj, Matrix4x4.Identity)]) }, 0x8C04u); + worldData.OutdoorDynamicsByCell[0x8C040005u] = new WalkFrameStaticRecords( + new[] { MakeRecord(302, 0x50000001, Vector3.Zero, [new MeshRef((uint)gfxObj, Matrix4x4.Identity)]) }, 0x8C04u); var driver = new WalkFrameDriver( fx.Dispatcher, new RecordingLeafRenderer(log), worldData, new RecordingTrace(log)); using DrawScope draw = fx.BeginDraw(); driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero); + var activeViews = new WalkPortalView(); + WalkCopyView.AppendFullViewportQuad( + activeViews, + ctx.Rays, + ctx.WorldViewpoint, + ctx.ViewportWidth, + ctx.ViewportHeight); + ((IWalkEventSink)driver).OnLandscapeViews(activeViews); ((IWalkEventSink)driver).OnLandscapeCellTurn(0x8C040005u); Assert.Empty(log); // no GPU work at Collect time; nothing recorded to the log yet driver.EndFrame(); @@ -615,9 +629,13 @@ public sealed class WalkFrameDriverTests // FW4 #132 positional invariant: the cell's emitters submit at its // own landscape turn, after its meshes flush. - Assert.Equal(new[] { "FLUSH:1:OutdoorStatic", "PARTICLES:12d" }, log); - GpuRecordedMultiDrawIndirect mdi = Assert.Single(fx.Device.Calls.OfType()); - Assert.Equal(1u, mdi.DrawCount); + Assert.Equal( + new[] { "FLUSH:2:OutdoorStatic,Dynamic", "PARTICLES:12d,12e" }, + log); + GpuRecordedMultiDrawIndirect[] mdi = + fx.Device.Calls.OfType().ToArray(); + Assert.Equal(2, mdi.Length); + Assert.Equal(2u, mdi.Aggregate(0u, static (sum, call) => sum + call.DrawCount)); } // ── Fixture (mirrors WalkStaticStreamPopulatorTests' DispatcherFixture —