Skip to content

perf(viewer): sew a level's walls into one mesh - #608

Open
toycenterboss-bot wants to merge 7 commits into
pascalorg:mainfrom
toycenterboss-bot:perf/wall-batching
Open

perf(viewer): sew a level's walls into one mesh#608
toycenterboss-bot wants to merge 7 commits into
pascalorg:mainfrom
toycenterboss-bot:perf/wall-batching

Conversation

@toycenterboss-bot

@toycenterboss-bot toycenterboss-bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Closes the draw-call half of #588 (and the "stuttering" half of #492): a floor of ~1000
walls issued 6520 draw calls at 42.7 FPS; sewing each level's walls into one mesh per
material set takes the same floor to 25 draw calls at 49.7 FPS, against this
renderer's ceiling of 50. Profiling fixture is the anonymised scene attached to #492
(gist,
1089 walls).

Scope, stated up front: the merge only runs in the up wall mode. cutaway re-assigns
wall materials from the camera's facing test, down and translucent make walls
see-through, and the merged mesh captures one material set when it is sewn — so it stands
down in all three, the same way it already does under isolation. In cutaway this PR
therefore buys nothing; recovering it needs the merged buffer ordered by wall normal so the
camera-dependent hidden set stays contiguous, which is a change of its own. See the
discussion below.

Two commits carry the work:

  • draw each wall material once instead of once per face run — wall geometry emitted
    one material group per run of same-material triangles, so a wall with alternating
    faces paid several draw calls for the same material. Sorting the index buffer by
    material collapses that to one group per material. 7982 → 6544 calls on its own.
  • sew a level's walls into one mesh — the batch itself.

Built on the dirty tracking from #556, as you asked in #588. The batch does not watch
the walls; it reads the same dirtyNodes signal the wall system runs off, from both ends:

  • the marks still standing when the batch's frame callback runs (walls the wall system
    deferred — a progressive import, or a mesh that has not mounted yet), and
  • the rebuild notices the wall system leaves behind for walls whose marks it has already
    cleared in that same frame. Neighbours re-mitred by the wall system's trailing-edge
    flush only show up here — that path calls updateWallGeometry directly and never marks
    anything dirty.

Per-frame cost is therefore the size of the dirty set, not the size of the floor.

The dragged wall falls out of the batch for free. It is marked on every pointermove
tick, so it is released in the same frame and stays released until the drag stops.
Re-sewing additionally waits for the wall system's deferred neighbour rebuilds to drain,
not just for a quiet window on the clock — otherwise a floor could be merged from geometry
that is about to change under it.

Deleting a wall marks the neighbours it re-mitres, not the node that went away, so
removals are caught by comparing the registry's wall count against the previous frame —
O(1) once the scene is quiet.

Nothing that relied on a wall being its own object breaks. Each run records the slice
every source wall contributed, so pulling a wall out rewrites the group list without
touching a buffer. A sewn wall moves to a layer no camera enables rather than having its
draw range emptied (three.js submits a draw call even for a zero-count group), which
leaves its children — opening cutters, treatments, the invisible collision child that
carries pointer events — rendering and picking exactly as before. Raycasters that must hit
real surfaces opt into that layer through setSurfaceRaycastLayers.

A level is only merged once it has at least 8 mergeable walls, and translucent or
cut-away walls keep the per-wall path, since merging would change their blend ordering.

Stacked on the isolation/solo layer fix

The first commit here belongs to a separate, smaller PR — #607, which gives
Object3D.layers a single owner so isolation and solo stop overwriting each other. The
batch is the third caller of that module. Happy to rebase this once that one lands, or to
squash them if you would rather review a single change.

The last commit stands the batch down entirely while an isolation filter is up: the merged
mesh hangs off the level root and the filter hides it along with everything else, so a
focused wall the batch had sewn in would be drawn by nobody. Teaching isolation about
merged geometry felt like the wrong place for that knowledge, so the batch steps aside
instead — one boolean check per frame.

How to test

Automated — bun run test --filter @pascal-app/viewer:

  • src/lib/wall-batch.test.ts includes a draw-call budget block: floors of 1, 10, 100
    and 1000 walls must all end at 3 runs and 3 groups. Verified by mutation — reverting the
    merge turns 6 groups into 2997 and fails six tests.
  • src/lib/geometry-groups.test.ts covers the material-group sorting.

Manually:

  1. Load the 1089-wall fixture from Lag / Stuttering when dragging or moving walls #492 and open the renderer info panel.
  2. Draw calls should read ~25 rather than ~6500.
  3. Drag a wall: it leaves the batch for the duration and rejoins once you let go — the
    merged floor never lags a frame behind the wall you moved.
  4. Add and delete walls; solo a level; isolate a wall. Each should look exactly as it does
    on main.

