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.
This commit is contained in:
@@ -15,6 +15,11 @@ export interface PostProcessor extends GpuRenderTargetReporter {
|
||||
setDynamics(acceleration: number, dawn?: number): void;
|
||||
setLens(state: LensState): 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;
|
||||
}
|
||||
|
||||
@@ -28,6 +33,8 @@ interface RenderTargetLike {
|
||||
interface ComposerLike {
|
||||
renderTarget1?: RenderTargetLike;
|
||||
renderTarget2?: RenderTargetLike;
|
||||
/** RenderPass (needsSwap false) draws the scene into this target. */
|
||||
readBuffer?: unknown;
|
||||
addPass(pass: unknown): void;
|
||||
setPixelRatio(pixelRatio: number): void;
|
||||
setSize(width: number, height: number): void;
|
||||
@@ -146,6 +153,9 @@ export function createPostProcessor(
|
||||
renderPass.camera = camera;
|
||||
composer.render();
|
||||
},
|
||||
sceneTarget() {
|
||||
return composer.readBuffer ?? composer.renderTarget2 ?? null;
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
|
||||
@@ -54,6 +54,7 @@ import { readIgnition } from '@/journey/ignition-feed';
|
||||
import { createUnderpass, underpassUniforms } from '@/scene/world/underpass';
|
||||
import { DEFAULT_LEAD_VH, nextSectionId, shadeDepth } from '@/journey/underpass';
|
||||
import { signRouteFor, signTriggerMaximumVhFor } from '@/journey/sign-routes';
|
||||
import { warmUpShaders, type ShaderWarmupResult } from '@/scene/world/shader-warmup';
|
||||
|
||||
declare const __PORTFOLIO_TEST_BUILD__: boolean;
|
||||
const TEST_BUILD =
|
||||
@@ -71,6 +72,8 @@ export function createWorldFog(color: number) {
|
||||
return new THREE.Fog(color, HORIZON_FOG_START, HORIZON_FOG_END);
|
||||
}
|
||||
|
||||
let warmupGenerations = 0;
|
||||
|
||||
export async function createProceduralSceneGeneration(
|
||||
context: SceneBuildContext,
|
||||
): 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) {
|
||||
context.resources.dispose();
|
||||
throw new DOMException('Scene build aborted', 'AbortError');
|
||||
@@ -939,11 +959,12 @@ export async function createProceduralSceneGeneration(
|
||||
// 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
|
||||
// rebuilds the scene). Draw everything once now, behind the poster,
|
||||
// through the real pipeline: renderer.compile() keys its programs for
|
||||
// the screen (sRGB output, tone mapping) and never matches the post
|
||||
// processor's linear targets, so every program compiled twice. The
|
||||
// drawing buffer is presented once per task: the cold frame below
|
||||
// is what the canvas shows.
|
||||
// through the real pipeline. The build's shader warm-up has already
|
||||
// linked the scene programs off-thread, so this draw is the safety
|
||||
// net: it builds the post passes' programs (not in the scene), uploads
|
||||
// textures, and finishes whatever a timed-out or extension-less
|
||||
// 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.
|
||||
const hidden: { visible: boolean }[] = [];
|
||||
const culled: { frustumCulled: boolean }[] = [];
|
||||
@@ -991,6 +1012,20 @@ export async function createProceduralSceneGeneration(
|
||||
}
|
||||
}
|
||||
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() {
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user