Skip to content

perf(nodes): batch ceiling undersides and slab bodies (charter row 16) - #789

Open
wass08 wants to merge 8 commits into
mainfrom
perf/surface-batching
Open

perf(nodes): batch ceiling undersides and slab bodies (charter row 16)#789
wass08 wants to merge 8 commits into
mainfrom
perf/surface-batching

Conversation

@wass08

@wass08 wass08 commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Metric

rich-4× idle frame CPU (charter backlog row 16): ceilings and slabs — 696 opaque, level-parented, one-geometry-per-node meshes on that fixture — draw through the per-level BatchedMesh containers of shared/node-batch/ like items, columns, doors and windows already do.

Same-load PR-vs-main pairs (the machine ran at load average ~7 all afternoon, so absolute numbers are inflated and only back-to-back pairs are quoted):

fixture idle frame CPU idle draws hover CPU
rich 4× (the metric) 9.1 → 8.3 ms (−9 %; −15 % in a second pair) 4 343 → 3 219 10.1 → 8.8
rich 2× 9.3 → 4.9 ms (−47 %) 2 580 → 2 018 8.4 → 5.0
Maxi 8× 15.2 → 10.9 ms (−28 %) 5 472 → 1 376 15.9 → 11.8
tower 2×18 6.0 → 5.4 ms (−10 %) 4 219 → 1 147 6.6 → 5.9
Maxi 1×/2×/4× within the noise band at 2–4 ms 999 → 487 / 1 587 → 563 / 2 877 → 833

Like-gesture edit receipts (wall/door body moves, undo) are flat or better; every fixture ends at dirty 0 with zero phantom marks. One receipt regressed deterministically (rich-2×, moving the wall behind a ceiling-hosted light: 32 frames vs 6–9) and turned out to be a real bug this batching exposed: the grid raycaster never enabled BATCHED_LAYER, so the ground ray under a dragged wall missed the now-batched slab, the wall jumped 8.5 m and created a bridge wall (135 walls re-marked). Fixed in dd745a0 (291 ms / 8 frames after, main 336 / 8), and the same guard was applied to every non-hover raycaster that can hit batched sources first-person door/window targeting, item/column support caps, accessory placement, door/window paint, wall paint — 7a64290; the support-cap and door/window ones were already exposed on main for the kinds batched since #760. The audit table of all 35 raycasters lives in the private charter folder.

What it took (beyond adding two kinds)

  • Ceiling dirty marks were consumed before the batch could see them. viewer/src/systems/ceiling/ceiling-system.tsx consumed at useFrame priority 0; the batch snapshots pending marks at 1. It now consumes at 2 like GeometrySystem (definition system.priority orders components, not frames — node-definitions.md said otherwise and is corrected).
  • The coloured ceiling underside was transparent: true with opacity 1 (introducing commit 967a905 calls it opaque; no fade). Now opaque, so it batches in the default textures-on appearance. Blend order against glass changes by a few hundred pixels (parity below).
  • Slab default side materials were created fresh per slab, so sides could never share a batch or a GLB material. They now use the viewer default cache; the legacy slab cache marks its materials as cached so rebuild disposal skips shared instances, and a cache clear rebuilds slabs and releases batches before disposing (after the next frame).
  • Surfaces move through useLiveTransforms, and paint previews swap materials on every fan-out target, without dirty marks. Both release now and defer joins; a paint commit ends its holds through a single owner (paint-preview-owner.ts) — the first cut leaked the hold forever on commit and reached columns/doors/windows too; an apply() that throws after the scene write ends holds without restoring stale materials.
  • Shadow flags are part of the container identity (ceilings cast none, slabs cast and receive); externalSelectedIds release like local selection; hidden-level candidates are re-offered when the level comes back.
  • Rebuilt surface geometry replaces instead of appending. release() used to keep obsolete packed geometry in used until an overflow rebuilt the whole bucket. Surfaces now own a stable slot with 25 % headroom and rejoin via setGeometryAt when the new geometry fits; overflow rebuilds size from live geometry only; multi-node releases flush once per frame; an emptied container survives the quiet window; released surfaces on a level wait while any wall there carries a live override and until the wall queue drains, then join in one wave. Counters under __pascalPerf.batchStats() (releases, joins, replacements, overflow rebuilds, bytes copied).
  • Found by the bake gate, pre-existing: the merged wall batch never carried pascalExport = 'strip', so a capture that landed while it was sewn exported 80 k duplicate wall vertices. Fixed (separate commit).
  • Perf receipts for drag:move now carry the moved node id (scope nodeId), so the matrix runner can refuse to diff unlike gestures.

