Learn Creative Coding (#69) - Post-Processing in 3D

Last episode we built a full lighting toolkit -- six light types, shadow mapping, three-point cinematic setups, colored mood lighting, fog, emissive materials, animated lights, shadow art, and fake volumetric beams. Our scenes looked good. Some of them looked great. But every one of them was the raw renderer output. What you saw was exactly what the GPU produced, pixel for pixel.
Real films don't work that way. After the camera captures the image, the colorist tweaks contrast, adds color grading, maybe some lens effects. Games do the same thing -- the raw 3D render goes through a stack of fullscreen effects before it hits the monitor. Bloom makes bright things glow with soft halos. Depth of field blurs the background like a real camera lens. Ambient occlusion darkens creases and corners. Chromatic aberration splits colors at the edges like a cheap lens. Film grain adds analog texture to a digital image.
This is post-processing: take the rendered image as a texture, run it through fragment shaders that modify it, output the result. We already did this in 2D back in episode 43 -- post-processing effects on a canvas. Same concept, now in Three.js's pipeline, with access to depth buffers, normal buffers, and the full 3D scene state.
The EffectComposer pipeline
Three.js has a post-processing system built around EffectComposer. The idea is simple: instead of rendering directly to the screen, you render to an offscreen framebuffer (a texture). Then you run that texture through a chain of passes -- each pass reads the previous output, applies some shader, and writes to the next framebuffer. The final pass writes to the screen.
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x080810);
const camera = new THREE.PerspectiveCamera(
60, window.innerWidth / window.innerHeight, 0.1, 100
);
camera.position.set(0, 3, 8);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
// some objects to look at
scene.add(new THREE.AmbientLight(0x222244, 0.8));
const sun = new THREE.DirectionalLight(0xffeedd, 2.0);
sun.position.set(4, 6, 3);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
scene.add(sun);
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(15, 15),
new THREE.MeshStandardMaterial({ color: 0x333333, roughness: 0.9 })
);
floor.rotation.x = -Math.PI / 2;
floor.receiveShadow = true;
scene.add(floor);
for (let i = 0; i < 6; i++) {
const geo = i % 2 === 0
? new THREE.BoxGeometry(0.6, 0.8 + Math.random() * 1.5, 0.6)
: new THREE.SphereGeometry(0.3 + Math.random() * 0.4, 24, 24);
const mesh = new THREE.Mesh(
geo,
new THREE.MeshStandardMaterial({
color: new THREE.Color().setHSL(Math.random(), 0.6, 0.4),
roughness: 0.4 + Math.random() * 0.4
})
);
mesh.position.set(
(Math.random() - 0.5) * 6,
geo.parameters.height ? geo.parameters.height / 2 : 0.5,
(Math.random() - 0.5) * 6
);
mesh.castShadow = true;
mesh.receiveShadow = true;
scene.add(mesh);
}
// set up the composer
const composer = new EffectComposer(renderer);
// first pass: render the scene normally into a framebuffer
const renderPass = new RenderPass(scene, camera);
composer.addPass(renderPass);
// that's it for now -- one pass, same result as renderer.render()
// we'll add effect passes next
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
controls.update();
// use composer.render() instead of renderer.render()
composer.render();
}
animate();
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
});
The key change from a normal Three.js app: composer.render() replaces renderer.render(scene, camera). The RenderPass does the actual scene rendering -- it's the entry point that produces the initial image. After that, every subsequent pass operates on that image as a 2D texture. The composer chains them in the order you add them.
Notice the resize handler also calls composer.setSize(). Without this, the post-processing framebuffers stay at the old resolution when the window resizes, and you get a stretched or cropped image. Easy to forget, annoying to debug.
Bloom: making things glow
Bloom is probably the most recognizable post-processing effect. It makes bright areas of the image bleed light into surrounding pixels, creating a soft glow halo. Neon signs, lava, lasers, sun glare -- bloom is what sells these as light sources rather than just bright pixels.
Remember in ep068 we said emissive materials with intensity above 1.0 would really shine with bloom? Here's where that promise lands.
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
// add bloom after the render pass
const bloomPass = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
0.8, // strength: how intense the glow is
0.4, // radius: how far the glow spreads
0.85 // threshold: brightness cutoff (pixels below this don't bloom)
);
composer.addPass(bloomPass);
Three parameters control the look:
Threshold determines what glows. Only pixels brighter than this value get the bloom treatment. Set it to 0 and everything blooms (dreamy, hazy look). Set it to 0.9 and only the brightest highlights glow (subtle, cinematic). For scenes with emissive materials, a threshold around 0.8-0.9 means only the emissive objects glow while the normally-lit surfaces stay clean.
Strength is the intensity multiplier on the glow. Higher values make the bloom more visible, lower values make it subtle. 0.5 is a gentle atmospheric glow. 1.5 is full sci-fi neon. Above 2.0 it starts washing out the image.
Radius controls how far the glow spreads from the bright source. Small radius gives tight halos close to the object. Large radius gives wide, soft light bleeding. For emissive orbs and neon tubes, a radius around 0.3-0.6 looks natural. For sun glare, go higher.
Let's add some emissive objects from ep068 to actually see bloom do its thing:
// glowing emissive sphere
const glowSphere = new THREE.Mesh(
new THREE.SphereGeometry(0.4, 32, 32),
new THREE.MeshStandardMaterial({
color: 0x000000,
emissive: 0xff4422,
emissiveIntensity: 3.0,
roughness: 0.2
})
);
glowSphere.position.set(0, 1.5, 0);
scene.add(glowSphere);
// pair with a real light so it illuminates surroundings
const glowLight = new THREE.PointLight(0xff4422, 2.0, 8);
glowLight.position.copy(glowSphere.position);
scene.add(glowLight);
// neon ring
const neonRing = new THREE.Mesh(
new THREE.TorusGeometry(1.2, 0.04, 16, 64),
new THREE.MeshStandardMaterial({
color: 0x000000,
emissive: 0x2266ff,
emissiveIntensity: 4.0
})
);
neonRing.position.set(0, 2, 0);
neonRing.rotation.x = Math.PI / 3;
scene.add(neonRing);
Without bloom: the emissive sphere is bright orange and the ring is bright blue, but they look like flat colored objects. With bloom: they radiate soft colored light into the surrounding image. The sphere has a warm orange halo. The ring has blue light bleeding along its curve. They look like they're actually emitting light, not just painted bright.
The difference is dramatic. Bloom is what turns "bright pixel" into "light source" in the viewer's perception. It simulates the way real camera lenses and human eyes scatter light from bright sources. Even the best physically-based materials look flat without it.
Screen-space ambient occlusion (SSAO)
Ambient occlusion darkens areas where surfaces meet at tight angles -- creases, corners, gaps between objects sitting on a floor. It's a contact shadow effect that adds depth and grounding even in flat-lit scenes. Without it, objects look like they're floating. With it, they feel like they have weight and sit on the surface.
import { SSAOPass } from 'three/addons/postprocessing/SSAOPass.js';
const ssaoPass = new SSAOPass(scene, camera, window.innerWidth, window.innerHeight);
ssaoPass.kernelRadius = 16; // how far to sample (world units)
ssaoPass.minDistance = 0.005; // ignore very close depth differences
ssaoPass.maxDistance = 0.1; // ignore far depth differences
ssaoPass.output = SSAOPass.OUTPUT.Default; // blended result
composer.addPass(ssaoPass);
SSAO works by sampling the depth buffer around each pixel. For every pixel, it checks nearby depths: "are there surfaces close by at different depths?" If many nearby samples are occluded (blocked by closer geometry), the pixel gets darkened. Corners and creases have lots of nearby occluders, so they darken. Flat open surfaces have few occluders, so they stay bright.
kernelRadius controls how far the sampling reaches. Larger values detect occlusion from farther surfaces but can look unrealistic (shadows where there shouldn't be). Smaller values give tight, subtle darkening right at contact points. 8-16 is a good range for most scenes.
minDistance and maxDistance are depth thresholds. Samples closer than minDistance are ignored (prevents self-occlusion artifacts). Samples farther than maxDistance are also ignored (limits the effect to nearby surfaces). Tweak these if you see haloing artifacts around objects.
You can visualize just the occlusion pass to see what it's doing:
// show only the AO texture (useful for tuning)
ssaoPass.output = SSAOPass.OUTPUT.SSAO;
// or show the beauty pass with AO applied
ssaoPass.output = SSAOPass.OUTPUT.Default;
// or the raw beauty without AO
ssaoPass.output = SSAOPass.OUTPUT.Beauty;
Switch between these to understand what SSAO is contributing. The SSAO-only view shows a greyscale image where white means "no occlusion" and darker grey means "occluded". You'll see dark halos around the base of every object sitting on the floor, dark lines where walls meet floors, and dark patches in any concavity. Toggle back to Default and those subtle shadows add a ton of visual depth.
Depth of field (DOF)
Depth of field simulates a camera lens. Objects at the focus distance are sharp. Everything closer or farther gets progressively blurry. This directs the viewer's eye to whatever you want them to look at -- the bokeh effect that makes photos and films look cinematic.
import { BokehPass } from 'three/addons/postprocessing/BokehPass.js';
const bokehPass = new BokehPass(scene, camera, {
focus: 5.0, // distance to the focus plane (world units from camera)
aperture: 0.002, // lens opening size (smaller = more blur)
maxblur: 0.01 // maximum blur amount
});
composer.addPass(bokehPass);
focus is the distance from the camera where objects are perfectly sharp. Set it to match the distance of your main subject. At focus distance = 5.0, anything 5 units from the camera is crisp. Objects at 3 or 7 units will be slightly blurred. Objects at 1 or 15 units will be very blurred.
aperture controls the strength of the effect. Smaller values = wider lens opening = more blur. This is counterintuitive if you know photography (where small f-numbers mean wide aperture), but in the shader the math works out this way. Start with 0.002-0.005 and adjust.
maxblur caps the blur radius so the background doesn't turn into an unrecognizable smear. 0.01 gives a soft cinematic background blur. 0.02 is heavy. Above 0.03 and distant objects become abstract colored blobs.
DOF is powerful for creative coding pieces because it controls attention. Put a glowing crystal at focus distance, blur the background columns and floor, and the viewer's eye goes straight to the crystal. The blur also hides any rough edges or simple geometry in the background -- everything out of focus looks beautiful because detail doesn't matter, only color and shape.
You can animate the focus distance for a rack-focus effect (shifting focus from one object to another):
function animate() {
requestAnimationFrame(animate);
const t = clock.getElapsedTime();
// slowly shift focus between near and far objects
bokehPass.uniforms['focus'].value = 4.0 + Math.sin(t * 0.3) * 2.0;
controls.update();
composer.render();
}
Built-in effect passes
Three.js ships a bunch of ready-made passes. Let me walk you through the most useful ones for creative coding:
import { FilmPass } from 'three/addons/postprocessing/FilmPass.js';
import { GlitchPass } from 'three/addons/postprocessing/GlitchPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
// film grain and scanlines: instant retro/analog look
const filmPass = new FilmPass(
0.25, // noise intensity
false // greyscale (true = desaturated film, false = color grain)
);
composer.addPass(filmPass);
// digital glitch: random displacement and color channel separation
const glitchPass = new GlitchPass();
glitchPass.goWild = false; // true = constant glitching, false = occasional bursts
composer.addPass(glitchPass);
// output pass: handles color space conversion
// add this as the LAST pass (gamma correction)
const outputPass = new OutputPass();
composer.addPass(outputPass);
FilmPass adds noise (grain) and optionally scanlines. Grain breaks up the clean digital look and adds analog warmth. Even very subtle grain (0.1-0.15) makes renders feel less sterile. For a retro CRT aesthetic, combine with scanlines at low intensity.
GlitchPass periodically disrupts the image with horizontal displacement, color channel separation, and noise. It's random and uncontrollable by design -- that's the aesthetic. Set goWild = true for constant glitching (seizure warning territory). The default false gives occasional short bursts that feel like a malfuntioning display.
OutputPass handles the final color space conversion (linear to sRGB). Without it, your post-processed image might look washed out or too dark depending on your renderer settings. Always add it as the last pass in the chain.
Other built-in passes worth knowing: DotScreenPass (halftone dot pattern), HalftonePass (more configurable halftone), AfterimagePass (ghosting/trail effect where previous frames linger). Each one is a single import and composer.addPass() call.
Custom shader passes
Here's where it gets interesting. Any 2D shader effect we built in episodes 32-45 can become a post-processing pass. Write a fragment shader, wrap it in ShaderPass, and it operates on the rendered scene as a texture. Chromatic aberration, color grading, pixelation, vignetting -- anything.
// chromatic aberration: split RGB channels slightly
const chromaticShader = {
uniforms: {
tDiffuse: { value: null }, // the input texture (previous pass output)
uAmount: { value: 0.003 }
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D tDiffuse;
uniform float uAmount;
varying vec2 vUv;
void main() {
vec2 offset = (vUv - 0.5) * uAmount;
float r = texture2D(tDiffuse, vUv + offset).r;
float g = texture2D(tDiffuse, vUv).g;
float b = texture2D(tDiffuse, vUv - offset).b;
gl_FragColor = vec4(r, g, b, 1.0);
}
`
};
const chromaticPass = new ShaderPass(chromaticShader);
composer.addPass(chromaticPass);
tDiffuse is the magic name. Three.js's ShaderPass automatically binds the previous pass's output texture to whatever uniform is called tDiffuse. Your fragment shader reads pixels from it with texture2D(tDiffuse, vUv) and outputs modified pixels. The vertex shader is almost always the same boilerplate -- just pass through UV coordinates.
The chromatic aberration shader samples the red, green, and blue channels from slightly different UV positions. The offset is based on distance from center, so the effect is stronger at the edges (like a real lens aberration). uAmount controls the strength -- 0.003 is subtle, 0.01 is clearly visible, above 0.02 it starts looking broken (which might be the vibe you want).
Let's build more:
// vignette: darken the edges of the frame
const vignetteShader = {
uniforms: {
tDiffuse: { value: null },
uIntensity: { value: 0.7 },
uSmoothness: { value: 0.4 }
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D tDiffuse;
uniform float uIntensity;
uniform float uSmoothness;
varying vec2 vUv;
void main() {
vec4 color = texture2D(tDiffuse, vUv);
float dist = distance(vUv, vec2(0.5));
float vignette = smoothstep(0.5, 0.5 - uSmoothness, dist) ;
vignette = mix(1.0, vignette, uIntensity);
gl_FragColor = vec4(color.rgb * vignette, 1.0);
}
`
};
// color grading: adjust brightness, contrast, saturation
const colorGradeShader = {
uniforms: {
tDiffuse: { value: null },
uBrightness: { value: 0.05 },
uContrast: { value: 1.15 },
uSaturation: { value: 1.2 }
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D tDiffuse;
uniform float uBrightness;
uniform float uContrast;
uniform float uSaturation;
varying vec2 vUv;
void main() {
vec4 color = texture2D(tDiffuse, vUv);
// brightness
color.rgb += uBrightness;
// contrast (pivot around 0.5)
color.rgb = (color.rgb - 0.5) * uContrast + 0.5;
// saturation
float grey = dot(color.rgb, vec3(0.299, 0.587, 0.114));
color.rgb = mix(vec3(grey), color.rgb, uSaturation);
gl_FragColor = vec4(clamp(color.rgb, 0.0, 1.0), 1.0);
}
`
};
const vignettePass = new ShaderPass(vignetteShader);
composer.addPass(vignettePass);
const colorGradePass = new ShaderPass(colorGradeShader);
composer.addPass(colorGradePass);
The vignette darkens the frame edges, drawing the eye toward the center. Classic cinematic framing. The color grading pass adjusts brightness, contrast, and saturation -- the same operations a photographer does in Lightroom, but in real-time on a 3D render. A slight contrast boost (1.15) and saturation bump (1.2) makes most scenes pop without looking overdone.
These custom passes are where all the shader knowledge from episodes 32-45 becomes directly applicable. Every 2D shader effect we built -- noise patterns, color manipulation, SDF shapes, feedback loops -- can be wrapped in a ShaderPass and applied to any 3D scene.
Pass ordering matters
The order you add passes to the composer changes the result. This might seem obvious but it catches people off guard. Think of it like Photoshop layers -- the same filters in different order give different images.
Some ordering rules that produce good results:
- RenderPass first (always -- this produces the initial image)
- SSAOPass early (it needs clean depth information, not post-processed pixels)
- UnrealBloomPass before color grading (so the glow gets graded too)
- Color grading in the middle (affects everything that came before)
- Chromatic aberration after color grading (lens effect, applied to the graded image)
- Vignette near the end (framing effect, applied to almost-final image)
- Film grain last (applied to the absolute final image, like real film grain)
- OutputPass very last (color space conversion)
// recommended order for a polished cinematic look
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
composer.addPass(ssaoPass);
composer.addPass(bloomPass);
composer.addPass(colorGradePass);
composer.addPass(chromaticPass);
composer.addPass(vignettePass);
composer.addPass(filmPass);
composer.addPass(new OutputPass());
Why SSAO before bloom? Because bloom brightens pixels. If SSAO runs after bloom, the ambient occlusion tries to darken already-bloomed areas, creating ugly dark halos around glowing objects. SSAO first, then bloom amplifies the properly-occluded image.
Why grain last? Real film grain is a property of the recording medium, not the scene. It sits on top of everything -- bloom, color grading, lens effects, everything. Grain applied earlier would get further processed by subsequent passes, which doesn't look right.
Experiment with the order. There are no absolute rules -- these are creative effects, not physical simulations. But the ordering above is a solid default for cinematic quality.
Render targets: layered compositing
Sometimes you want different effects on different parts of the scene. The foreground gets bloom but the background doesn't. Or the UI layer has no post-processing while the 3D world behind it gets the full treatment. Render targets let you do this by rendering parts of the scene separately and compositing the results.
// create a separate render target for the background
const bgTarget = new THREE.WebGLRenderTarget(
window.innerWidth, window.innerHeight,
{ minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter }
);
// separate the scene into layers
const bgLayer = new THREE.Layers();
bgLayer.set(1); // layer 1 for background
const fgLayer = new THREE.Layers();
fgLayer.set(0); // layer 0 for foreground (default)
// mark background objects
const bgSphere = new THREE.Mesh(
new THREE.SphereGeometry(30, 32, 32),
new THREE.MeshBasicMaterial({
color: 0x111122,
side: THREE.BackSide
})
);
bgSphere.layers.set(1);
scene.add(bgSphere);
// all other objects stay on layer 0 (default)
// in the animation loop:
function animate() {
requestAnimationFrame(animate);
controls.update();
// render background layer to its own target
camera.layers.set(1);
renderer.setRenderTarget(bgTarget);
renderer.render(scene, camera);
// render foreground layer with post-processing
camera.layers.set(0);
renderer.setRenderTarget(null);
composer.render();
}
Three.js Layers are a bitmask system. Each object can belong to one or more layers. The camera only renders objects on its currently enabled layers. By switching the camera's layer mask before rendering, you control what gets rendered into which target.
For creative coding, this opens up selective effects. Render the main scene with bloom and SSAO, render a particle overlay without post-processing, composite them with additive blending. Or render a background star field with heavy blur and a foreground character crisp. The creative control multiplies with every additional render target.
Creative exercise: cinematic glow scene
Allez, time to build a full scene with the post-processing pipeline. Emissive objects with bloom, SSAO for depth, color grading for mood, vignette for framing, subtle grain for texture:
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { SSAOPass } from 'three/addons/postprocessing/SSAOPass.js';
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x050508);
scene.fog = new THREE.FogExp2(0x050508, 0.04);
const camera = new THREE.PerspectiveCamera(
55, window.innerWidth / window.innerHeight, 0.1, 80
);
camera.position.set(0, 4, 10);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
// floor
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(30, 30),
new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.95 })
);
floor.rotation.x = -Math.PI / 2;
floor.receiveShadow = true;
scene.add(floor);
// columns in a ring
const columns = [];
for (let i = 0; i < 10; i++) {
const angle = (i / 10) * Math.PI * 2;
const r = 5;
const h = 3 + Math.random() * 1.5;
const col = new THREE.Mesh(
new THREE.CylinderGeometry(0.12, 0.15, h, 8),
new THREE.MeshStandardMaterial({ color: 0x222222, roughness: 0.8 })
);
col.position.set(Math.cos(angle) * r, h / 2, Math.sin(angle) * r);
col.castShadow = true;
col.receiveShadow = true;
scene.add(col);
columns.push(col);
}
// center: floating emissive orbs of different colors
const orbs = [];
const orbColors = [0xff3322, 0x2266ff, 0x22ff88, 0xff8800, 0xaa22ff];
for (let i = 0; i < orbColors.length; i++) {
const angle = (i / orbColors.length) * Math.PI * 2;
const orbMesh = new THREE.Mesh(
new THREE.SphereGeometry(0.2, 24, 24),
new THREE.MeshStandardMaterial({
color: 0x000000,
emissive: orbColors[i],
emissiveIntensity: 3.5,
roughness: 0.1
})
);
orbMesh.position.set(
Math.cos(angle) * 1.5,
2.5,
Math.sin(angle) * 1.5
);
scene.add(orbMesh);
const orbLight = new THREE.PointLight(orbColors[i], 1.5, 6);
orbLight.position.copy(orbMesh.position);
scene.add(orbLight);
orbs.push({ mesh: orbMesh, light: orbLight, angle, color: orbColors[i] });
}
// central crystal (large emissive focal point)
const crystalMat = new THREE.MeshStandardMaterial({
color: 0x000000,
emissive: 0xffffff,
emissiveIntensity: 2.0,
roughness: 0.0,
metalness: 0.2
});
const crystal = new THREE.Mesh(
new THREE.OctahedronGeometry(0.5, 0),
crystalMat
);
crystal.position.set(0, 2.5, 0);
crystal.rotation.set(0.3, 0, 0.5);
scene.add(crystal);
const crystalLight = new THREE.PointLight(0xffffff, 2.0, 8);
crystalLight.position.copy(crystal.position);
crystalLight.castShadow = true;
crystalLight.shadow.mapSize.set(1024, 1024);
scene.add(crystalLight);
// dim ambient
scene.add(new THREE.HemisphereLight(0x111122, 0x080808, 0.2));
// ---- POST-PROCESSING PIPELINE ----
const composer = new EffectComposer(renderer);
// 1. render the scene
composer.addPass(new RenderPass(scene, camera));
// 2. SSAO for contact shadows
const ssao = new SSAOPass(scene, camera, window.innerWidth, window.innerHeight);
ssao.kernelRadius = 12;
ssao.minDistance = 0.005;
ssao.maxDistance = 0.1;
composer.addPass(ssao);
// 3. bloom for emissive glow
const bloom = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
1.0, 0.5, 0.8
);
composer.addPass(bloom);
// 4. color grading
const gradeShader = {
uniforms: {
tDiffuse: { value: null },
uBrightness: { value: 0.02 },
uContrast: { value: 1.12 },
uSaturation: { value: 1.1 }
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D tDiffuse;
uniform float uBrightness;
uniform float uContrast;
uniform float uSaturation;
varying vec2 vUv;
void main() {
vec4 c = texture2D(tDiffuse, vUv);
c.rgb += uBrightness;
c.rgb = (c.rgb - 0.5) * uContrast + 0.5;
float g = dot(c.rgb, vec3(0.299, 0.587, 0.114));
c.rgb = mix(vec3(g), c.rgb, uSaturation);
gl_FragColor = vec4(clamp(c.rgb, 0.0, 1.0), 1.0);
}
`
};
composer.addPass(new ShaderPass(gradeShader));
// 5. vignette
const vigShader = {
uniforms: {
tDiffuse: { value: null },
uIntensity: { value: 0.6 }
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D tDiffuse;
uniform float uIntensity;
varying vec2 vUv;
void main() {
vec4 c = texture2D(tDiffuse, vUv);
float d = distance(vUv, vec2(0.5));
float v = smoothstep(0.5, 0.15, d);
c.rgb *= mix(1.0, v, uIntensity);
gl_FragColor = c;
}
`
};
composer.addPass(new ShaderPass(vigShader));
// 6. output (gamma correction)
composer.addPass(new OutputPass());
// ---- ANIMATION ----
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const t = clock.getElapsedTime();
// orbs orbit and bob
for (let i = 0; i < orbs.length; i++) {
const orb = orbs[i];
const a = orb.angle + t * 0.3;
orb.mesh.position.x = Math.cos(a) * 1.5;
orb.mesh.position.z = Math.sin(a) * 1.5;
orb.mesh.position.y = 2.5 + Math.sin(t * 0.8 + i * 1.2) * 0.3;
orb.light.position.copy(orb.mesh.position);
orb.light.intensity = 1.5 + Math.sin(t * 1.5 + i * 0.8) * 0.3;
}
// crystal spins and pulses
crystal.rotation.y = t * 0.4;
crystal.rotation.x = 0.3 + Math.sin(t * 0.6) * 0.1;
crystalMat.emissiveIntensity = 2.0 + Math.sin(t * 1.2) * 0.8;
crystalLight.intensity = 2.0 + Math.sin(t * 1.2) * 0.5;
controls.update();
composer.render();
}
animate();
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
});
This is a complete scene with a full post-processing pipeline. The columns and floor are standard PBR materials -- SSAO adds contact shadows at their bases. The five colored orbs and central crystal are emissive -- bloom makes them radiate colored light halos that mix where they overlap. Color grading bumps contrast and saturation slightly. The vignette pulls attention toward the center where the action is. Fog fades the outer columns into darkness.
Toggle each effect off one by one and watch the scene degrade. Without SSAO, the columns float above the floor. Without bloom, the orbs are just bright dots. Without the vignette, the composition feels directionless. Without color grading, everything looks a bit flat. Each effect adds a layer of visual quality that compounds into a polished result.
Performance considerations
Every pass is a full-screen render. The fragment shader runs for every pixel on screen. Five passes on a 1920x1080 display means processing ~10 million pixels total. That's meaningful GPU work.
The cheap ones: ShaderPass with simple math (vignette, color grading, chromatic aberration) -- these are basically free. A few arithmetic operations per pixel, the GPU handles millions of these per millisecond.
The moderate ones: UnrealBloomPass does multiple blur passes internally (it downsamples, blurs, upsamples at several resolutions). It's roughly equivalent to 5-8 simple passes. Still fast on modern hardware but you'll notice it on phones.
The expensive ones: SSAOPass samples many nearby depth values per pixel (the kernel). With a kernel size of 16+, it's doing texture lookups in a loop for every pixel. This is the most expensive common post-processing effect. If you're targeting weaker hardware, SSAO is the first thing to cut.
Combine simple effects: instead of three separate ShaderPasses for vignette, color grading, and chromatic aberration, you can combine them into a single shader that does all three in one pass. Three passes down to one. The GPU work is nearly the same (same number of operations per pixel) but you eliminate two framebuffer switches, which saves some overhead.
// combined pass: chromatic + grade + vignette in one shader
const combinedShader = {
uniforms: {
tDiffuse: { value: null },
uChromatic: { value: 0.003 },
uBrightness: { value: 0.02 },
uContrast: { value: 1.12 },
uSaturation: { value: 1.1 },
uVignette: { value: 0.6 }
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D tDiffuse;
uniform float uChromatic;
uniform float uBrightness;
uniform float uContrast;
uniform float uSaturation;
uniform float uVignette;
varying vec2 vUv;
void main() {
// chromatic aberration
vec2 offset = (vUv - 0.5) * uChromatic;
float r = texture2D(tDiffuse, vUv + offset).r;
float g = texture2D(tDiffuse, vUv).g;
float b = texture2D(tDiffuse, vUv - offset).b;
vec3 col = vec3(r, g, b);
// color grading
col += uBrightness;
col = (col - 0.5) * uContrast + 0.5;
float grey = dot(col, vec3(0.299, 0.587, 0.114));
col = mix(vec3(grey), col, uSaturation);
// vignette
float d = distance(vUv, vec2(0.5));
float vig = smoothstep(0.5, 0.15, d);
col *= mix(1.0, vig, uVignette);
gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
}
`
};
Same visual result, one pass instead of three. For a production creative coding piece this kind of optimization keeps the frame rate smooth on a wider range of hardware. The combined shader is also easier to tune -- all your "look" parameters in one place.
Tone mapping: the hidden post-process
There's one more post-processing step that Three.js handles before anything else: tone mapping. This maps HDR (high dynamic range) values from the renderer into the displayable [0, 1] range. It's set on the renderer itself, not as a composer pass:
// ACES filmic tone mapping (cinematic, good contrast, warm shadows)
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
// other options:
// THREE.LinearToneMapping (no mapping, raw values clamped)
// THREE.ReinhardToneMapping (soft rolloff, good for HDR)
// THREE.CineonToneMapping (Kodak film response, warm and contrasty)
// THREE.AgXToneMapping (modern, perceptually accurate, preserves hues)
ACES Filmic is the most popular choice for creative work. It gives a natural-looking contrast curve where shadows are slightly lifted (not pure black) and highlights roll off smoothly instead of clipping hard. It also adds subtle warmth to the shadows and coolness to the highlights, which is why many Three.js scenes look cinematic "for free" when you switch from Linear to ACES.
Tone mapping interacts with bloom. The bloom pass operates on HDR values before tone mapping, so emissive intensities above 1.0 contribute properly to the glow. If you were using Linear tone mapping, values above 1.0 would just clip to white -- no bloom, no glow. ACES preserves the brightness information so bloom has something to work with.
The toneMappingExposure multiplier is like a global brightness knob. Values below 1.0 darken the scene, above 1.0 brighten it. It's the simplest way to adjust overall brightness without changing any lights. Animate it for fade-in/fade-out effects.
What's ahead
We've covered the full post-processing toolkit: EffectComposer for chaining passes, RenderPass as the entry point, UnrealBloomPass for emissive glow, SSAOPass for contact shadows, BokehPass for depth of field, built-in effect passes for film grain and glitch, custom ShaderPass for any 2D effect you can dream up, pass ordering strategy, render targets for selective effects, performance optimization through pass combining, and tone mapping as the foundation. Every 3D scene we build from here can be polished with these tools.
Next time we'll look at instancing -- rendering massive quantities of objects efficiently. The columns and orbs in today's scene were individual meshes. What if you wanted a million particles, each with full geometry and lighting? InstancedMesh lets you draw them all in a single draw call, and combined with the post-processing pipeline from this episode, the results can be stunning.
't Komt erop neer...
- EffectComposer replaces
renderer.render()with a pass-based pipeline. RenderPass renders the scene to a framebuffer. Each subsequent pass reads the previous output as a texture, applies a shader, writes the result. The final pass outputs to screen. Always callcomposer.setSize()on window resize - UnrealBloomPass makes bright pixels glow with soft halos. Three parameters: threshold (brightness cutoff -- only pixels above this bloom), strength (glow intensity), radius (glow spread). Combined with emissive materials (emissiveIntensity > 1.0), bloom turns bright objects into convincing light sources
- SSAOPass darkens creases and contact points by sampling the depth buffer around each pixel. kernelRadius controls sampling range, minDistance/maxDistance filter artifacts. Run it BEFORE bloom in the pass chain so bloom doesn't amplify occlusion shadows
- BokehPass simulates camera lens depth of field. Focus distance determines the sharp plane, aperture and maxblur control blur strength. Animate focus for rack-focus effects. Directs viewer attention and hides rough background geometry
- Custom ShaderPass wraps any fragment shader as a post-processing effect. The
tDiffuseuniform receives the previous pass output. Chromatic aberration, vignette, color grading, pixelation -- any 2D shader from ep032-045 works as a post-processing pass - Pass ordering matters: SSAO early (needs clean depth), bloom before color grading (grade the glow), lens effects after grading, grain last (sits on top of everything), OutputPass at the very end for gamma correction
- Combine simple shader effects into a single pass for performance. Three arithmetic passes merged into one eliminates two framebuffer switches. Same visual result, less GPU overhead
- Tone mapping (renderer.toneMapping) happens before the composer and maps HDR values to displayable range. ACES Filmic gives cinematic contrast with soft highlight rolloff. toneMappingExposure is a global brightness multiplier. HDR tone mapping is what allows emissive materials with intensity > 1.0 to drive bloom properly
Sallukes! Thanks for reading.
X