Files
Portfolio/docs/overnight/compile-async-results.md
LucaP 26e5ff01fd perf: compile scene shaders off-thread before the first frame
I warm the world's programs with renderer.compileAsync() at the end of the
async build, with the composer's scene target bound so tone mapping and
output colour space key the same programs the composer draws with, and with
hidden subtrees revealed so the light count matches the frame-0 warm render.
The wait is capped at 2.5 s; the warm render stays as the safety net for the
post passes, texture uploads and anything a slow or extension-less driver
left. Test builds report renderer.info.programs, and a browser spec pins zero
program growth after frame 0.

On the Intel ARL laptop the frame-0 task drops from ~1.74 s to ~0.34 s with a
pixel-identical first frame; measurements in
docs/overnight/compile-async-results.md.
2026-10-12 18:34:12 +03:00

8.1 KiB

Issue 2, option A: compileAsync shader warm-up — measurements

Branch polish/compile-async, against baseline 71465fa.

What changed

  • src/scene/world/shader-warmup.ts (new): warmUpShaders() binds the composer's scene target, reveals hidden subtrees (so compile() gathers the same lights as the frame-0 warm render), calls renderer.compileAsync(), restores target and visibility right after the synchronous compile() part, then awaits the parallel link with a 2.5 s timeout (ready / timeout / failed).
  • src/scene/post-processing.ts: PostProcessor.sceneTarget() returns the EffectComposer readBuffer (the target RenderPass draws into). In three r185, WebGLPrograms.getParameters keys tone mapping and output colour space on the bound target. Any non-XR off-screen target gives the same key, so the precompiled programs are the ones the composer uses.
  • src/scene/world/environment.ts: at the end of the async build, call resize(), then await warmUpShaders(...). The frame-0 warm render stays as the safety net. Post-pass programs are not in the scene, so the warm render still builds those. It also uploads textures and finishes whatever a timed-out or extension-less warm-up left behind. Test builds dispatch portfolio-program-count whenever renderer.info.programs.length changes.
  • Regression checks:
    • tests/browser/shader-warmup.spec.ts: after frame 0 no program is ever created (idle ignition, then 12 wheel steps). It also checks that the warm-up ran and that frame 0 adds at most 12 programs on top of it (only the post passes; a mis-keyed warm-up would add about 60). Passes on desktop-chromium and mobile-chromium. In SwiftShader, desktop compiles 59 programs in the warm-up and frame 0 adds 9 post-pass programs, for 68 total. Mobile: 53 + 0 = 53.
    • tests/unit/shader-warmup.test.ts (fakes): target and visibility during compile, both restored afterwards, the timeout, the failure paths, and the timer cleanup.
    • tests/unit/world-models.test.ts: sceneTarget().

Method

  • Hardware: ANGLE (Intel, Mesa Intel(R) Graphics (ARL), OpenGL ES 3.2). Playwright Chromium 151.0.7922.34, headed on the desktop session. In a headless trial the frame-0 task took 3.1 s and every scroll frame took

    50 ms, so headed is the representative setup.

  • Flags: --use-gl=angle --use-angle=gl --ignore-gpu-blocklist --enable-gpu-rasterization --enable-webgl --disable-gpu-sandbox --disable-gpu-shader-disk-cache. These are the hardware-GL flags from the scratchpad shots.mjs; r6.mjs no longer existed. Every run launches a new browser with a fresh temporary profile. Viewport 1440x900, DPR 1, production preview build served by python3 -m http.server on 127.0.0.1:5181.
  • Runs were interleaved A/B/A/B. The harness is docs/overnight/compile-async-measure.mjs. An init script wraps linkProgram (count and timestamps, so it works on production builds), watches canvas[data-lit] with a MutationObserver (it fires in the microtask right after the frame-0 render, still inside that task), records PerformanceObserver('longtask') and rAF deltas. After lit it idles 2.5 s, then wheels 200 px every 80 ms for 10 s.
  • Power and load: ADP0/online read 1 before every run, and BAT0 reported "Not charging", 79 %. The coordinator reported battery power earlier. Load average was 6-11 because four other agents shared the machine. Absolute times are inflated and noisy. Compare the A/B differences, not the absolute numbers.

Results (median [min-max])

KHR_parallel_shader_compile available (6 + 6 runs)

