Files
skills/plugins/suaveplan-webgpu/skills/webgpu-threejs-tsl/docs/post-processing.md
admin 9bf56a806b Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 01:10:00 +01:00

8.8 KiB

Post-Processing with TSL in React Three Fiber

Three.js provides a node-based post-processing pipeline for the WebGPU backend. Effects are composed as TSL node chains, replacing the older EffectComposer/Pass pattern.

PostProcessing Class

The core class that manages the effect chain:

import { PostProcessing } from 'three/addons/renderers/common/PostProcessing.js';

This class takes a renderer and an outputNode -- a TSL node graph that processes the rendered scene into the final framebuffer.

R3F Integration Pattern

Since R3F manages the render loop, you need to hook into it at the right level. The PostProcessing class replaces R3F's default render call.

Pattern 1: Manual Render with useFrame

import { useRef, useMemo, useEffect } from 'react';
import { useFrame, useThree } from '@react-three/fiber';
import { PostProcessing } from 'three/addons/renderers/common/PostProcessing.js';
import { pass, bloom, fxaa } from 'three/tsl';

function WebGPUPostProcessing() {
  const { gl, scene, camera } = useThree();
  const postRef = useRef<PostProcessing | null>(null);

  useEffect(() => {
    const postProcessing = new PostProcessing(gl);
    const scenePass = pass(scene, camera);

    postProcessing.outputNode = scenePass
      .pipe(bloom({ strength: 0.5, radius: 0.4, threshold: 0.85 }))
      .pipe(fxaa());

    postRef.current = postProcessing;

    return () => {
      // Cleanup if needed
      postRef.current = null;
    };
  }, [gl, scene, camera]);

  useFrame(() => {
    if (postRef.current) {
      postRef.current.render();
    }
  }, 1); // Priority 1 to run after default scene updates

  return null;
}

Pattern 2: Disable R3F Auto-Render

For full control over rendering:

<Canvas
  gl={(canvas) => createWebGPURenderer(canvas)}
  frameloop="never"  // Disable auto-render
>
  <Scene />
  <ManualRenderLoop />
</Canvas>

function ManualRenderLoop() {
  const { gl, scene, camera, invalidate } = useThree();
  const postRef = useRef<PostProcessing | null>(null);

  useEffect(() => {
    const post = new PostProcessing(gl);
    const scenePass = pass(scene, camera);
    post.outputNode = scenePass.pipe(bloom({ strength: 1.0 }));
    postRef.current = post;
  }, [gl, scene, camera]);

  useFrame(() => {
    postRef.current?.render();
  });

  return null;
}

Built-in Effects

pass() -- Scene Render Pass

The entry point for all post-processing chains:

import { pass } from 'three/tsl';

const scenePass = pass(scene, camera);

// Access rendered textures
const beautyTexture = scenePass.getTextureNode();  // Color
const depthTexture = scenePass.getDepthNode();      // Depth
const normalTexture = scenePass.getNormalNode();     // View-space normals (if enabled)

bloom()

HDR bloom effect:

import { bloom } from 'three/tsl';

scenePass.pipe(bloom({
  strength: 0.5,    // Bloom intensity (0..2+)
  radius: 0.4,      // Blur radius (0..1)
  threshold: 0.85,  // Luminance threshold (0..1)
}));

fxaa()

Fast approximate anti-aliasing:

import { fxaa } from 'three/tsl';

scenePass.pipe(fxaa());

gaussianBlur()

Gaussian blur with configurable kernel:

import { gaussianBlur } from 'three/tsl';

scenePass.pipe(gaussianBlur({
  sigma: 2.0,       // Blur sigma
  direction: vec2(1, 0),  // Horizontal pass
}));

dotScreen()

Halftone dot pattern:

import { dotScreen } from 'three/tsl';

scenePass.pipe(dotScreen({
  scale: 4.0,
  angle: 0.5,
}));

film()

Film grain effect:

import { film } from 'three/tsl';

scenePass.pipe(film({
  intensity: 0.35,
  grayscale: false,
}));

sobel()

Edge detection:

import { sobel } from 'three/tsl';

scenePass.pipe(sobel());

Depth-Based Effects

SSAO (Screen-Space Ambient Occlusion)

import { ao } from 'three/tsl';

const scenePass = pass(scene, camera);
const depthNode = scenePass.getDepthNode();
const normalNode = scenePass.getNormalNode();

const ssao = ao({
  depthNode,
  normalNode,
  radius: 0.5,
  intensity: 1.0,
  samples: 16,
});

// Multiply AO into the beauty pass
postProcessing.outputNode = scenePass.getTextureNode().mul(ssao);

Depth of Field

import { dof } from 'three/tsl';

const scenePass = pass(scene, camera);

postProcessing.outputNode = scenePass.pipe(dof({
  focus: 5.0,        // Focus distance
  aperture: 0.025,   // Aperture size
  maxblur: 0.01,     // Maximum blur
}));

Custom Post-Processing Effects

Simple Full-Screen Effect

import { Fn, screenCoordinate, screenSize, texture } from 'three/tsl';

