Skip to content

Commit 5e5b62b

Browse files
wass08claude
andauthored
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>
1 parent aa30899 commit 5e5b62b

1 file changed

Lines changed: 39 additions & 6 deletions

File tree

  • packages/viewer/src/components/viewer

packages/viewer/src/components/viewer/index.tsx

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -249,11 +249,22 @@ function ToneMappingExposure() {
249249
}
250250

251251
function hasPendingSceneBuildWork() {
252-
const { dirtyNodes, nodes } = useScene.getState()
252+
const { dirtyNodes, nodes, rootNodeIds } = useScene.getState()
253253

254254
for (const id of dirtyNodes) {
255255
const node = nodes[id]
256256
if (!node) continue
257+
// Unreachable nodes (orphaned by a broken detach: the parent doesn't list
258+
// them in `children`, or no parent and not a root) never render — every
259+
// renderer enumerates the parent's `children` array — so no system will
260+
// ever build them or clear their mark. They must not hold scene-ready
261+
// hostage (observed in prod scene data: two dangling windows kept every
262+
// bake of that scene waiting out the full readiness cap).
263+
const parent = node.parentId ? nodes[node.parentId as AnyNodeId] : undefined
264+
const reachable = parent
265+
? (parent as { children?: string[] }).children?.includes(id) === true
266+
: rootNodeIds.includes(id)
267+
if (!reachable) continue
257268
const def = nodeRegistry.get(node.type)
258269
if (def?.geometry || def?.capabilities?.floorPlaced || DIRTY_BUILD_KINDS.has(node.type)) {
259270
return true
@@ -272,13 +283,16 @@ function hasCommittedSceneRoot() {
272283
function SceneReadyTracker({
273284
onSceneReadyChange,
274285
sceneReadyKey,
286+
sceneReadyMaxWaitMs,
275287
}: {
276288
onSceneReadyChange?: (ready: boolean) => void
277289
sceneReadyKey?: string | number | null
290+
sceneReadyMaxWaitMs?: number
278291
}) {
279292
const readyRef = useRef(false)
280293
const settledFramesRef = useRef(0)
281294
const waitedFramesRef = useRef(0)
295+
const waitStartRef = useRef<number | null>(null)
282296
const onSceneReadyChangeRef = useRef(onSceneReadyChange)
283297

284298
useEffect(() => {
@@ -290,17 +304,24 @@ function SceneReadyTracker({
290304
readyRef.current = false
291305
settledFramesRef.current = 0
292306
waitedFramesRef.current = 0
307+
waitStartRef.current = null
293308
onSceneReadyChangeRef.current?.(false)
294309
}, [sceneReadyKey])
295310

296311
useFrame(() => {
297312
if (!(onSceneReadyChangeRef.current && !readyRef.current)) return
298313

299314
waitedFramesRef.current += 1
300-
if (
301-
waitedFramesRef.current < SCENE_READY_MAX_WAIT_FRAMES &&
302-
(!hasCommittedSceneRoot() || hasPendingSceneBuildWork())
303-
) {
315+
waitStartRef.current ??= performance.now()
316+
// Give-up cap so a permanently-dirty node can't block readiness forever.
317+
// The frame-count default assumes display-rate frames; a host whose frame
318+
// cadence is decoupled from wall time (the headless bake page's timer-driven
319+
// loop runs 180 frames in 3.6s — faster than a cold item download) passes
320+
// `sceneReadyMaxWaitMs` to make the cap wall-clock instead.
321+
const capReached = sceneReadyMaxWaitMs
322+
? performance.now() - waitStartRef.current >= sceneReadyMaxWaitMs
323+
: waitedFramesRef.current >= SCENE_READY_MAX_WAIT_FRAMES
324+
if (!capReached && (!hasCommittedSceneRoot() || hasPendingSceneBuildWork())) {
304325
settledFramesRef.current = 0
305326
return
306327
}
@@ -345,6 +366,13 @@ interface ViewerProps {
345366
*/
346367
sceneReadyKey?: string | number | null
347368
onSceneReadyChange?: (ready: boolean) => void
369+
/**
370+
* Wall-clock give-up cap for scene readiness, replacing the default
371+
* frame-count cap. Set it on hosts whose frame cadence is decoupled from
372+
* real time (the headless bake page's timer-driven loop runs the default
373+
* 180-frame cap in ~3.6s — shorter than a cold item-model download).
374+
*/
375+
sceneReadyMaxWaitMs?: number
348376
/**
349377
* Skip the TSL post-processing pipeline (SSGI/denoise/ink/outline) and render
350378
* the scene directly. For headless/capture surfaces (the bake page) where
@@ -379,6 +407,7 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
379407
isolate,
380408
sceneReadyKey,
381409
onSceneReadyChange,
410+
sceneReadyMaxWaitMs,
382411
disablePostFx = false,
383412
},
384413
ref,
@@ -527,7 +556,11 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
527556
<ViewerCamera />
528557
<GPUDeviceWatcher />
529558
<ToneMappingExposure />
530-
<SceneReadyTracker onSceneReadyChange={onSceneReadyChange} sceneReadyKey={sceneReadyKey} />
559+
<SceneReadyTracker
560+
onSceneReadyChange={onSceneReadyChange}
561+
sceneReadyKey={sceneReadyKey}
562+
sceneReadyMaxWaitMs={sceneReadyMaxWaitMs}
563+
/>
531564

532565
<ErrorBoundary fallback={null} scope="viewer-scene">
533566
{/* <directionalLight position={[10, 10, 5]} intensity={0.5} castShadow

0 commit comments

Comments
 (0)