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.
214 lines
7.1 KiB
TypeScript
214 lines
7.1 KiB
TypeScript
import * as THREE from "three";
|
|
import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
|
|
import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js";
|
|
import { UnrealBloomPass } from "three/examples/jsm/postprocessing/UnrealBloomPass.js";
|
|
import { LensFinishPass, type LensState } from "@/scene/cinematic-grade";
|
|
import type {
|
|
GpuRenderTargetAllocation,
|
|
GpuRenderTargetReporter,
|
|
SceneResourceBag,
|
|
} from "@/scene/resources";
|
|
|
|
export interface PostProcessor extends GpuRenderTargetReporter {
|
|
resize(width: number, height: number, pixelRatio: number): void;
|
|
/** dawn (END OF SHIFT, 0..1) lowers the bloom threshold up to 10%. */
|
|
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;
|
|
}
|
|
|
|
interface RenderTargetLike {
|
|
width: number;
|
|
height: number;
|
|
samples?: number;
|
|
depthBuffer?: boolean;
|
|
}
|
|
|
|
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;
|
|
render(): void;
|
|
dispose(): void;
|
|
}
|
|
|
|
interface RenderPassLike {
|
|
scene: unknown;
|
|
camera: unknown;
|
|
dispose(): void;
|
|
}
|
|
|
|
interface BloomPassLike {
|
|
strength: number;
|
|
radius: number;
|
|
threshold: number;
|
|
materialHighPassFilter?: { dispose(): void };
|
|
renderTargetBright?: RenderTargetLike;
|
|
renderTargetsHorizontal?: readonly RenderTargetLike[];
|
|
renderTargetsVertical?: readonly (RenderTargetLike & { texture?: unknown })[];
|
|
dispose(): void;
|
|
}
|
|
|
|
interface OutputPassLike {
|
|
setStreakSource?(texture: unknown, strength?: number): void;
|
|
setLens?(state: LensState): void;
|
|
dispose(): void;
|
|
}
|
|
|
|
export interface PostProcessingFactories {
|
|
createComposer(renderer: unknown): ComposerLike;
|
|
createRenderPass(scene: unknown, camera: unknown): RenderPassLike;
|
|
createBloomPass(): BloomPassLike;
|
|
createOutputPass(): OutputPassLike;
|
|
}
|
|
|
|
// Lights read as light: a lower threshold lets lamps, trails and the sun's limb
|
|
// bloom, while the asphalt and the panels' surroundings stay below it.
|
|
const BLOOM_REST = { strength: 0.58, radius: 0.58, threshold: 0.85 } as const;
|
|
|
|
const productionFactories: PostProcessingFactories = {
|
|
createComposer: (renderer) => new EffectComposer(renderer),
|
|
createRenderPass: (scene, camera) => new RenderPass(scene, camera),
|
|
createBloomPass: () => {
|
|
const bloom = new UnrealBloomPass(
|
|
new THREE.Vector2(1, 1),
|
|
BLOOM_REST.strength,
|
|
BLOOM_REST.radius,
|
|
BLOOM_REST.threshold,
|
|
);
|
|
// These fullscreen passes overwrite every color pixel. No pass samples
|
|
// their depth, and scene depth lives in the composer's separate targets.
|
|
for (const target of [
|
|
bloom.renderTargetBright,
|
|
...bloom.renderTargetsHorizontal,
|
|
...bloom.renderTargetsVertical,
|
|
]) {
|
|
target.depthBuffer = false;
|
|
}
|
|
return bloom;
|
|
},
|
|
// The lens pass replaces OutputPass one for one: last pass, straight to the
|
|
// canvas, no swap. Neither RenderPass nor bloom swaps buffers, so swapping
|
|
// here would only alternate two identical full-resolution GPU targets.
|
|
createOutputPass: () => new LensFinishPass(),
|
|
};
|
|
|
|
export function createPostProcessor(
|
|
resources: SceneResourceBag,
|
|
renderer: unknown,
|
|
factories: PostProcessingFactories = productionFactories,
|
|
): PostProcessor {
|
|
const composer = factories.createComposer(renderer);
|
|
const renderPass = factories.createRenderPass(
|
|
new THREE.Scene(),
|
|
new THREE.Camera(),
|
|
);
|
|
const bloomPass = factories.createBloomPass();
|
|
const outputPass = factories.createOutputPass();
|
|
composer.addPass(renderPass);
|
|
composer.addPass(bloomPass);
|
|
composer.addPass(outputPass);
|
|
// A quarter-resolution bloom mip is already blurred: sampling it sideways
|
|
// gives anamorphic streaks without a target of their own.
|
|
outputPass.setStreakSource?.(bloomPass.renderTargetsVertical?.[1]?.texture ?? null, 0.22);
|
|
let disposed = false;
|
|
|
|
const processor: PostProcessor = {
|
|
gpuRenderTargets() {
|
|
return collectRenderTargetAllocations(composer, bloomPass);
|
|
},
|
|
resize(width, height, pixelRatio) {
|
|
if (disposed) return;
|
|
composer.setPixelRatio(clamp(pixelRatio, 0.5, 2));
|
|
composer.setSize(
|
|
Math.max(1, Math.floor(finiteOr(width, 1))),
|
|
Math.max(1, Math.floor(finiteOr(height, 1))),
|
|
);
|
|
},
|
|
setDynamics(acceleration, dawn = 0) {
|
|
if (disposed) return;
|
|
const energy = clamp(acceleration, 0, 1);
|
|
const daybreak = clamp(dawn, 0, 1);
|
|
bloomPass.strength = BLOOM_REST.strength + energy * 0.16;
|
|
bloomPass.radius = BLOOM_REST.radius + energy * 0.06;
|
|
bloomPass.threshold = (BLOOM_REST.threshold - energy * 0.05) * (1 - 0.1 * daybreak);
|
|
},
|
|
setLens(state) {
|
|
if (disposed) return;
|
|
outputPass.setLens?.(state);
|
|
},
|
|
render(scene, camera) {
|
|
if (disposed) return;
|
|
renderPass.scene = scene;
|
|
renderPass.camera = camera;
|
|
composer.render();
|
|
},
|
|
sceneTarget() {
|
|
return composer.readBuffer ?? composer.renderTarget2 ?? null;
|
|
},
|
|
dispose() {
|
|
if (disposed) return;
|
|
disposed = true;
|
|
renderPass.dispose();
|
|
// Three r185 omits this material from UnrealBloomPass.dispose().
|
|
bloomPass.materialHighPassFilter?.dispose();
|
|
bloomPass.dispose();
|
|
outputPass.dispose();
|
|
composer.dispose();
|
|
},
|
|
};
|
|
return resources.own("pass", processor);
|
|
}
|
|
|
|
function collectRenderTargetAllocations(
|
|
composer: ComposerLike,
|
|
bloomPass: BloomPassLike,
|
|
): readonly GpuRenderTargetAllocation[] {
|
|
const candidates: ReadonlyArray<
|
|
readonly [string, RenderTargetLike | undefined]
|
|
> = [
|
|
["composer-target-1", composer.renderTarget1],
|
|
["composer-target-2", composer.renderTarget2],
|
|
["bloom-bright", bloomPass.renderTargetBright],
|
|
...(bloomPass.renderTargetsHorizontal ?? []).map(
|
|
(target, index) => [`bloom-horizontal-${index}`, target] as const,
|
|
),
|
|
...(bloomPass.renderTargetsVertical ?? []).map(
|
|
(target, index) => [`bloom-vertical-${index}`, target] as const,
|
|
),
|
|
];
|
|
const seen = new Set<RenderTargetLike>();
|
|
return candidates.flatMap(([id, target]) => {
|
|
if (target === undefined || seen.has(target)) return [];
|
|
seen.add(target);
|
|
return [
|
|
{
|
|
id,
|
|
width: Math.max(1, Math.floor(finiteOr(target.width, 1))),
|
|
height: Math.max(1, Math.floor(finiteOr(target.height, 1))),
|
|
bytesPerPixel: 4,
|
|
attachments: target.depthBuffer === false ? 1 : 2,
|
|
samples: Math.max(1, Math.floor(finiteOr(target.samples ?? 1, 1))),
|
|
},
|
|
];
|
|
});
|
|
}
|
|
|
|
function finiteOr(value: number, fallback: number): number {
|
|
return Number.isFinite(value) ? value : fallback;
|
|
}
|
|
|
|
function clamp(value: number, minimum: number, maximum: number): number {
|
|
return THREE.MathUtils.clamp(finiteOr(value, minimum), minimum, maximum);
|
|
}
|