From 8c0d835c2fccaaf1c4d2d2ef52d640fa886c4c08 Mon Sep 17 00:00:00 2001 From: Andrei Efremov Date: Fri, 7 Aug 2026 11:06:55 +0300 Subject: [PATCH 1/7] fix(viewer): stop isolation and solo from overwriting each other's layers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/viewer/src/lib/isolation.test.ts | 71 +++++++++++++++ packages/viewer/src/lib/isolation.ts | 51 ++++------- .../viewer/src/lib/scene-visibility.test.ts | 90 +++++++++++++++++++ packages/viewer/src/lib/scene-visibility.ts | 58 ++++++++++++ packages/viewer/src/lib/shadow-only.ts | 28 ++---- 5 files changed, 245 insertions(+), 53 deletions(-) create mode 100644 packages/viewer/src/lib/isolation.test.ts create mode 100644 packages/viewer/src/lib/scene-visibility.test.ts create mode 100644 packages/viewer/src/lib/scene-visibility.ts diff --git a/packages/viewer/src/lib/isolation.test.ts b/packages/viewer/src/lib/isolation.test.ts new file mode 100644 index 000000000..9c64c88fc --- /dev/null +++ b/packages/viewer/src/lib/isolation.test.ts @@ -0,0 +1,71 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not +// depend on @types/bun so the import type is unresolved at compile time. +import { afterEach, describe, expect, test } from 'bun:test' +import type { AnyNodeId } from '@pascal-app/core' +import { sceneRegistry } from '@pascal-app/core' +import * as THREE from 'three' +import { applyIsolation, clearIsolation } from './isolation' +import { SCENE_LAYER, SHADOW_ONLY_LAYER } from './layers' +import { applyShadowOnly, clearShadowOnly } from './shadow-only' + +function register(id: string): THREE.Object3D { + const obj = new THREE.Object3D() + obj.layers.set(SCENE_LAYER) + sceneRegistry.nodes.set(id, obj) + return obj +} + +/** Isolation takes node ids; the registry only cares that the key matches. */ +function isolate(...ids: string[]): void { + applyIsolation(ids as ReadonlyArray) +} + +describe('isolation and solo, interleaved', () => { + afterEach(() => { + clearIsolation() + sceneRegistry.clear() + }) + + test('leaving solo while isolated keeps the filtered scene filtered', () => { + const level = register('level-1') + const focus = register('wall-1') + const original = level.layers.mask + + applyShadowOnly(level) + isolate('wall-1') + clearShadowOnly(level) + + expect(level.layers.isEnabled(SCENE_LAYER)).toBe(false) + expect(level.layers.isEnabled(SHADOW_ONLY_LAYER)).toBe(false) + expect(focus.layers.isEnabled(SCENE_LAYER)).toBe(true) + + clearIsolation() + expect(level.layers.mask).toBe(original) + }) + + test('leaving isolation while soloed keeps the level casting shadows', () => { + const level = register('level-1') + register('wall-1') + const original = level.layers.mask + + isolate('wall-1') + applyShadowOnly(level) + clearIsolation() + + expect(level.layers.isEnabled(SCENE_LAYER)).toBe(false) + expect(level.layers.isEnabled(SHADOW_ONLY_LAYER)).toBe(true) + + clearShadowOnly(level) + expect(level.layers.mask).toBe(original) + }) + + test('solo re-applied every frame does not accumulate', () => { + const level = register('level-1') + const original = level.layers.mask + + for (let frame = 0; frame < 5; frame += 1) applyShadowOnly(level) + clearShadowOnly(level) + + expect(level.layers.mask).toBe(original) + }) +}) diff --git a/packages/viewer/src/lib/isolation.ts b/packages/viewer/src/lib/isolation.ts index 3c990ac5b..409a80bc4 100644 --- a/packages/viewer/src/lib/isolation.ts +++ b/packages/viewer/src/lib/isolation.ts @@ -3,17 +3,10 @@ import type { AnyNodeId } from '@pascal-app/core' import { sceneRegistry } from '@pascal-app/core' import type { Object3D } from 'three' -import { SCENE_LAYER } from './layers' +import { hideFromScene, showInScene } from './scene-visibility' -// Marker on each Object3D we modify during isolation so we can restore -// the original `layers.mask` bitfield. Stored under a `Symbol` so it -// can't collide with any kind's own userData fields. -const ORIGINAL_LAYERS = Symbol('isolation:original-layers') - -type IsolationCarrier = Object3D & { [ORIGINAL_LAYERS]?: number } - -// Whether a subtree is currently isolated (some objects have SCENE_LAYER -// disabled). Read by consumers that must not act on the partial view — e.g. +// Whether a subtree is currently isolated (some objects are held off the +// scene layer). Read by consumers that must not act on the partial view — e.g. // the project-thumbnail autosave skips capturing while isolated so it never // snapshots a single focused item as the whole project's thumbnail. let isolationActive = false @@ -47,21 +40,21 @@ export function collectIsolationSubtree(ids: ReadonlyArray): Set | null): void { const keep = collectIsolationSubtree(ids as ReadonlyArray) // Iterate registered roots. For each one outside the keep set, - // disable `SCENE_LAYER` on it and on every descendant — *except* - // descendants that are themselves in `keep` (a kept node nested under - // a non-kept host: the isolated door under the hidden wall). + // hide it and every descendant — *except* descendants that are + // themselves in `keep` (a kept node nested under a non-kept host: + // the isolated door under the hidden wall). for (const [, obj] of sceneRegistry.nodes) { if (keep.has(obj)) continue hideRecursive(obj, keep) @@ -87,11 +80,7 @@ export function applyIsolation(ids: ReadonlyArray | null): void { function hideRecursive(obj: Object3D, keep: Set): void { if (keep.has(obj)) return - const carrier = obj as IsolationCarrier - if (carrier[ORIGINAL_LAYERS] === undefined) { - carrier[ORIGINAL_LAYERS] = obj.layers.mask - } - obj.layers.disable(SCENE_LAYER) + hideFromScene(obj, 'isolated') for (const child of obj.children) { hideRecursive(child, keep) } @@ -99,15 +88,11 @@ function hideRecursive(obj: Object3D, keep: Set): void { export function clearIsolation(): void { // We don't know which objects were touched without re-walking, so - // walk every registered root + its descendants and restore any - // stashed original-mask. `traverse` is cheap and idempotent here. + // walk every registered root + its descendants and drop the isolation + // reason wherever it was set. `traverse` is cheap and idempotent here. for (const [, obj] of sceneRegistry.nodes) { obj.traverse((child) => { - const carrier = child as IsolationCarrier - if (carrier[ORIGINAL_LAYERS] !== undefined) { - child.layers.mask = carrier[ORIGINAL_LAYERS] - delete carrier[ORIGINAL_LAYERS] - } + showInScene(child, 'isolated') }) } isolationActive = false diff --git a/packages/viewer/src/lib/scene-visibility.test.ts b/packages/viewer/src/lib/scene-visibility.test.ts new file mode 100644 index 000000000..b47ad42b6 --- /dev/null +++ b/packages/viewer/src/lib/scene-visibility.test.ts @@ -0,0 +1,90 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not +// depend on @types/bun so the import type is unresolved at compile time. +import { describe, expect, test } from 'bun:test' +import * as THREE from 'three' +import { OVERLAY_LAYER, SCENE_LAYER, SHADOW_ONLY_LAYER } from './layers' +import { hideFromScene, showInScene } from './scene-visibility' + +function sceneObject(): THREE.Object3D { + const obj = new THREE.Object3D() + obj.layers.set(SCENE_LAYER) + return obj +} + +describe('scene visibility', () => { + test('one reason hides and gives the exact mask back', () => { + const obj = sceneObject() + obj.layers.enable(OVERLAY_LAYER) + const original = obj.layers.mask + + hideFromScene(obj, 'isolated') + expect(obj.layers.isEnabled(SCENE_LAYER)).toBe(false) + expect(obj.layers.isEnabled(OVERLAY_LAYER)).toBe(true) + + showInScene(obj, 'isolated') + expect(obj.layers.mask).toBe(original) + }) + + test('the reason still standing decides the mask, whatever the order', () => { + const obj = sceneObject() + + hideFromScene(obj, 'shadow-only') + hideFromScene(obj, 'isolated') + + // Leaving solo first must not hand the scene layer back while the + // isolation filter is still up. + showInScene(obj, 'shadow-only') + expect(obj.layers.isEnabled(SCENE_LAYER)).toBe(false) + expect(obj.layers.isEnabled(SHADOW_ONLY_LAYER)).toBe(false) + + showInScene(obj, 'isolated') + expect(obj.layers.isEnabled(SCENE_LAYER)).toBe(true) + }) + + test('dropping isolation under solo leaves the object casting shadows', () => { + const obj = sceneObject() + + hideFromScene(obj, 'isolated') + hideFromScene(obj, 'shadow-only') + showInScene(obj, 'isolated') + + expect(obj.layers.isEnabled(SHADOW_ONLY_LAYER)).toBe(true) + expect(obj.layers.isEnabled(SCENE_LAYER)).toBe(false) + }) + + test('re-hiding for a reason already held changes nothing', () => { + const obj = sceneObject() + + hideFromScene(obj, 'shadow-only') + const held = obj.layers.mask + hideFromScene(obj, 'shadow-only') + expect(obj.layers.mask).toBe(held) + + showInScene(obj, 'shadow-only') + expect(obj.layers.isEnabled(SCENE_LAYER)).toBe(true) + }) + + test('dropping a reason that was never held is a no-op', () => { + const obj = sceneObject() + const original = obj.layers.mask + + showInScene(obj, 'isolated') + expect(obj.layers.mask).toBe(original) + + hideFromScene(obj, 'shadow-only') + showInScene(obj, 'isolated') + expect(obj.layers.isEnabled(SHADOW_ONLY_LAYER)).toBe(true) + expect(obj.layers.isEnabled(SCENE_LAYER)).toBe(false) + }) + + test('an object hidden while already off the scene layer stays off it', () => { + const obj = new THREE.Object3D() + obj.layers.set(OVERLAY_LAYER) + const original = obj.layers.mask + + hideFromScene(obj, 'isolated') + showInScene(obj, 'isolated') + expect(obj.layers.mask).toBe(original) + expect(obj.layers.isEnabled(SCENE_LAYER)).toBe(false) + }) +}) diff --git a/packages/viewer/src/lib/scene-visibility.ts b/packages/viewer/src/lib/scene-visibility.ts new file mode 100644 index 000000000..86bebe839 --- /dev/null +++ b/packages/viewer/src/lib/scene-visibility.ts @@ -0,0 +1,58 @@ +import type { Object3D } from 'three' +import { SCENE_LAYER, SHADOW_ONLY_LAYER } from './layers' + +/** + * Why an object is currently held off the scene layer. + * + * - `isolated` — outside the focused subtree of the viewer's isolation filter. + * - `shadow-only` — solo mode: out of the color passes, still casting shadows. + */ +export type HiddenReason = 'isolated' | 'shadow-only' + +/** + * Single owner of `Object3D.layers` for every feature that hides an object. + * + * Isolation and solo's shadow-caster pass both hide by clearing + * {@link SCENE_LAYER}, and they overlap freely — either can start or end while + * the other is up. While each stashed and restored the mask privately, the + * second to finish wrote back a mask the first had since changed. Recording + * *reasons* rather than masks makes the order irrelevant: the mask is + * recomputed from the one snapshot taken when the first reason arrived, and + * handed back only when the last one leaves. + */ +const HOLD = Symbol('pascal:scene-visibility:hold') + +type Hold = { original: number; reasons: Set } + +type Holder = Object3D & { [HOLD]?: Hold } + +/** Holds `obj` off the scene layer for `reason`. Idempotent per reason. */ +export function hideFromScene(obj: Object3D, reason: HiddenReason): void { + const holder = obj as Holder + const hold = holder[HOLD] ?? { original: obj.layers.mask, reasons: new Set() } + holder[HOLD] = hold + hold.reasons.add(reason) + applyHold(obj, hold) +} + +/** Drops `reason`, restoring the mask `obj` had before the first one arrived. */ +export function showInScene(obj: Object3D, reason: HiddenReason): void { + const holder = obj as Holder + const hold = holder[HOLD] + if (!hold) return + + hold.reasons.delete(reason) + if (hold.reasons.size > 0) { + applyHold(obj, hold) + return + } + + obj.layers.mask = hold.original + delete holder[HOLD] +} + +function applyHold(obj: Object3D, hold: Hold): void { + obj.layers.mask = hold.original + obj.layers.disable(SCENE_LAYER) + if (hold.reasons.has('shadow-only')) obj.layers.enable(SHADOW_ONLY_LAYER) +} diff --git a/packages/viewer/src/lib/shadow-only.ts b/packages/viewer/src/lib/shadow-only.ts index c63c10ab8..209c985c3 100644 --- a/packages/viewer/src/lib/shadow-only.ts +++ b/packages/viewer/src/lib/shadow-only.ts @@ -1,5 +1,5 @@ import type { Object3D } from 'three' -import { SCENE_LAYER, SHADOW_ONLY_LAYER } from './layers' +import { hideFromScene, showInScene } from './scene-visibility' /** * Shadow-caster-only hiding: removes an object (and its descendants) from the @@ -9,34 +9,22 @@ import { SCENE_LAYER, SHADOW_ONLY_LAYER } from './layers' * Uses layer masks instead of `visible = false` for two reasons: `visible` * cascades (and, critically, prunes the object from the shadow pass too), * while layers are tested per-object against the rendering camera — the main - * camera never enables {@link SHADOW_ONLY_LAYER}, but every shadow-casting + * camera never enables the shadow-only layer, but every shadow-casting * light's shadow camera does (see `lights.tsx`). * - * The original `layers.mask` is stashed under a private Symbol so - * {@link clearShadowOnly} restores the exact prior state. Both calls are - * idempotent and cheap to reapply. + * The mask itself belongs to `lib/scene-visibility.ts`, which reconciles this + * with the isolation filter. Both calls are idempotent and cheap to reapply — + * solo re-runs `applyShadowOnly` every frame so meshes rebuilt while hidden + * get re-hidden. */ - -const ORIGINAL_LAYERS = Symbol('pascal:shadow-only:original-layers') - -type ShadowOnlyCarrier = Object3D & { [ORIGINAL_LAYERS]?: number } - export function applyShadowOnly(root: Object3D): void { root.traverse((obj) => { - const carrier = obj as ShadowOnlyCarrier - if (carrier[ORIGINAL_LAYERS] === undefined) { - carrier[ORIGINAL_LAYERS] = obj.layers.mask - } - obj.layers.disable(SCENE_LAYER) - obj.layers.enable(SHADOW_ONLY_LAYER) + hideFromScene(obj, 'shadow-only') }) } export function clearShadowOnly(root: Object3D): void { root.traverse((obj) => { - const carrier = obj as ShadowOnlyCarrier - if (carrier[ORIGINAL_LAYERS] === undefined) return - obj.layers.mask = carrier[ORIGINAL_LAYERS] - delete carrier[ORIGINAL_LAYERS] + showInScene(obj, 'shadow-only') }) } From 636efadf6a8fe8f17ce1c0d23c30475f5d3241e8 Mon Sep 17 00:00:00 2001 From: Andrei Efremov Date: Wed, 5 Aug 2026 23:25:17 +0300 Subject: [PATCH 2/7] perf(viewer): draw each wall material once instead of once per face run 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 --- .../viewer/src/lib/geometry-groups.test.ts | 99 +++++++++++++++++++ packages/viewer/src/lib/geometry-groups.ts | 63 ++++++++++++ .../viewer/src/systems/wall/wall-system.tsx | 17 +--- 3 files changed, 164 insertions(+), 15 deletions(-) create mode 100644 packages/viewer/src/lib/geometry-groups.test.ts create mode 100644 packages/viewer/src/lib/geometry-groups.ts diff --git a/packages/viewer/src/lib/geometry-groups.test.ts b/packages/viewer/src/lib/geometry-groups.test.ts new file mode 100644 index 000000000..141d219c9 --- /dev/null +++ b/packages/viewer/src/lib/geometry-groups.test.ts @@ -0,0 +1,99 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not +// depend on @types/bun so the import type is unresolved at compile time. +import { describe, expect, test } from 'bun:test' +import * as THREE from 'three' +import { setGroupsSortedByMaterial } from './geometry-groups' + +function triangleSoup(triangleCount: number): THREE.BufferGeometry { + const geometry = new THREE.BufferGeometry() + const positions = new Float32Array(triangleCount * 9) + for (let triangle = 0; triangle < triangleCount; triangle += 1) { + positions[triangle * 9] = triangle + positions[triangle * 9 + 3] = triangle + 1 + positions[triangle * 9 + 7] = 1 + } + geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) + return geometry +} + +/** Triangles as vertex-index triples, in the order the GPU would draw them. */ +function drawnTriangles(geometry: THREE.BufferGeometry): number[][] { + const index = geometry.getIndex() + const count = index ? index.count : geometry.getAttribute('position').count + const triangles: number[][] = [] + for (let base = 0; base < count; base += 3) { + triangles.push( + index + ? [index.getX(base), index.getX(base + 1), index.getX(base + 2)] + : [base, base + 1, base + 2], + ) + } + return triangles +} + +describe('setGroupsSortedByMaterial', () => { + test('collapses interleaved materials into one group each', () => { + const geometry = triangleSoup(6) + + setGroupsSortedByMaterial(geometry, [0, 1, 0, 2, 1, 0]) + + expect(geometry.groups.map((group) => group.materialIndex)).toEqual([0, 1, 2]) + expect(geometry.groups.map((group) => group.count)).toEqual([9, 6, 3]) + expect(geometry.groups.map((group) => group.start)).toEqual([0, 9, 15]) + }) + + test('keeps every triangle exactly once, only reordered', () => { + const geometry = triangleSoup(6) + + setGroupsSortedByMaterial(geometry, [0, 1, 0, 2, 1, 0]) + + const drawn = drawnTriangles(geometry) + expect(drawn).toHaveLength(6) + expect([...drawn].sort((a, b) => a[0]! - b[0]!)).toEqual([ + [0, 1, 2], + [3, 4, 5], + [6, 7, 8], + [9, 10, 11], + [12, 13, 14], + [15, 16, 17], + ]) + }) + + test('draws each group with the material its triangles were assigned', () => { + const geometry = triangleSoup(4) + const assignment = [2, 0, 2, 1] + + setGroupsSortedByMaterial(geometry, assignment) + + const drawn = drawnTriangles(geometry) + for (const group of geometry.groups) { + for (let offset = 0; offset < group.count; offset += 3) { + const sourceTriangle = drawn[(group.start + offset) / 3]![0]! / 3 + expect(assignment[sourceTriangle]).toBe(group.materialIndex!) + } + } + }) + + test('leaves a single-material geometry unindexed', () => { + const geometry = triangleSoup(3) + + setGroupsSortedByMaterial(geometry, [1, 1, 1]) + + expect(geometry.getIndex()).toBeNull() + expect(geometry.groups).toEqual([{ start: 0, count: 9, materialIndex: 1 }]) + }) + + test('reorders an existing index buffer instead of the vertices', () => { + const geometry = triangleSoup(3) + geometry.setIndex([6, 7, 8, 0, 1, 2, 3, 4, 5]) + + setGroupsSortedByMaterial(geometry, [1, 0, 1]) + + expect(drawnTriangles(geometry)).toEqual([ + [0, 1, 2], + [6, 7, 8], + [3, 4, 5], + ]) + expect(geometry.getAttribute('position').getX(0)).toBe(0) + }) +}) diff --git a/packages/viewer/src/lib/geometry-groups.ts b/packages/viewer/src/lib/geometry-groups.ts new file mode 100644 index 000000000..64862ed9e --- /dev/null +++ b/packages/viewer/src/lib/geometry-groups.ts @@ -0,0 +1,63 @@ +import * as THREE from 'three' + +/** + * Rewrites a geometry's material groups so every material is drawn exactly once. + * + * A group is a contiguous slice of the index buffer, so a mesh whose triangles + * alternate between materials pays a draw call per *run*, not per material. + * Extruded walls hit this hard: `ExtrudeGeometry` emits the cap and side faces + * interleaved, so run-length grouping produces four groups for two materials — + * multiplied by a thousand walls, that is thousands of avoidable draw calls. + * Bucketing the triangles by material first makes each material one run. + * + * The geometry gains an index buffer if it had none. Triangle winding, vertex + * data and material assignment are untouched, so the rendered image is + * unchanged; only the order in which the GPU is asked to draw it differs. + */ +export function setGroupsSortedByMaterial( + geometry: THREE.BufferGeometry, + triangleMaterials: ArrayLike, +): void { + geometry.clearGroups() + + const position = geometry.getAttribute('position') + if (!position) return + + const sourceIndex = geometry.getIndex() + const triangleCount = Math.min( + triangleMaterials.length, + sourceIndex ? Math.floor(sourceIndex.count / 3) : Math.floor(position.count / 3), + ) + if (triangleCount === 0) return + + const buckets = new Map() + for (let triangle = 0; triangle < triangleCount; triangle += 1) { + const material = triangleMaterials[triangle] ?? 0 + const bucket = buckets.get(material) + if (bucket) bucket.push(triangle) + else buckets.set(material, [triangle]) + } + + const singleMaterial = buckets.size === 1 ? [...buckets.keys()][0] : undefined + if (singleMaterial !== undefined) { + geometry.addGroup(0, triangleCount * 3, singleMaterial) + return + } + + const reordered = new Uint32Array(triangleCount * 3) + let cursor = 0 + + for (const [material, triangles] of [...buckets].sort((left, right) => left[0] - right[0])) { + const groupStart = cursor + for (const triangle of triangles) { + const base = triangle * 3 + reordered[cursor] = sourceIndex ? sourceIndex.getX(base) : base + reordered[cursor + 1] = sourceIndex ? sourceIndex.getX(base + 1) : base + 1 + reordered[cursor + 2] = sourceIndex ? sourceIndex.getX(base + 2) : base + 2 + cursor += 3 + } + geometry.addGroup(groupStart, cursor - groupStart, material) + } + + geometry.setIndex(new THREE.BufferAttribute(reordered, 1)) +} diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index f84f48c0f..a80dc0bda 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -39,6 +39,7 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' import { computeBoundsTree } from 'three-mesh-bvh' import { ensureRenderableGeometryAttributes, prepareBrushForCSG } from '../../lib/csg-utils' +import { setGroupsSortedByMaterial } from '../../lib/geometry-groups' import { buildTerrainPerimeterFillGeometry } from '../../lib/terrain-perimeter-fill' import { clearLevelMiterCache, getCachedLevelMiters } from './level-miter-cache' import { @@ -333,21 +334,7 @@ function assignWallMaterialGroups( ) } - geometry.clearGroups() - - let currentMaterial = triangleMaterials[0] ?? 0 - let groupStart = 0 - - for (let triangleIndex = 1; triangleIndex < triangleCount; triangleIndex += 1) { - const materialIndex = triangleMaterials[triangleIndex] ?? 0 - if (materialIndex === currentMaterial) continue - - geometry.addGroup(groupStart * 3, (triangleIndex - groupStart) * 3, currentMaterial) - groupStart = triangleIndex - currentMaterial = materialIndex - } - - geometry.addGroup(groupStart * 3, (triangleCount - groupStart) * 3, currentMaterial) + setGroupsSortedByMaterial(geometry, triangleMaterials) } type SplitVertex = { From e11fc3ebf1e1eb635622966fa3dee83d244e665b Mon Sep 17 00:00:00 2001 From: Andrei Efremov Date: Wed, 5 Aug 2026 23:29:23 +0300 Subject: [PATCH 3/7] perf(viewer): sew a level's walls into one mesh 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 --- .../nodes/src/measurement/surface-query.ts | 10 +- packages/nodes/src/measurement/tool.tsx | 6 +- packages/nodes/src/wall/system.tsx | 6 +- packages/viewer/src/index.ts | 10 +- packages/viewer/src/lib/layers.ts | 24 ++ .../viewer/src/lib/scene-visibility.test.ts | 30 +- packages/viewer/src/lib/scene-visibility.ts | 20 +- packages/viewer/src/lib/wall-batch.test.ts | 122 +++++++ packages/viewer/src/lib/wall-batch.ts | 230 +++++++++++++ .../src/systems/wall/wall-batch-system.tsx | 308 ++++++++++++++++++ .../viewer/src/systems/wall/wall-system.tsx | 25 +- 11 files changed, 772 insertions(+), 19 deletions(-) create mode 100644 packages/viewer/src/lib/wall-batch.test.ts create mode 100644 packages/viewer/src/lib/wall-batch.ts create mode 100644 packages/viewer/src/systems/wall/wall-batch-system.tsx diff --git a/packages/nodes/src/measurement/surface-query.ts b/packages/nodes/src/measurement/surface-query.ts index a101bf2c9..0bcd58484 100644 --- a/packages/nodes/src/measurement/surface-query.ts +++ b/packages/nodes/src/measurement/surface-query.ts @@ -9,7 +9,7 @@ import { useScene, } from '@pascal-app/core' import type { MeasurementAxis, MeasurementAxisGuide, MeasurementPoint } from '@pascal-app/editor' -import { SCENE_LAYER, ZONE_LAYER } from '@pascal-app/viewer' +import { setSurfaceRaycastLayers, ZONE_LAYER } from '@pascal-app/viewer' import { type Camera, type InstancedMesh, @@ -698,7 +698,7 @@ function collectMeasurementAxisSurfaceIntersections( const origin = levelObject.localToWorld(new Vector3(...anchor)) const levelRotation = levelObject.getWorldQuaternion(new Quaternion()) const inverseLevelRotation = levelRotation.clone().invert() - raycaster.layers.set(SCENE_LAYER) + setSurfaceRaycastLayers(raycaster.layers) raycaster.near = 0 raycaster.far = maxDistance const intersections: MeasurementAxisSurfaceIntersection[] = [] @@ -761,9 +761,9 @@ export function createMeasurementSurfaceQuerySession( const verificationRaycaster = new Raycaster() const axisRaycaster = new Raycaster() const pointer = new Vector2() - pointerRaycaster.layers.set(SCENE_LAYER) - verificationRaycaster.layers.set(SCENE_LAYER) - axisRaycaster.layers.set(SCENE_LAYER) + setSurfaceRaycastLayers(pointerRaycaster.layers) + setSurfaceRaycastLayers(verificationRaycaster.layers) + setSurfaceRaycastLayers(axisRaycaster.layers) if (options.includeZoneLayer) pointerRaycaster.layers.enable(ZONE_LAYER) let context: MeasurementRaycastContext | null = null diff --git a/packages/nodes/src/measurement/tool.tsx b/packages/nodes/src/measurement/tool.tsx index 361b46d5f..ea429af04 100644 --- a/packages/nodes/src/measurement/tool.tsx +++ b/packages/nodes/src/measurement/tool.tsx @@ -45,7 +45,7 @@ import { useInteractionScope, useMeasurementDraft, } from '@pascal-app/editor' -import { SCENE_LAYER, useViewer } from '@pascal-app/viewer' +import { setSurfaceRaycastLayers, useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' import { useFrame, useThree } from '@react-three/fiber' import { type FC, useEffect, useMemo, useRef, useState } from 'react' @@ -435,7 +435,7 @@ export function collectMeasurementAxisSurfaceIntersections( const origin = levelObject.localToWorld(new Vector3(...anchor)) const levelRotation = levelObject.getWorldQuaternion(new Quaternion()) const raycaster = new Raycaster() - raycaster.layers.set(SCENE_LAYER) + setSurfaceRaycastLayers(raycaster.layers) raycaster.near = 0 raycaster.far = maxDistance const intersections: MeasurementAxisSurfaceIntersection[] = [] @@ -1775,7 +1775,7 @@ export const MeasurementTool: FC = () => { const surfaceQuery = useMemo(() => createMeasurementSurfaceQuerySession(scene), [scene]) useEffect(() => { - raycaster.current.layers.set(SCENE_LAYER) + setSurfaceRaycastLayers(raycaster.current.layers) }, []) useEffect(() => () => surfaceQuery.dispose(), [surfaceQuery]) diff --git a/packages/nodes/src/wall/system.tsx b/packages/nodes/src/wall/system.tsx index 7f880f340..5c25e39c5 100644 --- a/packages/nodes/src/wall/system.tsx +++ b/packages/nodes/src/wall/system.tsx @@ -1,7 +1,7 @@ 'use client' import { type AnyNodeId, useLiveNodeOverrides, useScene, type WallNode } from '@pascal-app/core' -import { WallCutout, WallSystem } from '@pascal-app/viewer' +import { WallBatchSystem, WallCutout, WallSystem } from '@pascal-app/viewer' import { useFrame } from '@react-three/fiber' import { buildWallTreatmentLevelData, useWallTreatmentLevelData } from './treatment-level-data' import { wallTreatmentProudOffsets } from './treatments' @@ -49,6 +49,9 @@ const WallTreatmentMiterSystem = () => { * bulk of the wall runtime (~820 lines in viewer). * - **`WallCutout`** — cutaway-mode hide/show logic based on camera * direction and `frontSide` / `backSide` interior/exterior tags. + * - **`WallBatchSystem`** — once a level stops changing, sews its opaque + * walls into one mesh per material set so a floor costs a handful of + * draw calls instead of one per wall face run. */ const WallSystems = () => { return ( @@ -56,6 +59,7 @@ const WallSystems = () => { + ) } diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 8494f1e3a..a65afe49f 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -89,7 +89,14 @@ export { isIsolationActive, } from './lib/isolation' export { configureKtx2Support, ensureKtx2Support } from './lib/ktx2-loader' -export { GRID_LAYER, OVERLAY_LAYER, SCENE_LAYER, ZONE_LAYER } from './lib/layers' +export { + BATCHED_LAYER, + GRID_LAYER, + OVERLAY_LAYER, + SCENE_LAYER, + setSurfaceRaycastLayers, + ZONE_LAYER, +} from './lib/layers' export { applyMaterialPresetToMaterials, BLUEPRINT_PALETTE, @@ -234,6 +241,7 @@ export { getOpeningCutoutBottomPadding, hasFlatOpeningCutoutBottom, } from './systems/wall/opening-cutout-geometry' +export { WallBatchSystem } from './systems/wall/wall-batch-system' export { getWallHideState, WallCutout } from './systems/wall/wall-cutout' export { getVisibleWallMaterials } from './systems/wall/wall-materials' // Wall internals re-exported so `@pascal-app/nodes`' registry-driven wall diff --git a/packages/viewer/src/lib/layers.ts b/packages/viewer/src/lib/layers.ts index a0603be00..22817ecd8 100644 --- a/packages/viewer/src/lib/layers.ts +++ b/packages/viewer/src/lib/layers.ts @@ -1,3 +1,5 @@ +import type { Layers } from 'three' + /** Default Three.js layer for main scene geometry. */ export const SCENE_LAYER = 0 @@ -34,3 +36,25 @@ export const GRID_LAYER = 3 * cascade) via `applyShadowOnly` / `clearShadowOnly` in `lib/shadow-only.ts`. */ export const SHADOW_ONLY_LAYER = 4 + +/** + * Layer for wall geometry that a level batch already draws (see + * `lib/wall-batch.ts`). No camera or pass enables it, so a sewn wall costs no + * draw call, yet the mesh keeps its place in the graph: its children (door and + * window cutters, treatments) still render, and the invisible `collision-mesh` + * that carries pointer events is untouched. + * + * Raycasters that query real surfaces must opt in via + * {@link setSurfaceRaycastLayers}, otherwise a sewn wall would stop answering + * measurement rays. + */ +export const BATCHED_LAYER = 5 + +/** + * Aims a raycaster at every real scene surface, whether a wall still draws + * itself or a level batch draws it for us. + */ +export function setSurfaceRaycastLayers(layers: Layers): void { + layers.set(SCENE_LAYER) + layers.enable(BATCHED_LAYER) +} diff --git a/packages/viewer/src/lib/scene-visibility.test.ts b/packages/viewer/src/lib/scene-visibility.test.ts index b47ad42b6..420d899ee 100644 --- a/packages/viewer/src/lib/scene-visibility.test.ts +++ b/packages/viewer/src/lib/scene-visibility.test.ts @@ -2,7 +2,7 @@ // depend on @types/bun so the import type is unresolved at compile time. import { describe, expect, test } from 'bun:test' import * as THREE from 'three' -import { OVERLAY_LAYER, SCENE_LAYER, SHADOW_ONLY_LAYER } from './layers' +import { BATCHED_LAYER, OVERLAY_LAYER, SCENE_LAYER, SHADOW_ONLY_LAYER } from './layers' import { hideFromScene, showInScene } from './scene-visibility' function sceneObject(): THREE.Object3D { @@ -52,6 +52,34 @@ describe('scene visibility', () => { expect(obj.layers.isEnabled(SCENE_LAYER)).toBe(false) }) + test('the batch outranks solo, and leaving solo does not un-sew the wall', () => { + const obj = sceneObject() + + hideFromScene(obj, 'batched') + hideFromScene(obj, 'shadow-only') + expect(obj.layers.isEnabled(BATCHED_LAYER)).toBe(true) + expect(obj.layers.isEnabled(SHADOW_ONLY_LAYER)).toBe(false) + + showInScene(obj, 'shadow-only') + expect(obj.layers.isEnabled(BATCHED_LAYER)).toBe(true) + expect(obj.layers.isEnabled(SCENE_LAYER)).toBe(false) + + showInScene(obj, 'batched') + expect(obj.layers.isEnabled(SCENE_LAYER)).toBe(true) + }) + + test('dropping the batch under solo leaves the wall casting shadows', () => { + const obj = sceneObject() + + hideFromScene(obj, 'shadow-only') + hideFromScene(obj, 'batched') + showInScene(obj, 'batched') + + expect(obj.layers.isEnabled(SHADOW_ONLY_LAYER)).toBe(true) + expect(obj.layers.isEnabled(SCENE_LAYER)).toBe(false) + expect(obj.layers.isEnabled(BATCHED_LAYER)).toBe(false) + }) + test('re-hiding for a reason already held changes nothing', () => { const obj = sceneObject() diff --git a/packages/viewer/src/lib/scene-visibility.ts b/packages/viewer/src/lib/scene-visibility.ts index 86bebe839..2e5a5d39b 100644 --- a/packages/viewer/src/lib/scene-visibility.ts +++ b/packages/viewer/src/lib/scene-visibility.ts @@ -1,20 +1,21 @@ import type { Object3D } from 'three' -import { SCENE_LAYER, SHADOW_ONLY_LAYER } from './layers' +import { BATCHED_LAYER, SCENE_LAYER, SHADOW_ONLY_LAYER } from './layers' /** * Why an object is currently held off the scene layer. * * - `isolated` — outside the focused subtree of the viewer's isolation filter. * - `shadow-only` — solo mode: out of the color passes, still casting shadows. + * - `batched` — a level's merged wall mesh draws this wall now. */ -export type HiddenReason = 'isolated' | 'shadow-only' +export type HiddenReason = 'isolated' | 'shadow-only' | 'batched' /** * Single owner of `Object3D.layers` for every feature that hides an object. * - * Isolation and solo's shadow-caster pass both hide by clearing - * {@link SCENE_LAYER}, and they overlap freely — either can start or end while - * the other is up. While each stashed and restored the mask privately, the + * Isolation, solo's shadow-caster pass and wall batching all hide by clearing + * {@link SCENE_LAYER}, and they overlap freely — a wall can be sewn into a + * batch, then soloed, then isolated. While each stashed and restored the mask privately, the * second to finish wrote back a mask the first had since changed. Recording * *reasons* rather than masks makes the order irrelevant: the mask is * recomputed from the one snapshot taken when the first reason arrived, and @@ -54,5 +55,14 @@ export function showInScene(obj: Object3D, reason: HiddenReason): void { function applyHold(obj: Object3D, hold: Hold): void { obj.layers.mask = hold.original obj.layers.disable(SCENE_LAYER) + + // A batched wall is both drawn and shadowed by the merged mesh, so it stays + // out of the shadow pass too — enabling the shadow-only bit would submit its + // triangles a second time, on top of the copy the batch already casts. + if (hold.reasons.has('batched')) { + obj.layers.enable(BATCHED_LAYER) + return + } + if (hold.reasons.has('shadow-only')) obj.layers.enable(SHADOW_ONLY_LAYER) } diff --git a/packages/viewer/src/lib/wall-batch.test.ts b/packages/viewer/src/lib/wall-batch.test.ts new file mode 100644 index 000000000..5171b7e08 --- /dev/null +++ b/packages/viewer/src/lib/wall-batch.test.ts @@ -0,0 +1,122 @@ +// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not +// depend on @types/bun so the import type is unresolved at compile time. +import { describe, expect, test } from 'bun:test' +import * as THREE from 'three' +import { applyWallBatchGroups, buildWallBatch, type WallBatchSource } from './wall-batch' + +/** One triangle per material index, laid out the way a wall arrives: non-indexed, groups sorted. */ +function wallLike(materialIndices: number[], offsetX: number): THREE.BufferGeometry { + const geometry = new THREE.BufferGeometry() + const positions = new Float32Array(materialIndices.length * 9) + const normals = new Float32Array(materialIndices.length * 9) + const uvs = new Float32Array(materialIndices.length * 6) + + for (let triangle = 0; triangle < materialIndices.length; triangle += 1) { + for (let vertex = 0; vertex < 3; vertex += 1) { + const base = triangle * 9 + vertex * 3 + positions[base] = offsetX + triangle + positions[base + 1] = vertex + normals[base] = 1 + } + geometry.addGroup(triangle * 3, 3, materialIndices[triangle] as number) + } + + geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) + geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)) + geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)) + return geometry +} + +function source( + nodeId: string, + materialIndices: number[], + offsetX: number, + moveX = 0, +): WallBatchSource { + return { + nodeId, + geometry: wallLike(materialIndices, offsetX), + matrix: new THREE.Matrix4().makeTranslation(moveX, 0, 0), + } +} + +function groupsOf(geometry: THREE.BufferGeometry) { + return geometry.groups.map((group) => [group.start, group.count, group.materialIndex]) +} + +describe('buildWallBatch', () => { + test('collapses every source into one run per material index', () => { + const batch = buildWallBatch([ + source('a', [0, 1, 2], 0), + source('b', [0, 1, 2], 10), + source('c', [0, 1, 2], 20), + ]) + + expect(batch).not.toBeNull() + expect(batch?.runs.map((run) => run.materialIndex)).toEqual([0, 1, 2]) + expect(groupsOf(batch?.geometry as THREE.BufferGeometry)).toEqual([ + [0, 9, 0], + [9, 9, 1], + [18, 9, 2], + ]) + }) + + test('bakes each source matrix into the merged positions', () => { + const batch = buildWallBatch([source('a', [0], 0), source('b', [0], 0, 5)]) + const positions = batch?.geometry.getAttribute('position') as THREE.BufferAttribute + + expect(positions.getX(0)).toBeCloseTo(0) + expect(positions.getX(3)).toBeCloseTo(5) + }) + + test('keeps only the attributes every source carries', () => { + const bare = source('b', [0], 0) + bare.geometry.deleteAttribute('uv') + + const batch = buildWallBatch([source('a', [0], 0), bare]) + + expect(batch?.geometry.getAttribute('position')).toBeDefined() + expect(batch?.geometry.getAttribute('normal')).toBeDefined() + expect(batch?.geometry.getAttribute('uv')).toBeUndefined() + }) + + test('records a slice per source inside every run', () => { + const batch = buildWallBatch([source('a', [0, 1], 0), source('b', [0, 1], 10)]) + const firstRun = batch?.runs[0] + + expect(firstRun?.slices.map((slice) => slice.nodeId)).toEqual(['a', 'b']) + expect(firstRun?.slices.map((slice) => slice.count)).toEqual([3, 3]) + }) +}) + +describe('applyWallBatchGroups', () => { + test('cuts a hidden wall out of every run without touching the buffers', () => { + const batch = buildWallBatch([ + source('a', [0, 1], 0), + source('b', [0, 1], 10), + source('c', [0, 1], 20), + ]) + if (!batch) throw new Error('batch expected') + + const positions = batch.geometry.getAttribute('position') + applyWallBatchGroups(batch, new Set(['b'])) + + expect(groupsOf(batch.geometry)).toEqual([ + [0, 3, 0], + [6, 3, 0], + [9, 3, 1], + [15, 3, 1], + ]) + expect(batch.geometry.getAttribute('position')).toBe(positions) + }) + + test('restores the full runs once nothing is hidden', () => { + const batch = buildWallBatch([source('a', [0], 0), source('b', [0], 10)]) + if (!batch) throw new Error('batch expected') + + applyWallBatchGroups(batch, new Set(['a'])) + applyWallBatchGroups(batch, new Set()) + + expect(groupsOf(batch.geometry)).toEqual([[0, 6, 0]]) + }) +}) diff --git a/packages/viewer/src/lib/wall-batch.ts b/packages/viewer/src/lib/wall-batch.ts new file mode 100644 index 000000000..ad8bd9c9d --- /dev/null +++ b/packages/viewer/src/lib/wall-batch.ts @@ -0,0 +1,230 @@ +import * as THREE from 'three' +import { hideFromScene, showInScene } from './scene-visibility' + +/** A contiguous vertex range one wall contributes to one material run. */ +export type WallBatchSlice = { nodeId: string; start: number; count: number } + +/** Every triangle drawn with one material index, in wall order. */ +export type WallBatchRun = { + materialIndex: number + start: number + count: number + slices: WallBatchSlice[] +} + +export type WallBatchSource = { + nodeId: string + geometry: THREE.BufferGeometry + /** Source-local to batch-root transform, baked into the merged vertices. */ + matrix: THREE.Matrix4 +} + +export type WallBatch = { + geometry: THREE.BufferGeometry + runs: WallBatchRun[] +} + +const BATCH_ATTRIBUTES = ['position', 'normal', 'uv', 'uv2'] as const +type BatchAttribute = (typeof BATCH_ATTRIBUTES)[number] +const ATTRIBUTE_ITEM_SIZE: Record = { + position: 3, + normal: 3, + uv: 2, + uv2: 2, +} + +type PlannedGroup = { materialIndex: number; start: number; count: number } +type PlannedSource = { source: WallBatchSource; groups: PlannedGroup[] } + +function planSource(source: WallBatchSource): PlannedSource | null { + const position = source.geometry.getAttribute('position') + if (!position || position.count === 0) return null + + const declared = + source.geometry.groups.length > 0 + ? source.geometry.groups + : [{ start: 0, count: position.count, materialIndex: 0 }] + + const groups: PlannedGroup[] = [] + for (const group of declared) { + const start = Math.max(0, group.start) + const count = Math.min(group.count, position.count - start) + if (count <= 0) continue + groups.push({ materialIndex: group.materialIndex ?? 0, start, count }) + } + + return groups.length > 0 ? { source, groups } : null +} + +/** + * Concatenates wall geometries into one buffer laid out material-major, + * wall-minor: every triangle sharing a material index ends up in a single + * contiguous run, so the merged mesh costs one draw call per material + * instead of one per wall per material. + * + * Vertices are baked into the batch root's frame, so the merged mesh needs + * no transform of its own. Each wall's slice of every run is recorded, which + * is what lets a single wall be pulled back out later without touching the + * buffers — see `applyWallBatchGroups`. + * + * Sources must be non-indexed (the wall pipeline's `applyWorldPlanarWallUVs` + * already de-indexes) and are skipped if they carry no positions. + */ +export function buildWallBatch(sources: readonly WallBatchSource[]): WallBatch | null { + const planned: PlannedSource[] = [] + const totals = new Map() + + for (const source of sources) { + const entry = planSource(source) + if (!entry) continue + planned.push(entry) + for (const group of entry.groups) { + totals.set(group.materialIndex, (totals.get(group.materialIndex) ?? 0) + group.count) + } + } + + if (planned.length === 0) return null + + const names = BATCH_ATTRIBUTES.filter((name) => + planned.every((entry) => entry.source.geometry.getAttribute(name)), + ) + if (!names.includes('position')) return null + + let totalVertices = 0 + for (const count of totals.values()) totalVertices += count + + const buffers = new Map( + names.map((name) => [name, new Float32Array(totalVertices * ATTRIBUTE_ITEM_SIZE[name])]), + ) + + const normalMatrix = new THREE.Matrix3() + const vector = new THREE.Vector3() + const runs: WallBatchRun[] = [] + let cursor = 0 + + for (const materialIndex of [...totals.keys()].sort((left, right) => left - right)) { + const runStart = cursor + const slices: WallBatchSlice[] = [] + + for (const entry of planned) { + const sliceStart = cursor + normalMatrix.getNormalMatrix(entry.source.matrix) + + for (const group of entry.groups) { + if (group.materialIndex !== materialIndex) continue + copyGroup(entry.source, group, names, buffers, cursor, normalMatrix, vector) + cursor += group.count + } + + if (cursor > sliceStart) { + slices.push({ nodeId: entry.source.nodeId, start: sliceStart, count: cursor - sliceStart }) + } + } + + runs.push({ materialIndex, start: runStart, count: cursor - runStart, slices }) + } + + const geometry = new THREE.BufferGeometry() + for (const name of names) { + geometry.setAttribute( + name, + new THREE.BufferAttribute(buffers.get(name)!, ATTRIBUTE_ITEM_SIZE[name]), + ) + } + geometry.computeBoundingSphere() + geometry.computeBoundingBox() + + const batch: WallBatch = { geometry, runs } + applyWallBatchGroups(batch, EMPTY_HIDDEN) + return batch +} + +const EMPTY_HIDDEN: ReadonlySet = new Set() + +function copyGroup( + source: WallBatchSource, + group: PlannedGroup, + names: readonly BatchAttribute[], + buffers: Map, + writeAt: number, + normalMatrix: THREE.Matrix3, + vector: THREE.Vector3, +) { + for (const name of names) { + const attribute = source.geometry.getAttribute(name) + const target = buffers.get(name)! + const itemSize = ATTRIBUTE_ITEM_SIZE[name] + + for (let offset = 0; offset < group.count; offset += 1) { + const from = group.start + offset + const to = (writeAt + offset) * itemSize + + if (name === 'position') { + vector.fromBufferAttribute(attribute, from).applyMatrix4(source.matrix) + target[to] = vector.x + target[to + 1] = vector.y + target[to + 2] = vector.z + } else if (name === 'normal') { + vector.fromBufferAttribute(attribute, from).applyMatrix3(normalMatrix).normalize() + target[to] = vector.x + target[to + 1] = vector.y + target[to + 2] = vector.z + } else { + target[to] = attribute.getX(from) + target[to + 1] = attribute.getY(from) + } + } + } +} + +/** + * Rewrites the merged geometry's draw groups so the listed walls are skipped. + * + * Pulling a wall out of the batch is what happens while it is being dragged: + * it goes back to drawing itself, and the merged mesh has to stop drawing it + * or the two would overlap. Because each wall owns a contiguous slice of each + * run, skipping it is a matter of splitting that run around the hole — no + * vertex data moves and nothing is re-uploaded to the GPU, so a drag costs a + * handful of group objects rather than a rebuild of the floor. + * + * Each hidden wall adds at most one extra group (one extra draw call) per run, + * so callers should re-merge once the holes stop being temporary. + */ +export function applyWallBatchGroups(batch: WallBatch, hidden: ReadonlySet): void { + batch.geometry.clearGroups() + + for (const run of batch.runs) { + let cursor = run.start + + if (hidden.size > 0) { + for (const slice of run.slices) { + if (!hidden.has(slice.nodeId)) continue + if (slice.start > cursor) { + batch.geometry.addGroup(cursor, slice.start - cursor, run.materialIndex) + } + cursor = slice.start + slice.count + } + } + + const end = run.start + run.count + if (end > cursor) batch.geometry.addGroup(cursor, end - cursor, run.materialIndex) + } +} + +/** + * Silences a wall the batch now draws. + * + * Emptying the draw range is not enough — three.js still submits a zero-count + * group, so 1000 muted walls cost 1000 draw calls. `visible = false` would + * cost nothing but takes the wall's children (cutters, treatments) down with + * it. Moving the mesh alone off the scene layer skips it in every pass while + * its subtree keeps rendering and picking. + */ +export function hideBatchedWall(mesh: THREE.Object3D): void { + hideFromScene(mesh, 'batched') +} + +/** Hands a wall back its own draw call — unless solo or isolation still hide it. */ +export function revealBatchedWall(mesh: THREE.Object3D): void { + showInScene(mesh, 'batched') +} diff --git a/packages/viewer/src/systems/wall/wall-batch-system.tsx b/packages/viewer/src/systems/wall/wall-batch-system.tsx new file mode 100644 index 000000000..81ab655a1 --- /dev/null +++ b/packages/viewer/src/systems/wall/wall-batch-system.tsx @@ -0,0 +1,308 @@ +'use client' + +import { type AnyNodeId, sceneRegistry, useScene, type WallNode } from '@pascal-app/core' +import { useFrame, useThree } from '@react-three/fiber' +import { useEffect, useRef } from 'react' +import { type Material, Matrix4, Mesh, type Object3D } from 'three' +import { SCENE_LAYER } from '../../lib/layers' +import { + applyWallBatchGroups, + buildWallBatch, + hideBatchedWall, + revealBatchedWall, + type WallBatch, +} from '../../lib/wall-batch' +import { drainRebuiltWalls, getPendingWallRebuildCount } from './wall-system' + +// A level's walls are merged only once they stop changing. Below this many +// walls a merge is not worth the buffer, and the leftovers (a selected wall, +// a lone partition) keep drawing themselves. +const MIN_BATCH_WALLS = 8 +// Quiet window after the last wall change before the merge runs. +const BATCH_SETTLE_MS = 180 + +type BatchRecord = { + levelId: string + mesh: Mesh + batch: WallBatch + hidden: Set + nodeIds: string[] +} + +const batchesByLevel = new Map() +const batchByNode = new Map() +const staleLevels = new Set() +const changedWalls = new Set() +const EMPTY_IDS: ReadonlySet = new Set() +let knownWallCount = -1 +let lastWallChangeAtMs = 0 + +/** + * Batched walls are drawn by the merged mesh but still picked, measured and + * highlighted through their own meshes, so the merged copy must stay out of + * every raycast. + */ +function skipRaycast() { + // intentionally empty — see the note above +} + +function showOwnGeometry(nodeId: string) { + const mesh = sceneRegistry.nodes.get(nodeId) as Mesh | undefined + if (mesh) revealBatchedWall(mesh) +} + +/** Hands a wall back to itself: the merged mesh stops drawing it, it resumes. */ +function releaseWall(nodeId: string) { + const record = batchByNode.get(nodeId) + if (record) { + record.hidden.add(nodeId) + applyWallBatchGroups(record.batch, record.hidden) + batchByNode.delete(nodeId) + } + showOwnGeometry(nodeId) +} + +function disposeLevelBatches(levelId: string) { + const records = batchesByLevel.get(levelId) + if (!records) return + + for (const record of records) { + record.mesh.removeFromParent() + record.batch.geometry.dispose() + for (const nodeId of record.nodeIds) { + if (batchByNode.get(nodeId) === record) batchByNode.delete(nodeId) + showOwnGeometry(nodeId) + } + } + + batchesByLevel.delete(levelId) +} + +type Candidate = { nodeId: string; mesh: Mesh; materials: Material[] } + +/** + * A wall joins a batch only if its whole material set is opaque. Translucent + * and cut-away walls depend on per-object blend ordering, which merging would + * change — they keep the per-wall path. + */ +function toCandidate(nodeId: string, node: WallNode): Candidate | null { + if (node.visible === false) return null + + const mesh = sceneRegistry.nodes.get(nodeId) as Mesh | undefined + if (!mesh?.visible) return null + // Solo's shadow-caster-only pass and the viewer's isolation filter both + // hide a wall by taking it off the scene layer. Sewing it in would put it + // back on screen through the merged mesh, which neither asked for. + if (!mesh.layers.isEnabled(SCENE_LAYER)) return null + if (!mesh.geometry?.getAttribute('position')) return null + + const materials = mesh.material + if (!Array.isArray(materials) || materials.length === 0) return null + if (materials.some((material) => material.transparent)) return null + + return { nodeId, mesh, materials } +} + +function materialSetKey(materials: readonly Material[]): string { + return materials.map((material) => material.uuid).join('|') +} + +function collectCandidates(levelId: string): Map { + const nodes = useScene.getState().nodes + const level = nodes[levelId as AnyNodeId] + const grouped = new Map() + if (level?.type !== 'level') return grouped + + for (const childId of level.children) { + const child = nodes[childId] + if (child?.type !== 'wall') continue + + const candidate = toCandidate(childId, child as WallNode) + if (!candidate) continue + + const key = materialSetKey(candidate.materials) + const bucket = grouped.get(key) + if (bucket) bucket.push(candidate) + else grouped.set(key, [candidate]) + } + + return grouped +} + +/** + * Walls on this level that no batch currently draws. + * + * Editing a wall drops it out of its batch — a group-list rewrite that touches + * no buffer — and it goes back to drawing itself. Re-sewing the level only + * pays off once enough walls have drifted out, so a single edit leaves the + * floor's merged mesh exactly where it was. + */ +function unbatchedWallCount(levelId: string): number { + const nodes = useScene.getState().nodes + const level = nodes[levelId as AnyNodeId] + if (level?.type !== 'level') return 0 + + let count = 0 + for (const childId of level.children) { + if (batchByNode.has(childId)) continue + const child = nodes[childId] + if (child?.type !== 'wall') continue + if (toCandidate(childId, child as WallNode)) count++ + } + + return count +} + +const localMatrix = new Matrix4() +const rootInverse = new Matrix4() + +function mergeLevel(levelId: string) { + disposeLevelBatches(levelId) + + const root = sceneRegistry.nodes.get(levelId) as Object3D | undefined + if (!root) return + + root.updateWorldMatrix(true, false) + rootInverse.copy(root.matrixWorld).invert() + + const records: BatchRecord[] = [] + + for (const candidates of collectCandidates(levelId).values()) { + if (candidates.length < MIN_BATCH_WALLS) continue + + const sources = candidates.map((candidate) => { + candidate.mesh.updateWorldMatrix(true, false) + return { + nodeId: candidate.nodeId, + geometry: candidate.mesh.geometry, + matrix: localMatrix.multiplyMatrices(rootInverse, candidate.mesh.matrixWorld).clone(), + } + }) + + const batch = buildWallBatch(sources) + if (!batch) continue + + const mesh = new Mesh(batch.geometry, candidates[0]!.materials) + mesh.name = 'wall-batch' + mesh.castShadow = true + mesh.receiveShadow = true + mesh.matrixAutoUpdate = false + mesh.raycast = skipRaycast + root.add(mesh) + + const record: BatchRecord = { + levelId, + mesh, + batch, + hidden: new Set(), + nodeIds: candidates.map((candidate) => candidate.nodeId), + } + records.push(record) + + for (const candidate of candidates) { + hideBatchedWall(candidate.mesh) + batchByNode.set(candidate.nodeId, record) + } + } + + if (records.length > 0) batchesByLevel.set(levelId, records) +} + +export const WallBatchSystem = () => { + const invalidate = useThree((state) => state.invalidate) + const wakeRef = useRef | null>(null) + + useFrame(() => runBatchFrame(invalidate, wakeRef), 5) + + useEffect( + () => () => { + if (wakeRef.current) clearTimeout(wakeRef.current) + for (const levelId of [...batchesByLevel.keys()]) disposeLevelBatches(levelId) + changedWalls.clear() + staleLevels.clear() + knownWallCount = -1 + }, + [], + ) + + return null +} + +/** + * Follows the scene's dirty tracking rather than watching the walls itself. + * + * A wall changes for exactly one reason the merged mesh cares about: the wall + * system rebuilt its geometry. That system already runs off `dirtyNodes`, so + * this reads the same signal from both ends — the marks still standing when + * this frame reaches us, and the rebuild notices the wall system left behind + * for the walls whose marks it has already cleared. Nothing here re-derives + * "did this wall move" on its own, and the per-frame cost is the size of the + * dirty set rather than the size of the floor. + */ +function runBatchFrame( + invalidate: () => void, + wakeRef: { current: ReturnType | null }, +) { + const wallIds = sceneRegistry.byType.wall ?? EMPTY_IDS + const nodes = useScene.getState().nodes + + // Walls the wall system rebuilt: it clears each mark as it goes, so by the + // time this runs the store no longer names them. + drainRebuiltWalls(changedWalls) + // Walls still marked: the wall system deferred them to a later frame (a + // progressive import) or their mesh had not mounted yet. + for (const nodeId of useScene.getState().dirtyNodes) { + if (wallIds.has(nodeId)) changedWalls.add(nodeId) + } + + let changed = changedWalls.size > 0 + + for (const nodeId of changedWalls) { + const record = batchByNode.get(nodeId) + if (record) staleLevels.add(record.levelId) + const node = nodes[nodeId as AnyNodeId] + if (node?.type === 'wall' && node.parentId) staleLevels.add(node.parentId) + releaseWall(nodeId) + } + changedWalls.clear() + + // A wall that left the scene carries no mark of its own — deleting one + // dirties the neighbours it re-mitres, not the node that went away. The + // wall count moving is the cheap tell that the batch needs reconciling. + if (wallIds.size !== knownWallCount) { + knownWallCount = wallIds.size + for (const [nodeId, record] of [...batchByNode]) { + if (wallIds.has(nodeId)) continue + staleLevels.add(record.levelId) + releaseWall(nodeId) + changed = true + } + } + + const now = performance.now() + if (changed) lastWallChangeAtMs = now + if (staleLevels.size === 0) return + + // Merging mid-edit would sew stale geometry in: a dragged wall's neighbours + // are deferred to the wall system's trailing-edge flush, and those rebuilds + // land after the drag's last dirty mark. Waiting on its queue — not just on + // a clock — is what keeps a re-sewn floor in step with the walls it copies. + const settled = + !changed && getPendingWallRebuildCount() === 0 && now - lastWallChangeAtMs >= BATCH_SETTLE_MS + + if (!settled) { + // The canvas renders on demand, so nothing would bring us back once the + // scene goes quiet — poke one frame after the window should have closed. + if (wakeRef.current) clearTimeout(wakeRef.current) + wakeRef.current = setTimeout(() => { + wakeRef.current = null + invalidate() + }, BATCH_SETTLE_MS + 20) + return + } + + for (const levelId of staleLevels) { + if (unbatchedWallCount(levelId) >= MIN_BATCH_WALLS) mergeLevel(levelId) + } + staleLevels.clear() +} diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index a80dc0bda..1ae16fb64 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -483,7 +483,22 @@ const WALL_PROGRESSIVE_TIME_BUDGET_MS = 8 let lastWallDirtyAtMs = 0 const pendingAdjacentByLevel = new Map>() -function getPendingAdjacentCount() { +// Walls whose geometry this system replaced since the last drain. +// +// The store's dirty mark is cleared the moment a wall is rebuilt, so anything +// running later in the same frame would never see it. This is that same +// signal, held until a consumer picks it up. Neighbours rebuilt by the +// trailing-edge flush land here too — those never carry a dirty mark at all. +const rebuiltWalls = new Set() + +/** Moves every rebuild notice collected so far into `into`. */ +export function drainRebuiltWalls(into: Set): void { + for (const wallId of rebuiltWalls) into.add(wallId) + rebuiltWalls.clear() +} + +/** Rebuilds this system still owes — neighbours deferred during a drag. */ +export function getPendingWallRebuildCount(): number { let count = 0 for (const ids of pendingAdjacentByLevel.values()) { count += ids.size @@ -575,6 +590,7 @@ export const WallSystem = () => { if (mesh) { updateWallGeometry(wallId, miterData) clearDirty(wallId as AnyNodeId) + rebuiltWalls.add(wallId) rebuiltWallIds.add(wallId) rebuiltWallsThisFrame += 1 } @@ -605,7 +621,7 @@ export const WallSystem = () => { // their correct miter joins. const quiet = !hasDirtyWalls && now - lastWallDirtyAtMs >= DRAG_FLUSH_MS if (quiet && pendingAdjacentByLevel.size > 0) { - const pendingCount = getPendingAdjacentCount() + const pendingCount = getPendingWallRebuildCount() const useProgressiveAdjacentRebuilds = pendingCount > WALL_PROGRESSIVE_DIRTY_THRESHOLD let rebuiltAdjacentThisFrame = 0 const adjacentFrameStartedAt = performance.now() @@ -628,7 +644,10 @@ export const WallSystem = () => { } const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh - if (mesh) updateWallGeometry(wallId, miterData) + if (mesh) { + updateWallGeometry(wallId, miterData) + rebuiltWalls.add(wallId) + } pendingIds.delete(wallId) rebuiltAdjacentThisFrame += 1 } From 21f4fdb8e27c0f55030c39935ddbd96fe71fe013 Mon Sep 17 00:00:00 2001 From: Andrei Efremov Date: Wed, 5 Aug 2026 23:38:31 +0300 Subject: [PATCH 4/7] test(viewer): guard the wall batch draw call budget 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 --- packages/viewer/src/lib/wall-batch.test.ts | 52 ++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/packages/viewer/src/lib/wall-batch.test.ts b/packages/viewer/src/lib/wall-batch.test.ts index 5171b7e08..d608af717 100644 --- a/packages/viewer/src/lib/wall-batch.test.ts +++ b/packages/viewer/src/lib/wall-batch.test.ts @@ -120,3 +120,55 @@ describe('applyWallBatchGroups', () => { expect(groupsOf(batch.geometry)).toEqual([[0, 6, 0]]) }) }) + +/** + * The guard for the merge itself: these numbers must not follow the wall count. + * Drop the batching and every wall goes back to owning its own draw range, so + * the run and group counts below jump from three to a thousand and this fails. + */ +describe('draw call budget', () => { + const MATERIALS = [0, 1, 2] + + function floor(wallCount: number): WallBatchSource[] { + return Array.from({ length: wallCount }, (_, index) => + source(`wall_${index}`, MATERIALS, 0, index * 4), + ) + } + + test('holds one draw range per material however many walls the floor has', () => { + for (const wallCount of [1, 10, 100, 1000]) { + const batch = buildWallBatch(floor(wallCount)) + if (!batch) throw new Error('batch expected') + + expect(batch.runs.length).toBe(MATERIALS.length) + expect(batch.geometry.groups.length).toBe(MATERIALS.length) + expect(batch.runs[0]?.slices.length).toBe(wallCount) + } + }) + + test('keeps every wall addressable inside the collapsed ranges', () => { + const batch = buildWallBatch(floor(1000)) + if (!batch) throw new Error('batch expected') + + for (const run of batch.runs) { + expect(new Set(run.slices.map((slice) => slice.nodeId)).size).toBe(1000) + expect(run.count).toBe(3000) + } + }) + + test('spends draw ranges on the holes, not on the floor', () => { + const batch = buildWallBatch(floor(1000)) + if (!batch) throw new Error('batch expected') + const positions = batch.geometry.getAttribute('position') + + applyWallBatchGroups(batch, new Set(['wall_500'])) + expect(batch.geometry.groups.length).toBe(MATERIALS.length * 2) + + applyWallBatchGroups(batch, new Set(['wall_100', 'wall_500', 'wall_900'])) + expect(batch.geometry.groups.length).toBe(MATERIALS.length * 4) + + applyWallBatchGroups(batch, new Set()) + expect(batch.geometry.groups.length).toBe(MATERIALS.length) + expect(batch.geometry.getAttribute('position')).toBe(positions) + }) +}) From d1162277de4a99a1afd658e2c964eadd5b9c56d2 Mon Sep 17 00:00:00 2001 From: Andrei Efremov Date: Fri, 7 Aug 2026 11:08:37 +0300 Subject: [PATCH 5/7] fix(viewer): stand the wall batch down while isolation is on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/systems/wall/wall-batch-system.tsx | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/viewer/src/systems/wall/wall-batch-system.tsx b/packages/viewer/src/systems/wall/wall-batch-system.tsx index 81ab655a1..31b78733f 100644 --- a/packages/viewer/src/systems/wall/wall-batch-system.tsx +++ b/packages/viewer/src/systems/wall/wall-batch-system.tsx @@ -4,6 +4,7 @@ import { type AnyNodeId, sceneRegistry, useScene, type WallNode } from '@pascal- import { useFrame, useThree } from '@react-three/fiber' import { useEffect, useRef } from 'react' import { type Material, Matrix4, Mesh, type Object3D } from 'three' +import { isIsolationActive } from '../../lib/isolation' import { SCENE_LAYER } from '../../lib/layers' import { applyWallBatchGroups, @@ -36,6 +37,7 @@ const changedWalls = new Set() const EMPTY_IDS: ReadonlySet = new Set() let knownWallCount = -1 let lastWallChangeAtMs = 0 +let batchingSuspended = false /** * Batched walls are drawn by the merged mesh but still picked, measured and @@ -221,6 +223,7 @@ export const WallBatchSystem = () => { changedWalls.clear() staleLevels.clear() knownWallCount = -1 + batchingSuspended = false }, [], ) @@ -279,6 +282,27 @@ function runBatchFrame( } } + // Isolation hides everything outside the focused subtree, and a level's + // merged mesh hangs off the level root — so it goes dark with everything + // else. A focused wall that the batch had sewn in would then be drawn by + // nobody: its own mesh is silent, its stand-in is hidden. Rather than teach + // the filter about merged geometry, the batch stands down for as long as the + // filter is up and sews the floors back together once it lifts. + const isolated = isIsolationActive() + if (isolated !== batchingSuspended) { + batchingSuspended = isolated + for (const levelId of [...batchesByLevel.keys()]) disposeLevelBatches(levelId) + staleLevels.clear() + if (!isolated) { + for (const levelId of sceneRegistry.byType.level ?? EMPTY_IDS) staleLevels.add(levelId) + } + changed = true + } + if (batchingSuspended) { + staleLevels.clear() + return + } + const now = performance.now() if (changed) lastWallChangeAtMs = now if (staleLevels.size === 0) return From e91b2a9fd27a8097fa0663db2573803c50a10a27 Mon Sep 17 00:00:00 2001 From: Andrei Efremov Date: Fri, 7 Aug 2026 14:07:03 +0300 Subject: [PATCH 6/7] stand the batch down in every wall mode but "up" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../wall/wall-batch-suspension.test.ts | 30 ++++++++ .../src/systems/wall/wall-batch-system.tsx | 71 ++++++++++++++++--- 2 files changed, 91 insertions(+), 10 deletions(-) create mode 100644 packages/viewer/src/systems/wall/wall-batch-suspension.test.ts diff --git a/packages/viewer/src/systems/wall/wall-batch-suspension.test.ts b/packages/viewer/src/systems/wall/wall-batch-suspension.test.ts new file mode 100644 index 000000000..5875169f9 --- /dev/null +++ b/packages/viewer/src/systems/wall/wall-batch-suspension.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from 'bun:test' +import type { WallMode } from '../../store/use-viewer' +import { canBatchWalls } from './wall-batch-system' + +/** + * The merged mesh captures one material set when it is sewn and never re-reads + * it, so it may only exist while every batched wall's materials hold still. + * These are the states in which that is true. + */ +describe('canBatchWalls', () => { + test('merges in the one mode that leaves wall materials alone', () => { + expect(canBatchWalls('up', false)).toBe(true) + }) + + test('stands down in cutaway — the facing test re-assigns materials as the camera turns', () => { + expect(canBatchWalls('cutaway', false)).toBe(false) + }) + + test('stands down in the modes that make walls see-through', () => { + expect(canBatchWalls('down', false)).toBe(false) + expect(canBatchWalls('translucent', false)).toBe(false) + }) + + test('stands down under isolation whatever the wall mode', () => { + const modes: WallMode[] = ['up', 'cutaway', 'down', 'translucent'] + for (const mode of modes) { + expect(canBatchWalls(mode, true)).toBe(false) + } + }) +}) diff --git a/packages/viewer/src/systems/wall/wall-batch-system.tsx b/packages/viewer/src/systems/wall/wall-batch-system.tsx index 31b78733f..b4bb2e599 100644 --- a/packages/viewer/src/systems/wall/wall-batch-system.tsx +++ b/packages/viewer/src/systems/wall/wall-batch-system.tsx @@ -13,6 +13,7 @@ import { revealBatchedWall, type WallBatch, } from '../../lib/wall-batch' +import useViewer, { type WallMode } from '../../store/use-viewer' import { drainRebuiltWalls, getPendingWallRebuildCount } from './wall-system' // A level's walls are merged only once they stop changing. Below this many @@ -105,6 +106,43 @@ function toCandidate(nodeId: string, node: WallNode): Candidate | null { return { nodeId, mesh, materials } } +/** + * Whether a level's walls may be merged at all right now. + * + * The merged mesh captures one material set when it is sewn and nothing + * re-reads it, so batching is only sound while every batched wall's materials + * hold still. That is true in one wall mode. `cutaway` re-assigns materials + * from the camera's facing test as the view turns, `down` makes every wall + * see-through and `translucent` does the same by definition — in all three the + * merged copy would keep drawing walls the cutaway pass has since turned to + * glass. Isolation is the other stand-down: it hides the level root the merged + * mesh hangs off, which would leave a focused batched wall drawn by nobody. + */ +export function canBatchWalls(wallMode: WallMode, isolationActive: boolean): boolean { + return !isolationActive && wallMode === 'up' +} + +/** + * Walls the cutaway pass is currently tinting — a selection or a delete hover. + * + * It paints them by swapping the materials on the wall's own mesh, which the + * merged mesh does not follow, so a lit wall goes back to drawing itself. There + * are only ever a handful, and a handful of extra draw calls is what the tint + * costs. + */ +function collectTintedWalls(wallIds: ReadonlySet): string[] { + const viewer = useViewer.getState() + const tinted: string[] = [] + + for (const id of viewer.selection.selectedIds) if (wallIds.has(id)) tinted.push(id) + for (const id of viewer.previewSelectedIds) if (wallIds.has(id)) tinted.push(id) + + const hovered = viewer.hoverHighlightMode === 'delete' ? viewer.hoveredId : null + if (hovered && wallIds.has(hovered)) tinted.push(hovered) + + return tinted +} + function materialSetKey(materials: readonly Material[]): string { return materials.map((material) => material.uuid).join('|') } @@ -269,6 +307,18 @@ function runBatchFrame( } changedWalls.clear() + // A tinted wall paints itself through materials the merged mesh never reads, + // so it goes back to drawing its own geometry for as long as it is lit. It + // stays out afterwards: one wall short of a batch is not worth re-sewing a + // floor over, and the level's own re-merge threshold decides when it is. + for (const nodeId of collectTintedWalls(wallIds)) { + if (!batchByNode.has(nodeId)) continue + const record = batchByNode.get(nodeId) + if (record) staleLevels.add(record.levelId) + releaseWall(nodeId) + changed = true + } + // A wall that left the scene carries no mark of its own — deleting one // dirties the neighbours it re-mitres, not the node that went away. The // wall count moving is the cheap tell that the batch needs reconciling. @@ -282,18 +332,19 @@ function runBatchFrame( } } - // Isolation hides everything outside the focused subtree, and a level's - // merged mesh hangs off the level root — so it goes dark with everything - // else. A focused wall that the batch had sewn in would then be drawn by - // nobody: its own mesh is silent, its stand-in is hidden. Rather than teach - // the filter about merged geometry, the batch stands down for as long as the - // filter is up and sews the floors back together once it lifts. - const isolated = isIsolationActive() - if (isolated !== batchingSuspended) { - batchingSuspended = isolated + // Two things make merging unsound, and both are handled the same way: the + // batch stands down for as long as they hold, and sews the floors back + // together once they lift. Isolation hides everything outside the focused + // subtree, and a level's merged mesh hangs off the level root — so it goes + // dark with everything else, leaving a focused batched wall drawn by nobody. + // Every wall mode but `up` re-assigns wall materials the merged mesh does not + // follow. See `canBatchWalls`. + const suspended = !canBatchWalls(useViewer.getState().wallMode, isIsolationActive()) + if (suspended !== batchingSuspended) { + batchingSuspended = suspended for (const levelId of [...batchesByLevel.keys()]) disposeLevelBatches(levelId) staleLevels.clear() - if (!isolated) { + if (!suspended) { for (const levelId of sceneRegistry.byType.level ?? EMPTY_IDS) staleLevels.add(levelId) } changed = true From 6052bf55b634e9df44cbd959310e7ca92f92122b Mon Sep 17 00:00:00 2001 From: Andrei Efremov Date: Fri, 7 Aug 2026 14:24:59 +0300 Subject: [PATCH 7/7] re-sew a floor when a theme or material switch re-makes its materials 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. --- .../src/systems/wall/wall-batch-system.tsx | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/packages/viewer/src/systems/wall/wall-batch-system.tsx b/packages/viewer/src/systems/wall/wall-batch-system.tsx index b4bb2e599..7c91a641e 100644 --- a/packages/viewer/src/systems/wall/wall-batch-system.tsx +++ b/packages/viewer/src/systems/wall/wall-batch-system.tsx @@ -31,6 +31,56 @@ type BatchRecord = { nodeIds: string[] } +/** + * The inputs that re-make every wall's materials without touching a single + * node. + * + * A wall whose own definition changes is marked dirty and leaves its batch on + * that signal. These four do not go through a node at all — they are viewer + * toggles and the scene's material library — yet the cutaway pass rebuilds + * every wall's material set from them, so a merged mesh holding the old set + * would keep a whole floor looking the way it did before the switch. They + * change only when someone deliberately flips a switch, so re-sewing the + * scene on them is cheap. + */ +type AppearanceInputs = { + shading: unknown + textures: unknown + colorPreset: unknown + sceneTheme: unknown + materials: object | null +} + +const lastAppearance: AppearanceInputs = { + shading: undefined, + textures: undefined, + colorPreset: undefined, + sceneTheme: undefined, + materials: null, +} + +function appearanceChanged(): boolean { + const viewer = useViewer.getState() + const materials = useScene.getState().materials as object + + if ( + lastAppearance.shading === viewer.shading && + lastAppearance.textures === viewer.textures && + lastAppearance.colorPreset === viewer.colorPreset && + lastAppearance.sceneTheme === viewer.sceneTheme && + lastAppearance.materials === materials + ) { + return false + } + + lastAppearance.shading = viewer.shading + lastAppearance.textures = viewer.textures + lastAppearance.colorPreset = viewer.colorPreset + lastAppearance.sceneTheme = viewer.sceneTheme + lastAppearance.materials = materials + return true +} + const batchesByLevel = new Map() const batchByNode = new Map() const staleLevels = new Set() @@ -262,6 +312,11 @@ export const WallBatchSystem = () => { staleLevels.clear() knownWallCount = -1 batchingSuspended = false + lastAppearance.shading = undefined + lastAppearance.textures = undefined + lastAppearance.colorPreset = undefined + lastAppearance.sceneTheme = undefined + lastAppearance.materials = null }, [], ) @@ -319,6 +374,15 @@ function runBatchFrame( changed = true } + // A theme, texture or material-library switch re-makes every wall's + // materials without marking a single node, so the merged copies have to be + // sewn again from the new ones. See `appearanceChanged`. + if (appearanceChanged()) { + for (const levelId of [...batchesByLevel.keys()]) disposeLevelBatches(levelId) + for (const levelId of sceneRegistry.byType.level ?? EMPTY_IDS) staleLevels.add(levelId) + changed = true + } + // A wall that left the scene carries no mark of its own — deleting one // dirties the neighbours it re-mitres, not the node that went away. The // wall count moving is the cheap tell that the batch needs reconciling.