fix(viewer): wall-clock scene-ready cap + skip unreachable dirty nodes (#484)

Two readiness fixes surfaced by the first successful headless bake of a
heavy scene (which exported with 33 items missing):

- The 180-FRAME give-up cap silently assumed display-rate frames. The
  bake page's timer-driven loop (?disable=draw) runs those frames in
  ~3.6s — shorter than a cold item-model download — so the cap fired
  and the export raced ahead of real content. New `sceneReadyMaxWaitMs`
  Viewer prop replaces the frame cap with a wall-clock one on hosts
  whose frame cadence is decoupled from time; default frame behavior
  unchanged for interactive surfaces.

- hasPendingSceneBuildWork now skips UNREACHABLE dirty nodes: orphans
  whose parent doesn't list them in `children` (or parentless non-roots)
  never render — every renderer enumerates the parent's children array —
  so no system ever builds them or clears their mark. Observed in prod
  scene data (two dangling windows parented to a level that doesn't list
  them): they held every bake of that scene to the full readiness cap.

Validated: the 200+-item office scene with ?disable=draw settles
organically in ~2s and exports the complete artifact; a one-shot 504'd
item is now WAITED for through its retry instead of racing the cap.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-07-09 21:01:23 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent aa30899090
commit 5e5b62b09e
@@ -249,11 +249,22 @@ function ToneMappingExposure() {
}
function hasPendingSceneBuildWork() {
const { dirtyNodes, nodes } = useScene.getState()
const { dirtyNodes, nodes, rootNodeIds } = useScene.getState()
for (const id of dirtyNodes) {
const node = nodes[id]
if (!node) continue
// Unreachable nodes (orphaned by a broken detach: the parent doesn't list
// them in `children`, or no parent and not a root) never render — every
// renderer enumerates the parent's `children` array — so no system will
// ever build them or clear their mark. They must not hold scene-ready
// hostage (observed in prod scene data: two dangling windows kept every
// bake of that scene waiting out the full readiness cap).
const parent = node.parentId ? nodes[node.parentId as AnyNodeId] : undefined
const reachable = parent
? (parent as { children?: string[] }).children?.includes(id) === true
: rootNodeIds.includes(id)
if (!reachable) continue
const def = nodeRegistry.get(node.type)
if (def?.geometry || def?.capabilities?.floorPlaced || DIRTY_BUILD_KINDS.has(node.type)) {
return true
@@ -272,13 +283,16 @@ function hasCommittedSceneRoot() {
function SceneReadyTracker({
onSceneReadyChange,
sceneReadyKey,
sceneReadyMaxWaitMs,
}: {
onSceneReadyChange?: (ready: boolean) => void
sceneReadyKey?: string | number | null
sceneReadyMaxWaitMs?: number
}) {
const readyRef = useRef(false)
const settledFramesRef = useRef(0)
const waitedFramesRef = useRef(0)
const waitStartRef = useRef<number | null>(null)
const onSceneReadyChangeRef = useRef(onSceneReadyChange)
useEffect(() => {
@@ -290,6 +304,7 @@ function SceneReadyTracker({
readyRef.current = false
settledFramesRef.current = 0
waitedFramesRef.current = 0
waitStartRef.current = null
onSceneReadyChangeRef.current?.(false)
}, [sceneReadyKey])
@@ -297,10 +312,16 @@ function SceneReadyTracker({
if (!(onSceneReadyChangeRef.current && !readyRef.current)) return
waitedFramesRef.current += 1
if (
waitedFramesRef.current < SCENE_READY_MAX_WAIT_FRAMES &&
(!hasCommittedSceneRoot() || hasPendingSceneBuildWork())
) {
waitStartRef.current ??= performance.now()
// Give-up cap so a permanently-dirty node can't block readiness forever.
// The frame-count default assumes display-rate frames; a host whose frame
// cadence is decoupled from wall time (the headless bake page's timer-driven
// loop runs 180 frames in 3.6s — faster than a cold item download) passes
// `sceneReadyMaxWaitMs` to make the cap wall-clock instead.
const capReached = sceneReadyMaxWaitMs
? performance.now() - waitStartRef.current >= sceneReadyMaxWaitMs
: waitedFramesRef.current >= SCENE_READY_MAX_WAIT_FRAMES
if (!capReached && (!hasCommittedSceneRoot() || hasPendingSceneBuildWork())) {
settledFramesRef.current = 0
return
}
@@ -345,6 +366,13 @@ interface ViewerProps {
*/
sceneReadyKey?: string | number | null
onSceneReadyChange?: (ready: boolean) => void
/**
* Wall-clock give-up cap for scene readiness, replacing the default
* frame-count cap. Set it on hosts whose frame cadence is decoupled from
* real time (the headless bake page's timer-driven loop runs the default
* 180-frame cap in ~3.6s — shorter than a cold item-model download).
*/
sceneReadyMaxWaitMs?: number
/**
* Skip the TSL post-processing pipeline (SSGI/denoise/ink/outline) and render
* the scene directly. For headless/capture surfaces (the bake page) where
@@ -379,6 +407,7 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
isolate,
sceneReadyKey,
onSceneReadyChange,
sceneReadyMaxWaitMs,
disablePostFx = false,
},
ref,
@@ -527,7 +556,11 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
<ViewerCamera />
<GPUDeviceWatcher />
<ToneMappingExposure />
<SceneReadyTracker onSceneReadyChange={onSceneReadyChange} sceneReadyKey={sceneReadyKey} />
<SceneReadyTracker
onSceneReadyChange={onSceneReadyChange}
sceneReadyKey={sceneReadyKey}
sceneReadyMaxWaitMs={sceneReadyMaxWaitMs}
/>
<ErrorBoundary fallback={null} scope="viewer-scene">
{/* <directionalLight position={[10, 10, 5]} intensity={0.5} castShadow