refactor(render): move outdoor dynamics onto landscape turns

This commit is contained in:
Erik 2026-08-31 03:57:45 +02:00
parent 474b05e7dc
commit 2127c955f6
8 changed files with 151 additions and 12 deletions

View file

@ -1211,8 +1211,17 @@ internal sealed class RenderScenePViewFrameBuilder
// statics, live dynamics, fades, picking, and particles. Do not // statics, live dynamics, fades, picking, and particles. Do not
// advertise a packed LookInObject range the dispatcher cannot and // advertise a packed LookInObject range the dispatcher cannot and
// must not consume a second time. // must not consume a second time.
BuildOutsideDynamicRoutes(writer, in input); // FW4 cutover: once a walk view source is present, every live
BuildDynamicLastRoute(writer, in input); // 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(); writer.Publish();
AcknowledgeCachedDirtyRecords(); AcknowledgeCachedDirtyRecords();
} }

View file

@ -148,6 +148,7 @@ public sealed class RetailFrameWalk
IRetailFrameWalkContext ctx, IWalkEventSink sink) IRetailFrameWalkContext ctx, IWalkEventSink sink)
{ {
sink.Emit(WalkEvent.Landscape(activeViews.ViewCount)); sink.Emit(WalkEvent.Landscape(activeViews.ViewCount));
sink.OnLandscapeViews(activeViews);
landscape.CalcDrawOrder(); landscape.CalcDrawOrder();
landscape.CheckBlocks(ctx.CyPlane, activeViews); landscape.CheckBlocks(ctx.CyPlane, activeViews);

View file

@ -77,6 +77,14 @@ public interface IWalkEventSink
{ {
void Emit(in WalkEvent walkEvent); void Emit(in WalkEvent walkEvent);
/// <summary>
/// Installs the exact view set <c>LScape::draw</c> is walking. The same
/// view set gates every landscape object's drawing sphere at its cell
/// turn (<c>Render::viewconeCheck</c>); consumers must copy any data they
/// retain because the walk owns and reuses this object.
/// </summary>
void OnLandscapeViews(WalkPortalView activeViews) { }
/// <summary> /// <summary>
/// Fires once per visited landscape cell, AFTER that cell's building /// Fires once per visited landscape cell, AFTER that cell's building
/// turn (if any) — <c>RenderDeviceD3D::DrawSortCell</c> @0x0059f140 /// turn (if any) — <c>RenderDeviceD3D::DrawSortCell</c> @0x0059f140

View file

@ -58,6 +58,11 @@ internal interface IWalkFrameWorldData
/// <see cref="IWalkEventSink.OnLandscapeCellTurn"/> computes.</summary> /// <see cref="IWalkEventSink.OnLandscapeCellTurn"/> computes.</summary>
WalkFrameStaticRecords GetOutdoorStatics(uint cellId); WalkFrameStaticRecords GetOutdoorStatics(uint cellId);
/// <summary>One visited landscape cell's live dynamic occupants. The
/// walk draws these at the same <c>DrawObjCell</c> turn as its statics,
/// through the landscape's currently installed view set.</summary>
WalkFrameStaticRecords GetOutdoorDynamics(uint cellId);
/// <summary>One building's own exterior shell content (<c>IsBuildingShell</c> /// <summary>One building's own exterior shell content (<c>IsBuildingShell</c>
/// records anchored at the building's position cell).</summary> /// records anchored at the building's position cell).</summary>
WalkFrameStaticRecords GetBuildingShellStatics(WalkBuilding building); WalkFrameStaticRecords GetBuildingShellStatics(WalkBuilding building);
@ -519,6 +524,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
private WalkDrawStage? _currentDcStage; private WalkDrawStage? _currentDcStage;
private bool _readyToReplay; private bool _readyToReplay;
private int _cellViewRouteIndex; private int _cellViewRouteIndex;
private int _landscapeViewRouteIndex;
internal WalkFrameDriver( internal WalkFrameDriver(
WbDrawDispatcher dispatcher, WbDrawDispatcher dispatcher,
@ -627,6 +633,7 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
VisitedLandscapeCellIds.Clear(); VisitedLandscapeCellIds.Clear();
InteriorFloodCells.Clear(); InteriorFloodCells.Clear();
_cellViewRouteIndex = 0; _cellViewRouteIndex = 0;
_landscapeViewRouteIndex = -1;
} }
/// <summary>Records the final segment mark (plan §FW3.2b-1's "at frame /// <summary>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.GetBuildingShellStatics(shellOwner)
: _worldData.GetOutdoorStatics(e.CellId), : _worldData.GetOutdoorStatics(e.CellId),
_staticParticleOwnerScratch); _staticParticleOwnerScratch);
if (e.Building is null)
{
UnionOwners(
_worldData.GetOutdoorDynamics(e.CellId),
_staticParticleOwnerScratch);
}
if (_staticParticleOwnerScratch.Count > 0) if (_staticParticleOwnerScratch.Count > 0)
_leafRenderer.DrawStaticParticles(_staticParticleOwnerScratch); _leafRenderer.DrawStaticParticles(_staticParticleOwnerScratch);
break; break;
@ -776,24 +789,44 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
void IWalkEventSink.OnLandscapeCellTurn(uint cellId) void IWalkEventSink.OnLandscapeCellTurn(uint cellId)
{ {
RequireOpenFrame(); 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); VisitedLandscapeCellIds.Add(cellId);
WalkFrameStaticRecords records = _worldData.GetOutdoorStatics(cellId); WalkFrameStaticRecords records = _worldData.GetOutdoorStatics(cellId);
WalkFrameStaticRecords dynamics = _worldData.GetOutdoorDynamics(cellId);
_populator.PopulateOutdoorStatics( _populator.PopulateOutdoorStatics(
_stream, cellId, records.Records, records.TupleLandblockId, _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 // FW4 (the #132 positional invariant): this cell's emitter owners
// submit AT THIS TURN, so nearer buildings' pre-punch barriers // submit AT THIS TURN, so nearer buildings' pre-punch barriers
// drain them against still-true depth — see // drain them against still-true depth — see
// WalkFrameEventKind.StaticParticles. Mark first so the cell's own // WalkFrameEventKind.StaticParticles. Mark first so the cell's own
// meshes flush ahead of its particle submission (retail's // meshes flush ahead of its particle submission (retail's
// per-object DrawObjCell order). // per-object DrawObjCell order).
if (HasAnyOwner(records)) if (HasAnyOwner(records) || HasAnyOwner(dynamics))
{ {
MarkIfGrown(); MarkIfGrown();
_events.Add(WalkFrameEvent.LandscapeCellParticles(cellId)); _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) private static bool HasAnyOwner(in WalkFrameStaticRecords records)
{ {
foreach (RenderProjectionRecord record in records.Records) foreach (RenderProjectionRecord record in records.Records)
@ -1020,7 +1053,11 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
return; return;
} }
WalkPortalView portalView = cell.TopView; CaptureViews(cellId, cell.TopView);
}
private void CaptureViews(uint cellId, WalkPortalView portalView)
{
int sliceStart = _lookInSlices.Count; int sliceStart = _lookInSlices.Count;
for (int sliceIndex = 0; sliceIndex < portalView.ViewCount; sliceIndex++) for (int sliceIndex = 0; sliceIndex < portalView.ViewCount; sliceIndex++)
{ {

View file

@ -63,10 +63,13 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
private readonly Dictionary<uint, WalkFrameStaticRecords> _cellCache = new(); private readonly Dictionary<uint, WalkFrameStaticRecords> _cellCache = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _cellDynamicCache = new(); private readonly Dictionary<uint, WalkFrameStaticRecords> _cellDynamicCache = new();
private readonly Dictionary<uint, List<RenderProjectionRecord>> _outdoorByCell = new(); private readonly Dictionary<uint, List<RenderProjectionRecord>> _outdoorByCell = new();
private readonly Dictionary<uint, List<RenderProjectionRecord>> _outdoorDynamicsByCell = new();
private readonly Dictionary<uint, List<RenderProjectionRecord>> _shellsByAnchor = new(); private readonly Dictionary<uint, List<RenderProjectionRecord>> _shellsByAnchor = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorMaterialized = new(); private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorMaterialized = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorDynamicsMaterialized = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _shellMaterialized = new(); private readonly Dictionary<uint, WalkFrameStaticRecords> _shellMaterialized = new();
private RenderProjectionRecord[] _sweepScratch = new RenderProjectionRecord[1024]; private RenderProjectionRecord[] _sweepScratch = new RenderProjectionRecord[1024];
private RenderProjectionRecord[] _dynamicSweepScratch = new RenderProjectionRecord[256];
private RenderProjectionRecord[] _cellScratch = new RenderProjectionRecord[256]; private RenderProjectionRecord[] _cellScratch = new RenderProjectionRecord[256];
private RenderProjectionRecord[] _cellDynamicScratch = new RenderProjectionRecord[256]; private RenderProjectionRecord[] _cellDynamicScratch = new RenderProjectionRecord[256];
@ -102,10 +105,13 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
_cellCache.Clear(); _cellCache.Clear();
_cellDynamicCache.Clear(); _cellDynamicCache.Clear();
_outdoorMaterialized.Clear(); _outdoorMaterialized.Clear();
_outdoorDynamicsMaterialized.Clear();
_shellMaterialized.Clear(); _shellMaterialized.Clear();
_arenaLength = 0; _arenaLength = 0;
foreach (List<RenderProjectionRecord> bucket in _outdoorByCell.Values) foreach (List<RenderProjectionRecord> bucket in _outdoorByCell.Values)
bucket.Clear(); bucket.Clear();
foreach (List<RenderProjectionRecord> bucket in _outdoorDynamicsByCell.Values)
bucket.Clear();
foreach (List<RenderProjectionRecord> bucket in _shellsByAnchor.Values) foreach (List<RenderProjectionRecord> bucket in _shellsByAnchor.Values)
bucket.Clear(); bucket.Clear();
@ -137,6 +143,29 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
_outdoorByCell[cellId] = bucket = new List<RenderProjectionRecord>(); _outdoorByCell[cellId] = bucket = new List<RenderProjectionRecord>();
bucket.Add(record); 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<RenderProjectionRecord>? bucket))
{
_outdoorDynamicsByCell[cellId] = bucket = new List<RenderProjectionRecord>();
}
bucket.Add(record);
}
} }
/// <summary>The landscape cell owning a RENDER-ORIGIN-RELATIVE position /// <summary>The landscape cell owning a RENDER-ORIGIN-RELATIVE position
@ -216,6 +245,27 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
return records; return records;
} }
public WalkFrameStaticRecords GetOutdoorDynamics(uint cellId)
{
if (_outdoorDynamicsMaterialized.TryGetValue(
cellId,
out WalkFrameStaticRecords cached))
{
return cached;
}
WalkFrameStaticRecords records =
_outdoorDynamicsByCell.TryGetValue(
cellId,
out List<RenderProjectionRecord>? 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) public WalkFrameStaticRecords GetBuildingShellStatics(WalkBuilding building)
{ {
uint anchor = AnchorCellId(building); uint anchor = AnchorCellId(building);

View file

@ -92,10 +92,19 @@ internal sealed class WalkStaticStreamPopulator
ReadOnlySpan<RenderProjectionRecord> records, ReadOnlySpan<RenderProjectionRecord> records,
uint tupleLandblockId, uint tupleLandblockId,
Vector3 cameraWorldPosition, Vector3 cameraWorldPosition,
Matrix4x4 viewProjection) => Matrix4x4 viewProjection,
PopulateCell( IWalkLookInViewSource? views = null,
stream, WalkDrawStage.OutdoorStatic, cellId, records, int viewRouteIndex = -1)
tupleLandblockId, cameraWorldPosition, viewProjection); {
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( internal void PopulateCellDynamics(
OrderedDrawStream stream, OrderedDrawStream stream,

View file

@ -273,10 +273,14 @@ public sealed class RenderScenePViewFrameProductTests
0x0100_0000_0000_0023, 0x0100_0000_0000_0023,
RenderProjectionClass.LiveDynamicRoot, RenderProjectionClass.LiveDynamicRoot,
parentCell: Cell); parentCell: Cell);
RenderProjectionRecord outdoorDynamic = Record(
0x0100_0000_0000_0024,
RenderProjectionClass.LiveDynamicRoot);
scene.Apply( scene.Apply(
[ [
RenderProjectionDelta.Register(Generation, 1, lookInDynamic), RenderProjectionDelta.Register(Generation, 1, lookInDynamic),
RenderProjectionDelta.Register(Generation, 2, rootDynamic), RenderProjectionDelta.Register(Generation, 2, rootDynamic),
RenderProjectionDelta.Register(Generation, 3, outdoorDynamic),
]); ]);
PortalVisibilityFrame portal = Portal(Cell); PortalVisibilityFrame portal = Portal(Cell);
@ -309,6 +313,9 @@ public sealed class RenderScenePViewFrameProductTests
Assert.DoesNotContain( Assert.DoesNotContain(
frame.RouteCandidates.ToArray(), frame.RouteCandidates.ToArray(),
candidate => candidate.Id == rootDynamic.Id); candidate => candidate.Id == rootDynamic.Id);
Assert.DoesNotContain(
frame.RouteCandidates.ToArray(),
candidate => candidate.Id == outdoorDynamic.Id);
Assert.NotNull(frame.WalkLookInViews); Assert.NotNull(frame.WalkLookInViews);
Assert.False(frame.WalkLookInViews!.SphereVisibleInLookInTurn( Assert.False(frame.WalkLookInViews!.SphereVisibleInLookInTurn(
routeIndex: 0, routeIndex: 0,

View file

@ -92,6 +92,7 @@ public sealed class WalkFrameDriverTests
public readonly Dictionary<uint, WalkFrameStaticRecords> CellStaticsByCell = new(); public readonly Dictionary<uint, WalkFrameStaticRecords> CellStaticsByCell = new();
public readonly Dictionary<uint, WalkFrameStaticRecords> CellDynamicsByCell = new(); public readonly Dictionary<uint, WalkFrameStaticRecords> CellDynamicsByCell = new();
public readonly Dictionary<uint, WalkFrameStaticRecords> OutdoorStaticsByCell = new(); public readonly Dictionary<uint, WalkFrameStaticRecords> OutdoorStaticsByCell = new();
public readonly Dictionary<uint, WalkFrameStaticRecords> OutdoorDynamicsByCell = new();
public readonly Dictionary<WalkBuilding, WalkFrameStaticRecords> ShellByBuilding = new(); public readonly Dictionary<WalkBuilding, WalkFrameStaticRecords> ShellByBuilding = new();
public readonly Dictionary<WalkBuilding, Matrix4x4> WorldTransformByBuilding = new(); public readonly Dictionary<WalkBuilding, Matrix4x4> WorldTransformByBuilding = new();
@ -104,6 +105,9 @@ public sealed class WalkFrameDriverTests
public WalkFrameStaticRecords GetOutdoorStatics(uint cellId) => public WalkFrameStaticRecords GetOutdoorStatics(uint cellId) =>
OutdoorStaticsByCell.GetValueOrDefault(cellId, WalkFrameStaticRecords.Empty); OutdoorStaticsByCell.GetValueOrDefault(cellId, WalkFrameStaticRecords.Empty);
public WalkFrameStaticRecords GetOutdoorDynamics(uint cellId) =>
OutdoorDynamicsByCell.GetValueOrDefault(cellId, WalkFrameStaticRecords.Empty);
public WalkFrameStaticRecords GetBuildingShellStatics(WalkBuilding building) => public WalkFrameStaticRecords GetBuildingShellStatics(WalkBuilding building) =>
ShellByBuilding.GetValueOrDefault(building, WalkFrameStaticRecords.Empty); ShellByBuilding.GetValueOrDefault(building, WalkFrameStaticRecords.Empty);
@ -601,12 +605,22 @@ public sealed class WalkFrameDriverTests
var worldData = new FakeWorldData(); var worldData = new FakeWorldData();
worldData.OutdoorStaticsByCell[0x8C040005u] = new WalkFrameStaticRecords( worldData.OutdoorStaticsByCell[0x8C040005u] = new WalkFrameStaticRecords(
new[] { MakeRecord(301, 0, Vector3.Zero, [new MeshRef((uint)gfxObj, Matrix4x4.Identity)]) }, 0x8C04u); 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( var driver = new WalkFrameDriver(
fx.Dispatcher, new RecordingLeafRenderer(log), worldData, new RecordingTrace(log)); fx.Dispatcher, new RecordingLeafRenderer(log), worldData, new RecordingTrace(log));
using DrawScope draw = fx.BeginDraw(); using DrawScope draw = fx.BeginDraw();
driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero); 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); ((IWalkEventSink)driver).OnLandscapeCellTurn(0x8C040005u);
Assert.Empty(log); // no GPU work at Collect time; nothing recorded to the log yet Assert.Empty(log); // no GPU work at Collect time; nothing recorded to the log yet
driver.EndFrame(); driver.EndFrame();
@ -615,9 +629,13 @@ public sealed class WalkFrameDriverTests
// FW4 #132 positional invariant: the cell's emitters submit at its // FW4 #132 positional invariant: the cell's emitters submit at its
// own landscape turn, after its meshes flush. // own landscape turn, after its meshes flush.
Assert.Equal(new[] { "FLUSH:1:OutdoorStatic", "PARTICLES:12d" }, log); Assert.Equal(
GpuRecordedMultiDrawIndirect mdi = Assert.Single(fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>()); new[] { "FLUSH:2:OutdoorStatic,Dynamic", "PARTICLES:12d,12e" },
Assert.Equal(1u, mdi.DrawCount); log);
GpuRecordedMultiDrawIndirect[] mdi =
fx.Device.Calls.OfType<GpuRecordedMultiDrawIndirect>().ToArray();
Assert.Equal(2, mdi.Length);
Assert.Equal(2u, mdi.Aggregate(0u, static (sum, call) => sum + call.DrawCount));
} }
// ── Fixture (mirrors WalkStaticStreamPopulatorTests' DispatcherFixture — // ── Fixture (mirrors WalkStaticStreamPopulatorTests' DispatcherFixture —