1,841 words
Status: accepted · 4 Sep 2026
Context
The upscaler plan needs
@pmndrs/upscaler, whose peer range starts at three r184, and the renderer
was on r182. The bump itself is two lines. What it takes to keep the gate
green across three releases of the WebGPU renderer is a set of facts about
r185 that nothing in this repository predicted, and the review of the bump
found that one of the things carried across from r182 had died in the move.
compileAsync builds after its walk, one object per turn. r182 built
pipelines during the walk. r185 queues one work item per object, processes
the queue once the walk is done, and yields between items through
yieldToMain() — scheduler.yield() where it exists and an animation frame
otherwise; NodeBuilder.buildAsync yields the same way after every shader
stage, nine times per fresh material. The GPU harness's frame stub never
fires, so every compile that walked anything hung after its first object.
Safari 26 has WebGPU and no scheduler at all, and Chrome before 129 has
postTask and no yield, so in those browsers every yield is a frame — ten
per warmed material on a visible tab, and on a hidden one, which Chrome gives
no frames, a boot that never lifts its cover. Boot is designed to complete
hidden (warmup.ts § framesPossible).
The material's setup reads the renderer's live render target, and only the
first queued object's setup runs inside the call. NodeMaterial.setup
decides whether the program writes depth from renderer.getRenderTarget()
at the moment it runs. The warm-up binds a target of the scene pass's shape
around compileAsync and puts the previous one back on the next line, which
on r182 covered every object. On r185 it covers the first: measured with two
meshes and a depthless target bound, the first compiles with frag_depth and
the second without, and the chain's first frame builds the pipeline the
warm-up was supposed to have built. Not reachable in the app today — the
frame loop leaves null bound, and null compiles as the pass's shape —
and one target change away from being reachable.
The draw guard is upstream now. ADR-0029
patched one hunk into WebGPUBackend.draw: a pipeline compileAsync had
registered but not yet built was skipped rather than handed to setPipeline
as undefined. The hunk was re-cut for r185.1 and carried across. It is dead
code there: r185's Renderer._renderObjectDirect calls the backend's draw
only when Pipelines.isReady says the GPU object has arrived, so nothing
pending reaches the hunk. Measured: with the hunk stripped
warmup.gpu.test.ts is green, and with the upstream gate disabled as well the
frame throws no overload matched for setPipeline. Every sentence that said
r185 as shipped throws was written from the r182 fact.
The typings changed under the graphs. @types/three 0.185 declares every
operator on Node<'float'> and its kin and none on Node, three hundred
errors' worth, almost all in the terrain kernel's aliases; the residue is
where the typings are narrower than WGSL — a mix with a per-channel weight,
a max on ints — and where a class is declared empty, NodeLibrary.
attribute() and instancedBufferAttribute() take an unconstrained type
parameter, so the literal second argument widens to string and the explicit
type argument beside it is load-bearing rather than a restatement.
Three smaller facts. renderOutput premultiplies, so through the
renderer's own output path an alpha-0 pixel is black however bright its rgb
was, and the harness clears to alpha 0 by default where production clears
opaque. NodeLibrary.addType refuses a second registration of a constant
with a warning and keeps the first, so a second installToneCurve on one
renderer installs nothing. RenderPipeline.render swaps xr.enabled off
around the quad as well as the tone mapping and the color space, and the
sensor's restore named two of the three. PostProcessing is RenderPipeline
since r183 and warns on every build under the old name;
WebGLCubeRenderTarget cannot be used with the WebGPU renderer since r183
and CubeRenderTarget is its replacement.
Decision
The renderer tracks the three release the upscaler's peer range starts at, carries no patch to it, and the things r185 changed under the warm-up and the harness are handled in the warm-up and the harness.
- No patch.
patches/is gone andpnpm-workspace.yamlnames nopatchedDependencies.warmup.gpu.test.tsholds the upstream gate rather than a hunk: the frame drawn inside the window between the walk and the promise is quiet, empty and builds no second pipeline, and the frame after it has the object. An upgrade that loses the gate throws in that test. - The yield is installed where a compile runs.
gpuSetup.tsputs Node's owntimers/promisesscheduler on the global for the harness, which keeps the frame stub honest — still no frame, still onenodeFrame.render/schedulerYield.tsgives a browser withoutscheduler.yieldone built on aMessageChannelround trip — one macrotask, not clamped the way a nestedsetTimeoutis and not throttled in a background tab the way timers are — added onto whateverschedulerexists and never over a nativeyield;main.tsxinstalls it before anything compiles. - The warm-up hands one renderable at a time to
compileAsync.warmRendererwalks the object's visible tree and issues a call per material-bearing node, all of them before the previous target is put back, so every material's setup runs in a synchronous half with the pass-shaped target bound.warmCompile's visibility toggle and theWarmRendererseam are unchanged, andsensor.gpu.test.tswarms two meshes with a depthless target bound and holds the chain's first frame to at most the quad's own pipeline. - A warm-up dummy is never culled.
compileAsyncculls against the camera's frustum as a render does, and a triangle at the render origin is in view only when the camera is near that origin and facing it.groundDummyandseaDummysetfrustumCulled = false; a warm-up that culled its dummy resolves exactly like one that built the pipeline. - The harness holds one pipeline at a time, and says what settles.
holdNextPipelinethrows if a hold is armed,release()disarms a hold nothing consumed,dispose()clears it; the three failing exits of a verb are onefail(), which waits out the late reports throughbreathe(). The session clears opaque, the waycreateRendererdoes. - The tone curve installs once per renderer. A second
installToneCurvereturns the first install's controls withheadroomset to what it asked for, because the library keeps the first graph for the renderer's life. - The casts the typings need live in one place.
noiseNodes.tsholdsmixPerChannelandmaxIntbesideasVectorandasField; the terrain kernel's aliases are the typings' own types,Boolincluded; the tone curve'smat3 × vec3needs no helper. - The renames are taken.
RenderPipelinein the sensor and the documents,CubeRenderTargetin the bake, the planet's stand-ins and their test.
Alternatives considered
- Keep the patch as belt and braces. Declined. A hunk under a gate that never lets its case through is a hunk to re-cut on every upgrade, with a pnpm patch hash pinning the version and a comment claiming a throw that does not happen. The test that holds the gate is the record of what an upgrade must not lose, and it is cheaper than the patch it replaces.
- Stay on r182 and vendor the upscaler. Declined. The library's peer
range is r184 and it verifies its reach into
backend.deviceand the texture data there; a vendored copy against r182 is a fork of the library and the renderer both. - Keep the warm target bound across the whole group's compile. Bind,
await, restore. Declined: the frame loop runs between the yields, and a
frame drawn with the four-pixel stand-in bound goes to the stand-in rather
than the canvas. The build-ahead in
Bodies.tsxcompiles in the task a body appears in, so the frame that would go missing is the one that shows it. - Stub
requestAnimationFramefor a hidden tab instead ofscheduler. Declined: the frame is the frame loop's own signal, three'snodeFrameadvances on it, and a shim that fired frames while hidden would run the loop against a canvas nobody is presenting — the presentation watchdog's whole subject. - Have the harness ask the device for the compilation info itself and await it, rather than waiting for the sink to go quiet. Declined as unsound: the device answers in order, so the harness's request resolves ahead of three's and proves nothing about when three's report lands.
Consequences
Measured on an Apple M-series at 1600×900, DPR 1, in the occluded rig, one Chrome on the GPU at a time, the world pinned by one save at tick 1158,
ir.gpu(60)through the chain, the smallest of three at each point, the baseline beingorigin/mainon r182 in its own worktree:Operating point r182, ms r185, ms Earth summit, converged 6.02 5.72 Proxima Centauri d from orbit 0.66 0.72 Proxima Centauri d summit, converged 4.45 4.74 Level within the spread of the runs. The plate gate at the same five points as ADR-0029 differs in 15 pixels at Earth from 14,402 km and 0 at the cutscene's frame 800; at the two summits and the Proxima look every differing pixel is a rock's sunlit face, by at most 29/255, because r185's
transformNormalis the inverse-transpose normal matrix where r182 approximated it. The rocks are lit by their true normals. The build log has the tables.Boot wall time is level. The
preloadmeasure under?timing=full— thescene warmedline'sms— at the home poster, same rig, six boots per build on a wiped Chrome profile, the first cold:Build Cold, ms Warm, median of five, ms Warm spread, ms r182 1,481 1,425 1,404–1,472 r185 1,508 1,430 1,422–1,449 The rig's Chrome has a native
scheduler.yield, so the shim is not in these figures, and a boot on a browser that needs it is not measured.A group of n renderables is n calls to
compileAsync; the census counts units a producer declares, not calls, so the progress bar does not move.A browser that reaches
self.scheduler.yieldfrom any library sees the shim on Safari and on Chrome before 129, and the native one elsewhere. The shim is a global installed at the entry point, which is where the log sink and the timeline are installed for the same reason.warmup.gpu.test.tsandsensor.gpu.test.tsare the two places that say when an upgrade moves the draw gate or the setup's read of the target again; nothing in the app does.The empty pixel of every GPU test is
(0, 0, 0, 255), so a claim that a frame drew nothing is a claim about the color channels, and the two tests that read whole buffers say so.R3F 9.7 still constructs a
THREE.Clock, which r185 deprecates with one console warning per boot; not ours.