diff --git a/packages/nodes/src/measurement/surface-query.ts b/packages/nodes/src/measurement/surface-query.ts index a101bf2c9b..0bcd584846 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 361b46d5fc..ea429af045 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 7f880f3400..5c25e39c5d 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 8494f1e3af..a65afe49fc 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/geometry-groups.test.ts b/packages/viewer/src/lib/geometry-groups.test.ts new file mode 100644 index 0000000000..141d219c9b --- /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 0000000000..64862ed9ec --- /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/lib/isolation.test.ts b/packages/viewer/src/lib/isolation.test.ts new file mode 100644 index 0000000000..9c64c88fcd --- /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 3c990ac5bb..409a80bc45 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/layers.ts b/packages/viewer/src/lib/layers.ts index a0603be00f..22817ecd8f 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 new file mode 100644 index 0000000000..420d899eef --- /dev/null +++ b/packages/viewer/src/lib/scene-visibility.test.ts @@ -0,0 +1,118 @@ +// @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 { BATCHED_LAYER, 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('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() + + 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 0000000000..2e5a5d39b0 --- /dev/null +++ b/packages/viewer/src/lib/scene-visibility.ts @@ -0,0 +1,68 @@ +import type { Object3D } from 'three' +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' | 'batched' + +/** + * Single owner of `Object3D.layers` for every feature that hides an object. + * + * 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 + * 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) + + // 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/shadow-only.ts b/packages/viewer/src/lib/shadow-only.ts index c63c10ab8e..209c985c3a 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') }) } 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 0000000000..d608af7171 --- /dev/null +++ b/packages/viewer/src/lib/wall-batch.test.ts @@ -0,0 +1,174 @@ +// @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]]) + }) +}) + +/** + * 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) + }) +}) diff --git a/packages/viewer/src/lib/wall-batch.ts b/packages/viewer/src/lib/wall-batch.ts new file mode 100644 index 0000000000..ad8bd9c9d5 --- /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-suspension.test.ts b/packages/viewer/src/systems/wall/wall-batch-suspension.test.ts new file mode 100644 index 0000000000..5875169f98 --- /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 new file mode 100644 index 0000000000..7c91a641e4 --- /dev/null +++ b/packages/viewer/src/systems/wall/wall-batch-system.tsx @@ -0,0 +1,447 @@ +'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 { isIsolationActive } from '../../lib/isolation' +import { SCENE_LAYER } from '../../lib/layers' +import { + applyWallBatchGroups, + buildWallBatch, + hideBatchedWall, + 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 +// 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[] +} + +/** + * 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() +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 + * 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 } +} + +/** + * 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('|') +} + +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 + batchingSuspended = false + lastAppearance.shading = undefined + lastAppearance.textures = undefined + lastAppearance.colorPreset = undefined + lastAppearance.sceneTheme = undefined + lastAppearance.materials = null + }, + [], + ) + + 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 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 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. + 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 + } + } + + // 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 (!suspended) { + 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 + + // 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 f84f48c0ff..1ae16fb64a 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 = { @@ -496,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 @@ -588,6 +590,7 @@ export const WallSystem = () => { if (mesh) { updateWallGeometry(wallId, miterData) clearDirty(wallId as AnyNodeId) + rebuiltWalls.add(wallId) rebuiltWallIds.add(wallId) rebuiltWallsThisFrame += 1 } @@ -618,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() @@ -641,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 }