Three.js From Zero · Article 07
Post-Processing: EffectComposer
Post-Processing: EffectComposer
Everything shipped so far renders a scene straight to the screen. Post-processing is the layer that runs after the scene renders — you take that image and pass it through a chain of shaders that add bloom, depth of field, SSAO, outlines, color grading, film grain, whatever you want. It's the difference between "3D scene" and "cinematic".
The demo is a neon scene (emissive cubes orbiting glowing rings) that runs through
EffectComposer with four passes you can toggle live. Turn them all off to see
the raw render; turn them all on and the scene looks like a render target for a magazine.
tDiffuse uniform and writes a new one.
The composer model: framebuffers + shader passes
On the GPU, "the screen" is a framebuffer — a rectangle of color pixels
(and optional depth + stencil). When you call renderer.render(scene, camera)
and there's no post-processing, the result goes directly to the canvas's default framebuffer
and you see it.
With post-processing, you insert a chain:
- Render the scene into an offscreen framebuffer instead of the canvas.
- Run a fullscreen shader that reads that framebuffer as a texture and writes to another offscreen framebuffer.
- Do it again for the next pass. And the next.
- The last pass writes to the canvas. That's what you see.
Each pass-shader takes "the previous image" as input and outputs a new image. That's it. Bloom, SSAO, DoF, tone mapping — they're all that pattern, just with fancier shaders.
The ping-pong part: you don't allocate a new framebuffer per pass. You
allocate two, and alternate — each pass reads from one and writes to the other. Next pass
swaps. EffectComposer handles this for you.
Setting up the composer
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
const composer = new EffectComposer(renderer);
composer.setPixelRatio(renderer.getPixelRatio());
composer.setSize(w, h);
composer.addPass(new RenderPass(scene, camera));
// ...additional passes here...
composer.addPass(new OutputPass());
// In the loop — call composer.render() instead of renderer.render()
renderer.setAnimationLoop(() => {
composer.render();
});
Three things worth knowing up front:
RenderPassis always first. It's what does the actual 3D render into the first framebuffer. Without it everything else has nothing to process.OutputPassis always last. In modern Three (r162+) the tone mapping and color-space conversion moved here. If you forget it, your scene looks wrong — washed out or too dark depending on which tone map you had set.- Once you use a composer, do not call
renderer.render()in the loop. Justcomposer.render(). Otherwise you get double-renders and inconsistent output.
Bloom — the one people notice
Bloom is what makes bright pixels bleed into their neighbors. Real cameras do it; screens don't; adding it back is the single biggest "this looks next-gen" lever in post.
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
const bloom = new UnrealBloomPass(
new THREE.Vector2(w, h),
0.9, // strength — how intensely bright pixels bleed
0.6, // radius — how far the bleed spreads
0.6, // threshold — how bright a pixel must be to bleed at all
);
composer.addPass(bloom);
The three numbers, how to tune:
- threshold — start here. 0.9 = only near-clipping pixels bloom. 0.2 = half the scene glows. For neon/emissive scenes use 0.5–0.8; for realistic sunlight scenes 0.85+.
- strength — 0.5 is tasteful, 1.5 is dreamy, 3 is "my eyes".
- radius — how wide the halo spreads. 0.2 is tight/crisp, 1.0 is diffuse.
Bloom only reads pixels that made it through tone mapping. If your
OutputPass is clamping everything below 1.0, you'll never get a threshold above
about 0.9 to do anything. Either keep bright emissives above the tone-map curve, or put
the bloom pass before the OutputPass (that's the order we use in the demo).
SSAO — grounded shadows in crevices
Screen-Space Ambient Occlusion: a shader that darkens pixels where geometry crowds together — under table edges, in wrinkles, between stacked objects. Fake but cheap global illumination:
import { SSAOPass } from 'three/addons/postprocessing/SSAOPass.js';
const ssao = new SSAOPass(scene, camera, w, h);
ssao.kernelRadius = 0.3; // how far to sample for occlusion
ssao.minDistance = 0.001;
ssao.maxDistance = 0.1;
composer.addPass(ssao);
When it matters: interior renders, product shots, dense scenes. When to skip it: sparse scenes with strong direct lighting already doing the shading work.
Alternative with less noise: GTAOPass (Ground Truth AO), shipped in modern
Three.js — higher quality at slightly higher cost.
Depth of field — cinematic focus
import { BokehPass } from 'three/addons/postprocessing/BokehPass.js';
const bokeh = new BokehPass(scene, camera, {
focus: 5.0, // distance at which things are sharp
aperture: 0.0008, // bigger = stronger blur
maxblur: 0.015,
});
composer.addPass(bokeh);
DoF reads the depth buffer to blur by distance. Because it needs the depth of the
scene, it must come after RenderPass but before bloom (otherwise bloom blurs
into the DoF instead of the other way around).
Most scenes should NOT use DoF — it's a deliberate cinematic choice. When every pixel is blurred you lose detail; use it for hero shots, reveal moments, menu backgrounds.
Antialiasing after post
When you use a composer, the MSAA you enabled on WebGLRenderer({ antialias: true })
is no longer active — MSAA only works on the default framebuffer, and you're
rendering into offscreen render targets now. Your edges get crunchy.
Fix: add an AA pass. Three options:
| Pass | Cost | Quality |
|---|---|---|
FXAAPass | Free | OK, slightly blurry |
SMAAPass | Cheap | Better than FXAA, crisper |
| MSAA render target | Moderate | Best, hardware-native |
The MSAA route uses Three's multisampled render target:
const rt = new THREE.WebGLRenderTarget(w, h, { samples: 4 });
const composer = new EffectComposer(renderer, rt);
Works on WebGL2 (all modern browsers). The demo above uses SMAA because it composes predictably with bloom.
FXAA vs SMAA vs TAA
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
import { FXAAShader } from 'three/addons/shaders/FXAAShader.js';
const fxaa = new ShaderPass(FXAAShader);
fxaa.material.uniforms.resolution.value.set(1/w, 1/h);
composer.addPass(fxaa);
import { SMAAPass } from 'three/addons/postprocessing/SMAAPass.js';
composer.addPass(new SMAAPass(w, h));
TAA (temporal AA) is TAARenderPass — higher quality but with subtle ghosting
on fast motion. Good for static scenes, bad for games.
Writing your own ShaderPass
This is the superpower. A ShaderPass takes a tiny GLSL program and runs it
over the previous frame's pixels. Color grading, vignettes, scanlines, pixelation — all the
same pattern.
The minimal shape of a shader for a pass:
const MyShader = {
uniforms: {
tDiffuse: { value: null }, // ← the previous frame's output, auto-wired
uAmount: { value: 0.15 },
uColor: { value: new THREE.Color(0x7ab5ff) },
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D tDiffuse;
uniform float uAmount;
uniform vec3 uColor;
varying vec2 vUv;
void main() {
vec4 src = texture2D(tDiffuse, vUv);
// mix toward a tint color — trivial color grade
vec3 graded = mix(src.rgb, src.rgb * uColor, uAmount);
gl_FragColor = vec4(graded, src.a);
}
`,
};
const grade = new ShaderPass(MyShader);
grade.uniforms.uAmount.value = 0.15;
composer.addPass(grade);
Every ShaderPass works like this. The magic uniform name is tDiffuse — the
composer wires the previous pass's output into it automatically. Change the name and you
have to hand-wire it.
The vignette pass in the demo
Same pattern, slightly different fragment shader:
fragmentShader: `
uniform sampler2D tDiffuse;
uniform float uAmount;
varying vec2 vUv;
void main() {
vec4 src = texture2D(tDiffuse, vUv);
// darken based on distance from center
vec2 d = vUv - vec2(0.5);
float dist = dot(d, d); // squared distance, cheap
float vig = smoothstep(0.0, 0.5, dist);
vec3 rgb = src.rgb * (1.0 - vig * uAmount);
gl_FragColor = vec4(rgb, src.a);
}
`
Toggle vignette in the demo to see it. That entire darkening effect is 7 lines of fragment shader.
Pass order matters
Post-processing is a pipeline, and the order defines the final look. A well-ordered chain:
RenderPass // 1. render the scene
SSAOPass // 2. darken crevices (needs fresh depth)
BokehPass // 3. blur by depth (also needs fresh depth)
UnrealBloomPass // 4. glow bright pixels of whatever came out of DoF
ShaderPass (grade) // 5. color grade the now-bloomed image
ShaderPass (vign) // 6. darken corners after grading
SMAAPass // 7. antialias near the end
OutputPass // 8. tone map + sRGB, last always
Cardinal rules:
- Depth-reading passes (SSAO, DoF, TAA) come right after
RenderPass. - Bloom comes before color grading — you want to bloom highlights, then tint the result.
- AA comes near the end, before OutputPass.
OutputPassis always last.
Outlines — a dedicated pass
import { OutlinePass } from 'three/addons/postprocessing/OutlinePass.js';
const outline = new OutlinePass(
new THREE.Vector2(w, h), scene, camera
);
outline.visibleEdgeColor.set('#38bdf8');
outline.hiddenEdgeColor.set('#1e293b');
outline.edgeStrength = 5;
outline.edgeThickness = 1.5;
composer.addPass(outline);
// Which meshes get outlined:
outline.selectedObjects = [hoveredMesh];
This is the "real" outline pass I promised in Article 06. Pick meshes at runtime by
assigning to selectedObjects. Internally it renders the selected meshes
separately, extracts edges, and composites a glow.
Performance budget
Post-processing isn't free. Rough costs on a mid-range GPU at 1080p:
| Pass | Typical frame cost |
|---|---|
| RenderPass | scene-dependent |
| SMAAPass | ~0.3ms |
| ShaderPass (simple) | ~0.1ms |
| UnrealBloomPass | ~1–2ms |
| SSAOPass | ~2–4ms |
| BokehPass | ~1–2ms |
| OutlinePass | ~1ms per selected object |
| OutputPass | ~0.1ms |
Your frame budget at 60 FPS is 16.6 ms. On mobile, halve it. Turning on bloom + SSAO + DoF all at once can eat your whole budget on a phone.
Two resolution tricks that give you 90% of the look for 50% of the cost:
- Render the composer at 75% resolution, upscale in OutputPass. Bloom/DoF/SSAO are low-frequency effects — no one notices.
- For bloom specifically, the pass already works at half-resolution internally. You can also skip bloom every other frame on battery-constrained devices.
Disposal
Composers and passes allocate render targets and compiled shaders. Clean up on unmount:
bloom.dispose();
ssao.dispose();
myShaderPass.material.dispose();
smaa.dispose();
composer.dispose(); // disposes its internal render targets
Common first-time pitfalls
- Scene looks washed out after adding composer. You forgot
OutputPassat the end, or you're still callingrenderer.render()somewhere. - Edges got crunchy. MSAA doesn't apply to offscreen render targets. Add SMAA/FXAA/TAA or use a multisampled render target.
- Bloom does nothing. Threshold too high, or your emissive values aren't actually above 1.0. Crank
emissiveIntensityon hero materials. - DoF blurs the wrong thing. Check
focusdistance — it's world units from the camera, not a normalized range. - Transparent objects are missing post effects. Some passes (SSAO, DoF) use the depth buffer, which transparents don't write to by default. Enable
depthWrite: truecautiously or sort manually. - FPS tanked after resize. Forgot
composer.setSize(w, h)— the internal render targets are still at the old size, or worse, re-allocating every frame because of a resize loop.
Exercises
- Write a scanline ShaderPass: modulate
src.rgbby0.9 + 0.1 * sin(vUv.y * height * 2.0). Retro CRT look in 1 line. - Build a pixelation pass:
vec2 p = floor(vUv * resolution / blockSize) * blockSize / resolution;then sample. Minecraft-at-a-distance effect. - Add
OutlinePassto the Article 06 solar system demo so hovered planets get a real cyan outline instead of the emissive fake.
What's next
Article 08 — Custom Shaders: GLSL + TSL. We've used a tiny
ShaderPass today; next article we go deep: ShaderMaterial,
onBeforeCompile to modify built-in materials, Node materials, and the new
TSL graph that's quietly replacing raw GLSL in modern Three.