feat(scene): add film grade and lens finish pass

This commit is contained in:
2026-10-10 19:50:38 +03:00
parent 5917284f24
commit 2080265568
3 changed files with 224 additions and 14 deletions

View File

@@ -2,7 +2,7 @@ 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 { OutputPass } from "three/examples/jsm/postprocessing/OutputPass.js";
import { LensFinishPass, type LensState } from "@/scene/cinematic-grade";
import type {
GpuRenderTargetAllocation,
GpuRenderTargetReporter,
@@ -12,6 +12,7 @@ import type {
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;
}
@@ -46,11 +47,13 @@ interface BloomPassLike {
materialHighPassFilter?: { dispose(): void };
renderTargetBright?: RenderTargetLike;
renderTargetsHorizontal?: readonly RenderTargetLike[];
renderTargetsVertical?: readonly RenderTargetLike[];
renderTargetsVertical?: readonly (RenderTargetLike & { texture?: unknown })[];
dispose(): void;
}
interface OutputPassLike {
setStreakSource?(texture: unknown, strength?: number): void;
setLens?(state: LensState): void;
dispose(): void;
}
@@ -61,11 +64,20 @@ export interface PostProcessingFactories {
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), 0.3, 0.38, 1.05);
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 [
@@ -77,14 +89,10 @@ const productionFactories: PostProcessingFactories = {
}
return bloom;
},
createOutputPass: () => {
const output = new OutputPass();
// Output is always the last pass and renders directly to the canvas.
// Neither RenderPass nor bloom swaps buffers, so swapping here would only
// alternate the scene between two identical full-resolution GPU targets.
output.needsSwap = false;
return output;
},
// 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(
@@ -102,6 +110,9 @@ export function createPostProcessor(
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 = {
@@ -119,9 +130,13 @@ export function createPostProcessor(
setDynamics(acceleration) {
if (disposed) return;
const energy = clamp(acceleration, 0, 1);
bloomPass.strength = 0.3 + energy * 0.12;
bloomPass.radius = 0.38 + energy * 0.06;
bloomPass.threshold = 1.05 - energy * 0.03;
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;