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.
153 lines
8.1 KiB
Markdown
153 lines
8.1 KiB
Markdown
# 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).
|