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
// 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();
}

View file

@ -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);

View file

@ -77,6 +77,14 @@ public interface IWalkEventSink
{
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>
/// Fires once per visited landscape cell, AFTER that cell's building
/// turn (if any) — <c>RenderDeviceD3D::DrawSortCell</c> @0x0059f140

View file

@ -58,6 +58,11 @@ internal interface IWalkFrameWorldData
/// <see cref="IWalkEventSink.OnLandscapeCellTurn"/> computes.</summary>
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>
/// records anchored at the building's position cell).</summary>
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;
}
/// <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.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++)
{

View file

@ -63,10 +63,13 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
private readonly Dictionary<uint, WalkFrameStaticRecords> _cellCache = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _cellDynamicCache = 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, WalkFrameStaticRecords> _outdoorMaterialized = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorDynamicsMaterialized = new();
private readonly Dictionary<uint, WalkFrameStaticRecords> _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<RenderProjectionRecord> bucket in _outdoorByCell.Values)
bucket.Clear();
foreach (List<RenderProjectionRecord> bucket in _outdoorDynamicsByCell.Values)
bucket.Clear();
foreach (List<RenderProjectionRecord> bucket in _shellsByAnchor.Values)
bucket.Clear();
@ -137,6 +143,29 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData
_outdoorByCell[cellId] = bucket = new List<RenderProjectionRecord>();
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
@ -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<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)
{
uint anchor = AnchorCellId(building);

View file

@ -92,10 +92,19 @@ internal sealed class WalkStaticStreamPopulator
ReadOnlySpan<RenderProjectionRecord> 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,