From e33914776b634ea7442d4f88e7f42d4bd52f3f5a Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 9 Jul 2026 19:00:21 -0400 Subject: [PATCH] =?UTF-8?q?feat(viewer):=20=3Fdisable=3Ddraw=20=E2=80=94?= =?UTF-8?q?=20skip=20scene=20rendering=20for=20graph-only=20consumers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The headless bake worker renders on SwiftShader (CPU), where per-frame vertex/draw cost dominates the whole capture — but a bake only reads the built scene graph; the pixels are thrown away. `?disable=draw` (same diagnostic-param family as postFx) renders an EMPTY scene each frame instead of the real one: useFrame systems and scene-ready keep ticking, per-frame cost collapses to a 64x64 clear. Rendering nothing at all does NOT work: with zero submitted frames Chromium's no-damage scheduler throttles rAF to exactly 1Hz (measured), starving the very systems the bake needs — hence the empty-scene draw. Measured on the office scene (200+ items) with a real GPU: capture 45.0s -> 8.4s (settle 37.6s -> 4.5s); output equivalent to a normal render (structural GLB diff clean; the ±13-accessor variance exists between normal runs too). CPU-only (worker) numbers via the prod-image container against a preview deploy. Co-Authored-By: Claude Fable 5 --- .../src/components/viewer/post-processing.tsx | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index f8f957e6b..e0988a03d 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -1,6 +1,6 @@ import { useFrame, useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Color, Layers, type Object3D, UnsignedByteType } from 'three' +import { Color, Layers, type Object3D, Scene, UnsignedByteType } from 'three' import { ssgi } from 'three/addons/tsl/display/SSGINode.js' import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js' import { @@ -57,6 +57,11 @@ export const SSGI_PARAMS = { // - outline: skip the merged-outline node and its 14 internal RTs // - postFx: bypass the whole RenderPipeline and use renderer.render(scene, camera) // directly — isolates raw scene-render cost from any post-FX overhead +// - draw: skip the render call entirely — frames still tick (useFrame +// systems, scene-ready) but no draw is ever submitted. For +// consumers that only need the built scene graph, never pixels: +// the headless bake worker renders on SwiftShader (CPU), where +// per-frame vertex/draw cost dominates the whole capture. function readPerfDisableFlags() { if (typeof window === 'undefined') { return { ao: false, denoise: false, outline: false, postFx: false } @@ -84,6 +89,17 @@ const PERF_POST_FX_DISABLED = .map((s) => s.trim()), ).has('postFx') +const PERF_DRAW_DISABLED = + typeof window !== 'undefined' && + new Set( + (new URLSearchParams(window.location.search).get('disable') ?? '') + .split(',') + .map((s) => s.trim()), + ).has('draw') + +// Stand-in scene for `?disable=draw` frames — cleared, never populated. +const emptyScene = new Scene() + const MAX_PIPELINE_RETRIES = 3 const RETRY_DELAY_MS = 500 @@ -562,6 +578,23 @@ const PostProcessingPasses = ({ return } + // `?disable=draw`: nothing downstream wants pixels — render an EMPTY scene + // instead of the real one. This is the only render call (positive-priority + // useFrame subscribers already disable R3F's automatic render), so the real + // scene is never drawn: per-frame vertex/draw cost drops to a single 64×64 + // clear, which is what makes headless bakes viable on SwiftShader (CPU). + // Rendering nothing at all is NOT an option — with zero submitted frames + // Chromium's no-damage scheduler throttles rAF to 1Hz (measured), stalling + // the useFrame systems the bake still needs. + if (PERF_DRAW_DISABLED) { + try { + ;(renderer as any).render(emptyScene, camera) + } catch { + // A failed empty draw changes nothing — systems keep ticking. + } + return + } + // Animate background colour toward the current scene theme target (same lerp as AnimatedBackground) bgTarget.current.set(getSceneTheme(useViewer.getState().sceneTheme).background) bgCurrent.current.lerp(bgTarget.current, Math.min(delta, 0.1) * 4)