Post-processing is where a shader gets its finish
I wrote a while ago about entering my post-processing era. That was the moment I realised almost none of my pieces felt done until they'd been through a grain pass, a vignette, maybe some chromatic aberration. The scene is the drawing. Post-processing is the print.
If you're building in React Three Fiber on WebGPU, the way you do this has changed. @react-three/postprocessing and the postprocessing library it wraps are built around WebGL's EffectComposer. On the WebGPURenderer the equivalent is Three's own PostProcessing class, driven by TSL nodes. This is the R3F setup I use, and the shape an effect needs so it can stack as a pass.
The mental model
With TSL you don't configure a chain of passes and their render targets one by one. There's a single outputNode. Start from the rendered scene as a texture, transform it however you like, and hand that graph to PostProcessing. Three works out the intermediate targets.
import { PostProcessing } from 'three/webgpu'
import { pass } from 'three/tsl'
const postProcessing = new PostProcessing(renderer)
// Render the scene to a texture we can read from
const scenePass = pass(scene, camera)
const sceneTexture = scenePass.getTextureNode('output')
// Any TSL expression over sceneTexture is now your post-processing pipeline
postProcessing.outputNode = sceneTexture
// In the loop, instead of renderer.render(scene, camera):
postProcessing.render()That's a no-op pipeline. The interesting part is whatever you do to sceneTexture before you assign it. You're working with a texture you can sample, not a colour.
Three shapes of function
In Fragments, three kinds of function all get called "effects". They aren't interchangeable.
Patterns are vec2 → float. Give them a UV, get a mask back. vignettePattern, grainTexturePattern, ledPattern. You can use them inside a material's colorNode, on a flat sketch, in a compute shader. Anywhere.
Adjustments are vec3 → vec3. Colour in, colour out. brightness, contrast, saturation, hueRotate. The image adjustments set. They don't care where the colour came from.
Effects are the post-processing shape. They take a texture and a UV, and return a vec4:
import { Fn, uv } from 'three/tsl'
export const someEffect = Fn((props) => {
const { input, inputUV = uv, ...params } = props || {}
const _uv = inputUV().toVar()
// ...
return input.sample(_uv) // vec4
})input is a texture node, not a colour. That's the whole point. A distortion effect needs to read the scene at a different UV than the one it's shading. Bulge, swirl, chromatic aberration all work by remapping the coordinate you sample at. A vec3 → vec3 function can't do that, because by the time you have a colour the sampling has already happened. Effects sit on top of patterns and adjustments and turn them into passes.
Wiring it into React Three Fiber
R3F v9 supports the WebGPU renderer through the gl prop, which is what the WebGPU Scene utility wraps. For post-processing, put a component inside the Canvas that grabs gl, scene and camera, builds a PostProcessing once, and calls render() from a useFrame that replaces R3F's default render.
The trimmed shape of the Post-Processing Component in Fragments:
import { useFrame, useThree } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
import { convertToTexture, mrt, output, pass, uniform } from 'three/tsl'
import * as THREE from 'three/webgpu'
type Layer = { effect: any; args?: Record<string, unknown> }
export const PostProcessing = ({ layers }: { layers: Layer[] }) => {
const { gl, scene, camera } = useThree()
const pipelineRef = useRef<THREE.PostProcessing | null>(null)
useEffect(() => {
const scenePass = pass(scene, camera)
scenePass.setMRT(mrt({ output })) // add `emissive` here if your effects want it
let node = scenePass.getTextureNode('output')
layers.forEach((layer, i) => {
// Numeric args become uniforms so slider changes don't recompile the shader
const args: Record<string, unknown> = {}
for (const [name, value] of Object.entries(layer.args ?? {})) {
args[name] = typeof value === 'number' ? uniform(value) : value
}
const color = layer.effect({ input: node, ...args })
// Every layer except the last is baked to a texture so the next one can .sample() it
node = i === layers.length - 1 ? color : convertToTexture(color)
})
const pipeline = new THREE.PostProcessing(gl as any)
pipeline.outputNode = node
pipelineRef.current = pipeline
return () => {
pipelineRef.current = null
}
}, [gl, scene, camera, layers])
// Priority 1 disables R3F's automatic render; we take over
useFrame(() => {
pipelineRef.current?.render()
}, 1)
return null
}convertToTexture is what makes stacking work. Each effect wants a texture as input, so the previous effect's colour output has to be rendered to one first. Numeric args are wrapped in uniform() so a control panel can mutate them without a rebuild. The useFrame priority tells R3F you're handling rendering. Leave it at the default and you'll draw the scene twice.
The real component also sets texture wrap modes per layer, so a mirror distortion can MirroredRepeatWrapping its input. It keeps a map of the uniforms so value changes are pushed in place, and it handles the ping-pong render-target history that the feedback trails effect needs to sample the previous frame. Same skeleton though.
Writing an effect
Given the shape above, a vignette is a pattern wrapped in a sample:
import { Fn, pow, smoothstep, uv } from 'three/tsl'
import { sdSphere } from '@/tsl/utils/sdf/shapes'
export const vignetteEffect = Fn((props) => {
const { input, inputUV = uv, smoothing = 0.25, exponent = 5 } = props || {}
const _uv = inputUV().toVar()
const centeredUV = _uv.sub(0.5).toVar()
const vignette = smoothstep(smoothing, 1, sdSphere(centeredUV)).oneMinus()
const vignetteMask = pow(vignette, exponent).toVar()
const originalColor = input.sample(_uv)
return originalColor.mul(vignetteMask)
})Grain is the same idea. Sample the scene, add a per-pixel offset:
import { dot, Fn, fract, sin, uv, vec2 } from 'three/tsl'
export const grainTextureEffect = Fn((props) => {
const { input, inputUV = uv, intensity = 0.1, scale = 1.0 } = props || {}
const _uv = inputUV().toVar()
const grain = fract(sin(dot(_uv.mul(scale), vec2(12.9898, 78.233))).mul(43758.5453123)).toVar()
const originalColor = input.sample(_uv)
return originalColor.add(grain.sub(0.5).mul(intensity))
})An adjustment wrapper is even thinner. Sample, run the vec3 → vec3 function, put the alpha back:
import { Fn, uv, vec4 } from 'three/tsl'
import { brightness } from '@/tsl/utils/color/adjustments/brightness'
export const brightnessAdjustment = Fn((props) => {
const { input, inputUV = uv, amount = 1 } = props || {}
const _uv = inputUV().toVar()
const color = input.sample(_uv)
return vec4(brightness(color.rgb, amount), color.a)
})The one that justifies the whole shape is a distortion, where the coordinate changes before the sample:
import { Fn, float, uv, vec2 } from 'three/tsl'
import { bulgeDistortion } from '@/tsl/distortion/bulge_distortion'
export const bulgeEffect = Fn((props) => {
const { input, inputUV = uv, strength = 0.5, radius = 0.3, power = 1.0, center = vec2(0.5) } = props || {}
const _uv = inputUV().toVar()
const distortedUV = bulgeDistortion(_uv, { strength: float(strength), radius: float(radius), power: float(power), center })
return input.sample(distortedUV)
})None of these declare a uniform, touch a render target or know what a pass is. Defaults are plain numbers. When the component hands in a uniform() instead, the same code runs. The inputUV parameter is there so a caller can pre-remap the UV before the effect gets it. That's how the kaleidoscope and mirror effects compose with others.
Stacking effects
With the component above, a look is an ordered array:
import { bulgeEffect } from '@/tsl/post_processing/bulge_distortion_effect'
import { grainTextureEffect } from '@/tsl/post_processing/grain_texture_effect'
import { vignetteEffect } from '@/tsl/post_processing/vignette_effect'
const layers = [
{ effect: bulgeEffect, args: { strength: 0.15, radius: 0.6 } },
{ effect: vignetteEffect, args: { smoothing: 0.3, exponent: 3 } },
{ effect: grainTextureEffect, args: { intensity: 0.06 } },
]
<PostProcessing layers={layers} />Order matters. Distortions first, because they move pixels and you want everything after them to be sharp. Grain last, so it sits on the image rather than getting smeared by anything after it. Vignette anywhere in between.
Define layers outside the component or useMemo it. A fresh array each render rebuilds the pipeline.
The effects in Fragments all follow this exact shape, so they stack freely: LED, CRT scanlines, dither, halftone, chromatic aberration, pixellation, the blurs, the distortions, and the image adjustments. Each page has the TSL source and a live sketch.
Things that bit me
- Sampling vs. colour. If an effect's
inputis a colour node rather than a texture node,.sample()doesn't exist and you get a confusing error. That's whatconvertToTexturebetween layers is for. - Colour space. The scene pass output is linear. Washed-out results after post usually mean a double conversion or none. The Color Space Correction utility is the fix I reach for.
- Resolution.
uv()inside an effect is screen space, so grain and dither scale with the canvas. Multiply by a resolution uniform if you want fixed-size texels. - Rebuilding. Changing an effect's parameters should mutate
uniform()values, not rebuildoutputNode. Rebuilding recompiles the shader and you'll see a hitch. - Emissive-driven bloom. Bloom looks far better reading an emissive channel than thresholding the whole image. Add
emissiveto themrt()and passscenePass.getTextureNode('emissive')into the effect's args.
Why bother, when the WebGL stack works?
The ergonomics. In the old stack a custom effect meant a class, a GLSL string, a uniform declaration block and a pass registration. Here it's a function with a known signature. I write a pattern once, wrap it in an effect in six lines, test it on a flat sketch, and drop it in front of a full scene without changing anything. Pattern, then adjustment, then effect. That's why the Fragments post-processing collection could grow to forty-odd effects without turning into a mess.
The post-processing era post is the companion if you want the why of each effect. What grain does to perception, why dither reads as tactile. If you're still setting up, the boilerplate has the component wired already.