Skip to content
Three.js Post-Processing & Effects — Complete Guide to Bloom, Film Grain, Glitch & Custom Shaders

Three.js Post-Processing & Effects — Complete Guide to Bloom, Film Grain, Glitch & Custom Shaders

DodaTech Updated Jun 6, 2026 12 min read

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:

  1. Take the photo (RenderPass — captures the scene into a buffer)
  2. Apply filters in sequence (each pass reads the previous result, modifies it, writes to a new buffer)
  3. 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

What is the difference between EffectComposer and just using renderer.render()?
renderer.render() draws directly to the screen. EffectComposer draws to an off-screen framebuffer, applies shader passes sequentially, and renders the final result. Required for bloom, DOF, SSAO.
How many passes can I add before performance becomes an issue?
3-5 lightweight passes (bloom, film, color grade) run at 60fps on most devices. SSR and SSAO are expensive — use 1-2 heavy passes max.
Why is my scene black after setting up the composer?
You likely forgot to add RenderPass as the first pass, or you’re still calling renderer.render() instead of composer.render().
Can I use post-processing on only part of the scene?
Yes. Use an additional render target to render specific objects, or use OutlinePass.selectedObjects to limit edge detection.
Does bloom work with transparent objects?
Bloom extracts bright pixels regardless of alpha. Transparent objects bloom based on their RGB contribution.
What is tDiffuse in ShaderPass uniforms?
The input texture containing the previous pass’s output. The composer sets this automatically. Your shader reads from it, applies an effect, and writes the result.

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

TopicDescriptionLink
ParticlesCombining particles with post-processing effectshttps://tutorials.dodatech.com/frontend/libraries/threejs/threejs-particles/
Interactivity & UIAdding UI controls for post-processinghttps://tutorials.dodatech.com/frontend/libraries/threejs/threejs-interactivity-ui/
AnimationAnimating shader uniforms over timehttps://tutorials.dodatech.com/frontend/libraries/threejs/threejs-animation/
Cameras & ControlsDepth-based effects like DOFhttps://tutorials.dodatech.com/frontend/libraries/threejs/threejs-cameras-controls/
Lights & ShadowsEmissive materials for bloom sourceshttps://tutorials.dodatech.com/frontend/libraries/threejs/threejs-lights-shadows/
WebGLThe shader language behind post-processingWebGL
JavaScriptLanguage fundamentalsJavaScript

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