Tutorial · Intermediate · ~50 min
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.