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>
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
- Each
.pipe()adds a full-screen pass. Minimize the chain length. - SSAO and DOF are the most expensive built-in effects.
fxaa()is cheap -- prefer it over MSAA for WebGPU post-processing.- Custom effects that only read
screenCoordinate/screenSizeare essentially free (single texture fetch per pixel). - 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).