Three.js Post-Processing & Effects — Complete Guide to Bloom, Film Grain, Glitch & Custom Shaders
Post-processing applies image effects to your Three.js rendered scene — bloom, film grain, glitch, depth of field, color grading. Instead of rendering directly to the screen, you render to an off-screen buffer and apply shader effects before the final output. This is what gives games and 3D applications their cinematic look.
What You’ll Learn
By the end of this tutorial, you’ll set up the EffectComposer pipeline, add bloom, film grain, and glitch effects, write custom shader passes, apply SSAO and SSR for realism, optimize performance, and build a complete interactive Post-Processing Studio with toggleable effects.
Why Post-Processing Matters
Post-processing is the difference between “a 3D render” and “a cinematic experience.” Bloom makes neon signs glow. Film grain adds a gritty texture. Depth of field focuses attention. In Durga Antivirus Pro, the 3D threat dashboard uses bloom to make critical threat indicators glow, a subtle film grain for a tactical aesthetic, and a custom color grading pass that shifts the scene to red during active breaches — creating an immediate emotional response in security analysts.
flowchart LR
A[Scene] --> B[RenderPass: Off-screen capture]
B --> C[SSAOPass: Ambient occlusion]
C --> D[UnrealBloomPass: Glow]
D --> E[OutlinePass: Edge detection]
E --> F[Color Grading: Hue/Saturation]
F --> G[FilmPass: Grain/Vignette]
G --> H[GlitchPass: Stylistic]
H --> I[Screen Output]
style D fill:#44aa88,color:#fff,stroke:none
How Post-Processing Works — The Pipeline Analogy
Think of post-processing like a photo editing workflow:
- Take the photo (RenderPass — captures the scene into a buffer)
- Apply filters in sequence (each pass reads the previous result, modifies it, writes to a new buffer)
- Output the final image (the last pass writes to the screen)
Each pass is like a layer in Photoshop. The order matters enormously — applying grain before bloom amplifies the noise; applying bloom before grain only blooms the clean image.
EffectComposer Setup
import * as THREE from "three";
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
// Create renderer as usual
var renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// Create the composer — it wraps the renderer
var composer = new EffectComposer(renderer);
// Always add RenderPass as the first pass
var renderPass = new RenderPass(scene, camera);
composer.addPass(renderPass);
// Replace renderer.render(scene, camera) with composer.render()
function animate() {
requestAnimationFrame(animate);
composer.render(); // ⚠️ NOT renderer.render()!
}
animate();Critical difference: renderer.render() draws directly to the screen. composer.render() draws to an off-screen framebuffer, applies each pass sequentially, and outputs the final result.
UnrealBloomPass — Glow Effect
Bloom extracts bright pixels, blurs them, and adds them back to create a glow around light sources:
var bloomPass = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
0.5, // strength: 0-3, higher = brighter glow
0.4, // radius: 0-1, larger = softer glow spread
0.85 // threshold: 0-1, minimum luminance to trigger bloom
);
composer.addPass(bloomPass);
// Tweak at runtime
bloomPass.strength = 1.2;
bloomPass.radius = 0.5;
bloomPass.threshold = 0.9;Three parameters to understand:
- Strength: How intense the glow is. 0.5 is subtle; 2+ is intense cyberpunk.
- Radius: How far the glow spreads. Small = tight neon glow. Large = soft dreamy haze.
- Threshold: What counts as “bright.” 0.85 means only the brightest pixels bloom. 0 means everything blooms.
FilmPass — Grain & Vignette
Adds cinematic noise and darkened corners:
import { FilmPass } from "three/addons/postprocessing/FilmPass.js";
var filmPass = new FilmPass(
0.35, // grain intensity (0-1)
0.5, // scanline intensity
2048, // scanline count
false // grayscale?
);
composer.addPass(filmPass);GlitchPass — Digital Distortion
Simulates VHS tracking errors and digital glitches:
import { GlitchPass } from "three/addons/postprocessing/GlitchPass.js";
var glitchPass = new GlitchPass();
glitchPass.goWild = false; // Random sporadic glitches
// glitchPass.goWild = true; // Constant glitching (performance heavy!)
composer.addPass(glitchPass);ShaderPass — Custom Effects
Write your own fragment shader for unique effects:
import { ShaderPass } from "three/addons/postprocessing/ShaderPass.js";
var customPass = new ShaderPass({
uniforms: {
tDiffuse: { value: null }, // Input from previous pass (ALWAYS include)
uIntensity: { value: 1.0 },
uColor: { value: new THREE.Color(0xff4488) }
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D tDiffuse;
uniform float uIntensity;
uniform vec3 uColor;
varying vec2 vUv;
void main() {
vec4 color = texture2D(tDiffuse, vUv);
color.rgb = mix(color.rgb, uColor, uIntensity * 0.3);
gl_FragColor = color;
}
`
});
composer.addPass(customPass);The tDiffuse uniform: The composer automatically sets this to the output of the previous pass. Always declare it. Your fragment shader reads from it, applies an effect, and writes the result.
Invert Colors Shader
var invertPass = new ShaderPass({
uniforms: { tDiffuse: { value: null } },
vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
fragmentShader: `
uniform sampler2D tDiffuse;
varying vec2 vUv;
void main() {
vec4 color = texture2D(tDiffuse, vUv);
gl_FragColor = vec4(1.0 - color.rgb, color.a);
}
`
});Pass Ordering — Why Order Matters
Wrong ordering causes artifacts. Here’s the correct sequence:
composer.addPass(renderPass); // 1. Always first
composer.addPass(ssaoPass); // 2. SSAO on raw scene depth
composer.addPass(ssrPass); // 3. Screen-space reflections
composer.addPass(bloomPass); // 4. Bloom after base rendering
composer.addPass(outlinePass); // 5. Outlines on top
composer.addPass(afterimagePass); // 6. Motion trail
composer.addPass(huePass); // 7. Color grading
composer.addPass(filmPass); // 8. Grain last
composer.addPass(glitchPass); // 9. Stylistic overlay at end
Resizing the Composer
window.addEventListener("resize", function() {
var width = window.innerWidth;
var height = window.innerHeight;
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
composer.setSize(width, height); // ⚠️ Required!
bloomPass.resolution.set(width, height);
});Performance Optimization
Resolution Scaling
Render effects at half resolution for mobile:
var scale = 0.5;
var width = Math.floor(window.innerWidth * scale);
var height = Math.floor(window.innerHeight * scale);
composer.setSize(width, height);
renderer.domElement.style.width = window.innerWidth + "px";
renderer.domElement.style.height = window.innerHeight + "px";Pixel Ratio Cap
Post-processing pass count multiplies with pixel ratio. At 2x retina, each pass processes 4× pixels. Cap it:
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));Common Mistakes
1. Forgetting composer.setSize() on resize
The composer’s internal render targets stay at the old resolution. Output becomes stretched or pixelated.
2. Wrong pass order
Bloom applied after film grain amplifies the noise. SSAO applied after bloom looks washed out. Place SSAO/SSR early, bloom in the middle, grain/stylistic effects last.
3. Not adding RenderPass first
The composer needs an initial populated render target. Without RenderPass as the first pass, downstream passes receive black.
4. Bloom threshold too low
Setting threshold to 0 makes the entire scene glow, washing everything out. Start with 0.85 and adjust down.
5. Creating passes before renderer is ready
Passes create internal render targets based on renderer size. Create them after renderer.setSize().
6. Memory leaks from not disposing passes
When removing a pass, call pass.dispose() to free GPU memory.
7. High pixel ratio with many passes
At devicePixelRatio 2, a 1920×1080 canvas becomes 3840×2160 — 4× the pixels. With 5 passes, that’s 5× framebuffer writes. Cap pixel ratio.
Practice Questions
Q1: What is the EffectComposer and why do you need it? A: The EffectComposer manages a chain of render targets and shader passes. Instead of rendering directly to screen, it renders to off-screen buffers, applies each pass in sequence, and outputs the final result.
Q2: Why must RenderPass be the first pass added to the composer? A: RenderPass captures the scene into an off-screen texture. Every subsequent pass reads from the previous pass’s output. Without RenderPass, there’s no initial image to process.
Q3: What happens if you add FilmPass before UnrealBloomPass? A: Film grain would be amplified by bloom, making the grain look like bright noise specks. Grain should come after bloom or at least late in the pipeline.
Q4: Why is post-processing more expensive at higher pixel ratios? A: Each pass processes every pixel. At 2x pixel ratio, there are 4× as many pixels. With 5 passes, that’s 20× the pixel processing of 1x at 1 pass.
Q5: What does the tDiffuse uniform do in a ShaderPass?
A: It’s the input texture containing the previous pass’s output. The composer automatically sets this uniform. Your fragment shader reads from tDiffuse, applies an effect, and writes the result.
Challenge: Build a scene with emissive objects (set emissive and emissiveIntensity on materials) and a bloom pass. Add a color grading pass that desaturates the scene while keeping bloom colors vibrant. Toggle each effect with keyboard shortcuts.
FAQ
Try It Yourself
Copy this complete HTML file. It’s an interactive Post-Processing Studio with toggleable bloom, film grain, glitch, and wave distortion effects.
<!DOCTYPE html>
<html>
<head>
<title>Post-Processing Studio — Three.js</title>
<style>
body { margin: 0; overflow: hidden; font-family: sans-serif; }
#ui {
position: absolute; top: 16px; right: 16px;
background: rgba(10,10,20,0.85); backdrop-filter: blur(8px);
color: #fff; padding: 16px; border-radius: 8px;
min-width: 220px; font-size: 13px;
display: flex; flex-direction: column; gap: 10px;
border: 1px solid rgba(255,255,255,0.1); z-index: 10;
}
#ui label { display: flex; align-items: center; gap: 8px; cursor: pointer; }
#ui input[type="range"] { flex: 1; accent-color: #ff4488; }
#ui input[type="checkbox"] { accent-color: #ff4488; }
#ui h3 { margin: 0 0 6px 0; font-size: 14px; color: #ff4488; }
#ui .val { min-width: 32px; text-align: right; font-size: 12px; color: #aaa; }
</style>
</head>
<body>
<div id="ui">
<h3>Post-Processing Studio</h3>
<label><input type="checkbox" id="chkBloom" checked> Bloom</label>
<label style="padding-left:20px;">Strength <input type="range" id="bloomStrength" min="0" max="2" step="0.01" value="0.8"><span class="val" id="bloomVal">0.80</span></label>
<label><input type="checkbox" id="chkFilm" checked> Film Grain</label>
<label style="padding-left:20px;">Grain <input type="range" id="filmGrain" min="0" max="1" step="0.01" value="0.35"><span class="val" id="filmVal">0.35</span></label>
<label><input type="checkbox" id="chkGlitch"> Glitch</label>
<label><input type="checkbox" id="chkCustom" checked> Wave Distort</label>
<label style="padding-left:20px;">Amplitude <input type="range" id="waveAmp" min="0" max="0.05" step="0.001" value="0.015"><span class="val" id="waveVal">0.015</span></label>
</div>
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
}}
</script>
<script type="module">
import * as THREE from "three";
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 { FilmPass } from "three/addons/postprocessing/FilmPass.js";
import { GlitchPass } from "three/addons/postprocessing/GlitchPass.js";
import { ShaderPass } from "three/addons/postprocessing/ShaderPass.js";
var scene = new THREE.Scene();
scene.background = new THREE.Color(0x111122);
var camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(3, 2, 6);
var renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
document.body.appendChild(renderer.domElement);
var clock = new THREE.Clock();
var knotGeo = new THREE.TorusKnotGeometry(1, 0.3, 128, 32);
var knotMat = new THREE.MeshPhysicalMaterial({ color: 0xff4488, roughness: 0.2, metalness: 0.8, emissive: 0xff2244, emissiveIntensity: 0.3, clearcoat: 0.5 });
var knot = new THREE.Mesh(knotGeo, knotMat);
knot.position.set(0, 0.5, 0);
scene.add(knot);
var floor = new THREE.Mesh(new THREE.PlaneGeometry(8, 8), new THREE.MeshPhysicalMaterial({ color: 0x222244, roughness: 0.1, metalness: 0.9, side: THREE.DoubleSide, transparent: true, opacity: 0.8 }));
floor.rotation.x = -Math.PI / 2;
floor.position.y = -0.8;
scene.add(floor);
var ambient = new THREE.AmbientLight(0x222244, 0.5);
scene.add(ambient);
var light1 = new THREE.DirectionalLight(0xff88aa, 2);
light1.position.set(2, 5, 3);
scene.add(light1);
var light2 = new THREE.DirectionalLight(0x4488ff, 1.5);
light2.position.set(-3, 2, -4);
scene.add(light2);
var composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
var bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 0.8, 0.4, 0.85);
composer.addPass(bloomPass);
var filmPass = new FilmPass(0.35, 0.0, 2048, false);
composer.addPass(filmPass);
var glitchPass = new GlitchPass();
glitchPass.goWild = false;
composer.addPass(glitchPass);
var wavePass = new ShaderPass({
uniforms: { tDiffuse: { value: null }, uTime: { value: 0 }, uAmplitude: { value: 0.015 } },
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 uAmplitude; varying vec2 vUv; void main() { vec2 uv = vUv; uv.x += sin(uv.y * 40.0 + uTime * 2.5) * uAmplitude; uv.y += cos(uv.x * 35.0 + uTime * 1.8) * uAmplitude; gl_FragColor = texture2D(tDiffuse, uv); }`
});
composer.addPass(wavePass);
document.getElementById("chkBloom").addEventListener("change", function() { bloomPass.enabled = this.checked; });
document.getElementById("bloomStrength").addEventListener("input", function() { bloomPass.strength = parseFloat(this.value); document.getElementById("bloomVal").textContent = this.value; });
document.getElementById("chkFilm").addEventListener("change", function() { filmPass.enabled = this.checked; });
document.getElementById("filmGrain").addEventListener("input", function() { filmPass.uniforms.nIntensity.value = parseFloat(this.value); document.getElementById("filmVal").textContent = this.value; });
document.getElementById("chkGlitch").addEventListener("change", function() { glitchPass.enabled = this.checked; });
document.getElementById("chkCustom").addEventListener("change", function() { wavePass.enabled = this.checked; });
document.getElementById("waveAmp").addEventListener("input", function() { wavePass.uniforms.uAmplitude.value = parseFloat(this.value); document.getElementById("waveVal").textContent = this.value; });
window.addEventListener("resize", function() {
var w = window.innerWidth, h = window.innerHeight;
camera.aspect = w / h; camera.updateProjectionMatrix();
renderer.setSize(w, h);
composer.setSize(w, h);
bloomPass.resolution.set(w, h);
});
function animate() {
requestAnimationFrame(animate);
var t = clock.getElapsedTime();
knot.rotation.x = t * 0.3; knot.rotation.y = t * 0.5;
knot.position.y = 0.5 + Math.sin(t * 0.8) * 0.15;
wavePass.uniforms.uTime.value = t;
composer.render();
}
animate();
</script>
</body>
</html>What’s Next
| Topic | Description | Link |
|---|---|---|
| Particles | Combining particles with post-processing effects | https://tutorials.dodatech.com/frontend/libraries/threejs/threejs-particles/ |
| Interactivity & UI | Adding UI controls for post-processing | https://tutorials.dodatech.com/frontend/libraries/threejs/threejs-interactivity-ui/ |
| Animation | Animating shader uniforms over time | https://tutorials.dodatech.com/frontend/libraries/threejs/threejs-animation/ |
| Cameras & Controls | Depth-based effects like DOF | https://tutorials.dodatech.com/frontend/libraries/threejs/threejs-cameras-controls/ |
| Lights & Shadows | Emissive materials for bloom sources | https://tutorials.dodatech.com/frontend/libraries/threejs/threejs-lights-shadows/ |
| WebGL | The shader language behind post-processing | WebGL |
| JavaScript | Language fundamentals | JavaScript |
You’ve mastered Three.js post-processing — from EffectComposer setup to bloom, film grain, glitch, SSAO, SSR, and custom shader passes. The same post-processing pipeline powers the threat-responsive color grading in Durga Antivirus Pro, where active security breaches trigger a desaturation effect with intensified red bloom, giving analysts an immediate visual alert.
What’s Next
Congratulations on completing this Threejs Post Processing tutorial! Here’s where to go from here:
- Practice daily — Consistency is more important than long study sessions
- Build a project — Apply what you learned by building something real
- Explore related topics — Check out other tutorials in the same category
- Join the community — Discuss with other learners and share your progress
Remember: every expert was once a beginner. Keep coding!
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro