Files
Portfolio/src/scene/post-processing.ts

202 lines
6.6 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;
setDynamics(acceleration: number): void;
setLens(state: LensState): void;
render(scene: unknown, camera: unknown): void;
dispose(): void;
}
interface RenderTargetLike {
width: number;
height: number;
samples?: number;
depthBuffer?: boolean;
}
interface ComposerLike {
renderTarget1?: RenderTargetLike;
renderTarget2?: RenderTargetLike;
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) {
if (disposed) return;
const energy = clamp(acceleration, 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;
},
setLens(state) {
if (disposed) return;
outputPass.setLens?.(state);
},
render(scene, camera) {
if (disposed) return;
renderPass.scene = scene;
renderPass.camera = camera;
composer.render();
},
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);
}