metric baseline 71465fa option A
time to first scene frame (data-lit, ms after navigation start) 2788 [2733-3431] 2667 [2422-2960]
frame-0 task (long task that contains frame 0) 1737 [1706-1771] 340 [323-405]
longest main-thread task 1737 [1706-1771] 458 [422-521] (page boot JS, not the scene)
programs linked before frame 0 / inside frame 0 / after 3 / 68 / 0 62 (async, during the build) / 9 (post passes) / 0
frame after frame 0 (presentation hitch) 177 [154-229] 98 [0-167]
scroll frames >50 ms in 10 s (per run) 13, 6, 5, 20, 72, 42 12, 6, 3, 29, 72, 28
worst scroll frame 146 [71-229] 158 [71-250]
long tasks during scroll none none

Timeline from three more interleaved runs per variant, ms after navigation:

first link last link before frame 0 frame 0 starts first frame (lit)
baseline 1497 / 834 / 842 3 links only 1621 / 944 / 964 3144 / 2396 / 2453
option A 752 / 810 / 818 918 / 978 / 994 (62 links) 1933 / 2109 / 2152 2248 / 2417 / 2454

Option A issues the 59 scene links in about 170 ms, inside the build's existing ~420 ms boot task, so it adds no new long task. The build then awaits the driver for about 1.15 s while the main thread is free (poster up, page scrollable). Frame 0 follows at about 300 ms.

KHR_parallel_shader_compile hidden (getExtension patched, 3 + 3 runs)

metric baseline option A
first scene frame 2611 [2604-2686] 2473 [2433-2820]
frame-0 task 1647 [1631-1679] 1418 [1400-1559]
links inside frame 0 68 9 (the other 62 were queued during the build)
links after frame 0 0 0

Without the extension three marks every program ready immediately, so compileAsync resolves after one 10 ms timeout. The links queued during the build overlap the rest of the build, and the warm render then blocks on the first status query. The result is still a little better than baseline, never worse. Behaviour is the same as before, with no timeout path.

Visual correctness

Frame 0 was captured with canvas.toDataURL() inside the frame-0 task, 3 interleaved runs per variant. All 15 pairwise comparisons (base-base, new-new, base-new) have max channel difference 0: option A's first frame is pixel-identical to the baseline's.

Pros and cons (measured)

Pros

  • The frame-0 main-thread block drops from about 1.74 s to about 0.34 s (-80 %). The longest main-thread task on the page goes from the scene's 1.7 s to page boot (~0.45 s).
  • About 1.15 s of shader linking now runs on the driver's threads while the poster shows and the page stays responsive.
  • The hitch on the frame after frame 0 is also smaller (177 -> 98 ms median).
  • Frame 0 is pixel-identical. The regression test pins zero program growth after frame 0. On this machine no programs were linked after frame 0 in 10 s of scrolling, in either variant.
  • No extension: slightly better than baseline. Slow driver: the 2.5 s timeout hands the remainder to the warm render, which is the old behaviour.

Cons

  • Time to first scene frame does not improve much (-120 ms median with KHR, within noise). The total link work is about the same; it moved off the main thread, it did not shrink. The win is responsiveness and long-task size, not an earlier frame.
  • The approach depends on three's key internals: the bound target decides tone mapping and colour space, and compile() gathers lights with traverseVisible. A three upgrade that changes either would quietly double-compile. The browser spec guards this by allowing frame 0 to add at most 12 programs on top of the warm-up (9 post passes today).
  • Frame 0 still costs about 330 ms: 9 post-pass programs, texture uploads and the first full draw. Options to cut it further: precompile the post passes' materials on a fullscreen quad through the same warm-up, or combine with option B (slicing).
  • After a timeout, three's compileAsync keeps polling every 10 ms until every program reports ready. This is harmless, but the poll would throw once if the generation were disposed in the meantime.
  • The build waits for the parallel compile, so the first-frame watchdog (5 s, armed before the build) now also covers the compile. The 2.5 s cap keeps that bounded.

Verification

npx vue-tsc --noEmit clean. npx vitest run: 84 files, 978 tests passed. npm run build:preview: exit 0, "violations": []. npx playwright test tests/browser/shader-warmup.spec.ts: 2 passed (desktop-chromium and mobile-chromium).