← 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;
  }
}