ADR-0030: three r185 — the renderer tracks the release the upscaler needs, and carries no patch to it

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 and pnpm-workspace.yaml names no patchedDependencies. warmup.gpu.test.ts holds 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.ts puts Node's own timers/promises scheduler on the global for the harness, which keeps the frame stub honest — still no frame, still one nodeFrame. render/schedulerYield.ts gives a browser without scheduler.yield one built on a MessageChannel round trip — one macrotask, not clamped the way a nested setTimeout is and not throttled in a background tab the way timers are — added onto whatever scheduler exists and never over a native yield; main.tsx installs it before anything compiles.
  • The warm-up hands one renderable at a time to compileAsync. warmRenderer walks 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 the WarmRenderer seam are unchanged, and sensor.gpu.test.ts warms 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. compileAsync culls 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. groundDummy and seaDummy set frustumCulled = 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. holdNextPipeline throws if a hold is armed, release() disarms a hold nothing consumed, dispose() clears it; the three failing exits of a verb are one fail(), which waits out the late reports through breathe(). The session clears opaque, the way createRenderer does.
  • The tone curve installs once per renderer. A second installToneCurve returns the first install's controls with headroom set 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.ts holds mixPerChannel and maxInt beside asVector and asField; the terrain kernel's aliases are the typings' own types, Bool included; the tone curve's mat3 × vec3 needs no helper.
  • The renames are taken. RenderPipeline in the sensor and the documents, CubeRenderTarget in 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.device and 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.tsx compiles in the task a body appears in, so the frame that would go missing is the one that shows it.
  • Stub requestAnimationFrame for a hidden tab instead of scheduler. Declined: the frame is the frame loop's own signal, three's nodeFrame advances 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 being origin/main on 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 transformNormal is 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 preload measure under ?timing=full — the scene warmed line's ms — 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.yield from 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.ts and sensor.gpu.test.ts are 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.