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

@@ -0,0 +1,194 @@
import * as THREE from 'three';
import { FullScreenQuad, Pass } from 'three/examples/jsm/postprocessing/Pass.js';
/**
* One film grade for every tier. It replaces three's CustomToneMapping, so the
* low tier (materials tone-map straight to the canvas) and the post tiers (the
* lens pass tone-maps the composed frame) share the same look at no extra cost.
*
* ACES first, then in display space: a deeper toe so the haze sits on true
* black, a gentle S-curve, shadows cooled towards indigo-teal, highlights warmed
* towards amber, and a small saturation lift for the neon.
*/
const GRADE_FUNCTION = /* glsl */ `
vec3 CustomToneMapping( vec3 color ) {
const vec3 luma = vec3( 0.2126, 0.7152, 0.0722 );
vec3 graded = pow( max( ACESFilmicToneMapping( color ), 0.0 ), vec3( 0.4545 ) );
graded = max( graded - 0.03, 0.0 ) / 0.97;
graded = mix( graded, graded * graded * ( 3.0 - 2.0 * graded ), 0.3 );
float level = dot( graded, luma );
float shadows = 1.0 - smoothstep( 0.0, 0.45, level );
float highlights = smoothstep( 0.52, 1.0, level );
graded *= mix( vec3( 1.0 ), vec3( 0.78, 0.92, 1.18 ), shadows * 0.7 );
graded += vec3( 0.055, 0.02, -0.025 ) * highlights;
graded = mix( vec3( dot( graded, luma ) ), graded, 1.16 );
return pow( clamp( graded, 0.0, 1.0 ), vec3( 2.2 ) );
}`;
type TextureLike = object;
interface RendererLike {
toneMappingExposure: number;
setRenderTarget(target: unknown): void;
}
let installed = false;
/** Idempotent: patches the shared tone-mapping chunk before any program compiles. */
export function installCinematicGrade(): void {
if (installed) return;
const chunk = THREE.ShaderChunk.tonemapping_pars_fragment;
const stub = /vec3 CustomToneMapping\( vec3 color \) \{ return color; \}/u;
if (!stub.test(chunk)) return;
THREE.ShaderChunk.tonemapping_pars_fragment = chunk.replace(stub, GRADE_FUNCTION);
installed = true;
}
export interface LensState {
/** Seconds, for grain. */
time: number;
/** Letterbox bar height as a fraction of the frame, top and bottom. */
letterbox: number;
/** Additive exposure flash, 0 at rest. */
flash: number;
/** Drive energy: widens aberration and streaks a little. */
energy: number;
}
/**
* The output pass with a lens: tone mapping + grade, edge chromatic
* aberration, anamorphic streaks sampled from a bloom mip, vignette, grain and
* letterbox. It replaces OutputPass one for one, so it allocates no target.
*/
export class LensFinishPass extends Pass {
readonly uniforms = {
tDiffuse: { value: null as TextureLike | null },
tStreak: { value: null as TextureLike | null },
uStreak: { value: 0 },
uAspect: { value: 1 },
uTime: { value: 0 },
uLetterbox: { value: 0 },
uAberration: { value: 0.0035 },
uGrain: { value: 0.03 },
uVignette: { value: 0.42 },
uFlash: { value: 0 },
toneMappingExposure: { value: 1 },
};
private readonly material: { dispose(): void };
private readonly quad: { render(renderer: RendererLike): void; dispose(): void };
private streakStrength = 0.2;
constructor() {
super();
installCinematicGrade();
this.needsSwap = false;
this.material = new THREE.RawShaderMaterial({
name: 'LensFinishShader',
uniforms: this.uniforms,
defines: { SRGB_TRANSFER: '' },
vertexShader: /* glsl */ `
precision highp float;
attribute vec3 position;
attribute vec2 uv;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}`,
fragmentShader: /* glsl */ `
precision highp float;
uniform sampler2D tDiffuse;
uniform sampler2D tStreak;
uniform float uStreak;
uniform float uAspect;
uniform float uTime;
uniform float uLetterbox;
uniform float uAberration;
uniform float uGrain;
uniform float uVignette;
uniform float uFlash;
varying vec2 vUv;
#include <tonemapping_pars_fragment>
#include <colorspace_pars_fragment>
float grainHash( vec2 p ) {
vec3 p3 = fract( vec3( p.xyx ) * 0.1031 );
p3 += dot( p3, p3.yzx + 33.33 );
return fract( ( p3.x + p3.y ) * p3.z );
}
void main() {
vec2 centered = vUv - 0.5;
float radius2 = dot( centered * vec2( uAspect, 1.0 ), centered * vec2( uAspect, 1.0 ) );
vec2 shift = centered * radius2 * uAberration;
vec3 color = vec3(
texture2D( tDiffuse, vUv - shift ).r,
texture2D( tDiffuse, vUv ).g,
texture2D( tDiffuse, vUv + shift ).b
);
if ( uStreak > 0.0 ) {
vec3 streak = vec3( 0.0 );
for ( int i = 1; i <= 7; i++ ) {
float o = float( i ) * 0.034;
float w = exp( -float( i ) * 0.42 );
streak += ( texture2D( tStreak, vec2( vUv.x + o, vUv.y ) ).rgb
+ texture2D( tStreak, vec2( vUv.x - o, vUv.y ) ).rgb ) * w;
}
float streakLevel = max( dot( streak, vec3( 0.3, 0.45, 0.25 ) ) - 0.08, 0.0 );
color += vec3( 0.32, 0.62, 1.0 ) * streakLevel * uStreak;
}
color *= 1.0 + uFlash;
color = CustomToneMapping( color );
color = sRGBTransferOETF( vec4( color, 1.0 ) ).rgb;
float vignette = smoothstep( 0.18, 1.05, length( centered * vec2( 1.0, 0.86 ) ) * 1.42 );
color *= 1.0 - uVignette * vignette;
color += ( grainHash( gl_FragCoord.xy + fract( uTime * 7.31 ) * 311.0 ) - 0.5 ) * uGrain;
float bars = step( vUv.y, uLetterbox ) + step( 1.0 - uLetterbox, vUv.y );
color = mix( max( color, 0.0 ), vec3( 0.0 ), clamp( bars, 0.0, 1.0 ) );
gl_FragColor = vec4( color, 1.0 );
}`,
depthTest: false,
depthWrite: false,
});
this.quad = new FullScreenQuad(this.material);
}
setStreakSource(texture: TextureLike | null, strength = 0.2): void {
this.uniforms.tStreak.value = texture;
this.streakStrength = strength;
this.uniforms.uStreak.value = texture ? strength : 0;
}
setLens(state: LensState): void {
const energy = THREE.MathUtils.clamp(state.energy, 0, 1);
this.uniforms.uTime.value = state.time;
this.uniforms.uLetterbox.value = THREE.MathUtils.clamp(state.letterbox, 0, 0.2);
this.uniforms.uFlash.value = Math.max(0, state.flash);
this.uniforms.uAberration.value = 0.0035 + energy * 0.006;
if (this.uniforms.tStreak.value) {
this.uniforms.uStreak.value = this.streakStrength * (1 + energy * 0.6);
}
}
setSize(width: number, height: number): void {
this.uniforms.uAspect.value = width / Math.max(1, height);
}
render(
renderer: RendererLike,
writeBuffer: unknown,
readBuffer: { texture: TextureLike },
): void {
this.uniforms.tDiffuse.value = readBuffer.texture;
this.uniforms.toneMappingExposure.value = renderer.toneMappingExposure;
renderer.setRenderTarget(this.renderToScreen ? null : writeBuffer);
this.quad.render(renderer);
}
dispose(): void {
this.material.dispose();
this.quad.dispose();
}
}

