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(); 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); }