Screenshots

Same scene (the 1089-wall fixture from #492), same camera pose, same window, same overlay.

Before — main: 6 767 draw calls, 0 FPS.

before

After — this branch: 39 draw calls, 50 FPS.

after

Two things about that pair are worth stating plainly rather than leaving you to spot them.

The overlay is not in main. It is part of another unlanded change of mine, so I applied
it locally to both sides purely as a measuring rig, together with a frame-the-scene-on-load
change — without that, main opens this scene with the camera inside the geometry and there
is no reliable way to get the same pose twice. The rig is three cherry-picked commits, touches
nothing in this PR, and was identical on both sides. The camera pose was set from the console
(setLookAt(0, 230, 78, 0, 0, 0)), so it is the same to the pixel rather than eyeballed. Both sides are pushed if you want to reproduce the shots yourself:
demo/main-rig is
upstream main plus the rig, demo/batched-rig
is this branch plus the same four commits. They are measuring scaffolding, not
proposals — the overlay probe in them would not pass this repo's lint as it stands.

The counters in the shots differ slightly from the headline numbers above (6 767 vs 6 520,
39 vs 25) because these were taken at a different camera pose and a smaller viewport than the
measurement run — draw calls scale with what is in frustum, and the batch splits per material
set in view. Same scene, same machine, same renderer.

The two pictures do not show the same amount of wall, and I want to be precise about why.
Wall geometry is built incrementally across frames, and the main capture had not finished
building when I took the shot: the canvas renders on demand and each frame there costs ~6.7k
draw calls, so under a scripted camera it crawled — 1 130 to 3 608 triangles over 100 seconds
of continuous motion. That is a property of how I drove the capture, not a claim that main
never gets there. In the idle measurement quoted above, main had the same floor fully built at
60 536 triangles and 42.7 FPS. Read these two images for the draw-call counters; the amount of
wall drawn is an artefact of my capture method, and I did not want to quietly crop it out.

Checklist

  • Tested locally with bun dev
  • bun check clean
  • bun check-types clean
  • Targets main

toycenterboss-bot and others added 5 commits August 7, 2026 11:06
…yers

Both features hide an object by clearing its scene layer, and each stashed
the previous `layers.mask` under its own private Symbol, restoring it
wholesale on the way out. That only holds while they nest. Interleave them
and the second to finish writes back a mask the first has since changed:

  solo a floor, isolate a wall, leave solo

hands every level its scene layer straight back, so leaving solo un-hides
exactly what the isolation filter was hiding. Clearing the filter afterwards
then restores the mask isolation captured *during* solo, and the level is
stuck shadow-caster-only with nothing soloed — invisible until reload.

`lib/scene-visibility.ts` takes the mask over. Callers name a reason rather
than a mask, the mask is recomputed from the one snapshot taken when the
first reason arrived, and the original is handed back only when the last
reason leaves. Order stops mattering, and two duplicated stash
implementations collapse into one.

The new `isolation.test.ts` drives the real pair in both interleavings; both
cases fail on `main` and pass here.

Co-Authored-By: Claude <noreply@anthropic.com>
Material groups are contiguous slices of the index buffer, so triangles
that alternate between materials cost a draw call per run rather than per
material. ExtrudeGeometry interleaves the cap and side faces, so every
wall was emitting four groups for two materials.

Bucketing the triangles by material before grouping cuts the floor from
8812 draw calls to 6544 and lifts the idle frame rate from 41.6 FPS to
the 50 FPS frame-limiter ceiling. The image is unchanged: same triangles,
same materials, only the order they are handed to the GPU differs.

Co-Authored-By: Claude <noreply@anthropic.com>
A floor of a thousand walls issued a thousand draw calls, because every
wall carried its own mesh and its own material groups. Sewing them into
one geometry per level takes the same floor from 6520 draw calls to 25.

The merge follows the scene's dirty tracking rather than watching the
walls itself. A wall matters to the merged mesh for exactly one reason --
the wall system rebuilt its geometry -- and that system already runs off
`dirtyNodes`, so this reads the same signal from both ends: the marks
still standing when the frame reaches it, and the rebuild notices the
wall system leaves behind for the walls whose marks it has already
cleared. The per-frame cost is the size of the dirty set, not the size of
the floor.

That is also what keeps a dragged wall out of the batch. It is marked on
every pointermove tick, so it is released in the same frame and stays
released until the drag stops. Re-sewing waits for the wall system's
deferred neighbour rebuilds to drain as well, so a floor is never merged
from geometry that is about to change under it.

The batch keeps a range-to-node map, so nothing that relied on a wall
being its own object breaks: each run records the slice every source
wall contributed, and hiding a wall from the batch rewrites the group
list without touching a buffer. Pointer picking never went through the
merged mesh anyway, it rides the wall's own invisible collision child.

A wall the batch draws moves to a layer no camera enables, rather than
emptying its draw range: three.js submits a draw call even for a
zero-count group, so an emptied range saves nothing. Moving the mesh
alone leaves its children -- opening cutters, treatments, the collision
child -- rendering as before, which `visible = false` would not. That
move goes through `lib/scene-visibility.ts` as a third reason alongside
isolation and solo, so a wall that is sewn in and also hidden by one of
those unwinds correctly whichever ends first; batching outranks the
shadow-caster pass, since the merged mesh already casts the wall's
shadow.

Raycasters that must hit real surfaces opt into that layer through
`setSurfaceRaycastLayers`, otherwise a sewn wall would stop answering
measurement rays.

Co-Authored-By: Claude <noreply@anthropic.com>
The merge is only worth having while the draw ranges stay flat as the floor
grows, and nothing was watching that: the existing tests all run on three
walls, where one range per material and one per wall look the same.

Builds floors of 1, 10, 100 and 1000 walls and asserts the run and group
counts stay at one per material, that every wall keeps its own addressable
slice, and that hiding walls costs ranges proportional to the holes rather
than to the floor. Reverting the merge turns 6 groups into 2997 and takes
these down with it.

Co-Authored-By: Claude <noreply@anthropic.com>
The isolation filter hides everything outside the focused subtree, and a
level's merged wall mesh hangs off the level root, so it goes dark with
everything else. Isolate a wall the batch had sewn in and nobody draws it:
its own mesh is silent because the batch owns it, and its stand-in is
hidden because the filter never heard of merged geometry.

Teaching the filter about the batch would put the knowledge in the wrong
place — isolation is a viewer-wide concern and the batch is an
implementation detail of one system. So the batch steps aside instead: it
releases every wall while a filter is up and re-sews the affected levels
once it lifts. That costs one boolean check per frame and leaves isolation
exactly as it was.

Co-Authored-By: Claude <noreply@anthropic.com>
Comment thread packages/viewer/src/systems/wall/wall-batch-system.tsx

@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 1 potential issue.

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 87a4d25. Configure here.

const node = nodes[nodeId as AnyNodeId]
if (node?.type === 'wall' && node.parentId) staleLevels.add(node.parentId)
releaseWall(nodeId)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hidden walls still batch-drawn

High Severity

Turning a wall off with node.visible (or its mesh visible) hides the per-wall mesh but does not remove it from the merged batch. toCandidate skips invisible walls only when re-sewing; runBatchFrame never releases batched walls on visibility changes, so the batch mesh keeps drawing geometry that should be hidden.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 87a4d25. Configure here.

@toycenterboss-bot

toycenterboss-bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Bugbot is right, and thanks — this is a real hole and I had missed it. Fixed in e91b2a9.

What was wrong. WallCutout re-assigns each wall's materials from the camera's facing
test, and it writes them to the wall's own mesh. The batch has already taken that mesh off
the scene layer and is drawing the wall itself, with the material set it captured at merge
time — so in cutaway the merged walls stayed solid however far you turned the camera. The
one mode whose entire purpose is to let you see inside did nothing for any batched wall.

Why my own testing missed it. The default wall mode is up, where getWallHideState
forces hideWall to false and every wall keeps one stable material set. I ran the whole
draw-call check-list — selection, level filters, opening cutouts, editing — in the default
mode and never switched to cutaway. My check-list had a hole exactly the shape of this bug.

One correction to the report. Selection itself is not affected: it is drawn by the merged
outline pass over outliner.selectedObjects, not by the material swap. What was lost is the
colour tint (getSelectionHighlightMaterials), and only for walls whose face bands are off.
That is fixed too — a tinted or delete-hovered wall now drops out of its batch and draws
itself, the way an edited wall already did.

The fix stands the batch down in every mode but up, behind the same predicate as the
existing isolation stand-down rather than a second mechanism (canBatchWalls, with unit
tests in wall-batch-suspension.test.ts). down and translucent had the same latent
problem for the same reason and are covered by it.

The honest cost: in cutaway this PR now buys nothing at all. Draw calls there stay where
they are on main.

How I would recover it, as a follow-up rather than here. The machinery is already in
place: applyWallBatchGroups rewrites the merged geometry's draw groups without touching a
buffer, so walls can leave and rejoin the batch for the price of a few group objects. What is
missing is ordering. Walls currently land in the buffer in level.children order, so the
walls the facing test flips are scattered through it, and each hole costs a draw call — hide
400 walls and you are back where you started. Order each run by the azimuth of the wall's
world normal (bucketed by side configuration first, since getWallHideState also reads
frontSide/backSide) and the flipped set becomes a contiguous arc — two ranges at worst,
counting the wrap. Recomputing it can hang off the same throttle WallCutout already uses,
so it is not per-frame work.

That gets cutaway into the low hundreds rather than 6.5k, but not down to the tens: the
walls that turn to glass are transparent: true and cannot join a merged mesh without
losing per-object blend ordering, so they each draw themselves. Closing that last gap needs a
second merged mesh for the see-through set and a judgement about whether unsorted blending
between ghost walls is visible — the material is a screen-door dot pattern shared by all of
them, so it may well not be, but that is a question for side-by-side captures, not for
reasoning. Happy to open it as a follow-up PR, or to fold it in here if you would rather
review the whole thing at once.

The merged mesh captures one material set when it is sewn and nothing
re-reads it. That holds in "up", where a wall's materials never move. It
does not hold anywhere else: "cutaway" re-assigns them from the camera's
facing test every time the view turns far enough, and "down" and
"translucent" make every wall see-through. In those modes the merged copy
kept drawing walls the cutaway pass had already turned to glass, so
rotating the camera left the near walls solid and the mode did nothing.

Isolation already had a stand-down for a related reason; this puts both
behind one predicate rather than growing a second mechanism.

Selection and delete-hover tints are applied the same way — by swapping
the materials on the wall's own mesh — so a tinted wall now drops out of
its batch and draws itself, the way an edited one already did. Selection
itself was never affected: it is drawn by the outline pass, not the tint.

The batch therefore buys nothing in cutaway mode. Recovering it there
needs the merged buffer ordered by wall normal so the camera-dependent
hidden set stays contiguous, which is a change of its own.
A wall that changes carries a dirty mark, and the batch already lets it go
on that signal. Four inputs re-make every wall's material set without
touching a node: the shading, texture and colour-preset toggles, the scene
theme, and the scene's material library. The cutaway pass rebuilds all the
wall materials from them and assigns them to the per-wall meshes; the
merged mesh held whatever set it captured when it was sewn, so flipping any
of them left a whole batched floor looking the way it did before.

Watched by identity, so the check is four comparisons and a reference test
per frame. They only move when someone deliberately flips a switch, which
makes re-sewing the scene the cheap answer rather than the expensive one.
@toycenterboss-bot

Copy link
Copy Markdown
Contributor Author

Second pass — one of these was a real hole I had left open, the other I do not think reproduces.

"Batched walls ignore cutout modes" — the remaining part was real. Fixed in 6052bf5.

The mode half of this went away with the stand-down in the previous commit, and a wall whose
own definition changes already left its batch on the dirty mark. But Bugbot's phrase "live
material edits" pointed at a case neither of those covers: the shading, texture and
colour-preset toggles, the scene theme, and the scene's material library live in the viewer
store and in useScene().materials, not on any node. Flipping one re-makes every wall's
material set and the cutaway pass assigns the new ones to the per-wall meshes — while the
merged mesh kept whatever set it captured when it was sewn. A whole batched floor would have
gone on looking the way it did before the switch. Those five inputs are now watched by
identity and re-sew the scene when any of them moves; it costs four comparisons and a
reference test per frame, and they only move when someone deliberately flips a switch.

"Hidden walls still batch-drawn" — I do not think this one holds, and here is my working.

Visibility is not set on the mesh directly. The tree's visibility toggle calls
updateNode(nodeId, { visible }) (tree-node-actions.tsx), updateNode is
updateNodesAction(set, get, [{ id, data }]) (use-scene.ts:1435), and that action collects
each updated id into pendingUpdates and calls get().markDirty(id) on them
(node-actions.ts, the rAF block at the end). runBatchFrame drains dirtyNodes at the top
of every frame and calls releaseWall on each wall it finds there — so hiding a wall does
take it out of the batch, and toCandidate's visibility checks then keep it out of the next
merge.

The one thing I will grant is that markDirty is deferred to the next animation frame there,
so a hidden wall can survive one extra frame inside the merged mesh before it is released. I
have not tried to close that: it is one frame, and the same deferral already governs every
other edit the batch reacts to.

If the finding was aimed at a path I have not looked at — something that writes mesh.visible
or the node's visible field without going through updateNode — I would like to know which,
because that would be a genuine hole and I would rather fix it than argue about it.

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