Core Philosophy
- Post-processing applies effects to the final rendered image: bloom, depth of field, glitch, motion blur, color grading, antialiasing, outlines
- Mechanics: render the scene into a render target (a texture, called a buffer outside Three.js), then draw that texture on a screen-covering plane whose fragment shader applies the effect. Chained passes ping-pong between two render targets because you cannot read a target while writing to it; the last pass draws straight to the canvas
EffectComposerhandles all of that for you: target creation, ping-pong swapping, feeding each pass the previous pass's texture, final canvas draw- Every pass renders every frame. Passes are a real performance cost; add only what earns its place
EffectComposer setup (vanilla Three.js)
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js'
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js'
const effectComposer = new EffectComposer(renderer)
effectComposer.setSize(sizes.width, sizes.height)
effectComposer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
const renderPass = new RenderPass(scene, camera)
effectComposer.addPass(renderPass)
RenderPassmust come first: it renders the scene into the composer's render target instead of the canvas- In the tick loop, replace
renderer.render(scene, camera)witheffectComposer.render() - The composer must be resized like the renderer, or the image goes soft after the window changes. In the resize handler call BOTH
effectComposer.setSize(...)andeffectComposer.setPixelRatio(...) - Toggle any pass with
pass.enabled = falseto test passes in isolation
Built-in passes
DotScreenPass: black and white raster effect, no parameters neededGlitchPass: movie-hack screen glitches.goWild = trueglitches non-stop (flash warning)UnrealBloomPass: glow for lights, fire, lasers. Three main knobs:strength(how strong the glow),radius(how far it spreads),threshold(luminosity above which things glow). Defaults glow everything; raisethresholdand tune- Some effects ship as raw shaders, not passes. Wrap them in
ShaderPass:
import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js'
import { RGBShiftShader } from 'three/examples/jsm/shaders/RGBShiftShader.js'
effectComposer.addPass(new ShaderPass(RGBShiftShader))
Fixing the color (gamma / color space)
- The moment any pass beyond RenderPass is active, the image goes dark:
renderer.outputColorSpace = THREE.SRGBColorSpaceno longer applies because passes render into render targets, which do not handle color space the same way - Fix: add a
ShaderPasswithGammaCorrectionShaderas the LAST color pass to convert back to sRGB
import { GammaCorrectionShader } from 'three/examples/jsm/shaders/GammaCorrectionShader.js'
const gammaCorrectionPass = new ShaderPass(GammaCorrectionShader)
effectComposer.addPass(gammaCorrectionPass)
- Pass order matters: effects first, gamma correction after them, and an antialias pass (if any) after the gamma correction
Fixing the antialias
The composer's default WebGLRenderTarget has no antialias, so edges alias again once a second pass is active (invisible if you only have the RenderPass, and usually invisible on screens with pixel ratio above 1). Options:
- Multisampled render target (WebGL 2 only). Provide your own target with
samples:
const renderTarget = new THREE.WebGLRenderTarget(800, 600, {
samples: renderer.getPixelRatio() === 1 ? 2 : 0
})
const effectComposer = new EffectComposer(renderer, renderTarget)
Width and height can be placeholders; setSize() resizes the target. More samples means better antialias and worse performance; 0 disables it. Skip samples entirely when pixel ratio is above 1, the density already hides aliasing
2. Antialias pass: FXAA (fast, slightly blurry), SMAA (usually better than FXAA, costlier, not MSAA), SSAA (best quality, worst performance), TAA (fast, limited). Add it AFTER the gamma correction pass
3. Combine both: samples is silently ignored on WebGL 1, so leave it set and add SMAA only as the fallback:
if(renderer.getPixelRatio() === 1 && !renderer.capabilities.isWebGL2)
{
effectComposer.addPass(new SMAAPass())
}
- To test the WebGL 1 path, temporarily swap
WebGLRendererforWebGL1Renderer
Custom ShaderPass
A pass shader is a plain object with uniforms, vertexShader, fragmentShader. The vertex shader is almost always the same screen-plane boilerplate plus a vUv varying.
const TintShader = {
uniforms:
{
tDiffuse: { value: null },
uTint: { value: null }
},
vertexShader: `
varying vec2 vUv;
void main()
{
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
vUv = uv;
}
`,
fragmentShader: `
uniform sampler2D tDiffuse;
uniform vec3 uTint;
varying vec2 vUv;
void main()
{
vec4 color = texture2D(tDiffuse, vUv);
color.rgb += uTint;
gl_FragColor = color;
}
`
}
const tintPass = new ShaderPass(TintShader)
tintPass.material.uniforms.uTint.value = new THREE.Vector3()
effectComposer.addPass(tintPass)
tDiffuseis the magic uniform: the composer injects the previous pass's texture into it. Declare it withvalue: nulland never set it yourself- Set uniform values on
pass.material.uniforms.<name>.valueAFTER creating the pass, never inside the shader object. The shader object is a template meant to be reused by multiple passes - Displacement effects sample
tDiffuseat distorted coordinates instead of tinting:vec2 newUv = vec2(vUv.x, vUv.y + sin(vUv.x * 10.0 + uTime) * 0.1);. DriveuTimefrom the clock in the tick loop - A normal map texture can drive the displacement (
texture2D(uNormalMap, vUv).xyz * 2.0 - 1.0, offset UV by its xy) for interface-style distortion, but a screen-fitted texture stretches on non-matching aspect ratios - For clean projects, move custom passes into their own files and shaders into
.glslfiles
R3F: @react-three/postprocessing
- The pmndrs
postprocessinglibrary replaces "passes" with "effects" and merges them into as few shader passes as possible, preserving your order. Independent passes each doing their own depth or normal renders is exactly the performance problem it solves, so stacking many effects stays cheap - Install
@react-three/postprocessing(which pulls inpostprocessing), and addpostprocessingitself as an explicit dependency since you import enums and base classes from it - Its
EffectComposershares a name with the vanilla one but is a different class. No RenderPass needed; the first render is handled for you
import { EffectComposer, ToneMapping, Bloom, Vignette } from '@react-three/postprocessing'
import { ToneMappingMode, BlendFunction, GlitchMode } from 'postprocessing'
<EffectComposer>
<Vignette offset={ 0.3 } darkness={ 0.9 } />
<Bloom luminanceThreshold={ 1.1 } mipmapBlur />
<ToneMapping mode={ ToneMappingMode.ACES_FILMIC } />
</EffectComposer>
- Colors look wrong the moment
<EffectComposer>mounts: tone mapping is deactivated in the post-processing pipeline. Add<ToneMapping>yourself as the LAST child. Its default mode is AgX, which reads gray-ish; R3F's usual look isToneMappingMode.ACES_FILMIC - The ordering logic: effects apply to linear, unaffected color, tone mapping tweaks the result at the end
multisamplingon<EffectComposer>controls antialiasing (default 8, set 0 to disable for performance)- Reload the page after adding or tweaking an effect if results look stale
- Docs are split across the
postprocessingrepo/docs/demo and the react-postprocessing repo; expect to dig in both
R3F effects worth knowing
<Vignette offset darkness>: darkens corners. It cannot darken a transparent background; attach a background color (<color args={['#ffffff']} attach="background" />) or the corners stay untouched<Glitch delay duration strength mode>: ranges as[min, max]arrays, modes fromGlitchMode(for exampleCONSTANT_MILD). Flash warning<Noise premultiply blendFunction>: raw default is ugly;BlendFunction.SOFT_LIGHT,OVERLAY,SCREEN, orAVERAGEpluspremultiply(multiplies noise by the input color before blending, darker but better integrated) makes it usable<DepthOfField focusDistance focalLength bokehScale>: focusDistance is where the image is sharp, focalLength the distance to maximum blur, bokehScale the blur radius. The first two are NORMALIZED against camera near/far, not world units, so expect to tweak (for example 0.025 / 0.025 / 6)- Every effect accepts
blendFunction, Photoshop-style layer blending from theBlendFunctionenum. Default isNORMAL. Cycling through them in a debug UI (Leva) beats guessing
Bloom in R3F
<Bloom />with defaults makes everything glow. RaiseluminanceThreshold(default 0.9) so only intentionally bright things bloommipmapBluris the good-looking version: downscaled renders combine into a bloom texture added to the base render, large sun-like glows without a performance hit. Use itintensityscales the overall bloom (default 1)- Push materials over the threshold three ways:
- Color channels above 1:
<meshStandardMaterial color={ [ 4, 1, 2 ] } />(array form, not a color string, escapes the 0 to 1 range) - Emissive:
<meshStandardMaterial color="white" emissive="orange" emissiveIntensity={ 2 } /> meshBasicMaterialhas no emissive; give itscolorchannel values above 1 for a uniform glow
- Color channels above 1:
- Bloom is the easiest effect to overdo. Be subtle
Custom effects (pmndrs postprocessing)
Two layers: an Effect subclass for postprocessing, then a thin React component exposing it.
The Effect class
import { BlendFunction, Effect } from 'postprocessing'
import { Uniform } from 'three'
const fragmentShader = /* glsl */`
uniform float frequency;
uniform float amplitude;
uniform float time;
void mainUv(inout vec2 uv)
{
uv.y += sin(uv.x * frequency + time) * amplitude;
}
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor)
{
outputColor = vec4(0.8, 1.0, 0.5, inputColor.a);
}
`
export default class DrunkEffect extends Effect
{
constructor({ frequency, amplitude, blendFunction = BlendFunction.DARKEN })
{
super('DrunkEffect', fragmentShader, {
blendFunction,
uniforms: new Map([
[ 'frequency', new Uniform(frequency) ],
[ 'amplitude', new Uniform(amplitude) ],
[ 'time', new Uniform(0) ]
])
})
}
update(renderer, inputBuffer, deltaTime)
{
this.uniforms.get('time').value += deltaTime
}
}
Rules the merged-shader architecture imposes:
- The color function MUST be
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor). Renaming, omitting, or changing parameters breaks the merge.const inmeans read-only copy,outmeans write it to produce your result inputColoris the running color from previous effects,uvthe screen coordinates (0,0 bottom left to 1,1 top right). You cannot mutateinputColor; copy it (vec4 color = inputColor;) and write the copy tooutputColor. Touch only.rgbunless you mean to change alpha- UV distortion goes in a separate
void mainUv(inout vec2 uv)function (inoutis read-write). TheuvinsidemainImageis read-only and meant for sampling other textures, not for warping the render - Uniforms go into
super()'s option object as aMapof[ name, new Uniform(value) ], and are read back withthis.uniforms.get('time').value - The
update(renderer, inputBuffer, deltaTime)method runs every frame automatically; accumulate time withdeltaTime, never a fixed per-frame increment, or speed depends on frame rate - Forward
blendFunctioninto the options and give it a sensible default. Without one, an effect that outputs a flat color renders as a solid-color screen and looks like a bug to whoever mounts it bare - The
/* glsl */comment before the template string enables syntax highlighting with thees6-string-htmlVS Code extension
The React wrapper
import DrunkEffect from './DrunkEffect.jsx'
export default function Drunk(props)
{
const effect = new DrunkEffect(props)
return <primitive ref={ props.ref } object={ effect } />
}
- Return the instance through
<primitive object={...}>inside<EffectComposer>, keeping<ToneMapping>last - Since React 19,
refarrives insidepropsdirectly, noforwardRefneeded; pass it to the<primitive>so consumers can reach the effect instance - Props spread cleanly from a Leva
useControlsobject:<Drunk ref={ drunkRef } { ...drunkProps } />
Common mistakes
- Forgetting the RenderPass, or adding effect passes before it
- Not calling
effectComposer.setSizeandsetPixelRatioon resize, giving a stretched low-resolution image - Missing the gamma correction pass, so the whole render is dark; or placing it before other effects instead of after them
- Judging antialiasing with only the RenderPass active (canvas antialias still applies there) or on a high pixel ratio screen where aliasing is invisible
- Relying on render target
sampleson WebGL 1, where it is silently ignored - Setting uniform values inside the shader template object instead of on
pass.material.uniforms.<name>.valueafter creating the pass - In R3F, omitting
<ToneMapping>(washed-out colors), placing it before other effects, or keeping the AgX default when the scene was tuned for ACES Filmic - Expecting Vignette to affect a transparent background without attaching a scene background color
- Using
luminanceThresholddefaults and wondering why everything blooms, or passing bright colors as strings instead of over-1 channel arrays - In a custom effect, changing the
mainImagesignature, mutatinginputColor, warping the read-onlyuvinstead of implementingmainUv, or animating with a fixed increment instead ofdeltaTime - Shipping a custom effect with no default
blendFunction - Stacking passes freely in vanilla Three.js as if they were free: each is a full-frame render every frame