Merge branch 'polish/compile-async' into redesign/overnight

This commit is contained in:
2026-10-12 19:13:02 +03:00
8 changed files with 576 additions and 5 deletions

View File

@@ -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;

View File

@@ -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;

View 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 };
}