feat(scene): add film grade and lens finish pass
This commit is contained in:
194
src/scene/cinematic-grade.ts
Normal file
194
src/scene/cinematic-grade.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
1
src/scene/three.d.ts
vendored
1
src/scene/three.d.ts
vendored
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user