From 2080265568e82954c2fbc16d4d7a9fef919b699c Mon Sep 17 00:00:00 2001 From: LucaP Date: Sat, 10 Oct 2026 19:50:38 +0300 Subject: [PATCH] feat(scene): add film grade and lens finish pass --- src/scene/cinematic-grade.ts | 194 +++++++++++++++++++++++++++++++++++ src/scene/post-processing.ts | 43 +++++--- src/scene/three.d.ts | 1 + 3 files changed, 224 insertions(+), 14 deletions(-) create mode 100644 src/scene/cinematic-grade.ts diff --git a/src/scene/cinematic-grade.ts b/src/scene/cinematic-grade.ts new file mode 100644 index 0000000..b3564ed --- /dev/null +++ b/src/scene/cinematic-grade.ts @@ -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 + #include + + 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(); + } +} diff --git a/src/scene/post-processing.ts b/src/scene/post-processing.ts index 9f5d740..93b269a 100644 --- a/src/scene/post-processing.ts +++ b/src/scene/post-processing.ts @@ -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; diff --git a/src/scene/three.d.ts b/src/scene/three.d.ts index 6f69236..212eb0e 100644 --- a/src/scene/three.d.ts +++ b/src/scene/three.d.ts @@ -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/OutputPass.js'; declare module 'three/examples/jsm/loaders/GLTFLoader.js'; +declare module 'three/examples/jsm/postprocessing/Pass.js';