Skip to content

Node batching: items, columns, doors and windows draw through BatchedMesh containers - #760

Merged
wass08 merged 15 commits into
mainfrom
perf/item-instancing
Sep 4, 2026
Merged

Node batching: items, columns, doors and windows draw through BatchedMesh containers#760
wass08 merged 15 commits into
mainfrom
perf/item-instancing

Conversation

@wass08

@wass08 wass08 commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Metric (charter backlog 3a+3b): idle frame CPU across the fixture ladder. Measured: −26% rich 2× (10.7→7.8ms), −37% Maxi 8× (21.1→13.2ms), −8..−14% everywhere else; in-page noise-cancelled A/B: rich 2× −19%, rich 4× −27%.

What

The frame's bottleneck is CPU draw submission (~3µs per mesh: pipeline/bind/uniform setup per object; the GPU idles at 1–2ms). Items, columns and wall-hosted openings now render through THREE.BatchedMesh containers — one per (level, material, attribute-signature) — paying setup once per batch and ~0.3µs per instance, with per-instance frustum culling intact.

Architecture mirrors the proven wall batch (shared/node-batch/{types,store,candidates,system}):

  • Sources stay mounted and draw-hide via the existing 'batched' layer hold — picking, hover outlines, selection paint, measuring and GLB export are untouched by construction. Batch meshes are draw-only (noop raycast, pascalExport='strip').
  • Membership follows the dirty signal + count tell + settle window. Lit nodes release and draw themselves; a dirty/lit/mid-gesture host wall cascades to its openings; a door/window animation record releases its node for the tween; isolation stands everything down; a level-subtree remount is detected by parent identity and re-sewn.
  • Existing batches always accept rejoins; new batches need MIN_BATCH_ENTRIES — a scene of a few unique items creates zero batches and pays zero overhead (verified: 4-item scene, 0 batches, wave quiesces).
  • Bake/thumbnail pages (?disable=draw) don't batch at all, and the release-everything paths sweep stale 'batched' holds so exports can never lose geometry.
  • ?perf gains a batch membership row (on WebGPU each batched instance still counts in drawCalls — the backend loops drawIndexed per visible instance — so the DRAW number cannot show the win; frame/encode ms is the metric).

Evidence (charter plans/performance/editor-scalable-scene-runtime.md in private-editor)

  • Matrix (7 fixtures, quiet machine, vs post-dirty-lifecycle baseline): idle −8..−37%, hover −8..−30%, wall-move settle −66/−67% on two fixtures, dirty lifecycle intact.
  • Isolation fixtures (in-page A/B, batching toggled live): 100× sofa −29%, 32 assets ×10 −29%, AI-generated textured items ×25 −15% (copies share GLB-cached materials — no per-texture fragmentation), AI+catalog 360 −27%.
  • Per-instance frustum culling verified live (mixed fixture draws 350/810 instances at default framing).

Gates

  • Editing e2e 8/8: perf-regression-pack (door place/reselect, undo, wall endpoint), click-batched-wall, click-hidden-wall, opening placement.
  • Visual: cross-build pixel parity ≤0.006% (items); in-session batched-vs-released diff 0.4% confined to coplanar-seam AA and transparent blend order (diff image reviewed — no missing/displaced geometry).
  • Bake: tri+byte identical batching-on vs batching-off on the same scene.
  • Item-move settle parity (order-controlled ×5; earlier matrix wobble was cold-path single-sampling).

Trade-offs + the v1→v3 ladder (unmount sources / indirect multi-draw) and follow-ups (ceilings+slabs coverage, wall-mode-aware wall batch for the rich-4× <12ms target) are documented in the charter's architecture notes.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK


Note

Medium Risk
Large new render-path subsystem with many release paths (export, isolation, gestures); incorrect batch membership or stale 'batched' holds could cause missing geometry or frozen poses, though capture sweeps and invariants are explicitly designed to mitigate that.

Overview
Adds a node batching pipeline (shared/node-batch) that mirrors the wall batch but uses THREE.BatchedMesh per (level, material, attribute-signature) so static items, columns, and wall-hosted doors/windows can draw through instanced containers after a settle window, while source meshes stay mounted and are only draw-hidden with the existing 'batched' layer hold.

