Merge branch 'polish/compile-async' into redesign/overnight
This commit is contained in:
106
docs/overnight/compile-async-measure.mjs
Normal file
106
docs/overnight/compile-async-measure.mjs
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
// usage: node measure.mjs <label> <runs> [--shot] [--nokhr] [--noscroll]
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
import { writeFileSync, mkdirSync, appendFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
const [label, runsArg] = process.argv.slice(2);
|
||||||
|
const SHOT = process.argv.includes('--shot');
|
||||||
|
const NOKHR = process.argv.includes('--nokhr');
|
||||||
|
const NOSCROLL = process.argv.includes('--noscroll');
|
||||||
|
const runs = Number(runsArg ?? 5);
|
||||||
|
const out = process.env.OUT ?? 'compile-async-out'; // runs from the repo root; serve dist on 127.0.0.1:5181 first
|
||||||
|
mkdirSync(out, { recursive: true });
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
const init = `(() => {
|
||||||
|
const T = { links: [], lit: null, linksAtLit: null, longtasks: [], frames: [], shot: null };
|
||||||
|
window.__m = T;
|
||||||
|
for (const C of [WebGL2RenderingContext, WebGLRenderingContext]) {
|
||||||
|
const link = C.prototype.linkProgram;
|
||||||
|
C.prototype.linkProgram = function (p) { T.links.push(performance.now()); return link.call(this, p); };
|
||||||
|
if (${NOKHR}) {
|
||||||
|
const ge = C.prototype.getExtension;
|
||||||
|
C.prototype.getExtension = function (n) { return n === 'KHR_parallel_shader_compile' ? null : ge.call(this, n); };
|
||||||
|
const gs = C.prototype.getSupportedExtensions;
|
||||||
|
C.prototype.getSupportedExtensions = function () { return gs.call(this).filter((e) => e !== 'KHR_parallel_shader_compile'); };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
new PerformanceObserver((l) => { for (const e of l.getEntries()) T.longtasks.push([e.startTime, e.duration]); })
|
||||||
|
.observe({ type: 'longtask', buffered: true });
|
||||||
|
const mo = new MutationObserver(() => {
|
||||||
|
if (T.lit !== null) return;
|
||||||
|
const c = document.querySelector('canvas.journey-canvas');
|
||||||
|
if (!c || c.dataset.lit === undefined) return;
|
||||||
|
T.lit = performance.now();
|
||||||
|
T.linksAtLit = T.links.length;
|
||||||
|
if (${SHOT}) T.shot = c.toDataURL('image/png');
|
||||||
|
});
|
||||||
|
mo.observe(document, { subtree: true, attributes: true, attributeFilter: ['data-lit'] });
|
||||||
|
let last = null;
|
||||||
|
const loop = (t) => { if (last !== null) T.frames.push([t, t - last]); last = t; requestAnimationFrame(loop); };
|
||||||
|
requestAnimationFrame(loop);
|
||||||
|
})();`;
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
for (let i = 0; i < runs; i += 1) {
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
channel: 'chromium',
|
||||||
|
headless: process.env.HEADED !== '1',
|
||||||
|
args: ['--use-gl=angle', '--use-angle=gl', '--ignore-gpu-blocklist', '--enable-gpu-rasterization',
|
||||||
|
'--enable-webgl', '--disable-gpu-sandbox', '--disable-gpu-shader-disk-cache'],
|
||||||
|
});
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
|
||||||
|
const page = await context.newPage();
|
||||||
|
const errors = [];
|
||||||
|
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
|
||||||
|
await page.addInitScript(init);
|
||||||
|
await page.goto('http://127.0.0.1:5181/', { waitUntil: 'load' });
|
||||||
|
await page.waitForFunction(() => window.__m.lit !== null, null, { timeout: 20000 });
|
||||||
|
const env = await page.evaluate(() => {
|
||||||
|
const gl = document.createElement('canvas').getContext('webgl2');
|
||||||
|
const ext = gl.getExtension('WEBGL_debug_renderer_info');
|
||||||
|
return {
|
||||||
|
renderer: ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER),
|
||||||
|
khr: gl.getExtension('KHR_parallel_shader_compile') !== null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await sleep(2500);
|
||||||
|
const scrollStart = await page.evaluate(() => performance.now());
|
||||||
|
if (!NOSCROLL) {
|
||||||
|
await page.mouse.move(720, 450);
|
||||||
|
const t0 = Date.now();
|
||||||
|
while (Date.now() - t0 < 10000) {
|
||||||
|
await page.mouse.wheel(0, 200);
|
||||||
|
await sleep(80);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await sleep(10000);
|
||||||
|
}
|
||||||
|
const scrollEnd = await page.evaluate(() => performance.now());
|
||||||
|
const m = await page.evaluate(() => window.__m);
|
||||||
|
const nav = await page.evaluate(() => performance.getEntriesByType('navigation')[0].toJSON());
|
||||||
|
const r = {
|
||||||
|
run: i,
|
||||||
|
renderer: env.renderer,
|
||||||
|
khr: env.khr,
|
||||||
|
litMs: Math.round(m.lit),
|
||||||
|
linksBeforeLit: m.linksAtLit,
|
||||||
|
linksAfterLit: m.links.length - m.linksAtLit,
|
||||||
|
linksAfterLitTimes: m.links.slice(m.linksAtLit).map((t) => Math.round(t - m.lit)),
|
||||||
|
frame0Task: (() => { const t = m.longtasks.find(([st, d]) => st <= m.lit && m.lit <= st + d + 5); return t ? Math.round(t[1]) : null; })(),
|
||||||
|
linksInFrame0Task: (() => { const t = m.longtasks.find(([st, d]) => st <= m.lit && m.lit <= st + d + 5); return t ? m.links.filter((x) => x >= t[0] && x <= m.lit).length : null; })(),
|
||||||
|
timeline: (() => { const t = m.longtasks.find(([st, d]) => st <= m.lit && m.lit <= st + d + 5); const f0 = t ? t[0] : m.lit; const pre = m.links.filter((x) => x < f0); return { firstLink: Math.round(m.links[0]), lastBuildLink: pre.length ? Math.round(pre[pre.length - 1]) : null, buildLinks: pre.length, frame0Start: Math.round(f0), lit: Math.round(m.lit) }; })(),
|
||||||
|
longestTask: Math.round(Math.max(0, ...m.longtasks.map((x) => x[1]))),
|
||||||
|
longTasks: m.longtasks.filter((x) => x[1] > 100).map((x) => [Math.round(x[0]), Math.round(x[1])]),
|
||||||
|
framesOver50Idle: m.frames.filter(([t, d]) => t > m.lit + 50 && t < scrollStart && d > 50).map(([t, d]) => [Math.round(t - m.lit), Math.round(d)]),
|
||||||
|
framesOver50Scroll: m.frames.filter(([t, d]) => t >= scrollStart && t <= scrollEnd && d > 50).map(([t, d]) => [Math.round(t - scrollStart), Math.round(d)]),
|
||||||
|
worstScrollFrame: Math.round(Math.max(0, ...m.frames.filter(([t]) => t >= scrollStart && t <= scrollEnd).map((x) => x[1]))),
|
||||||
|
scrollY: await page.evaluate(() => scrollY),
|
||||||
|
domContentLoaded: Math.round(nav.domContentLoadedEventEnd),
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
if (SHOT && m.shot) writeFileSync(`${out}/${label}-frame0-${Date.now()}.png`, Buffer.from(m.shot.split(',')[1], 'base64'));
|
||||||
|
console.log(JSON.stringify(r));
|
||||||
|
results.push(r);
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
appendFileSync(`${out}/${label}.jsonl`, results.map((r) => JSON.stringify(r)).join('\n') + '\n');
|
||||||
152
docs/overnight/compile-async-results.md
Normal file
152
docs/overnight/compile-async-results.md
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
# 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).
|
||||||
@@ -15,6 +15,11 @@ export interface PostProcessor extends GpuRenderTargetReporter {
|
|||||||
setDynamics(acceleration: number, dawn?: number): void;
|
setDynamics(acceleration: number, dawn?: number): void;
|
||||||
setLens(state: LensState): void;
|
setLens(state: LensState): void;
|
||||||
render(scene: unknown, camera: unknown): void;
|
render(scene: unknown, camera: unknown): void;
|
||||||
|
/**
|
||||||
|
* The off-screen target the scene pass draws into. Bind it while compiling
|
||||||
|
* scene programs: three keys tone mapping and output colour space on it.
|
||||||
|
*/
|
||||||
|
sceneTarget(): unknown;
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,6 +33,8 @@ interface RenderTargetLike {
|
|||||||
interface ComposerLike {
|
interface ComposerLike {
|
||||||
renderTarget1?: RenderTargetLike;
|
renderTarget1?: RenderTargetLike;
|
||||||
renderTarget2?: RenderTargetLike;
|
renderTarget2?: RenderTargetLike;
|
||||||
|
/** RenderPass (needsSwap false) draws the scene into this target. */
|
||||||
|
readBuffer?: unknown;
|
||||||
addPass(pass: unknown): void;
|
addPass(pass: unknown): void;
|
||||||
setPixelRatio(pixelRatio: number): void;
|
setPixelRatio(pixelRatio: number): void;
|
||||||
setSize(width: number, height: number): void;
|
setSize(width: number, height: number): void;
|
||||||
@@ -146,6 +153,9 @@ export function createPostProcessor(
|
|||||||
renderPass.camera = camera;
|
renderPass.camera = camera;
|
||||||
composer.render();
|
composer.render();
|
||||||
},
|
},
|
||||||
|
sceneTarget() {
|
||||||
|
return composer.readBuffer ?? composer.renderTarget2 ?? null;
|
||||||
|
},
|
||||||
dispose() {
|
dispose() {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import { readIgnition } from '@/journey/ignition-feed';
|
|||||||
import { createUnderpass, underpassUniforms } from '@/scene/world/underpass';
|
import { createUnderpass, underpassUniforms } from '@/scene/world/underpass';
|
||||||
import { DEFAULT_LEAD_VH, nextSectionId, shadeDepth } from '@/journey/underpass';
|
import { DEFAULT_LEAD_VH, nextSectionId, shadeDepth } from '@/journey/underpass';
|
||||||
import { signRouteFor, signTriggerMaximumVhFor } from '@/journey/sign-routes';
|
import { signRouteFor, signTriggerMaximumVhFor } from '@/journey/sign-routes';
|
||||||
|
import { warmUpShaders, type ShaderWarmupResult } from '@/scene/world/shader-warmup';
|
||||||
|
|
||||||
declare const __PORTFOLIO_TEST_BUILD__: boolean;
|
declare const __PORTFOLIO_TEST_BUILD__: boolean;
|
||||||
const TEST_BUILD =
|
const TEST_BUILD =
|
||||||
@@ -71,6 +72,8 @@ export function createWorldFog(color: number) {
|
|||||||
return new THREE.Fog(color, HORIZON_FOG_START, HORIZON_FOG_END);
|
return new THREE.Fog(color, HORIZON_FOG_START, HORIZON_FOG_END);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let warmupGenerations = 0;
|
||||||
|
|
||||||
export async function createProceduralSceneGeneration(
|
export async function createProceduralSceneGeneration(
|
||||||
context: SceneBuildContext,
|
context: SceneBuildContext,
|
||||||
): Promise<SceneGeneration> {
|
): Promise<SceneGeneration> {
|
||||||
@@ -353,6 +356,23 @@ export async function createProceduralSceneGeneration(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// SHADER WARM-UP: link the world's programs on the driver's compile threads
|
||||||
|
// while the poster is still up, so frame 0 finds them ready instead of
|
||||||
|
// compiling ~60 programs inside one task (src/scene/world/shader-warmup.ts).
|
||||||
|
// Size the canvas and the composer targets first: the camera and the
|
||||||
|
// targets are then exactly what frame 0 draws with.
|
||||||
|
resize();
|
||||||
|
const warmup: ShaderWarmupResult = await warmUpShaders(
|
||||||
|
renderer,
|
||||||
|
scene,
|
||||||
|
camera,
|
||||||
|
postProcessor?.sceneTarget() ?? null,
|
||||||
|
);
|
||||||
|
const warmupPrograms = (renderer.info.programs as unknown[] | null)?.length ?? 0;
|
||||||
|
// Test builds: renderer.info.programs must not grow after frame 0 (issue 2).
|
||||||
|
const warmupGeneration = (warmupGenerations += 1);
|
||||||
|
let programCount = -1;
|
||||||
|
|
||||||
if (context.signal.aborted) {
|
if (context.signal.aborted) {
|
||||||
context.resources.dispose();
|
context.resources.dispose();
|
||||||
throw new DOMException('Scene build aborted', 'AbortError');
|
throw new DOMException('Scene build aborted', 'AbortError');
|
||||||
@@ -944,11 +964,12 @@ export async function createProceduralSceneGeneration(
|
|||||||
// its program as a 70-280 ms hitch inside the opening shot (three slow
|
// its program as a 70-280 ms hitch inside the opening shot (three slow
|
||||||
// frames end the crane; one over 250 ms is a quality downgrade that
|
// frames end the crane; one over 250 ms is a quality downgrade that
|
||||||
// rebuilds the scene). Draw everything once now, behind the poster,
|
// rebuilds the scene). Draw everything once now, behind the poster,
|
||||||
// through the real pipeline: renderer.compile() keys its programs for
|
// through the real pipeline. The build's shader warm-up has already
|
||||||
// the screen (sRGB output, tone mapping) and never matches the post
|
// linked the scene programs off-thread, so this draw is the safety
|
||||||
// processor's linear targets, so every program compiled twice. The
|
// net: it builds the post passes' programs (not in the scene), uploads
|
||||||
// drawing buffer is presented once per task: the cold frame below
|
// textures, and finishes whatever a timed-out or extension-less
|
||||||
// is what the canvas shows.
|
// warm-up left. The drawing buffer is presented once per task: the
|
||||||
|
// cold frame below is what the canvas shows.
|
||||||
// Culling is off too: the car sits outside the crane's frustum.
|
// Culling is off too: the car sits outside the crane's frustum.
|
||||||
const hidden: { visible: boolean }[] = [];
|
const hidden: { visible: boolean }[] = [];
|
||||||
const culled: { frustumCulled: boolean }[] = [];
|
const culled: { frustumCulled: boolean }[] = [];
|
||||||
@@ -996,6 +1017,20 @@ export async function createProceduralSceneGeneration(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
renderedFrames += 1;
|
renderedFrames += 1;
|
||||||
|
if (TEST_BUILD) {
|
||||||
|
const programs = (renderer.info.programs as unknown[] | null)?.length ?? 0;
|
||||||
|
if (programs !== programCount) {
|
||||||
|
programCount = programs;
|
||||||
|
window.dispatchEvent(new CustomEvent('portfolio-program-count', {
|
||||||
|
detail: {
|
||||||
|
generation: warmupGeneration,
|
||||||
|
frame: renderedFrames,
|
||||||
|
programs,
|
||||||
|
warmup: { ...warmup, programs: warmupPrograms },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
dispose() {
|
dispose() {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
|
|||||||
96
src/scene/world/shader-warmup.ts
Normal file
96
src/scene/world/shader-warmup.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
// SHADER WARM-UP: compile the world's programs off the main thread before the
|
||||||
|
// first frame (issue 2, option A).
|
||||||
|
//
|
||||||
|
// renderer.compileAsync() asks the driver to link every material's program and
|
||||||
|
// then polls KHR_parallel_shader_compile's COMPLETION_STATUS instead of blocking
|
||||||
|
// on LINK_STATUS, so the scene build awaits the compile while the poster stays
|
||||||
|
// up and the main thread stays free. Two details make the precompiled programs
|
||||||
|
// the ones the real frames use:
|
||||||
|
// - three r185 keys a program's tone mapping and output colour space on the
|
||||||
|
// BOUND render target (WebGLPrograms.getParameters): null means "the screen"
|
||||||
|
// (sRGB, tone mapped), any off-screen target means "linear, untouched". The
|
||||||
|
// scene is drawn into the composer's target, so that target must be bound.
|
||||||
|
// - the light count is part of the key, and compile() gathers lights with
|
||||||
|
// traverseVisible(). The frame-0 warm render draws with every object visible,
|
||||||
|
// so the compile reveals the same hidden subtrees.
|
||||||
|
// Without the extension three flags every program ready at once and resolves
|
||||||
|
// after one 10 ms timeout: the links are queued, and the warm render waits on
|
||||||
|
// them as before. A slow driver gets TIMEOUT_MS; the warm render then finishes
|
||||||
|
// whatever is left synchronously, as it always did.
|
||||||
|
|
||||||
|
export const SHADER_WARMUP_TIMEOUT_MS = 2_500;
|
||||||
|
|
||||||
|
export type ShaderWarmupOutcome = 'ready' | 'timeout' | 'failed';
|
||||||
|
|
||||||
|
export interface ShaderWarmupResult {
|
||||||
|
outcome: ShaderWarmupOutcome;
|
||||||
|
/** Wall time the build waited, ms. */
|
||||||
|
waited: number;
|
||||||
|
/** KHR_parallel_shader_compile was available (true off-thread compile). */
|
||||||
|
parallel: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WarmupObject {
|
||||||
|
visible: boolean;
|
||||||
|
traverse(callback: (object: WarmupObject) => void): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WarmupRenderer {
|
||||||
|
getRenderTarget(): unknown;
|
||||||
|
setRenderTarget(target: unknown): void;
|
||||||
|
compileAsync(scene: unknown, camera: unknown): Promise<unknown>;
|
||||||
|
extensions: { has(name: string): boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WarmupScheduler {
|
||||||
|
now(): number;
|
||||||
|
setTimeout(callback: () => void, delay: number): unknown;
|
||||||
|
clearTimeout(handle: unknown): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultScheduler: WarmupScheduler = {
|
||||||
|
now: () => performance.now(),
|
||||||
|
setTimeout: (callback, delay) => globalThis.setTimeout(callback, delay),
|
||||||
|
clearTimeout: (handle) => globalThis.clearTimeout(handle as ReturnType<typeof setTimeout>),
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function warmUpShaders(
|
||||||
|
renderer: WarmupRenderer,
|
||||||
|
scene: WarmupObject,
|
||||||
|
camera: unknown,
|
||||||
|
/** The target the scene pass draws into; null when there is no composer. */
|
||||||
|
sceneTarget: unknown,
|
||||||
|
timeoutMs = SHADER_WARMUP_TIMEOUT_MS,
|
||||||
|
scheduler: WarmupScheduler = defaultScheduler,
|
||||||
|
): Promise<ShaderWarmupResult> {
|
||||||
|
const started = scheduler.now();
|
||||||
|
const parallel = renderer.extensions.has('KHR_parallel_shader_compile');
|
||||||
|
let pending: Promise<unknown>;
|
||||||
|
// compile() itself is synchronous: target and visibility only need to hold
|
||||||
|
// for this call, not for the wait.
|
||||||
|
const previousTarget = renderer.getRenderTarget();
|
||||||
|
const hidden: WarmupObject[] = [];
|
||||||
|
try {
|
||||||
|
scene.traverse((object) => {
|
||||||
|
if (object.visible) return;
|
||||||
|
object.visible = true;
|
||||||
|
hidden.push(object);
|
||||||
|
});
|
||||||
|
renderer.setRenderTarget(sceneTarget);
|
||||||
|
pending = renderer.compileAsync(scene, camera);
|
||||||
|
} catch {
|
||||||
|
return { outcome: 'failed', waited: scheduler.now() - started, parallel };
|
||||||
|
} finally {
|
||||||
|
for (const object of hidden) object.visible = false;
|
||||||
|
renderer.setRenderTarget(previousTarget);
|
||||||
|
}
|
||||||
|
let timer: unknown = null;
|
||||||
|
const outcome = await Promise.race<ShaderWarmupOutcome>([
|
||||||
|
pending.then(() => 'ready' as const, () => 'failed' as const),
|
||||||
|
new Promise<ShaderWarmupOutcome>((resolve) => {
|
||||||
|
timer = scheduler.setTimeout(() => resolve('timeout'), timeoutMs);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
scheduler.clearTimeout(timer);
|
||||||
|
return { outcome, waited: scheduler.now() - started, parallel };
|
||||||
|
}
|
||||||
55
tests/browser/shader-warmup.spec.ts
Normal file
55
tests/browser/shader-warmup.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { expect, test } from '../helpers/serve-dist';
|
||||||
|
|
||||||
|
// Issue 2 (cold-GPU first frame): the build links the world's programs with
|
||||||
|
// renderer.compileAsync() against the composer's target, and frame 0 draws
|
||||||
|
// everything once as a safety net. After frame 0 no program may be created:
|
||||||
|
// each later one is a 70-280 ms hitch inside the opening shot. Test builds
|
||||||
|
// dispatch portfolio-program-count whenever renderer.info.programs changes.
|
||||||
|
|
||||||
|
interface ProgramCountEvent {
|
||||||
|
generation: number;
|
||||||
|
frame: number;
|
||||||
|
programs: number;
|
||||||
|
warmup: { outcome: 'ready' | 'timeout' | 'failed'; waited: number; parallel: boolean; programs: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('compiles every scene program before the first frame and none after it', async ({ page, distUrl }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const events: unknown[] = [];
|
||||||
|
(window as unknown as { __programCounts: unknown[] }).__programCounts = events;
|
||||||
|
window.addEventListener('portfolio-program-count', (event) => {
|
||||||
|
events.push(structuredClone((event as CustomEvent).detail));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||||
|
await expect(page.locator('canvas.journey-canvas')).toHaveAttribute('data-lit', '', { timeout: 20_000 });
|
||||||
|
|
||||||
|
// The ignition hold, then the pull-away and the first chapters.
|
||||||
|
await page.waitForTimeout(3_500);
|
||||||
|
await page.mouse.move(200, 300);
|
||||||
|
for (let step = 0; step < 12; step += 1) {
|
||||||
|
await page.mouse.wheel(0, 400);
|
||||||
|
await page.waitForTimeout(120);
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
const events = await page.evaluate(
|
||||||
|
() => (window as unknown as { __programCounts: ProgramCountEvent[] }).__programCounts,
|
||||||
|
);
|
||||||
|
expect(events.length).toBeGreaterThan(0);
|
||||||
|
for (const generation of new Set(events.map((event) => event.generation))) {
|
||||||
|
const own = events.filter((event) => event.generation === generation);
|
||||||
|
const first = own[0]!;
|
||||||
|
// The first report comes right after frame 0.
|
||||||
|
expect(first.frame).toBe(1);
|
||||||
|
// The warm-up compiled the scene before frame 0 (or gave up on a slow driver).
|
||||||
|
expect(['ready', 'timeout']).toContain(first.warmup.outcome);
|
||||||
|
expect(first.warmup.programs).toBeGreaterThan(0);
|
||||||
|
expect(first.programs).toBeGreaterThanOrEqual(first.warmup.programs);
|
||||||
|
// Frame 0 adds only the post passes (9 on desktop). A warm-up keyed for
|
||||||
|
// the wrong target or light count would add the whole scene again (~60).
|
||||||
|
expect(first.programs - first.warmup.programs).toBeLessThanOrEqual(12);
|
||||||
|
// No growth afterwards.
|
||||||
|
expect(own.slice(1)).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
115
tests/unit/shader-warmup.test.ts
Normal file
115
tests/unit/shader-warmup.test.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { warmUpShaders, type WarmupRenderer, type WarmupScheduler } from '@/scene/world/shader-warmup';
|
||||||
|
|
||||||
|
interface Node {
|
||||||
|
name: string;
|
||||||
|
visible: boolean;
|
||||||
|
children: Node[];
|
||||||
|
traverse(callback: (object: Node) => void): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = (name: string, visible: boolean, children: Node[] = []): Node => ({
|
||||||
|
name,
|
||||||
|
visible,
|
||||||
|
children,
|
||||||
|
traverse(callback) {
|
||||||
|
callback(this);
|
||||||
|
for (const child of this.children) child.traverse(callback);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const manualScheduler = () => {
|
||||||
|
let clock = 0;
|
||||||
|
const timers = new Map<number, () => void>();
|
||||||
|
let next = 1;
|
||||||
|
const scheduler: WarmupScheduler = {
|
||||||
|
now: () => clock,
|
||||||
|
setTimeout(callback, delay) {
|
||||||
|
const id = next++;
|
||||||
|
timers.set(id, () => {
|
||||||
|
clock += delay;
|
||||||
|
callback();
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
clearTimeout(handle) {
|
||||||
|
timers.delete(handle as number);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { scheduler, fire: () => [...timers.values()].forEach((run) => run()), pending: () => timers.size };
|
||||||
|
};
|
||||||
|
|
||||||
|
const fakeRenderer = (compile: () => Promise<unknown>, parallel = true) => {
|
||||||
|
const seen: { target: unknown; visible: string[] }[] = [];
|
||||||
|
const state = { target: 'screen-previous' as unknown };
|
||||||
|
let scene: Node | null = null;
|
||||||
|
const renderer: WarmupRenderer = {
|
||||||
|
getRenderTarget: () => state.target,
|
||||||
|
setRenderTarget: (target) => {
|
||||||
|
state.target = target;
|
||||||
|
},
|
||||||
|
compileAsync: (root) => {
|
||||||
|
scene = root as Node;
|
||||||
|
const visible: string[] = [];
|
||||||
|
scene.traverse((object) => {
|
||||||
|
if (object.visible) visible.push(object.name);
|
||||||
|
});
|
||||||
|
seen.push({ target: state.target, visible });
|
||||||
|
return compile();
|
||||||
|
},
|
||||||
|
extensions: { has: (name) => parallel && name === 'KHR_parallel_shader_compile' },
|
||||||
|
};
|
||||||
|
return { renderer, seen, state };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('shader warm-up', () => {
|
||||||
|
it('compiles against the scene target with every subtree visible, then restores both', async () => {
|
||||||
|
const lamp = node('hidden-light', false);
|
||||||
|
const scene = node('root', true, [node('car', true), node('cone', false, [lamp])]);
|
||||||
|
const { renderer, seen, state } = fakeRenderer(() => Promise.resolve());
|
||||||
|
const { scheduler } = manualScheduler();
|
||||||
|
|
||||||
|
const result = await warmUpShaders(renderer, scene, {}, 'composer-target', 2_500, scheduler);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ outcome: 'ready', parallel: true });
|
||||||
|
expect(seen).toEqual([
|
||||||
|
{ target: 'composer-target', visible: ['root', 'car', 'cone', 'hidden-light'] },
|
||||||
|
]);
|
||||||
|
expect(state.target).toBe('screen-previous');
|
||||||
|
expect(scene.children[1]!.visible).toBe(false);
|
||||||
|
expect(lamp.visible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives up after the timeout so a slow driver never holds the build', async () => {
|
||||||
|
const { renderer } = fakeRenderer(() => new Promise(() => undefined));
|
||||||
|
const timers = manualScheduler();
|
||||||
|
const pending = warmUpShaders(renderer, node('root', true), {}, null, 2_500, timers.scheduler);
|
||||||
|
timers.fire();
|
||||||
|
await expect(pending).resolves.toMatchObject({ outcome: 'timeout', waited: 2_500 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears its timer once the compile resolves', async () => {
|
||||||
|
const { renderer } = fakeRenderer(() => Promise.resolve(), false);
|
||||||
|
const timers = manualScheduler();
|
||||||
|
const result = await warmUpShaders(renderer, node('root', true), {}, null, 2_500, timers.scheduler);
|
||||||
|
expect(result).toMatchObject({ outcome: 'ready', parallel: false });
|
||||||
|
expect(timers.pending()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a throwing compile as failed and still restores the renderer and scene', async () => {
|
||||||
|
const hidden = node('hidden', false);
|
||||||
|
const { renderer, state } = fakeRenderer(() => {
|
||||||
|
throw new Error('link failed');
|
||||||
|
});
|
||||||
|
const result = await warmUpShaders(renderer, node('root', true, [hidden]), {}, 'target', 2_500, manualScheduler().scheduler);
|
||||||
|
expect(result.outcome).toBe('failed');
|
||||||
|
expect(state.target).toBe('screen-previous');
|
||||||
|
expect(hidden.visible).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a rejected compile as failed', async () => {
|
||||||
|
const { renderer } = fakeRenderer(() => Promise.reject(new Error('lost context')));
|
||||||
|
const result = await warmUpShaders(renderer, node('root', true), {}, null, 2_500, manualScheduler().scheduler);
|
||||||
|
expect(result.outcome).toBe('failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -404,6 +404,8 @@ describe("procedural world models", () => {
|
|||||||
expect(bloomPass.strength).toBe(restingBloom.strength);
|
expect(bloomPass.strength).toBe(restingBloom.strength);
|
||||||
expect(bloomPass.threshold).toBe(restingBloom.threshold);
|
expect(bloomPass.threshold).toBe(restingBloom.threshold);
|
||||||
processor.render({}, {});
|
processor.render({}, {});
|
||||||
|
// The shader warm-up binds the scene pass's target (EffectComposer's readBuffer).
|
||||||
|
expect(processor.sceneTarget()).toBe(composer.renderTarget2);
|
||||||
const allocations = resources.gpuBudgetSnapshot({
|
const allocations = resources.gpuBudgetSnapshot({
|
||||||
tier: "high",
|
tier: "high",
|
||||||
defaultFramebuffer: {
|
defaultFramebuffer: {
|
||||||
|
|||||||
Reference in New Issue
Block a user