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 —