NodeBatchSystem is mounted once from the item system bundle and orchestrates join/release from dirty marks, selection/hover tint, live overrides, door/window animations, appearance changes, isolation, and thumbnail:before-capture (plus no-op on ?disable=draw). Candidates skip interactive/animated items, unsettled GLBs, transparent/array materials, and hosted child nodes; the item renderer now stamps itemHasAnimations when a GLB has clips.

The ?perf overlay gains a batch membership row via publishPerfBatchStats, a drawComposition probe, optional ?spike=batch BatchedMesh spike, and window.__itemBatch debugging hooks.

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

wass08 and others added 7 commits September 3, 2026 08:38
Feeds charter backlog #3: projected draws for per-item merge vs
per-asset instancing, plus a meshes-by-kind bucket for the non-item
side of the budget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK
400-4,900 instances, two geometries, one material, shadows on. Verdict:
renders/shadows/post-FX clean; ~0.3us encode per instance (~10x cheaper
than a real mesh); per-instance frustum culling active (draw/tri track
the camera). WebGPU counts each multi-draw segment in info.render, so
the 3a metric is encode/frame-cpu ms, not draw calls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK
…tics (charter 3a)

Level-parented items render through one BatchedMesh per (level, material,
attribute-signature) — membership follows the dirty signal, lit items
release and draw themselves (outline/selection paint untouched), joins
wait for a quiet window, isolation stands the whole thing down, and
'thumbnail:before-capture' hands every item its meshes back so exports
never see a batch (belt: batches carry pascalExport='strip').

The container is incremental — instance add/delete per membership
change, geometry deduped per batch, capacity grown 2x on overflow. An
existing batch always accepts a rejoining item; only new batches need
MIN_BATCH_ENTRIES. v1 scope: items whose parent is a level; hosted
items (wall/ceiling/roof) move on host edits that never dirty the item,
so they keep drawing themselves. Interactive/animated/transparent items
excluded by the same rule as walls.

Rich 2x: 114 of 270 items batched (304 instances, 37 batches), idle
frame cpu 10.65 -> ~8.3ms. Full matrix + gates follow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK
…surface

A React remount of a level subtree (thumbnail capture level shuffling,
tool-state swings) replaces the registry groups: imperatively-parented
batch meshes die with the old group while fresh source clones mount with
no layer hold. pruneDetached() spots the orphaned batches by parent
identity each frame and re-stales their items. ?perf probe gains
stats/wave/census/batchRender hooks for scripted verification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK
On WebGPU each batched instance still counts once in drawCalls (the
backend loops drawIndexed per visible instance), so a batched scene
looked no cheaper by the panel's DRAW number. The row now reads e.g.
'581 (272 batched · 2 mesh)' — live per sample, so per-instance frustum
culling is visible as the camera moves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK
The first cut read _multiDrawCount, which snapshots whichever camera
culled the batch last — shadow vs main vs outline passes made the
number flip (206 -> 11 on hover) and read as items dropping out of the
batch. The batch system now publishes its membership (items/instances/
containers) to the panel store; the row is stable and only moves when
membership actually changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK
…3a+3b)

item-batch generalizes to shared/node-batch: same wall-batch semantics,
BatchedMesh containers per (level, material, attribute-signature), now
covering items, columns and wall-hosted openings (resolved to the host
wall's level). New release triggers: a dirty WALL cascades to its
openings (the wall edit moves them without marking them), a host wall
that is lit or mid-gesture releases its openings, and a door/window
whose animation record appears draws itself for the tween. Hitboxes
(material.visible=false) and glass (transparent) never batch. Bake
pages (?disable=draw) stand batching down entirely, and the release-
everything paths sweep every level subtree for stale 'batched' holds —
a system can rebuild a batched node's meshes and orphan the tracked
refs, and a stale hold is exactly what the GLB exporter would prune.

Rich 2x: 188 nodes / 1,108 instances batched, in-page A/B frame
9.1 -> 7.4ms (-19%); rich 4x: 2,280 instances, 19.3 -> 14.1ms (-27%).
Gates: e2e 8/8; bake tri+byte parity batching-on vs off; in-session
pixel diff 0.4% confined to seam AA and transparent blend order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK
@pascal

pascal Bot commented Sep 4, 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: 9b34fc56-47be-4feb-acb6-7e2b2ee46476

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK
Comment thread packages/nodes/src/shared/node-batch/system.tsx
Comment thread packages/nodes/src/shared/node-batch/store.ts
… findings)

