From 69aa272004b84d62d1ee235abe683cd08b1074ff Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 15 Jun 2026 12:36:06 -0400 Subject: [PATCH] perf(editor): stabilize 3D opening-guide rendering, remove per-tick GPU churn Reuse one THREE.Line + preallocated position buffer per guide slot, mutating endpoints in place each drag tick instead of rebuilding the geometry, line, and two Vector3s and re-uploading the GPU buffer every frame. Key guides by a stable semantic id (sill / head / gap:side / vertical / spacing:i) so a slot that persists keeps its React element and drei pill mounted as the guide set churns, rather than remounting under shifting index keys. Also: make useOpeningGuides.clear() a no-op when already empty so the common no-guide hover frame doesn't push a fresh [] and re-render to the same nothing; dispose the move-tool cursor EdgesGeometry on unmount; and memoize the placement-tool cursor EdgesGeometry (static fallback dims) so it isn't reallocated and orphaned on every render during placement. Reviewed by Codex (peer + adversarial): no correctness, hook-order, or GPU-leak regressions. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../editor/opening-guides-3d-layer.tsx | 44 ++++++++++++------- .../editor/src/store/use-opening-guides.ts | 16 +++++-- packages/nodes/src/door/move-tool.tsx | 1 + packages/nodes/src/door/tool.tsx | 16 ++++--- .../src/shared/opening-guides-runtime.ts | 15 +++++-- packages/nodes/src/window/move-tool.tsx | 1 + packages/nodes/src/window/tool.tsx | 16 ++++--- 7 files changed, 77 insertions(+), 32 deletions(-) diff --git a/packages/editor/src/components/editor/opening-guides-3d-layer.tsx b/packages/editor/src/components/editor/opening-guides-3d-layer.tsx index fb7299f6..8b39ef50 100644 --- a/packages/editor/src/components/editor/opening-guides-3d-layer.tsx +++ b/packages/editor/src/components/editor/opening-guides-3d-layer.tsx @@ -2,8 +2,8 @@ import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' -import { memo, useEffect, useMemo } from 'react' -import { BufferGeometry, Line as ThreeLine, Vector3 } from 'three' +import { memo, useEffect, useLayoutEffect, useMemo } from 'react' +import { BufferGeometry, Float32BufferAttribute, Line as ThreeLine } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../lib/constants' import useOpeningGuides, { @@ -56,8 +56,8 @@ export const OpeningGuides3DLayer = memo(function OpeningGuides3DLayer() { if (guides.length === 0) return null return ( <> - {guides.map((guide, i) => ( - + {guides.map((guide) => ( + ))} ) @@ -114,17 +114,31 @@ function GuideSegment({ to: OpeningGuideVec3 material: LineBasicNodeMaterial }) { - // Build a concrete THREE.Line and mount it via : the intrinsic - // JSX element collides with React's SVG , so keeps - // the typing clean and gives us direct control of layers + renderOrder. - const line = useMemo(() => { - const geometry = new BufferGeometry().setFromPoints([new Vector3(...from), new Vector3(...to)]) - const object = new ThreeLine(geometry, material) - object.frustumCulled = false - object.layers.set(EDITOR_LAYER) - object.renderOrder = 1000 - return object - }, [from, to, material]) + // Build the THREE.Line once with a preallocated 2-point position buffer and + // mount it via (the intrinsic JSX element collides with + // React's SVG ). `material` is a module-level constant, so this memo + // runs exactly once per mounted slot; subsequent drag ticks mutate the + // existing buffer in place via the layout effect below rather than rebuilding + // the geometry, line, and GPU buffer every frame. + const { line, position } = useMemo(() => { + const position = new Float32BufferAttribute(new Float32Array(6), 3) + const geometry = new BufferGeometry() + geometry.setAttribute('position', position) + const line = new ThreeLine(geometry, material) + line.frustumCulled = false + line.layers.set(EDITOR_LAYER) + line.renderOrder = 1000 + return { line, position } + }, [material]) + + const [fx, fy, fz] = from + const [tx, ty, tz] = to + useLayoutEffect(() => { + position.setXYZ(0, fx, fy, fz) + position.setXYZ(1, tx, ty, tz) + position.needsUpdate = true + }, [position, fx, fy, fz, tx, ty, tz]) + useEffect(() => () => line.geometry.dispose(), [line]) return } diff --git a/packages/editor/src/store/use-opening-guides.ts b/packages/editor/src/store/use-opening-guides.ts index ca2cc71a..6b9cf2c8 100644 --- a/packages/editor/src/store/use-opening-guides.ts +++ b/packages/editor/src/store/use-opening-guides.ts @@ -9,14 +9,19 @@ import { create } from 'zustand' export type OpeningGuideVec3 = [number, number, number] +// A stable identity per guide slot (`sill`, `head`, `gap:left`, `vertical`, +// `spacing:0`, …) so the renderer can key by semantic role: as the guide set +// churns each drag tick, a slot that persists keeps its React element — and its +// drei `` portal — mounted instead of remounting when the list shape +// shifts under index keys. export type OpeningGuide3D = // A measured line + distance pill: sill (floor → bottom edge), head (top edge // → wall top), or along-wall edge-to-edge proximity. - | { kind: 'dimension'; from: OpeningGuideVec3; to: OpeningGuideVec3; value: number } + | { kind: 'dimension'; id: string; from: OpeningGuideVec3; to: OpeningGuideVec3; value: number } // A dashed line connecting two openings that share a sill / centre / top. - | { kind: 'align-line'; from: OpeningGuideVec3; to: OpeningGuideVec3 } + | { kind: 'align-line'; id: string; from: OpeningGuideVec3; to: OpeningGuideVec3 } // A Figma-style "=" badge marking one gap in an equal-spacing run. - | { kind: 'badge'; at: OpeningGuideVec3; value: number } + | { kind: 'badge'; id: string; at: OpeningGuideVec3; value: number } type OpeningGuidesState = { guides: OpeningGuide3D[] @@ -27,7 +32,10 @@ type OpeningGuidesState = { const useOpeningGuides = create((set) => ({ guides: [], set: (guides) => set({ guides }), - clear: () => set({ guides: [] }), + // No-op when already empty so the common no-guide hover frame (fallback + // cursor, invalid target, roof hover) doesn't push a fresh `[]` and notify + // subscribers — the layer would re-render to the same nothing every tick. + clear: () => set((s) => (s.guides.length > 0 ? { guides: [] } : s)), })) export default useOpeningGuides diff --git a/packages/nodes/src/door/move-tool.tsx b/packages/nodes/src/door/move-tool.tsx index d3799bab..ec6e37de 100644 --- a/packages/nodes/src/door/move-tool.tsx +++ b/packages/nodes/src/door/move-tool.tsx @@ -644,6 +644,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) => boxGeo.dispose() return geo }, [movingDoorNode]) + useEffect(() => () => edgesGeo.dispose(), [edgesGeo]) return ( diff --git a/packages/nodes/src/door/tool.tsx b/packages/nodes/src/door/tool.tsx index 78dda6e7..d93971cd 100644 --- a/packages/nodes/src/door/tool.tsx +++ b/packages/nodes/src/door/tool.tsx @@ -22,7 +22,7 @@ import { useAlignmentGuides, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useRef } from 'react' +import { useEffect, useMemo, useRef } from 'react' import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' import { @@ -585,10 +585,16 @@ const DoorTool: React.FC = () => { } }, []) - // Cursor geometry: door outline. - const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07) - const edgesGeo = new EdgesGeometry(boxGeo) - boxGeo.dispose() + // Cursor geometry: door outline. Static dims, so build it once and dispose on + // unmount rather than reallocating (and orphaning) an EdgesGeometry on every + // re-render during placement. + const edgesGeo = useMemo(() => { + const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07) + const geo = new EdgesGeometry(boxGeo) + boxGeo.dispose() + return geo + }, []) + useEffect(() => () => edgesGeo.dispose(), [edgesGeo]) return ( diff --git a/packages/nodes/src/shared/opening-guides-runtime.ts b/packages/nodes/src/shared/opening-guides-runtime.ts index dcc22b49..1729e923 100644 --- a/packages/nodes/src/shared/opening-guides-runtime.ts +++ b/packages/nodes/src/shared/opening-guides-runtime.ts @@ -105,10 +105,14 @@ export function publishOpeningGuides3D(args: { const out: OpeningGuide3D[] = [] + // Stable `id`s keyed on the guide's semantic role (not list position) so the + // 3D layer can keep a persisting slot's element + `` pill mounted as the + // set churns each tick — see `OpeningGuide3D`. if (guides.sillHead) { if (guides.sillHead.sill > MIN_DIMENSION_M) { out.push({ kind: 'dimension', + id: 'sill', from: toWorld(centerS, 0), to: toWorld(centerS, guides.sillHead.bottomY), value: guides.sillHead.sill, @@ -117,6 +121,7 @@ export function publishOpeningGuides3D(args: { if (guides.sillHead.head > MIN_DIMENSION_M) { out.push({ kind: 'dimension', + id: 'head', from: toWorld(centerS, guides.sillHead.topY), to: toWorld(centerS, wallHeight), value: guides.sillHead.head, @@ -127,6 +132,7 @@ export function publishOpeningGuides3D(args: { for (const gap of guides.gaps) { out.push({ kind: 'dimension', + id: `gap:${gap.side}`, from: toWorld(gap.fromS, centerY), to: toWorld(gap.toS, centerY), value: gap.distance, @@ -140,6 +146,7 @@ export function publishOpeningGuides3D(args: { const hi = Math.max(centerS + width / 2, target.centerS + target.width / 2) out.push({ kind: 'align-line', + id: 'vertical', from: toWorld(lo, guides.vertical.y), to: toWorld(hi, guides.vertical.y), }) @@ -147,13 +154,15 @@ export function publishOpeningGuides3D(args: { } if (guides.equalSpacing) { - for (const seg of guides.equalSpacing.segments) { + const { gap, segments } = guides.equalSpacing + segments.forEach((seg, i) => { out.push({ kind: 'badge', + id: `spacing:${i}`, at: toWorld((seg.fromS + seg.toS) / 2, centerY), - value: guides.equalSpacing.gap, + value: gap, }) - } + }) } useOpeningGuides.getState().set(out) diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index 6f64a6de..2793e57b 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -702,6 +702,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode boxGeo.dispose() return geo }, [movingWindowNode]) + useEffect(() => () => edgesGeo.dispose(), [edgesGeo]) return ( diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index 2e92c0f6..ccbce2fd 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -23,7 +23,7 @@ import { useAlignmentGuides, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useRef } from 'react' +import { useEffect, useMemo, useRef } from 'react' import { BoxGeometry, EdgesGeometry, type Group, type LineSegments, Vector3 } from 'three' import { LineBasicNodeMaterial } from 'three/webgpu' import { @@ -631,10 +631,16 @@ const WindowTool: React.FC = () => { } }, []) - // Cursor geometry: window outline rectangle. - const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07) - const edgesGeo = new EdgesGeometry(boxGeo) - boxGeo.dispose() + // Cursor geometry: window outline rectangle. Static dims, so build it once and + // dispose on unmount rather than reallocating (and orphaning) an EdgesGeometry + // on every re-render during placement. + const edgesGeo = useMemo(() => { + const boxGeo = new BoxGeometry(FALLBACK_WIDTH, FALLBACK_HEIGHT, 0.07) + const geo = new EdgesGeometry(boxGeo) + boxGeo.dispose() + return geo + }, []) + useEffect(() => () => edgesGeo.dispose(), [edgesGeo]) return (