← back to Dw War Room
src/effects/PostProcessing.ts
194 lines
// ═══════════════════════════════════════════════
// DW War Room 3D — Post-Processing Effects Pipeline
// Sci-fi command center visual upgrade:
// - Unreal Bloom (subtle glow on emissive elements)
// - Scanlines overlay (barely perceptible CRT feel)
// - Film grain (organic noise, not a rave)
// ═══════════════════════════════════════════════
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 { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js';
// ─── Scanlines Shader ────────────────────────────────────────────────────────
// Horizontal lines with 0.04 opacity — enough to hint at a CRT monitor without
// actually making anyone squint.
const ScanlinesShader = {
name: 'ScanlinesShader',
uniforms: {
tDiffuse: { value: null as THREE.Texture | null },
uResolution: { value: new THREE.Vector2(1, 1) },
uOpacity: { value: 0.0 },
uLineSpacing: { value: 3.0 }, // pixels between scanlines
},
vertexShader: /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: /* glsl */ `
uniform sampler2D tDiffuse;
uniform vec2 uResolution;
uniform float uOpacity;
uniform float uLineSpacing;
varying vec2 vUv;
void main() {
vec4 color = texture2D(tDiffuse, vUv);
// Compute screen-space Y pixel coordinate
float screenY = vUv.y * uResolution.y;
// Every uLineSpacing pixels draw a dark scanline band
float line = mod(floor(screenY), uLineSpacing);
float scanline = step(uLineSpacing - 1.0, line); // 1.0 on the dark stripe, 0.0 elsewhere
// Darken by uOpacity on scanline rows, pass through elsewhere
color.rgb *= 1.0 - (scanline * uOpacity);
gl_FragColor = color;
}
`,
};
// ─── Film Grain Shader ────────────────────────────────────────────────────────
// Temporal noise seeded by a time uniform so grains shift every frame.
// Opacity 0.03 — you'd notice its absence more than its presence.
const FilmGrainShader = {
name: 'FilmGrainShader',
uniforms: {
tDiffuse: { value: null as THREE.Texture | null },
uTime: { value: 0.0 },
uOpacity: { value: 0.008 },
},
vertexShader: /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: /* glsl */ `
uniform sampler2D tDiffuse;
uniform float uTime;
uniform float uOpacity;
varying vec2 vUv;
// Fast pseudo-random hash
float hash(vec2 p) {
p = fract(p * vec2(234.34, 435.345));
p += dot(p, p + 34.23);
return fract(p.x * p.y);
}
void main() {
vec4 color = texture2D(tDiffuse, vUv);
// Seed changes per frame so grain is temporal, not static burn-in
float grain = hash(vUv + fract(uTime * 0.1));
// Remap from [0,1] → [-1,1] then attenuate heavily
float noise = (grain - 0.5) * 2.0 * uOpacity;
color.rgb += noise;
gl_FragColor = color;
}
`,
};
// ─── PostProcessingPipeline ───────────────────────────────────────────────────
export class PostProcessingPipeline {
private composer: EffectComposer;
private bloomPass: UnrealBloomPass;
private scanlinesPass: ShaderPass;
private filmGrainPass: ShaderPass;
private clock: THREE.Clock;
/**
* Usage:
* this.postProcessing = new PostProcessingPipeline(renderer, scene, camera);
* // In animate():
* this.postProcessing.render(this.scene, this.camera);
* // In onResize():
* this.postProcessing.resize(w, h);
*/
constructor(
renderer: THREE.WebGLRenderer,
scene: THREE.Scene,
camera: THREE.Camera
) {
this.clock = new THREE.Clock();
const { width, height } = renderer.getSize(new THREE.Vector2());
// ── Composer ───────────────────────────────────────────────────────────
this.composer = new EffectComposer(renderer);
// ── Pass 1: Standard render ────────────────────────────────────────────
const renderPass = new RenderPass(scene, camera);
this.composer.addPass(renderPass);
// ── Pass 2: Bloom ──────────────────────────────────────────────────────
// strength 0.25 — subtle professional glow, not arcade neon
// radius 0.5 — tighter spread for a cleaner look
// threshold 0.65 — catches more emissive surfaces without blowing out
this.bloomPass = new UnrealBloomPass(
new THREE.Vector2(width, height),
0.25, // strength — reduced from 0.4 for architectural subtlety
0.5, // radius
0.65 // threshold — lowered from 0.85 for controlled professional bloom
);
this.composer.addPass(this.bloomPass);
// ── Pass 3: Scanlines ──────────────────────────────────────────────────
this.scanlinesPass = new ShaderPass(ScanlinesShader);
this.scanlinesPass.uniforms['uResolution'].value.set(width, height);
this.composer.addPass(this.scanlinesPass);
// ── Pass 4: Film grain ─────────────────────────────────────────────────
this.filmGrainPass = new ShaderPass(FilmGrainShader);
this.composer.addPass(this.filmGrainPass);
}
/**
* Call this instead of renderer.render(scene, camera) in your animation loop.
* The grain shader receives the elapsed time so each frame has unique noise.
*/
render(_scene: THREE.Scene, _camera: THREE.Camera): void {
// Advance grain time uniform so noise shifts every frame
this.filmGrainPass.uniforms['uTime'].value = this.clock.getElapsedTime();
this.composer.render();
}
/**
* Call from onResize() to keep the render target and bloom resolution in sync.
*/
resize(width: number, height: number): void {
this.composer.setSize(width, height);
this.bloomPass.resolution.set(width, height);
this.scanlinesPass.uniforms['uResolution'].value.set(width, height);
}
/** Expose composer for debugging / extending in the future */
getComposer(): EffectComposer {
return this.composer;
}
}