From 290e731ce3c6aa7913a8997484b9cde29679c152 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 23 Jun 2026 20:48:20 +0200 Subject: [PATCH] perf(pview): batch per-cell cell-object draws into one cross-cell draw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Rendering/RetailPViewRenderer.cs | 85 +++++++++++++++---- 1 file changed, 68 insertions(+), 17 deletions(-) diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index dae38278..55a11308 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -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 _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 _allCellStatics = new(); + private readonly HashSet _cellObjCells = new(); /// /// #118 stage assignment for a dynamic under an INTERIOR root: does it draw