View File

@@ -2,7 +2,7 @@ import * as THREE from "three";
import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js"; import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js"; import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js";
import { UnrealBloomPass } from "three/examples/jsm/postprocessing/UnrealBloomPass.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 { import type {
GpuRenderTargetAllocation, GpuRenderTargetAllocation,
GpuRenderTargetReporter, GpuRenderTargetReporter,
@@ -12,6 +12,7 @@ import type {
export interface PostProcessor extends GpuRenderTargetReporter { export interface PostProcessor extends GpuRenderTargetReporter {
resize(width: number, height: number, pixelRatio: number): void; resize(width: number, height: number, pixelRatio: number): void;
setDynamics(acceleration: number): void; setDynamics(acceleration: number): void;
setLens(state: LensState): void;
render(scene: unknown, camera: unknown): void; render(scene: unknown, camera: unknown): void;
dispose(): void; dispose(): void;
} }
@@ -46,11 +47,13 @@ interface BloomPassLike {
materialHighPassFilter?: { dispose(): void }; materialHighPassFilter?: { dispose(): void };
renderTargetBright?: RenderTargetLike; renderTargetBright?: RenderTargetLike;
renderTargetsHorizontal?: readonly RenderTargetLike[]; renderTargetsHorizontal?: readonly RenderTargetLike[];
renderTargetsVertical?: readonly RenderTargetLike[]; renderTargetsVertical?: readonly (RenderTargetLike & { texture?: unknown })[];
dispose(): void; dispose(): void;
} }
interface OutputPassLike { interface OutputPassLike {
setStreakSource?(texture: unknown, strength?: number): void;
setLens?(state: LensState): void;
dispose(): void; dispose(): void;
} }
@@ -61,11 +64,20 @@ export interface PostProcessingFactories {
createOutputPass(): OutputPassLike; 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 = { const productionFactories: PostProcessingFactories = {
createComposer: (renderer) => new EffectComposer(renderer), createComposer: (renderer) => new EffectComposer(renderer),
createRenderPass: (scene, camera) => new RenderPass(scene, camera), createRenderPass: (scene, camera) => new RenderPass(scene, camera),
createBloomPass: () => { 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 // These fullscreen passes overwrite every color pixel. No pass samples
// their depth, and scene depth lives in the composer's separate targets. // their depth, and scene depth lives in the composer's separate targets.
for (const target of [ for (const target of [
@@ -77,14 +89,10 @@ const productionFactories: PostProcessingFactories = {
} }
return bloom; return bloom;
}, },
createOutputPass: () => { // The lens pass replaces OutputPass one for one: last pass, straight to the
const output = new OutputPass(); // canvas, no swap. Neither RenderPass nor bloom swaps buffers, so swapping
// Output is always the last pass and renders directly to the canvas. // here would only alternate two identical full-resolution GPU targets.
// Neither RenderPass nor bloom swaps buffers, so swapping here would only createOutputPass: () => new LensFinishPass(),
// alternate the scene between two identical full-resolution GPU targets.
output.needsSwap = false;
return output;
},
}; };
export function createPostProcessor( export function createPostProcessor(
@@ -102,6 +110,9 @@ export function createPostProcessor(
composer.addPass(renderPass); composer.addPass(renderPass);
composer.addPass(bloomPass); composer.addPass(bloomPass);
composer.addPass(outputPass); 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; let disposed = false;
const processor: PostProcessor = { const processor: PostProcessor = {
@@ -119,9 +130,13 @@ export function createPostProcessor(
setDynamics(acceleration) { setDynamics(acceleration) {
if (disposed) return; if (disposed) return;
const energy = clamp(acceleration, 0, 1); const energy = clamp(acceleration, 0, 1);
bloomPass.strength = 0.3 + energy * 0.12; bloomPass.strength = BLOOM_REST.strength + energy * 0.16;
bloomPass.radius = 0.38 + energy * 0.06; bloomPass.radius = BLOOM_REST.radius + energy * 0.06;
bloomPass.threshold = 1.05 - energy * 0.03; bloomPass.threshold = BLOOM_REST.threshold - energy * 0.05;
},
setLens(state) {
if (disposed) return;
outputPass.setLens?.(state);
}, },
render(scene, camera) { render(scene, camera) {
if (disposed) return; if (disposed) return;

View File

@@ -7,3 +7,4 @@ declare module 'three/examples/jsm/postprocessing/RenderPass.js';
declare module 'three/examples/jsm/postprocessing/UnrealBloomPass.js'; declare module 'three/examples/jsm/postprocessing/UnrealBloomPass.js';
declare module 'three/examples/jsm/postprocessing/OutputPass.js'; declare module 'three/examples/jsm/postprocessing/OutputPass.js';
declare module 'three/examples/jsm/loaders/GLTFLoader.js'; declare module 'three/examples/jsm/loaders/GLTFLoader.js';
declare module 'three/examples/jsm/postprocessing/Pass.js';