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