Files
2026-03-28 13:57:54 +08:00

11 KiB

TSL Post-Processing

Post-processing applies effects to the rendered image. TSL provides both built-in effects and the ability to create custom effects.

Basic Setup

import * as THREE from 'three/webgpu';
import { pass } from 'three/tsl';

// Create renderer
const renderer = new THREE.WebGPURenderer();
await renderer.init();

// Create post-processing
const postProcessing = new THREE.PostProcessing(renderer);

// Create scene pass
const scenePass = pass(scene, camera);
const scenePassColor = scenePass.getTextureNode('output');

// Output (passthrough)
postProcessing.outputNode = scenePassColor;

// Render with post-processing
function animate() {
  postProcessing.render();  // Not renderer.render()
}

Built-in Effects

Bloom

import { bloom } from 'three/addons/tsl/display/BloomNode.js';

const scenePass = pass(scene, camera);
const scenePassColor = scenePass.getTextureNode('output');

// Add bloom
const bloomPass = bloom(scenePassColor);

// Configure
bloomPass.threshold.value = 0.5;   // Brightness threshold
bloomPass.strength.value = 1.0;    // Bloom intensity
bloomPass.radius.value = 0.5;      // Blur radius

// Combine original + bloom
postProcessing.outputNode = scenePassColor.add(bloomPass);

Gaussian Blur

import { gaussianBlur } from 'three/addons/tsl/display/GaussianBlurNode.js';

const blurred = gaussianBlur(scenePassColor, vec2(2.0)); // Blur strength
postProcessing.outputNode = blurred;

FXAA (Anti-aliasing)

import { fxaa } from 'three/addons/tsl/display/FXAANode.js';

postProcessing.outputNode = fxaa(scenePassColor);

SMAA (Anti-aliasing)

import { smaa } from 'three/addons/tsl/display/SMAANode.js';

postProcessing.outputNode = smaa(scenePassColor);

Depth of Field

import { dof } from 'three/addons/tsl/display/DepthOfFieldNode.js';

const scenePass = pass(scene, camera);
const colorNode = scenePass.getTextureNode('output');
const depthNode = scenePass.getTextureNode('depth');

const dofPass = dof(colorNode, depthNode, {
  focus: 5.0,      // Focus distance
  aperture: 0.025, // Aperture size
  maxblur: 0.01    // Maximum blur
});

postProcessing.outputNode = dofPass;

Motion Blur

import { motionBlur } from 'three/addons/tsl/display/MotionBlurNode.js';

const scenePass = pass(scene, camera);
const velocityPass = scenePass.getTextureNode('velocity');

const motionBlurPass = motionBlur(scenePassColor, velocityPass);
postProcessing.outputNode = motionBlurPass;

Screen Space Reflections (SSR)

import { ssr } from 'three/addons/tsl/display/SSRNode.js';

const scenePass = pass(scene, camera);
const colorNode = scenePass.getTextureNode('output');
const depthNode = scenePass.getTextureNode('depth');
const normalNode = scenePass.getTextureNode('normal');

const ssrPass = ssr(colorNode, depthNode, normalNode, camera);
postProcessing.outputNode = ssrPass;

Ambient Occlusion (SSAO)

import { ao } from 'three/addons/tsl/display/AmbientOcclusionNode.js';

const scenePass = pass(scene, camera);
const depthNode = scenePass.getTextureNode('depth');
const normalNode = scenePass.getTextureNode('normal');

const aoPass = ao(depthNode, normalNode, camera);
postProcessing.outputNode = scenePassColor.mul(aoPass);

Film Grain

import { film } from 'three/addons/tsl/display/FilmNode.js';

const filmPass = film(scenePassColor, {
  intensity: 0.5,
  grayscale: false
});
postProcessing.outputNode = filmPass;

Outline

import { outline } from 'three/addons/tsl/display/OutlineNode.js';

const outlinePass = outline(scene, camera, selectedObjects, {
  edgeStrength: 3.0,
  edgeGlow: 0.0,
  edgeThickness: 1.0,
  visibleEdgeColor: new THREE.Color(0xffffff),
  hiddenEdgeColor: new THREE.Color(0x190a05)
});

