Files
editor/packages/plugin-trees/src/instanced.tsx
T
58339c64f5 fix(plugin-trees): consume dirty marks so scene-ready fires for plant scenes (#476)
Instanced plant kinds (trees/grass/flowers) never cleared their dirty
marks: FloorElevationSystem deliberately leaves the mark for kinds with
a def.system, expecting that system to clear it after its own work, but
InstancedKindSystem never participated in the dirty protocol. The marks
lived forever, hasPendingSceneBuildWork() never went false, and the
Viewer's scene-ready signal stalled at SCENE_READY_MAX_WAIT_FRAMES on
every plant-containing scene — measured on the headless bake worker as
~190s (180 frames x ~1s SwiftShader frames) of pure cap-wait per bake.

Clear the marks in a priority-2 useFrame pass: after the priority-1
floor-elevation lift in the same frame, and only for nodes whose proxy
is registered (instances rebuild synchronously from the store, so a
rendered node is already built). Validated by baking Wawa House
locally: settle 22.5s -> 11.6s (the remainder is genuine asset
loading), exported GLB byte-identical with and without the fix.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 07:39:36 -04:00

330 lines
12 KiB
TypeScript

'use client'
import {
type AnyNodeId,
sceneRegistry,
useLiveNodeOverrides,
useLiveTransforms,
useRegistry,
useScene,
} from '@pascal-app/core'
import { useNodeEvents, useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber'
import { useLayoutEffect, useMemo, useRef } from 'react'
import { type BufferGeometry, type InstancedMesh, type Material, Matrix4, Object3D } from 'three'
import { toStaticMaterial } from './wind-node'
/**
* Generic instanced-rendering core shared by every plant kind (trees, flowers,
* …). A kind plugs in two pure functions — `variantKeyOf` (how to bucket nodes
* that can share geometry) and `getVariant` (cached geometry for a node) — and
* gets forest-scale instancing plus true-silhouette selection for free.
*/
export type SubMesh = { geometry: BufferGeometry; material: Material | Material[] }
export type VariantData = { subMeshes: SubMesh[]; naturalHeight: number }
/** The shape every placeable plant node shares. */
export interface Placeable {
id: string
type: string
parentId: string | null
position: [number, number, number]
rotation: [number, number, number]
height: number
visible?: boolean
}
const DUMMY = new Object3D()
const INSTANCE_MATRIX = new Matrix4()
const NO_RAYCAST = () => {}
// Wind is a TSL vertex bend baked into the variant materials (see `wind-node.ts`)
// — animated on the GPU, so the instance matrices here stay static.
// ── Collective instanced renderer (a `def.system`) ───────────────────────────
export function InstancedKindSystem<N extends Placeable>({
kind,
variantKeyOf,
getVariant,
}: {
kind: string
variantKeyOf: (node: N) => string
getVariant: (node: N) => VariantData
}) {
const scene = useScene((s) => s.nodes)
const hoveredId = useViewer((s) => s.hoveredId)
const selectedIds = useViewer((s) => s.selection.selectedIds)
// Hovered/selected plants render through their proxy instead (real geometry,
// static materials) so the outline matches the visible mesh and a move drag
// animates in realtime — skip them here to avoid a double draw. Keyed by the
// *relevant* ids only, so hovering unrelated kinds doesn't churn matrices.
const activeKey = useMemo(() => {
const ids: string[] = []
if (hoveredId && (scene[hoveredId as AnyNodeId]?.type as string) === kind) ids.push(hoveredId)
for (const id of selectedIds) {
if ((scene[id as AnyNodeId]?.type as string) === kind) ids.push(id)
}
return ids.sort().join('|')
}, [scene, kind, hoveredId, selectedIds])
const nodes = useMemo(() => {
const active = new Set(activeKey ? activeKey.split('|') : [])
return Object.values(scene).filter(
(n) => (n.type as string) === kind && !active.has(n.id as string),
) as unknown as N[]
}, [scene, kind, activeKey])
// Consume the dirty marks for this kind. Instances rebuild synchronously
// from the store (the memos above), so a rendered node is already "built" —
// but `FloorElevationSystem` deliberately leaves the mark for kinds with a
// `def.system`, expecting that system to clear it. Without this pass the
// marks live forever: `hasPendingSceneBuildWork` never goes false, so the
// scene-ready signal (and every headless bake) stalls at its frame cap.
// Priority 2 = after the priority-1 floor-elevation lift in the same frame;
// clearing only registered nodes leaves unmounted proxies for a later frame.
useFrame(() => {
const { dirtyNodes, nodes: sceneNodes, clearDirty } = useScene.getState()
if (dirtyNodes.size === 0) return
for (const id of dirtyNodes) {
const node = sceneNodes[id]
if (!node || (node.type as string) !== kind) continue
if (!sceneRegistry.nodes.has(id)) continue
clearDirty(id)
}
}, 2)
return <InstancedNodes getVariant={getVariant} nodes={nodes} variantKeyOf={variantKeyOf} />
}
/**
* Instance a given set of nodes, bucketed by geometry variant. Two callers:
* - `InstancedKindSystem` (editor `def.system`) passes every node of a kind with
* `localSpace={false}` — instances live at the scene root, so each matrix folds
* in the parent level's world matrix (positions are stored level-local).
* - the baked `/viewer` (`bakeReplaceRenderer`) passes one level's nodes with
* `localSpace` — the meshes are portaled into that baked level (which supplies
* the level transform), so instance matrices stay level-local, and the meshes
* are `NO_RAYCAST` (scenery; a pick would resolve to the level anyway).
*
* Instancing carries the per-tree wind phase for free via `instanceIndex`; a
* per-node render (one mesh each) would give every tree phase 0 → a whole
* variant sways in unison.
*/
export function InstancedNodes<N extends Placeable>({
nodes,
variantKeyOf,
getVariant,
localSpace = false,
}: {
nodes: N[]
variantKeyOf: (node: N) => string
getVariant: (node: N) => VariantData
localSpace?: boolean
}) {
const buckets = useMemo(() => {
const map = new Map<string, { sample: N; nodes: N[] }>()
for (const node of nodes) {
const key = variantKeyOf(node)
const bucket = map.get(key)
if (bucket) bucket.nodes.push(node)
else map.set(key, { sample: node, nodes: [node] })
}
return Array.from(map, ([key, value]) => ({ key, ...value }))
}, [nodes, variantKeyOf])
return (
<>
{buckets.map((bucket) => (
<Variant
getVariant={getVariant}
key={bucket.key}
localSpace={localSpace}
nodes={bucket.nodes}
sample={bucket.sample}
/>
))}
</>
)
}
function Variant<N extends Placeable>({
sample,
nodes,
getVariant,
localSpace,
}: {
sample: N
nodes: N[]
getVariant: (node: N) => VariantData
localSpace: boolean
}) {
const data = useMemo(() => getVariant(sample), [sample, getVariant])
return (
<>
{data.subMeshes.map((subMesh, i) => (
<InstancedSubMesh
key={i}
localSpace={localSpace}
naturalHeight={data.naturalHeight}
nodes={nodes}
subMesh={subMesh}
/>
))}
</>
)
}
function InstancedSubMesh<N extends Placeable>({
subMesh,
nodes,
naturalHeight,
localSpace,
}: {
subMesh: SubMesh
nodes: N[]
naturalHeight: number
localSpace: boolean
}) {
const ref = useRef<InstancedMesh>(null)
// Round capacity up so the InstancedMesh isn't recreated on every placement —
// only when crossing a 32-instance boundary. `dispose={null}` keeps the shared
// (cached) geometry/material alive across any recreation.
const capacity = Math.max(16, Math.ceil(nodes.length / 32) * 32)
useLayoutEffect(() => {
const mesh = ref.current
if (!mesh) return
for (let i = 0; i < nodes.length; i += 1) {
const node = nodes[i]
if (!node) continue
const scale = node.height / naturalHeight
DUMMY.position.set(node.position[0], node.position[1], node.position[2])
DUMMY.rotation.set(node.rotation[0], node.rotation[1], node.rotation[2])
DUMMY.scale.set(scale, scale, scale)
DUMMY.updateMatrix()
// `localSpace`: portaled into the parent level, which supplies the level
// transform — matrices stay level-local. Otherwise instances live at the
// scene root, so fold in the parent level's world matrix.
const parent =
!localSpace && node.parentId ? sceneRegistry.nodes.get(node.parentId) : undefined
if (parent) {
parent.updateWorldMatrix(true, false)
INSTANCE_MATRIX.multiplyMatrices(parent.matrixWorld, DUMMY.matrix)
mesh.setMatrixAt(i, INSTANCE_MATRIX)
} else {
mesh.setMatrixAt(i, DUMMY.matrix)
}
}
mesh.count = nodes.length
mesh.instanceMatrix.needsUpdate = true
mesh.computeBoundingSphere()
}, [nodes, naturalHeight, localSpace])
return (
<instancedMesh
args={[subMesh.geometry as BufferGeometry, subMesh.material as Material, capacity]}
castShadow
dispose={null}
frustumCulled={false}
raycast={localSpace ? NO_RAYCAST : undefined}
ref={ref}
/>
)
}
// ── Per-node selection proxy (a `def.renderer`) ──────────────────────────────
const toStatic = (material: Material | Material[]) =>
Array.isArray(material) ? material.map(toStaticMaterial) : toStaticMaterial(material)
/**
* Per-node proxy that keeps the host's selection machinery working for an
* instanced kind. The registered group carries the node transform (host
* contract: move tools drive `sceneRegistry.nodes.get(id)` imperatively with
* absolute level-local positions and mirror them via `useLiveTransforms` —
* see `ParametricNodeRenderer`), so registering a nested child would apply
* drag deltas in the node's rotated frame. The box collider is a positioned
* sibling: the raycast target, kept out of the registered group so the
* outline pass (which traces the registered object) shows the true
* silhouette, not a box.
*
* While hovered/selected the collective system skips this node and the proxy
* mounts the real geometry with **static twins** of the wind materials — the
* outline mask renders with an override material and can't follow GPU sway,
* so the plant holds still while outlined and the silhouette matches exactly.
* During a GLB export the geometry mounts with the real materials instead, so
* the exporter (which clones only the `scene-renderer` subtree, not the
* collective InstancedMesh) captures each plant; the collider is dropped so it
* doesn't bake as a phantom solid.
*/
export function KindProxy<N extends Placeable & { id: string }>({
node,
getVariant,
colliderRadius,
}: {
node: N
getVariant: (node: N) => VariantData
colliderRadius: (node: N) => number
}) {
const registeredRef = useRef<Object3D>(null!)
const handlers = useNodeEvents(node as never, node.type as never)
useRegistry(node.id as AnyNodeId, node.type, registeredRef)
const isExporting = useViewer((s) => s.isExporting)
const active = useViewer(
(s) => s.hoveredId === node.id || s.selection.selectedIds.includes(node.id as never),
)
const showGeometry = active || isExporting
// Live drag transform — the move tool writes the same absolute position
// imperatively to the registered group; applying it React-side too keeps the
// two in agreement (and moves the collider along with the drag). The rotate /
// resize gizmos publish through `useLiveNodeOverrides` instead — fold that in
// too (mirrors ParametricNodeRenderer) so the plant turns live mid-drag
// rather than snapping on commit.
const live = useLiveTransforms((s) => s.get(node.id))
const liveOverride = useLiveNodeOverrides((s) => s.overrides.get(node.id))
const overridePosition = liveOverride?.position as [number, number, number] | undefined
const overrideRotation = liveOverride?.rotation as [number, number, number] | undefined
const position = live?.position ?? overridePosition ?? node.position ?? [0, 0, 0]
const baseRotation = overrideRotation ?? node.rotation ?? [0, 0, 0]
const rotation: [number, number, number] = live
? [baseRotation[0], live.rotation, baseRotation[2]]
: baseRotation
const height = Math.max(0.2, node.height ?? 1)
const radius = colliderRadius(node)
const variant = useMemo(
() => (showGeometry ? getVariant(node) : null),
[showGeometry, node, getVariant],
)
const geometryScale = variant ? height / variant.naturalHeight : 1
return (
<group visible={node.visible !== false} {...handlers}>
{!isExporting && (
<mesh position={[position[0], position[1] + height / 2, position[2]]}>
<boxGeometry args={[radius * 2, height, radius * 2]} />
<meshBasicMaterial colorWrite={false} depthWrite={false} />
</mesh>
)}
<group position={position} ref={registeredRef} rotation={rotation}>
{variant && (
<group scale={geometryScale}>
{variant.subMeshes.map((subMesh, i) => (
<mesh
dispose={null}
geometry={subMesh.geometry}
key={i}
material={isExporting ? subMesh.material : toStatic(subMesh.material)}
raycast={NO_RAYCAST}
/>
))}
</group>
)}
</group>
</group>
)
}