The FW3.4 dense-Arwic pair triggered the +/-20% stop rule (+33.5% CPU p50, 14x frame allocation). This slice removes the three measured costs without changing GPU command order (the referee suites assert identical recorded call sequences): - WalkFrameDriver: Collect (ONE walk per frame - no GPU work; leaf calls and flush points become a recorded event list; the driver absorbed the renderer collection pass and exposes the visited sets) + Replay (prepare the whole stream once, then replay events, interleaving DrawOrderedRange with leaf calls in the exact recorded order). RunFrame = Collect+Replay for existing callers. - WbDrawDispatcher: SubmitOrderedStream split into PrepareOrderedStream (all sections + commands + merge runs uploaded once per frame) and DrawOrderedRange (bind-once latch; per-run pipeline + DrawIdOffset + DrawIndirectRangeRhi). Load-bearing correctness catch from the implementation round: merge runs take FORCED BREAKS at the recorded event marks - whole-stream merging must not fuse two segments that retail separates with a leaf GPU call (shell, punch); the straddle assert stays as a dead-code safety net. - WalkProductionWorldData: WalkFrameStaticRecords carries an ArraySegment into a per-frame grow-only arena; the per-cell fresh-array copies (the 1.9 MB/frame alloc p50) are gone - zero steady-state allocation after warmup. Suites (lead-verified): full Release build 0 warnings; hermetic 6,758/0; Walk lane 209/1; InstalledDat Walk conformance 40/1 untouched. Next: the dense-Arwic re-measure against the same-session baseline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
265 lines
13 KiB
C#
265 lines
13 KiB
C#
using System.Numerics;
|
||
using System.Runtime.InteropServices;
|
||
using AcDream.App.Rendering.Scene;
|
||
|
||
namespace AcDream.App.Rendering.Walk;
|
||
|
||
/// <summary>
|
||
/// Campaign FW3.2b-2: the production <see cref="IWalkFrameWorldData"/> over
|
||
/// the retained scene (<see cref="RenderSceneQuery"/>) and the FW3.1
|
||
/// <see cref="WalkBuildingRegistry"/>. Rebuilt facts per frame via
|
||
/// <see cref="BeginFrame"/>:
|
||
///
|
||
/// <list type="bullet">
|
||
/// <item>Cell statics — <see cref="RenderSceneQuery.CopyCellStaticsTo"/> on
|
||
/// demand, one arena segment per distinct cell per frame (a cell can be
|
||
/// visited once by the root flood OR once per admitting look-in portal; the
|
||
/// per-frame cache keeps the copy single).</item>
|
||
/// <item>Outdoor statics — ONE <see cref="RenderSceneQuery.CopyIndexTo"/>
|
||
/// sweep bucketed by landscape cell id
|
||
/// (<c>(lb & 0xFFFF0000) | (cellX*8 + cellY + 1)</c> from the record's
|
||
/// world position — the same encoding <see cref="IWalkEventSink.OnLandscapeCellTurn"/>
|
||
/// computes), EXCLUDING building shells (they draw at their building's own
|
||
/// shell turn, retail <c>CPhysicsPart::Draw(parts, 0)</c> @0x0059f331, not
|
||
/// at the cell's <c>DrawObjCell</c> turn).</item>
|
||
/// <item>Building shells — the same sweep's <c>IsBuildingShell</c> records
|
||
/// bucketed by <c>Source.BuildingShellAnchorCellId</c>; a
|
||
/// <see cref="WalkBuilding"/> maps to its anchor via its first
|
||
/// non-exit portal's destination (the SAME rule
|
||
/// <c>LandblockLoader</c> used to author the anchor).</item>
|
||
/// </list>
|
||
///
|
||
/// The tuple landblock id handed to the classifier is the frame's player
|
||
/// landblock — the packed path's own convention for every
|
||
/// <c>RenderFrameEntityDrawRequest</c>.
|
||
///
|
||
/// <para>Campaign FW3.4a: <see cref="GetCellStatics"/>, <see cref="GetOutdoorStatics"/>,
|
||
/// and <see cref="GetBuildingShellStatics"/> used to materialize their result
|
||
/// with <c>_cellScratch[..count]</c> / <c>[.. bucket]</c> — a FRESH
|
||
/// <c>RenderProjectionRecord[]</c> allocation per distinct cell/anchor per
|
||
/// frame. At a town-density frame (dozens of cells) that was the single
|
||
/// largest contributor to the FW3.4 perf checkpoint's 14× frame-allocation
|
||
/// regression (1.9 MB/frame p50). <see cref="_arena"/> replaces it: a
|
||
/// grow-only buffer, reset to length 0 once per frame in
|
||
/// <see cref="BeginFrame"/>, that every materialization call
|
||
/// <see cref="AppendToArena"/>s its records into instead of snapshotting a
|
||
/// new array — after the arena reaches its steady-state size (a few frames
|
||
/// of warmup, same shape as <see cref="_sweepScratch"/>/<see cref="_cellScratch"/>'s
|
||
/// existing grow-on-demand pattern), zero further heap allocation occurs
|
||
/// here. Every <see cref="WalkFrameStaticRecords.Records"/> segment is
|
||
/// STRICTLY per-frame scratch — nothing holds one across a frame boundary
|
||
/// (the driver/populator consume it immediately, matching
|
||
/// <see cref="_sweepScratch"/>'s existing lifetime contract) — so reusing the
|
||
/// same backing array's memory next frame is safe.</para>
|
||
/// </summary>
|
||
internal sealed class WalkProductionWorldData : IWalkFrameWorldData
|
||
{
|
||
private readonly WalkBuildingRegistry _buildings;
|
||
private RenderSceneQuery _scene;
|
||
private uint _tupleLandblockId;
|
||
private int _renderCenterLbX;
|
||
private int _renderCenterLbY;
|
||
|
||
private readonly Dictionary<uint, WalkFrameStaticRecords> _cellCache = new();
|
||
private readonly Dictionary<uint, List<RenderProjectionRecord>> _outdoorByCell = new();
|
||
private readonly Dictionary<uint, List<RenderProjectionRecord>> _shellsByAnchor = new();
|
||
private readonly Dictionary<uint, WalkFrameStaticRecords> _outdoorMaterialized = new();
|
||
private readonly Dictionary<uint, WalkFrameStaticRecords> _shellMaterialized = new();
|
||
private RenderProjectionRecord[] _sweepScratch = new RenderProjectionRecord[1024];
|
||
private RenderProjectionRecord[] _cellScratch = new RenderProjectionRecord[256];
|
||
|
||
// Campaign FW3.4a: the per-frame, grow-only materialization arena — see
|
||
// this type's own doc comment.
|
||
private RenderProjectionRecord[] _arena = new RenderProjectionRecord[4096];
|
||
private int _arenaLength;
|
||
|
||
internal WalkProductionWorldData(WalkBuildingRegistry buildings)
|
||
{
|
||
_buildings = buildings ?? throw new ArgumentNullException(nameof(buildings));
|
||
}
|
||
|
||
/// <summary>Rebuilds the frame's outdoor/shell buckets and clears the
|
||
/// per-cell cache. Call once per frame before the driver runs.
|
||
/// <paramref name="renderCenterLbX"/>/<paramref name="renderCenterLbY"/>
|
||
/// are the streaming recenter origin: record positions are
|
||
/// RENDER-ORIGIN-RELATIVE (each landblock's entities carry
|
||
/// <c>(lbX − CenterX)·192</c> offsets), so mapping a position back to
|
||
/// its TRUE landblock byte needs the center added back — the first
|
||
/// connected gate of the FW3.2b-2 cutover shipped without this and most
|
||
/// outdoor scenery landed in garbage buckets no walk turn ever reads.</summary>
|
||
internal void BeginFrame(
|
||
RenderSceneQuery scene,
|
||
uint tupleLandblockId,
|
||
int renderCenterLbX,
|
||
int renderCenterLbY)
|
||
{
|
||
_scene = scene;
|
||
_tupleLandblockId = tupleLandblockId;
|
||
_renderCenterLbX = renderCenterLbX;
|
||
_renderCenterLbY = renderCenterLbY;
|
||
_cellCache.Clear();
|
||
_outdoorMaterialized.Clear();
|
||
_shellMaterialized.Clear();
|
||
_arenaLength = 0;
|
||
foreach (List<RenderProjectionRecord> bucket in _outdoorByCell.Values)
|
||
bucket.Clear();
|
||
foreach (List<RenderProjectionRecord> bucket in _shellsByAnchor.Values)
|
||
bucket.Clear();
|
||
|
||
// CopyIndexTo THROWS on an undersized destination (ArchRenderScene
|
||
// validates up front — the first connected gate run of the FW3.2b-2
|
||
// cutover crashed on exactly this at Aerlinthe's 5,040 outdoor
|
||
// statics), so presize from the query's own index counts.
|
||
int required = _scene.IndexCounts.For(RenderSceneIndex.OutdoorStatic);
|
||
if (required > _sweepScratch.Length)
|
||
{
|
||
_sweepScratch = new RenderProjectionRecord[
|
||
Math.Max(required, _sweepScratch.Length * 2)];
|
||
}
|
||
int count = _scene.CopyIndexTo(RenderSceneIndex.OutdoorStatic, _sweepScratch);
|
||
for (int i = 0; i < count; i++)
|
||
{
|
||
ref readonly RenderProjectionRecord record = ref _sweepScratch[i];
|
||
if (record.EntityPayload.IsBuildingShell)
|
||
{
|
||
uint anchor = record.Source.BuildingShellAnchorCellId;
|
||
if (!_shellsByAnchor.TryGetValue(anchor, out List<RenderProjectionRecord>? shells))
|
||
_shellsByAnchor[anchor] = shells = new List<RenderProjectionRecord>();
|
||
shells.Add(record);
|
||
continue;
|
||
}
|
||
uint cellId = LandscapeCellId(
|
||
record.Transform.Position, _renderCenterLbX, _renderCenterLbY);
|
||
if (!_outdoorByCell.TryGetValue(cellId, out List<RenderProjectionRecord>? bucket))
|
||
_outdoorByCell[cellId] = bucket = new List<RenderProjectionRecord>();
|
||
bucket.Add(record);
|
||
}
|
||
}
|
||
|
||
/// <summary>The landscape cell owning a RENDER-ORIGIN-RELATIVE position
|
||
/// — retail's 24 m cell grid inside the 192 m landblock, producing the
|
||
/// same TRUE <c>(lb & 0xFFFF0000) | (cellX*8 + cellY + 1)</c> encoding
|
||
/// the walk's landscape turn emits: the relative block index
|
||
/// (<c>floor(p/192)</c>) plus the streaming center recovers the true
|
||
/// landblock byte, because entity positions carry
|
||
/// <c>(lbX − CenterX)·192</c> world offsets
|
||
/// (<c>LandblockBuildFactory</c>'s <c>worldOffset</c>).</summary>
|
||
internal static uint LandscapeCellId(
|
||
Vector3 relativePosition, int renderCenterLbX, int renderCenterLbY)
|
||
{
|
||
int relBlockX = (int)MathF.Floor(relativePosition.X / 192f);
|
||
int relBlockY = (int)MathF.Floor(relativePosition.Y / 192f);
|
||
float localX = relativePosition.X - relBlockX * 192f;
|
||
float localY = relativePosition.Y - relBlockY * 192f;
|
||
int cellX = Math.Clamp((int)(localX / 24f), 0, 7);
|
||
int cellY = Math.Clamp((int)(localY / 24f), 0, 7);
|
||
uint landblock =
|
||
((uint)(byte)(renderCenterLbX + relBlockX) << 24)
|
||
| ((uint)(byte)(renderCenterLbY + relBlockY) << 16);
|
||
return landblock | (uint)(cellX * 8 + cellY + 1);
|
||
}
|
||
|
||
public WalkFrameStaticRecords GetCellStatics(uint cellId)
|
||
{
|
||
if (_cellCache.TryGetValue(cellId, out WalkFrameStaticRecords cached))
|
||
return cached;
|
||
// Same up-front-validation contract as CopyIndexTo: presize from the
|
||
// query's own count rather than probing with an undersized span.
|
||
int required = _scene.GetCellStaticCount(cellId);
|
||
if (required > _cellScratch.Length)
|
||
{
|
||
_cellScratch = new RenderProjectionRecord[
|
||
Math.Max(required, _cellScratch.Length * 2)];
|
||
}
|
||
int count = _scene.CopyCellStaticsTo(cellId, _cellScratch);
|
||
WalkFrameStaticRecords records = count == 0
|
||
? WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId }
|
||
: new WalkFrameStaticRecords(
|
||
AppendToArena(_cellScratch.AsSpan(0, count)), _tupleLandblockId);
|
||
_cellCache[cellId] = records;
|
||
return records;
|
||
}
|
||
|
||
public WalkFrameStaticRecords GetOutdoorStatics(uint cellId)
|
||
{
|
||
if (_outdoorMaterialized.TryGetValue(cellId, out WalkFrameStaticRecords cached))
|
||
return cached;
|
||
WalkFrameStaticRecords records =
|
||
_outdoorByCell.TryGetValue(cellId, out List<RenderProjectionRecord>? bucket)
|
||
&& bucket.Count > 0
|
||
? new WalkFrameStaticRecords(
|
||
AppendToArena(CollectionsMarshal.AsSpan(bucket)), _tupleLandblockId)
|
||
: WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId };
|
||
_outdoorMaterialized[cellId] = records;
|
||
return records;
|
||
}
|
||
|
||
public WalkFrameStaticRecords GetBuildingShellStatics(WalkBuilding building)
|
||
{
|
||
uint anchor = AnchorCellId(building);
|
||
if (anchor == 0)
|
||
return WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId };
|
||
if (_shellMaterialized.TryGetValue(anchor, out WalkFrameStaticRecords cached))
|
||
return cached;
|
||
WalkFrameStaticRecords records =
|
||
_shellsByAnchor.TryGetValue(anchor, out List<RenderProjectionRecord>? shells)
|
||
&& shells.Count > 0
|
||
? new WalkFrameStaticRecords(
|
||
AppendToArena(CollectionsMarshal.AsSpan(shells)), _tupleLandblockId)
|
||
: WalkFrameStaticRecords.Empty with { TupleLandblockId = _tupleLandblockId };
|
||
_shellMaterialized[anchor] = records;
|
||
return records;
|
||
}
|
||
|
||
/// <summary>Copies <paramref name="source"/> into <see cref="_arena"/> at
|
||
/// its current length, growing the arena first if needed (doubling, or
|
||
/// exactly enough for an unusually large sweep — the same growth shape
|
||
/// <see cref="_sweepScratch"/>/<see cref="_cellScratch"/> already use),
|
||
/// and returns the segment the copy landed in. A prior frame's growth can
|
||
/// leave an earlier-returned segment pointing at a retired backing array
|
||
/// — harmless, since that array's content stays valid and nothing reads
|
||
/// a segment across a frame boundary (see this type's own doc
|
||
/// comment).</summary>
|
||
private ArraySegment<RenderProjectionRecord> AppendToArena(
|
||
ReadOnlySpan<RenderProjectionRecord> source)
|
||
{
|
||
if (source.Length == 0)
|
||
return ArraySegment<RenderProjectionRecord>.Empty;
|
||
|
||
int required = _arenaLength + source.Length;
|
||
if (required > _arena.Length)
|
||
{
|
||
var grown = new RenderProjectionRecord[Math.Max(required, _arena.Length * 2)];
|
||
Array.Copy(_arena, grown, _arenaLength);
|
||
_arena = grown;
|
||
}
|
||
|
||
source.CopyTo(_arena.AsSpan(_arenaLength, source.Length));
|
||
var segment = new ArraySegment<RenderProjectionRecord>(_arena, _arenaLength, source.Length);
|
||
_arenaLength += source.Length;
|
||
return segment;
|
||
}
|
||
|
||
/// <summary>The building's authored shell anchor: its first non-exit
|
||
/// portal's destination cell — the SAME rule <c>LandblockLoader</c> used
|
||
/// when it stamped <c>BuildingShellAnchorCellId</c> on the shell entity.</summary>
|
||
internal static uint AnchorCellId(WalkBuilding building)
|
||
{
|
||
foreach (ref readonly WalkBldPortal portal in building.Portals.AsSpan())
|
||
{
|
||
if (portal.OtherCellId != 0xFFFFFFFFu)
|
||
return portal.OtherCellId;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
public Matrix4x4 GetBuildingWorldTransform(WalkBuilding building)
|
||
{
|
||
if (!_buildings.TryGetEntry(building, out WalkBuildingFactory.Entry? entry))
|
||
{
|
||
throw new InvalidOperationException(
|
||
$"walk building 0x{building.PositionCellId:X8} has no committed registry entry");
|
||
}
|
||
return entry.WorldTransform;
|
||
}
|
||
}
|