postProcessing.outputNode = scenePassColor.add(outlinePass);

Chromatic Aberration

import { chromaticAberration } from 'three/addons/tsl/display/ChromaticAberrationNode.js';

const caPass = chromaticAberration(scenePassColor, {
  offset: vec2(0.002, 0.002)
});
postProcessing.outputNode = caPass;

Color Adjustments

Grayscale

import { grayscale } from 'three/tsl';

postProcessing.outputNode = grayscale(scenePassColor);

Saturation

import { saturation } from 'three/tsl';

// 0 = grayscale, 1 = normal, 2 = oversaturated
postProcessing.outputNode = saturation(scenePassColor, 1.5);

Hue Shift

import { hue } from 'three/tsl';

// Shift hue by radians
postProcessing.outputNode = hue(scenePassColor, time.mul(0.5));

Vibrance

import { vibrance } from 'three/tsl';

postProcessing.outputNode = vibrance(scenePassColor, 0.5);

Posterize

import { posterize } from 'three/tsl';

// Reduce color levels
postProcessing.outputNode = posterize(scenePassColor, 8);

Sepia

import { sepia } from 'three/addons/tsl/display/SepiaNode.js';

postProcessing.outputNode = sepia(scenePassColor);

3D LUT

import { lut3D } from 'three/addons/tsl/display/Lut3DNode.js';

const lutTexture = new THREE.Data3DTexture(lutData, size, size, size);
postProcessing.outputNode = lut3D(scenePassColor, lutTexture, size);

Custom Post-Processing

Basic Custom Effect

import { Fn, screenUV, float, vec4 } from 'three/tsl';

const customEffect = Fn(() => {
  const color = scenePassColor.toVar();

  // Invert colors
  color.rgb.assign(float(1.0).sub(color.rgb));

  return color;
});

postProcessing.outputNode = customEffect();

Vignette Effect

const vignette = Fn(() => {
  const color = scenePassColor.toVar();

  // Distance from center
  const uv = screenUV;
  const dist = uv.sub(0.5).length();

  // Vignette falloff
  const vignette = float(1.0).sub(dist.mul(1.5)).clamp(0, 1);

  color.rgb.mulAssign(vignette);
  return color;
});

postProcessing.outputNode = vignette();

CRT/Scanline Effect

import { viewportSharedTexture } from 'three/tsl';

const crtEffect = Fn(() => {
  const uv = screenUV;

  // Sample scene at offset UVs for RGB separation (chromatic aberration)
  const uvR = uv.add(vec2(0.002, 0));
  const uvG = uv;
  const uvB = uv.sub(vec2(0.002, 0));

  // Use viewportSharedTexture to sample at different UV coordinates
  const r = viewportSharedTexture(uvR).r;
  const g = viewportSharedTexture(uvG).g;
  const b = viewportSharedTexture(uvB).b;

  const color = vec4(r, g, b, 1.0).toVar();

  // Scanlines
  const scanline = uv.y.mul(screenSize.y).mul(0.5).sin().mul(0.1).add(0.9);
  color.rgb.mulAssign(scanline);

  // Vignette
  const dist = uv.sub(0.5).length();
  color.rgb.mulAssign(float(1.0).sub(dist.mul(0.5)));

  return color;
});

// Note: For this effect, apply after scene rendering
postProcessing.outputNode = crtEffect();

Pixelate Effect

const pixelSize = uniform(8.0);

const pixelate = Fn(() => {
  const uv = screenUV;
  const pixelUV = uv.mul(screenSize).div(pixelSize).floor().mul(pixelSize).div(screenSize);
  return texture(scenePassColor, pixelUV);
});

postProcessing.outputNode = pixelate();

Edge Detection (Sobel)