A same-size add-and-remove slipped past the count tell and left the
removed node's instances drawing as ghosts — membership now diffs ids
against last frame's registry. Released geometries drop their packed
mapping at refcount zero, so a rejoin re-packs current vertex content
(a rebuilt geometry can reuse its uuid) instead of the copy captured at
first join; the orphaned range stays until the existing overflow
rebuild reclaims it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK
Comment thread packages/nodes/src/shared/node-batch/store.ts
Comment thread packages/nodes/src/shared/node-batch/store.ts Outdated
…ugbot round 2)

The refcount fix traded one problem for another: dropping the packed
mapping at zero made every hover release/rejoin cycle re-pack the same
geometry, inflating used until premature overflow rebuilds. The mapping
now survives cycles and carries a content stamp (position version +
counts) — reuse is free, and a geometry rebuilt in place under the same
uuid re-packs instead of instancing stale vertices. Whole-container
frustumCulled goes off: its bounding sphere is computed at first cull,
so instances joining farther out later could vanish with the whole
batch — per-instance culling already owns visibility.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK
Comment thread packages/nodes/src/shared/node-batch/candidates.ts
…date walk (Bugbot round 3)

An item can host other items (a shelf's books) whose registered groups
mount inside the host's group — the walk packed those meshes as the
HOST's instances, freezing the child at join pose with no release of
its own. The walk now cuts at every hosted child's registered group;
hosted nodes keep drawing themselves (they are outside v1 batch scope
by the level-parent rule anyway).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK
Comment thread packages/nodes/src/shared/node-batch/candidates.ts
Comment thread packages/nodes/src/shared/node-batch/candidates.ts
Comment thread packages/nodes/src/shared/node-batch/system.tsx
…ing (Bugbot round 4)

- A hidden wall hides its openings through group visibility; batch
  instances hang off the level root and kept drawing them. Openings of
  an invisible wall are no longer candidates (the wall's visibility
  dirty-mark cascades the release).
- ItemAnimation autoplays a GLB's first clip even without an
  interactive effect; such items batched and froze mid-motion. The
  renderer stamps clip presence on the registry group and candidates
  exclude it.
- Below-threshold candidates were dropped after their wave, so copies
  placed more than a settle window apart never gathered into a batch.
  A new id now re-stales every unbatched node, letting the copy that
  crosses MIN_BATCH_ENTRIES pull earlier ones in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK
Comment thread packages/nodes/src/shared/node-batch/system.tsx
wass08 and others added 2 commits September 4, 2026 09:24
…bot round 5) + import sort

store.has locked a partially-joined node (some meshes under the
new-batch threshold) out of every later wave. Partial members are now
tracked; a new placement releases them for a full re-collect, so the
copy that makes a leftover bucket viable pulls them in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 393a494. Configure here.

Comment thread packages/nodes/src/shared/node-batch/system.tsx
Comment thread packages/nodes/src/shared/node-batch/system.tsx
Live overrides now release an already-batched node (a collaborator's
remote drag or a programmatic move carries no local selection to tint
it) and defer, rather than drop, a stale one — the commit mark
re-offers it. Threshold-short candidates land in a leftover set that
re-offers as a group when a NEW leftover arrives, so a copy that was
deferred (selected, dirty, loading) while its peers' wave ran can still
gather them into a batch; a stable leftover set re-offers nothing and
small scenes stay quiet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nq2rLE18tFVES2LU6HGokK
@wass08
wass08 merged commit ab0aa82 into main Sep 4, 2026
3 checks passed
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