const vignetteEffect = Fn(([inputTexture]) => {
  const uv = screenCoordinate.div(screenSize);
  const center = vec2(0.5, 0.5);
  const dist = distance(uv, center);
  const vignette = smoothstep(float(0.8), float(0.4), dist);

  return inputTexture.mul(vignette);
});

const scenePass = pass(scene, camera);
postProcessing.outputNode = vignetteEffect(scenePass.getTextureNode());

Color Grading

const colorGrade = Fn(([inputTexture]) => {
  const col = inputTexture.toVar();

  // Lift / Gamma / Gain
  const lift = vec3(0.0, 0.0, 0.05);    // Shadow tint
  const gamma = vec3(1.0, 0.95, 0.9);    // Midtone tint
  const gain = vec3(1.1, 1.05, 1.0);     // Highlight tint

  col.assign(col.mul(gain).add(lift));
  col.assign(pow(col, vec3(1.0).div(gamma)));

  // Contrast
  col.assign(col.sub(0.5).mul(1.2).add(0.5));

  // Saturation
  const lum = dot(col, vec3(0.2126, 0.7152, 0.0722));
  col.assign(mix(vec3(lum), col, float(1.3)));

  return vec4(col, float(1.0));
});

Chromatic Aberration

const chromaticAberration = Fn(([inputTexture]) => {
  const uv = screenCoordinate.div(screenSize);
  const center = vec2(0.5, 0.5);
  const dir = uv.sub(center);
  const offset = float(0.003);

  const r = texture(inputTexture, uv.add(dir.mul(offset))).r;
  const g = texture(inputTexture, uv).g;
  const b = texture(inputTexture, uv.sub(dir.mul(offset))).b;

  return vec4(r, g, b, float(1.0));
});

Effect Chaining

Effects can be chained with .pipe():

postProcessing.outputNode = scenePass
  .pipe(bloom({ strength: 0.5, threshold: 0.8 }))
  .pipe(fxaa())
  .pipe(film({ intensity: 0.15 }));

Or composed manually:

const beauty = scenePass.getTextureNode();
const bloomed = bloom({ strength: 0.5 })(beauty);
const graded = colorGrade(bloomed);
const final = vignetteEffect(graded);

postProcessing.outputNode = final;

Uniform-Driven Effects (React State)

function DynamicPostFX({ bloomStrength = 0.5 }) {
  const { gl, scene, camera } = useThree();
  const uBloom = useMemo(() => uniform(bloomStrength), []);
  const postRef = useRef<PostProcessing | null>(null);

  // Sync React prop to GPU uniform
  useEffect(() => {
    uBloom.value = bloomStrength;
  }, [bloomStrength, uBloom]);

  useEffect(() => {
    const post = new PostProcessing(gl);
    const scenePass = pass(scene, camera);

    post.outputNode = scenePass.pipe(bloom({
      strength: uBloom,
      radius: 0.4,
      threshold: 0.85,
    }));

    postRef.current = post;
  }, [gl, scene, camera, uBloom]);

  useFrame(() => {
    postRef.current?.render();
  }, 1);

  return null;
}

R3F Post-Processing Component Pattern

Encapsulate post-processing into a reusable component:

interface PostFXProps {
  bloomStrength?: number;
  bloomThreshold?: number;
  enableFXAA?: boolean;
  enableFilm?: boolean;
  filmIntensity?: number;
}

function PostFX({
  bloomStrength = 0.5,
  bloomThreshold = 0.85,
  enableFXAA = true,
  enableFilm = false,
  filmIntensity = 0.1,
}: PostFXProps) {
  const { gl, scene, camera } = useThree();
  const postRef = useRef<PostProcessing | null>(null);

  useEffect(() => {
    const post = new PostProcessing(gl);
    const scenePass = pass(scene, camera);

    let output = scenePass.pipe(bloom({
      strength: bloomStrength,
      threshold: bloomThreshold,
    }));

    if (enableFXAA) output = output.pipe(fxaa());
    if (enableFilm) output = output.pipe(film({ intensity: filmIntensity }));

    post.outputNode = output;
    postRef.current = post;
  }, [gl, scene, camera, bloomStrength, bloomThreshold, enableFXAA, enableFilm, filmIntensity]);

  useFrame(() => {
    postRef.current?.render();
  }, 1);

  return null;
}

// Usage in scene
<Canvas gl={(canvas) => createWebGPURenderer(canvas)}>
  <Scene />
  <PostFX bloomStrength={0.8} enableFilm />
</Canvas>

Performance Notes

  1. Each .pipe() adds a full-screen pass. Minimize the chain length.
  2. SSAO and DOF are the most expensive built-in effects.
  3. fxaa() is cheap -- prefer it over MSAA for WebGPU post-processing.
  4. Custom effects that only read screenCoordinate / screenSize are essentially free (single texture fetch per pixel).
  5. For @suaveplan/r3f-performance, post-processing quality can be adjusted dynamically by updating uniforms based on the current performance tier (e.g., reduce bloom samples on low-end devices).