Post-Processing Effects in Three.js
A raw renderer.render(scene, camera) call gives you one
image per frame with no room for cinematic finishing. Three.js's
EffectComposer chains full-screen shader
passes on top of that image — bloom, ambient
occlusion, anti-aliasing, colour grading — to turn a flat render
into something that looks shipped. This guide builds a complete
composer pipeline, explains what each built-in pass actually does
under the hood, and shows how to write your own
ShaderPass.
1. How EffectComposer Works
EffectComposer (from
three/examples/jsm/postprocessing/EffectComposer.js)
replaces a single render call with a queue of
passes. Each pass reads a source texture,
runs a full-screen fragment shader over it, and writes to a
destination texture — the next pass in the chain reads that
output. Internally the composer ping-pongs between two
WebGLRenderTargets (readBuffer and
writeBuffer) so passes never read and write the
same texture at once.
scene ──RenderPass──▸ RT_A ──BloomPass──▸ RT_B
RT_B ──SSAOPass──▸ RT_A ──FXAAPass──▸ RT_B
RT_B ──OutputPass──▸ screen (canvas)
Each pass: color_out(uv) = f(color_in(uv), neighbourhood, uniforms)
Every pass exposes three flags that control this flow:
enabled (skip entirely), needsSwap
(whether it produced a new texture the next pass should read),
and renderToScreen (write directly to the canvas
instead of a render target — normally only the last pass does
this, though modern Three.js prefers a dedicated
OutputPass at the end for correct color-space and
tone-mapping handling).
2. Setting Up RenderPass + OutputPass
Every composer pipeline starts with a RenderPass
(draws the 3D scene into the first render target) and ends with
an OutputPass (applies tone mapping and converts
from linear working space to sRGB for display). Everything
added between them operates on the rendered image, not the
scene graph.
import * as THREE
from
'three';
import { EffectComposer }
from
'three/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass }
from
'three/examples/jsm/postprocessing/RenderPass.js';
import { OutputPass }
from
'three/examples/jsm/postprocessing/OutputPass.js';
const renderer =
new THREE.WebGLRenderer({ antialias:
true }); renderer.setSize(innerWidth, innerHeight); renderer.toneMapping =
THREE.ACESFilmicToneMapping; renderer.toneMappingExposure =
1.0;
const composer =
new THREE.EffectComposer(renderer); composer.setSize(innerWidth,
innerHeight); composer.setPixelRatio(Math.min(devicePixelRatio,
2));
const renderPass =
new THREE.RenderPass(scene, camera); composer.addPass(renderPass);
// ...additional passes get inserted here (bloom, SSAO, AA)
const outputPass =
new THREE.OutputPass(); composer.addPass(outputPass);
// Replace renderer.render() with:
function animate() {
composer.render();
requestAnimationFrame(animate); }
requestAnimationFrame(animate);
composer.setSize() (and
composer.setPixelRatio()) on window resize —
only calling renderer.setSize() leaves the
composer's internal render targets at the old resolution,
which stretches the image.
3. UnrealBloomPass: Glow on Bright Areas
Bloom simulates light bleeding around very bright regions, the
way a camera lens does. UnrealBloomPass implements
this in three steps: extract pixels above a luminance
threshold, blur that extracted image at several mip levels
(giving a soft, wide falloff instead of a uniform blur), then
additively composite the blurred result back onto the original
image.
bright(c) = c if luminance(c) > threshold, else 0
bloom = Σᵢ blur(downsample(bright), mipᵢ) · strength
output = tonemap(scene_color + bloom)
import { UnrealBloomPass }
from
'three/examples/jsm/postprocessing/UnrealBloomPass.js';
const bloomPass =
new THREE.UnrealBloomPass( new THREE.Vector2(innerWidth, innerHeight), 0.9,
// strength 0.4,
// radius 0.85
// threshold );
composer.addPass(bloomPass);
// For HDR values only emissive materials or bright
lights exceed the threshold
const sunMat =
new THREE.MeshStandardMaterial({ emissive: 0xffaa33, emissiveIntensity:
3.0, // > 1.0 blows past the 0.85 threshold });
strength scales how much bloom is added back;
threshold controls which pixels count as "bright"
(lower threshold = more of the scene glows);
radius widens the blur kernel per mip level. Start
at strength 0.8–1.2, threshold 0.8–0.9 for a subtle effect —
anything lower than 0.6 threshold tends to bloom the whole
scene into a haze.
4. SSAOPass: Screen-Space Ambient Occlusion
Ambient occlusion darkens creases and contact points — corners,
crevices, where two surfaces meet — where ambient light is
naturally blocked. SSAOPass approximates this
per-pixel using the scene's depth and normal buffers: for each
pixel it samples a hemisphere of nearby points in view space
and checks how many of them are occluded by geometry that is
closer to the camera.
for each sample sᵢ in hemisphere around normal n at point p:
sample_depth = depthBuffer(project(p + sᵢ · radius))
occluded if sample_depth < view_z(p + sᵢ · radius) − bias
import { SSAOPass }
from
'three/examples/jsm/postprocessing/SSAOPass.js';
const ssaoPass =
new THREE.SSAOPass(scene, camera, innerWidth, innerHeight); ssaoPass.kernelRadius =
8; ssaoPass.minDistance =
0.002; ssaoPass.maxDistance =
0.15; ssaoPass.output =
SSAOPass.OUTPUT.Default; // Beauty; use .SSAO to debug the occlusion buffer alone
composer.addPass(ssaoPass);
RenderPass. It is also
one of the heavier passes (multiple depth samples per pixel);
on integrated GPUs, drop the sample count or switch to a
baked/lightmap AO solution instead.
5. Anti-Aliasing: FXAA vs SMAA vs TAA
renderer.antialias = true uses hardware MSAA on
the main framebuffer, but MSAA is discarded once the scene is
rendered into an intermediate render target for post-processing
— so composer pipelines need a post-process anti-aliasing pass
instead.
| Technique | Cost | Quality | Notes |
|---|---|---|---|
| FXAA | Very low (single pass) | Blurs fine detail | Best for low-end / mobile |
| SMAA | Low–medium | Sharper edges than FXAA | Good general default |
| TAA | Medium (needs history buffer) | Best, handles sub-pixel detail | Ghosting on fast motion |
import { ShaderPass }
from
'three/examples/jsm/postprocessing/ShaderPass.js';
import { FXAAShader }
from
'three/examples/jsm/shaders/FXAAShader.js';
const fxaaPass =
new THREE.ShaderPass(FXAAShader); const pixelRatio =
renderer.getPixelRatio();
fxaaPass.material.uniforms['resolution'].value.set( 1 /
(innerWidth * pixelRatio), 1 /
(innerHeight * pixelRatio) ); composer.addPass(fxaaPass);
6. Writing a Custom ShaderPass
Every built-in pass is really just a ShaderPass
with a specific GLSL program attached. You can write your own
by supplying an object with uniforms,
vertexShader, and fragmentShader —
the input image always arrives as tDiffuse.
const ColorGradeShader = { uniforms: {
tDiffuse: { value: null }, uContrast: {
value: 1.1 }, uSaturation: { value:
1.2 }, uTint: { value:
new THREE.Color(0xfff4e6) }, }, vertexShader:
`
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`, fragmentShader:
`
uniform sampler2D tDiffuse;
uniform float uContrast;
uniform float uSaturation;
uniform vec3 uTint;
varying vec2 vUv;
void main() {
vec3 col = texture2D(tDiffuse, vUv).rgb;
// Contrast: pivot around mid-grey
col = (col - 0.5) * uContrast + 0.5;
// Saturation: mix toward luminance
float luma = dot(col, vec3(0.2126, 0.7152, 0.0722));
col = mix(vec3(luma), col, uSaturation);
// Multiplicative colour tint (warm highlights)
col *= uTint;
gl_FragColor = vec4(col, 1.0);
}
`, };
const gradePass =
new THREE.ShaderPass(ColorGradeShader); composer.addPass(gradePass);
tDiffuse?
ShaderPass automatically binds the previous
pass's output render target to a uniform named
tDiffuse before rendering — your fragment shader
just needs to declare
uniform sampler2D tDiffuse; and sample it with
vUv. No manual render-target wiring required.
7. Film Grain and Vignette
Grain and vignette are cheap single-pass effects that add a lot of perceived polish for almost no GPU cost — both are pure functions of UV coordinates and a noise seed, no extra texture samples of neighbouring pixels required.
const GrainVignetteShader = { uniforms: {
tDiffuse: { value: null }, uTime: {
value: 0 }, uGrainAmount: { value:
0.035 }, uVignetteStrength: { value:
0.35 }, }, vertexShader:
`varying vec2 vUv; void main() { vUv = uv; gl_Position =
projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`, fragmentShader:
`
uniform sampler2D tDiffuse;
uniform float uTime;
uniform float uGrainAmount;
uniform float uVignetteStrength;
varying vec2 vUv;
float hash(vec2 p) {
return fract(sin(dot(p, vec2(12.9898, 78.233)) + uTime) * 43758.5453);
}
void main() {
vec3 col = texture2D(tDiffuse, vUv).rgb;
// Radial vignette: darken toward corners
vec2 uv2 = vUv * (1.0 - vUv.yx);
float vig = uv2.x * uv2.y * 15.0;
vig = clamp(pow(vig, 0.25), 0.0, 1.0);
col *= mix(1.0, vig, uVignetteStrength);
// Additive film grain, re-seeded every frame via uTime
float grain = (hash(vUv) - 0.5) * uGrainAmount;
col += grain;
gl_FragColor = vec4(col, 1.0);
}
`, };
// In the render loop, update uTime each frame for animated grain:
grainPass.uniforms.uTime.value = performance.now() * 0.001;
8. Performance and Render Order
Each pass is a full-screen draw with its own fragment shader cost — on a 4K canvas that's 8.3 million shader invocations per pass, per frame. A pipeline with five passes at 4K can easily out-cost the actual scene geometry. Order and resolution choices matter more than most people expect.
-
Recommended order:
RenderPass→SSAOPass(needs raw depth/normals) →UnrealBloomPass(needs lit HDR colour) → colour grade / grain / vignette (cheap, cosmetic) → anti-aliasing (FXAAorSMAA, must run last so it smooths the final composited image, not an intermediate one) →OutputPass(tone mapping + colour space). -
Render bloom at half or quarter resolution.
UnrealBloomPassalready downsamples internally, but cappingcomposer.setPixelRatio()at 1.5–2 on high-DPI displays saves real bandwidth across every pass. -
Disable passes you don't need per frame,
don't remove and re-add them — toggle
pass.enabled = false, which skips the pass with near-zero overhead versus rebuilding the composer. - Batch cheap cosmetic passes into one ShaderPass where possible (e.g. grain + vignette + colour grade in a single fragment shader, as in Section 7) instead of chaining three separate full-screen draws.
-
Profile with
renderer.info.renderand a GPU frame-time overlay (e.g. Spector.js or the browser's WebGL inspector) before adding passes speculatively — SSAO and bloom are usually the two most expensive line items.
FXAA ≈ 0.3–0.5 ms
Bloom (5 mip levels, half-res) ≈ 0.8–1.5 ms
SSAO (16 samples) ≈ 1.5–3.0 ms
Custom single-sample ShaderPass (grain/vignette/grade) ≈ 0.1–0.3 ms
Budget rule: total post-processing ≤ 4–5 ms to hold 60 FPS (16.6 ms frame budget) alongside scene rendering.
renderer.outputColorSpace mismatched with the
composer's assumptions. If colours look washed out or
over-saturated after adding post-processing, check that
renderer.outputColorSpace = THREE.SRGBColorSpace
and that OutputPass is the last pass in the
chain — tone mapping should happen once, at the very end.