Merge branch 'polish/compile-async' into redesign/overnight
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');
|
||||
@@ -944,11 +964,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 }[] = [];
|
||||
@@ -996,6 +1017,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