Skip to content

Post-Processing

A Three.js guide for coding agents. Also covers post processing three.js, effectcomposer, render pass, shaderpass, custom pass shader, tDiffuse uniform, and 21 more.

Show all 27 aliases

post processing three.js, effectcomposer, render pass, shaderpass, custom pass shader, tDiffuse uniform, unrealbloompass, bloom effect, glitch pass, dot screen pass, rgb shift, gamma correction shader, colors look dark after composer, srgb output broken passes, antialias gone after post processing, smaa fxaa pass, webglrendertarget samples, ping pong buffering, react-three/postprocessing, pmndrs postprocessing effects, tone mapping effect r3f, blend function, custom effect mainImage mainUv, drunk effect, depth of field bokeh, vignette effect, how do I add bloom in r3f

Core Philosophy

  • Post-processing applies effects to the final rendered image: bloom, depth of field, glitch, motion blur, color grading, antialiasing, outlines
  • Mechanics: render the scene into a render target (a texture, called a buffer outside Three.js), then draw that texture on a screen-covering plane whose fragment shader applies the effect. Chained passes ping-pong between two render targets because you cannot read a target while writing to it; the last pass draws straight to the canvas
  • EffectComposer handles all of that for you: target creation, ping-pong swapping, feeding each pass the previous pass's texture, final canvas draw
  • Every pass renders every frame. Passes are a real performance cost; add only what earns its place

EffectComposer setup (vanilla Three.js)

import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js'
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js'

const effectComposer = new EffectComposer(renderer)
effectComposer.setSize(sizes.width, sizes.height)
effectComposer.setPixelRatio(Math.min(window.devicePixelRatio, 2))

const renderPass = new RenderPass(scene, camera)
effectComposer.addPass(renderPass)
  • RenderPass must come first: it renders the scene into the composer's render target instead of the canvas
  • In the tick loop, replace renderer.render(scene, camera) with effectComposer.render()
  • The composer must be resized like the renderer, or the image goes soft after the window changes. In the resize handler call BOTH effectComposer.setSize(...) and effectComposer.setPixelRatio(...)
  • Toggle any pass with pass.enabled = false to test passes in isolation

Built-in passes

  • DotScreenPass: black and white raster effect, no parameters needed
  • GlitchPass: movie-hack screen glitches. goWild = true glitches non-stop (flash warning)
  • UnrealBloomPass: glow for lights, fire, lasers. Three main knobs: strength (how strong the glow), radius (how far it spreads), threshold (luminosity above which things glow). Defaults glow everything; raise threshold and tune
  • Some effects ship as raw shaders, not passes. Wrap them in ShaderPass:
import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js'
import { RGBShiftShader } from 'three/examples/jsm/shaders/RGBShiftShader.js'

effectComposer.addPass(new ShaderPass(RGBShiftShader))

Fixing the color (gamma / color space)

  • The moment any pass beyond RenderPass is active, the image goes dark: renderer.outputColorSpace = THREE.SRGBColorSpace no longer applies because passes render into render targets, which do not handle color space the same way
  • Fix: add a ShaderPass with GammaCorrectionShader as the LAST color pass to convert back to sRGB
import { GammaCorrectionShader } from 'three/examples/jsm/shaders/GammaCorrectionShader.js'

const gammaCorrectionPass = new ShaderPass(GammaCorrectionShader)
effectComposer.addPass(gammaCorrectionPass)
  • Pass order matters: effects first, gamma correction after them, and an antialias pass (if any) after the gamma correction

Fixing the antialias

The composer's default WebGLRenderTarget has no antialias, so edges alias again once a second pass is active (invisible if you only have the RenderPass, and usually invisible on screens with pixel ratio above 1). Options:

  1. Multisampled render target (WebGL 2 only). Provide your own target with samples:
const renderTarget = new THREE.WebGLRenderTarget(800, 600, {
    samples: renderer.getPixelRatio() === 1 ? 2 : 0
})
const effectComposer = new EffectComposer(renderer, renderTarget)

Width and height can be placeholders; setSize() resizes the target. More samples means better antialias and worse performance; 0 disables it. Skip samples entirely when pixel ratio is above 1, the density already hides aliasing 2. Antialias pass: FXAA (fast, slightly blurry), SMAA (usually better than FXAA, costlier, not MSAA), SSAA (best quality, worst performance), TAA (fast, limited). Add it AFTER the gamma correction pass 3. Combine both: samples is silently ignored on WebGL 1, so leave it set and add SMAA only as the fallback:

if(renderer.getPixelRatio() === 1 && !renderer.capabilities.isWebGL2)
{
    effectComposer.addPass(new SMAAPass())
}
  • To test the WebGL 1 path, temporarily swap WebGLRenderer for WebGL1Renderer

Custom ShaderPass

A pass shader is a plain object with uniforms, vertexShader, fragmentShader. The vertex shader is almost always the same screen-plane boilerplate plus a vUv varying.

const TintShader = {
    uniforms:
    {
        tDiffuse: { value: null },
        uTint: { value: null }
    },
    vertexShader: `
        varying vec2 vUv;
        void main()
        {
            gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
            vUv = uv;
        }
    `,
    fragmentShader: `
        uniform sampler2D tDiffuse;
        uniform vec3 uTint;
        varying vec2 vUv;
        void main()
        {
            vec4 color = texture2D(tDiffuse, vUv);
            color.rgb += uTint;
            gl_FragColor = color;
        }
    `
}

const tintPass = new ShaderPass(TintShader)
tintPass.material.uniforms.uTint.value = new THREE.Vector3()
effectComposer.addPass(tintPass)
  • tDiffuse is the magic uniform: the composer injects the previous pass's texture into it. Declare it with value: null and never set it yourself
  • Set uniform values on pass.material.uniforms.<name>.value AFTER creating the pass, never inside the shader object. The shader object is a template meant to be reused by multiple passes
  • Displacement effects sample tDiffuse at distorted coordinates instead of tinting: vec2 newUv = vec2(vUv.x, vUv.y + sin(vUv.x * 10.0 + uTime) * 0.1);. Drive uTime from the clock in the tick loop
  • A normal map texture can drive the displacement (texture2D(uNormalMap, vUv).xyz * 2.0 - 1.0, offset UV by its xy) for interface-style distortion, but a screen-fitted texture stretches on non-matching aspect ratios
  • For clean projects, move custom passes into their own files and shaders into .glsl files

R3F: @react-three/postprocessing

  • The pmndrs postprocessing library replaces "passes" with "effects" and merges them into as few shader passes as possible, preserving your order. Independent passes each doing their own depth or normal renders is exactly the performance problem it solves, so stacking many effects stays cheap
  • Install @react-three/postprocessing (which pulls in postprocessing), and add postprocessing itself as an explicit dependency since you import enums and base classes from it
  • Its EffectComposer shares a name with the vanilla one but is a different class. No RenderPass needed; the first render is handled for you
import { EffectComposer, ToneMapping, Bloom, Vignette } from '@react-three/postprocessing'
import { ToneMappingMode, BlendFunction, GlitchMode } from 'postprocessing'

<EffectComposer>
    <Vignette offset={ 0.3 } darkness={ 0.9 } />
    <Bloom luminanceThreshold={ 1.1 } mipmapBlur />
    <ToneMapping mode={ ToneMappingMode.ACES_FILMIC } />
</EffectComposer>
  • Colors look wrong the moment <EffectComposer> mounts: tone mapping is deactivated in the post-processing pipeline. Add <ToneMapping> yourself as the LAST child. Its default mode is AgX, which reads gray-ish; R3F's usual look is ToneMappingMode.ACES_FILMIC
  • The ordering logic: effects apply to linear, unaffected color, tone mapping tweaks the result at the end
  • multisampling on <EffectComposer> controls antialiasing (default 8, set 0 to disable for performance)
  • Reload the page after adding or tweaking an effect if results look stale
  • Docs are split across the postprocessing repo/docs/demo and the react-postprocessing repo; expect to dig in both

R3F effects worth knowing

  • <Vignette offset darkness>: darkens corners. It cannot darken a transparent background; attach a background color (<color args={['#ffffff']} attach="background" />) or the corners stay untouched
  • <Glitch delay duration strength mode>: ranges as [min, max] arrays, modes from GlitchMode (for example CONSTANT_MILD). Flash warning
  • <Noise premultiply blendFunction>: raw default is ugly; BlendFunction.SOFT_LIGHT, OVERLAY, SCREEN, or AVERAGE plus premultiply (multiplies noise by the input color before blending, darker but better integrated) makes it usable
  • <DepthOfField focusDistance focalLength bokehScale>: focusDistance is where the image is sharp, focalLength the distance to maximum blur, bokehScale the blur radius. The first two are NORMALIZED against camera near/far, not world units, so expect to tweak (for example 0.025 / 0.025 / 6)
  • Every effect accepts blendFunction, Photoshop-style layer blending from the BlendFunction enum. Default is NORMAL. Cycling through them in a debug UI (Leva) beats guessing

Bloom in R3F

  • <Bloom /> with defaults makes everything glow. Raise luminanceThreshold (default 0.9) so only intentionally bright things bloom
  • mipmapBlur is the good-looking version: downscaled renders combine into a bloom texture added to the base render, large sun-like glows without a performance hit. Use it
  • intensity scales the overall bloom (default 1)
  • Push materials over the threshold three ways:
    • Color channels above 1: <meshStandardMaterial color={ [ 4, 1, 2 ] } /> (array form, not a color string, escapes the 0 to 1 range)
    • Emissive: <meshStandardMaterial color="white" emissive="orange" emissiveIntensity={ 2 } />
    • meshBasicMaterial has no emissive; give its color channel values above 1 for a uniform glow
  • Bloom is the easiest effect to overdo. Be subtle

Custom effects (pmndrs postprocessing)

Two layers: an Effect subclass for postprocessing, then a thin React component exposing it.

The Effect class

import { BlendFunction, Effect } from 'postprocessing'
import { Uniform } from 'three'

const fragmentShader = /* glsl */`
    uniform float frequency;
    uniform float amplitude;
    uniform float time;

    void mainUv(inout vec2 uv)
    {
        uv.y += sin(uv.x * frequency + time) * amplitude;
    }

    void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor)
    {
        outputColor = vec4(0.8, 1.0, 0.5, inputColor.a);
    }
`

export default class DrunkEffect extends Effect
{
    constructor({ frequency, amplitude, blendFunction = BlendFunction.DARKEN })
    {
        super('DrunkEffect', fragmentShader, {
            blendFunction,
            uniforms: new Map([
                [ 'frequency', new Uniform(frequency) ],
                [ 'amplitude', new Uniform(amplitude) ],
                [ 'time', new Uniform(0) ]
            ])
        })
    }

    update(renderer, inputBuffer, deltaTime)
    {
        this.uniforms.get('time').value += deltaTime
    }
}

Rules the merged-shader architecture imposes:

  • The color function MUST be void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor). Renaming, omitting, or changing parameters breaks the merge. const in means read-only copy, out means write it to produce your result
  • inputColor is the running color from previous effects, uv the screen coordinates (0,0 bottom left to 1,1 top right). You cannot mutate inputColor; copy it (vec4 color = inputColor;) and write the copy to outputColor. Touch only .rgb unless you mean to change alpha
  • UV distortion goes in a separate void mainUv(inout vec2 uv) function (inout is read-write). The uv inside mainImage is read-only and meant for sampling other textures, not for warping the render
  • Uniforms go into super()'s option object as a Map of [ name, new Uniform(value) ], and are read back with this.uniforms.get('time').value
  • The update(renderer, inputBuffer, deltaTime) method runs every frame automatically; accumulate time with deltaTime, never a fixed per-frame increment, or speed depends on frame rate
  • Forward blendFunction into the options and give it a sensible default. Without one, an effect that outputs a flat color renders as a solid-color screen and looks like a bug to whoever mounts it bare
  • The /* glsl */ comment before the template string enables syntax highlighting with the es6-string-html VS Code extension

The React wrapper

import DrunkEffect from './DrunkEffect.jsx'

export default function Drunk(props)
{
    const effect = new DrunkEffect(props)
    return <primitive ref={ props.ref } object={ effect } />
}
  • Return the instance through <primitive object={...}> inside <EffectComposer>, keeping <ToneMapping> last
  • Since React 19, ref arrives inside props directly, no forwardRef needed; pass it to the <primitive> so consumers can reach the effect instance
  • Props spread cleanly from a Leva useControls object: <Drunk ref={ drunkRef } { ...drunkProps } />

Common mistakes

  • Forgetting the RenderPass, or adding effect passes before it
  • Not calling effectComposer.setSize and setPixelRatio on resize, giving a stretched low-resolution image
  • Missing the gamma correction pass, so the whole render is dark; or placing it before other effects instead of after them
  • Judging antialiasing with only the RenderPass active (canvas antialias still applies there) or on a high pixel ratio screen where aliasing is invisible
  • Relying on render target samples on WebGL 1, where it is silently ignored
  • Setting uniform values inside the shader template object instead of on pass.material.uniforms.<name>.value after creating the pass
  • In R3F, omitting <ToneMapping> (washed-out colors), placing it before other effects, or keeping the AgX default when the scene was tuned for ACES Filmic
  • Expecting Vignette to affect a transparent background without attaching a scene background color
  • Using luminanceThreshold defaults and wondering why everything blooms, or passing bright colors as strings instead of over-1 channel arrays
  • In a custom effect, changing the mainImage signature, mutating inputColor, warping the read-only uv instead of implementing mainUv, or animating with a fixed increment instead of deltaTime
  • Shipping a custom effect with no default blendFunction
  • Stacking passes freely in vanilla Three.js as if they were free: each is a full-frame render every frame

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-three-js-guide({ topic: "post-processing" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems