acdream/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs
Erik 212f5a12e5 perf(render) Campaign FW3.4a: one walk pass; prepare-once/draw-ranges; arena records
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>
2026-08-30 17:01:26 +02:00

265 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 &amp; 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 &amp; 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;
}
}