perf(pview): batch per-cell cell-object draws into one cross-cell draw

DrawCellObjectLists called WbDrawDispatcher.Draw once per visible cell (each
orphaning 6 SSBOs + full state setup) — the top CPU-submission sink at dense
Arwic (cellobjects ~3.5ms/frame, measured via [CPU-PHASE]; the frame is
~96% CPU-bound, GPU only 0.5ms). Apply the shipped cells-shell batching
pattern to cell OBJECTS: collapse N per-cell draws into ONE cross-cell draw.

Two-loop structure preserves the statics-before-particles depth order:
  loop 1 — per-cell viewcone cull, accumulate all survivors + the union of
           cell ids;
  one batched DrawEntityBucket for every cell's statics;
  loop 2 — per-cell DrawCellParticles, after the statics own the depth buffer.

Correctness: same survivor set (union visibleCellIds gate is equivalent to the
per-cell {cellId} filter); transparency composites equal-or-better (the
dispatcher sorts opaque front-to-back + transparent back-to-front globally,
WbDrawDispatcher.cs:1469-1470); particles still occlude against same-cell
statics (loop 2 runs after the batched draw); dynamics-last + shells + seals
unchanged. Also drops the per-cell new[]{entry} alloc (~50-100/frame) to one.

Pixels identical — draw-mechanism speed only (render-perf is not faithfulness-
gated per the project steer). Spec: docs/superpowers/specs/2026-06-23-cellobject-
draw-batching-design.md. Build + full test suite green (App pview replay tests
incl. HouseExitWalkReplay/Issue130DoorwayStrip pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-23 20:48:20 +02:00
parent 6491798edf
commit 290e731ce3

View file

@ -806,6 +806,66 @@ public sealed class RetailPViewRenderer
// statics-through-walls fix: a static whose sphere is outside every
// view of its cell no longer paints through the wall (the cottage
// phantom staircase's draw path).
// Dense-town FPS iteration-1 (spec 2026-06-23-cellobject-draw-batching):
// the per-cell DrawEntityBucket calls below were the top CPU sink at Arwic
// (cellobjects ~3.5 ms/frame; each WbDrawDispatcher.Draw orphans 6 SSBOs +
// full state setup). Collapse them into ONE cross-cell batched draw — the
// shipped cells-shell batching pattern applied to cell OBJECTS. Two loops
// preserve the statics-before-particles depth order: loop 1 culls +
// accumulates every cell's survivors and draws them once; loop 2 runs the
// per-cell particle passes AFTER the statics own the depth buffer (particles
// depth-test but write no depth). The dispatcher sorts opaque front-to-back
// and transparent back-to-front by group distance (WbDrawDispatcher.cs:
// 1469-1470), so cross-cell batching composites correctly — equal-or-better
// than the old per-cell-bucketed order. visibleCellIds = the union of cells,
// so the dispatcher admits exactly the same survivor set.
// Loop 1: per-cell viewcone cull → accumulate survivors + the union of cells.
_allCellStatics.Clear();
_cellObjCells.Clear();
for (int i = pvFrame.OrderedVisibleCells.Count - 1; i >= 0; i--)
{
uint cellId = pvFrame.OrderedVisibleCells[i];
if (!drawableCells.Contains(cellId))
continue;
if (!partition.ByCell.TryGetValue(cellId, out var bucket) || bucket.Count == 0)
continue;
int survivorsBefore = _allCellStatics.Count;
foreach (var e in bucket)
{
EntitySphere(e, out var c, out float r);
if (viewcone.SphereVisibleInCell(cellId, c, r))
_allCellStatics.Add(e);
}
int survivors = _allCellStatics.Count - survivorsBefore;
if (survivors > 0)
_cellObjCells.Add(cellId);
// BR-2 phantom-site probe (T3-updated): post-viewcone survivors.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbePhantomEnabled)
EmitPhantomObjsProbe(cellId, survivors);
}
// ONE batched static-object draw for every visible cell (was N per-cell
// WbDrawDispatcher.Draw calls). T1: per-cell STATIC lists only — dynamics
// draw in DrawDynamicsLast. T3 (BR-5): each static was sphere-tested against
// ITS cell's views above (the statics-through-walls fix is preserved by the
// cull; only the draw is batched).
if (_allCellStatics.Count > 0)
{
UseIndoorMembershipOnlyRouting();
DrawEntityBucket(ctx, _allCellStatics, _cellObjCells);
}
// Loop 2: per-cell particles, AFTER the batched statics are in the depth
// buffer. T3 (BR-5): particles gate through the SAME viewcone as their
// owners — the callback receives the cone-surviving entity set, so an
// emitter attached to a culled static no longer draws through the wall
// (the candle-flames-through-walls fix). Re-cull per cell to rebuild the
// owner set; the sphere tests are negligible next to the draw submission
// just removed. Consumed synchronously within this iteration (scratch reuse).
for (int i = pvFrame.OrderedVisibleCells.Count - 1; i >= 0; i--)
{
uint cellId = pvFrame.OrderedVisibleCells[i];
@ -822,24 +882,9 @@ public sealed class RetailPViewRenderer
if (viewcone.SphereVisibleInCell(cellId, c, r))
_cellStaticScratch.Add(e);
}
if (_cellStaticScratch.Count == 0)
continue;
// BR-2 phantom-site probe (T3-updated): post-viewcone survivors.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbePhantomEnabled)
EmitPhantomObjsProbe(cellId, _cellStaticScratch.Count);
if (_cellStaticScratch.Count > 0)
{
_oneCell.Clear();
_oneCell.Add(cellId);
UseIndoorMembershipOnlyRouting();
DrawEntityBucket(ctx, _cellStaticScratch, _oneCell);
}
// T3 (BR-5): particles gate through the SAME viewcone as their
// owners — the callback receives the cone-surviving entity set, so
// an emitter attached to a culled static no longer draws through
// the wall (the candle-flames-through-walls fix). Consumed
// synchronously within this iteration (scratch list reuse).
foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId))
ctx.DrawCellParticles?.Invoke(new RetailPViewCellSliceContext(cellId, slice, _cellStaticScratch));
}
@ -855,6 +900,12 @@ public sealed class RetailPViewRenderer
// #121: cone-surviving dynamics whose emitters draw in the dynamics
// particle pass (survivors minus outside-stage). Cleared per use.
private readonly List<WorldEntity> _dynamicsParticleScratch = new();
// Dense-town FPS iteration-1 (cellobject batching): all visible cells'
// viewcone-surviving statics accumulated for ONE batched DrawEntityBucket,
// plus the union of their cell ids for the dispatcher's visibleCellIds gate.
// Cleared at the top of DrawCellObjectLists.
private readonly List<WorldEntity> _allCellStatics = new();
private readonly HashSet<uint> _cellObjCells = new();
/// <summary>
/// #118 stage assignment for a dynamic under an INTERIOR root: does it draw