Gates

  • Editing: private e2e gate 9/9 (perf-regression pack ×5, batched-wall click, hidden-wall click, floor placement, slab room paint), twice.
  • Visual: outline-parity.ts poses H/S/SH/SI on rich 4× + Maxi 1×: 0.36–0.46 % pixels differ, localized to window glass, glass-room seams and two small floor patches; selection census identical on the stable poses (the 3a/3b batching row accepted 0.4 % of the same kind).
  • Bake: rich-2× raw GLB node/mesh/texture/animation sets identical to main's minus the leaked wall-batch meshes; materials 91 → 60 (deduplicated slab defaults), −1.1 MB.
  • Unit: bun test packages/nodes --randomize --seed=1 2 127 pass; discriminating tests for every mechanism above (the new node-batch tests were written to fail without each fix).
  • Four independent review rounds (read-only recon → audit → perf diagnosis → audit of the fix round) closed one blocker and eight should-fixes; the two diagnoses are recorded in the charter.

Follow-ups filed (idea inbox, not changed here)

  • The editor's standalone undo marks every node dirty on top of core's changed-node diff (history.ts refreshSceneAfterHistoryJump), releasing every batched member on every level per undo.
  • Window move on rich-4× spends 12–17 s in long tasks on main and on this branch alike.

Charter: plans/performance/editor-scalable-scene-runtime.md row 16 (private PR follows with the evidence row, dashboard refresh and the gesture-aware runner).

🤖 Generated with Claude Code

https://claude.ai/code/session_01Jmmz2AMwTzcnKsSHHPEMhN


Note

Medium Risk
Large changes to batch membership, material cache lifecycle, and many editor raycast paths; regressions would show up as wrong placement, paint, or support surfaces rather than auth/data issues.

Overview
Extends level BatchedMesh batching to ceilings and slabs (alongside items, columns, doors/windows), with a reworked batch store: per-shadow containers, reserved geometry slots for mutable surfaces, coalesced releases, and deferred empty-container disposal. Sources stay mounted but draw-hidden; live transforms, slot paint previews, external selection, and material cache clears now release and re-offer batch members on the same rules as hover/selection.

Raycasting for tools and walkthrough no longer misses batched floors/slabs: non-hover raycasters call setSurfaceRaycastLayers so hits still use source meshes when batch draws take over. Ceiling rebuild timing moves to frame priority 2 so the batch’s priority-1 dirty snapshot sees geometry changes; ceiling underside materials are opaque so they can batch in the default appearance.

Paint gets a shared paint-preview-owner (fan-out cleanup/commit) and slot-preview holds that keep batched nodes on their sources until previews settle. clearMaterialCache registers cleanups and disposes after consumers rebuild; selection highlights and slab caches hook in so previews never restore disposed materials. Wall merged batches and node batches are tagged pascalExport: 'strip' for GLB capture.

Reviewed by Cursor Bugbot for commit 7a64290. Bugbot is set up for automated code reviews on this repo. Configure here.

@pascal

pascal Bot commented Sep 8, 2026

Copy link
Copy Markdown

I hit an error while handling your request (Model unavailable on AI Gateway free tier: Free tier users do not have access to this model. Upgrade to paid credits at https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%3Fmodal%3Dtop-up for unrestricted…).

Please try again, rephrase, or reach out if it keeps failing.

Error id: d4012a5d-4673-411a-98cb-33f3158b6e6c

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant