Tutorial · Intermediate · ~50 min
Three.js · WebGLRenderTarget · GLSL · Post-processing

RenderTarget & Post-processing in Three.js

Post-processing renders the scene to a texture, then applies full-screen shader passes to add bloom, depth of field, vignette, or any custom effect. This tutorial builds the pipeline from scratch using WebGLRenderTarget and a fullscreen quad — no external post-processing libraries needed.

1Render scene to a texture

import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.160/build/three.module.js'; function makeRT(w, h) { return new THREE.WebGLRenderTarget(w, h, { minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter, format: THREE.RGBAFormat, type: THREE.HalfFloatType, // HDR range for bloom }); } let rt = makeRT(innerWidth, innerHeight); // In render loop: renderer.setRenderTarget(rt); // render TO texture renderer.render(scene, camera); renderer.setRenderTarget(null); // reset to screen framebuffer // rt.texture is now the scene as sampled image
Use HalfFloatType render targets for HDR bloom — values above 1.0 encode over-bright areas. Regular UnsignedByteType clamps everything to [0,1] and loses the bloom signal.

2Fullscreen quad setup

// A plane covering the entire NDC cube const quadGeo = new THREE.PlaneGeometry(2, 2); const quadCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); const quadScene = new THREE.Scene(); // The post-process material reads the scene texture const postMat = new THREE.ShaderMaterial({ uniforms: { u_tex: { value: null }, u_res: { value: new THREE.Vector2(innerWidth, innerHeight) }, u_time: { value: 0 }, }, vertexShader: ` varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position, 1.0); } `, fragmentShader: ` uniform sampler2D u_tex; varying vec2 vUv; void main() { gl_FragColor = texture2D(u_tex, vUv); } `, }); const quad = new THREE.Mesh(quadGeo, postMat); quadScene.add(quad);

3Bloom: threshold + Gaussian blur

// Pass 1: extract bright regions above threshold const brightFrag = /* glsl */ ` uniform sampler2D u_tex; uniform float u_threshold; varying vec2 vUv; void main() { vec3 col = texture2D(u_tex, vUv).rgb; float lum = dot(col, vec3(0.2126, 0.7152, 0.0722)); gl_FragColor = vec4(lum > u_threshold ? col : vec3(0.0), 1.0); } `; // Pass 2: 9-tap Gaussian blur (horizontal) const blurHFrag = /* glsl */ ` uniform sampler2D u_tex; uniform vec2 u_res; varying vec2 vUv; const float W[5] = float[](0.2270, 0.1945, 0.1216, 0.0540, 0.0162); void main() { vec2 px = vec2(1.0 / u_res.x, 0.0); vec3 col = texture2D(u_tex, vUv).rgb * W[0]; for (int i = 1; i < 5; i++) { col += texture2D(u_tex, vUv + float(i) * px).rgb * W[i]; col += texture2D(u_tex, vUv - float(i) * px).rgb * W[i]; } gl_FragColor = vec4(col, 1.0); } `; // Vertical blur is identical with py = vec2(0.0, 1.0/u_res.y) // Pass 3: composite: original + bloom const compositeFrag = /* glsl */ ` uniform sampler2D u_scene; uniform sampler2D u_bloom; uniform float u_strength; varying vec2 vUv; void main() { vec3 scene = texture2D(u_scene, vUv).rgb; vec3 bloom = texture2D(u_bloom, vUv).rgb; gl_FragColor = vec4(scene + bloom * u_strength, 1.0); } `;

4Chromatic aberration

const chromaFrag = /* glsl */ ` uniform sampler2D u_tex; uniform float u_amount; // e.g. 0.003 varying vec2 vUv; void main() { vec2 dir = vUv - 0.5; // direction from centre float dist = length(dir); // Sample R, G, B channels at slightly different positions float r = texture2D(u_tex, vUv + dir * u_amount * dist).r; float g = texture2D(u_tex, vUv).g; float b = texture2D(u_tex, vUv - dir * u_amount * dist).b; gl_FragColor = vec4(r, g, b, 1.0); } `;

5Vignette and scanlines

const vigFrag = /* glsl */ ` uniform sampler2D u_tex; uniform float u_time; varying vec2 vUv; void main() { vec3 col = texture2D(u_tex, vUv).rgb; // Vignette 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); // CRT scanlines float scan = sin(vUv.y * 800.0) * 0.04; // Film grain float noise = fract(sin(dot(vUv, vec2(12.9898, 78.233)) + u_time) * 43758.5453); noise = (noise - 0.5) * 0.04; col = col * vig - scan + noise; gl_FragColor = vec4(col, 1.0); } `;

6Multi-pass pipeline

// Helper to run a shader pass: src texture → dst RenderTarget (or screen) function pass(shader, uniforms, srcTex, dst) { postMat.fragmentShader = shader; postMat.uniforms = { u_tex: { value: srcTex }, ...uniforms }; postMat.needsUpdate = true; renderer.setRenderTarget(dst ?? null); renderer.render(quadScene, quadCam); } const brightRT = makeRT(innerWidth / 2, innerHeight / 2); // half-res for bloom const blurH = makeRT(innerWidth / 2, innerHeight / 2); const blurV = makeRT(innerWidth / 2, innerHeight / 2); renderer.setAnimationLoop(t => { // 1. Render scene to HDR RT renderer.setRenderTarget(rt); renderer.render(scene, camera); // 2. Extract bright areas (half resolution) pass(brightFrag, { u_threshold: { value: 0.85 } }, rt.texture, brightRT); // 3. Horizontal blur pass(blurHFrag, { u_res: { value: new THREE.Vector2(brightRT.width, brightRT.height) } }, brightRT.texture, blurH); // 4. Vertical blur pass(blurVFrag, { u_res: { value: new THREE.Vector2(blurH.width, blurH.height) } }, blurH.texture, blurV); // 5. Composite + tone map to screen postMat.fragmentShader = compositeFrag; postMat.uniforms = { u_scene: { value: rt.texture }, u_bloom: { value: blurV.texture }, u_strength: { value: 1.5 }, }; postMat.needsUpdate = true; renderer.setRenderTarget(null); renderer.render(quadScene, quadCam); });
The bloom pass runs at half resolution to save bandwidth. This is a simple 4:1 downsample — for higher quality use a mobile Kawase or dual Kawase blur which gives Gaussian results at fewer samples.