⚡ Tutorial · Three.js · Rendering
📅 July 2026 ⏱ 17 min 🎓 Intermediate

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.

Pass chain (conceptual):

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);
Common pitfall: forgetting to call 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.

luminance(c) = 0.2126·c.r + 0.7152·c.g + 0.0722·c.b
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 vs. 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.

AO(p) = 1 − (occluded_samples / total_samples)

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);
SSAOPass runs before bloom and AA in the chain — it needs the raw depth/normal buffers of the scene, so insert it directly after 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);
FXAA needs resolution uniforms updated on resize — it works on the rasterised image, so it must know exact pixel dimensions to sample neighbouring texels correctly. Forgetting this on a resize handler is the most common FXAA bug (edges look jagged again after resizing the window).

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);
Why 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.

Approximate relative cost per full-screen pass (1080p, mid-range GPU):

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.
Common pitfall: leaving 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.