const sobelEdge = Fn(() => {
  const uv = screenUV;
  const texelSize = vec2(1.0).div(screenSize);

  // Sample 3x3 kernel
  const tl = luminance(texture(scenePassColor, uv.add(texelSize.mul(vec2(-1, -1)))));
  const tc = luminance(texture(scenePassColor, uv.add(texelSize.mul(vec2(0, -1)))));
  const tr = luminance(texture(scenePassColor, uv.add(texelSize.mul(vec2(1, -1)))));
  const ml = luminance(texture(scenePassColor, uv.add(texelSize.mul(vec2(-1, 0)))));
  const mr = luminance(texture(scenePassColor, uv.add(texelSize.mul(vec2(1, 0)))));
  const bl = luminance(texture(scenePassColor, uv.add(texelSize.mul(vec2(-1, 1)))));
  const bc = luminance(texture(scenePassColor, uv.add(texelSize.mul(vec2(0, 1)))));
  const br = luminance(texture(scenePassColor, uv.add(texelSize.mul(vec2(1, 1)))));

  // Sobel operators
  const gx = tl.add(ml.mul(2)).add(bl).sub(tr).sub(mr.mul(2)).sub(br);
  const gy = tl.add(tc.mul(2)).add(tr).sub(bl).sub(bc.mul(2)).sub(br);

  const edge = sqrt(gx.mul(gx).add(gy.mul(gy)));

  return vec4(vec3(edge), 1.0);
});

postProcessing.outputNode = sobelEdge();

Multiple Render Targets (MRT)

Access multiple buffers from the scene pass:

import { mrt, output } from 'three/tsl';

const scenePass = pass(scene, camera);

// Set up MRT
scenePass.setMRT(mrt({
  output: output,           // Color output
  normal: normalView,       // View-space normals
  depth: depth              // Depth buffer
}));

// Access individual targets
const colorTexture = scenePass.getTextureNode('output');
const normalTexture = scenePass.getTextureNode('normal');
const depthTexture = scenePass.getTextureNode('depth');

Selective Bloom with MRT

Bloom only emissive objects by rendering emissive to a separate target:

import { pass, mrt, output, emissive } from 'three/tsl';
import { bloom } from 'three/addons/tsl/display/BloomNode.js';

const postProcessing = new THREE.PostProcessing(renderer);
const scenePass = pass(scene, camera);

// Render both color and emissive to separate targets
scenePass.setMRT(mrt({
  output: output,
  emissive: emissive
}));

// Get the texture nodes
const colorTexture = scenePass.getTextureNode('output');
const emissiveTexture = scenePass.getTextureNode('emissive');

// Apply bloom only to emissive
const bloomPass = bloom(emissiveTexture);
bloomPass.threshold.value = 0.0;  // Bloom all emissive
bloomPass.strength.value = 1.5;
bloomPass.radius.value = 0.5;

// Combine: original color + bloomed emissive
postProcessing.outputNode = colorTexture.add(bloomPass);

This approach prevents non-emissive bright areas (like white surfaces) from blooming.

Chaining Effects

const scenePass = pass(scene, camera);
const color = scenePass.getTextureNode('output');

// Chain multiple effects
let output = color;

// 1. Apply bloom
const bloomPass = bloom(output);
output = output.add(bloomPass.mul(0.5));

// 2. Apply color grading
output = saturation(output, 1.2);

// 3. Apply vignette
const dist = screenUV.sub(0.5).length();
const vignette = float(1.0).sub(dist.mul(0.5));
output = output.mul(vignette);

// 4. Apply FXAA
output = fxaa(output);

postProcessing.outputNode = output;

Conditional Effects

const effectEnabled = uniform(true);

const conditionalEffect = Fn(() => {
  const color = scenePassColor;
  return select(effectEnabled, grayscale(color), color);
});

postProcessing.outputNode = conditionalEffect();

// Toggle at runtime
effectEnabled.value = false;

Transitions

import { transition } from 'three/addons/tsl/display/TransitionNode.js';

const scenePassA = pass(sceneA, camera);
const scenePassB = pass(sceneB, camera);

const transitionProgress = uniform(0);

const transitionPass = transition(
  scenePassA.getTextureNode('output'),
  scenePassB.getTextureNode('output'),
  transitionProgress,
  texture(transitionTexture)  // Optional transition texture
);

postProcessing.outputNode = transitionPass;

// Animate transition
function animate() {
  transitionProgress.value = Math.sin(time) * 0.5 + 0.5;
  postProcessing.render();
}