../

Shaders, post-processing & WebGPU

Custom rendering in Three.js r18x: ShaderMaterial and RawShaderMaterial with typed uniforms, the built-ins three injects, patching stock materials with onBeforeCompile, EffectComposer post-processing and why it ends with OutputPass, render targets, then WebGPURenderer from three/webgpu: node materials, TSL, the RenderPipeline post stack and compute. Raw APIs underneath: WebGL, WebGPU; scene setup in Fundamentals.

Two renderers, two shader paths

WebGLRenderer (three)WebGPURenderer (three/webgpu)
BackendWebGL2WebGPU, falls back to WebGL2 automatically
Custom shadersGLSL: ShaderMaterial, RawShaderMaterial, onBeforeCompileTSL node graphs → WGSL or GLSL
Stock materialsMeshStandardMaterial …same classes converted internally, or *NodeMaterial
Post-processingEffectComposer + passes (addons)RenderPipeline + TSL nodes
Computenonerenderer.compute(node) with TSL
Initsynchronousasync: await renderer.init()
Maturitystable, most examples and librariesproduction-usable, API still moving between releases

GLSL materials do not run on WebGPURenderer, and TSL node materials need WebGPURenderer. Pick one path per project.

ShaderMaterial vs Raw

ShaderMaterialRawShaderMaterial
Prefix injected#version 300 es, precision, matrices, position/normal/uv, definesonly #version handling
gl_FragColor, texture2Dwork (macro-mapped to GLSL ES 3.00)write your own out vec4
#include <chunk>yesyes, but you declare what chunks need
Tone mapping / color spaceonly if you include the chunksnever
Fog, lights, shadowsopt-in (fog: true, lights: true + UniformsLib)manual
Use formost custom effectsfull control, porting existing shaders

Set glslVersion: GLSL3 to write GLSL ES 3.00 (in/out, your own fragment output) in either class.

import { Color, ShaderMaterial } from "three";
import type { IUniform } from "three";
 
type WaveUniforms = {
  uTime: IUniform<number>;
  uColor: IUniform<Color>;
};
const uniforms: WaveUniforms = {
  uTime: { value: 0 },
  uColor: { value: new Color("#4f8cff") },
};
 
const mat = new ShaderMaterial({
  uniforms,
  vertexShader: VERT,
  fragmentShader: FRAG,
  transparent: false,
});
// per frame: typed, no string lookups
uniforms.uTime.value = timer.getElapsed();
wave.vert
uniform float uTime;
varying vec2 vUv;
varying float vH;
 
void main() {
  vUv = uv;
  vec3 p = position;
  p.z += sin(p.x * 4.0 + uTime) * 0.1;
  vH = p.z;
  gl_Position = projectionMatrix * modelViewMatrix
    * vec4(p, 1.0);
}
wave.frag
uniform vec3 uColor;
varying vec2 vUv;
varying float vH;
 
void main() {
  vec3 c = uColor * (0.7 + vH * 3.0);
  gl_FragColor = vec4(c, 1.0);
  #include <tonemapping_fragment>
  #include <colorspace_fragment>
}

Without the last two includes a ShaderMaterial writes linear values straight to the sRGB canvas and looks dark and saturated compared with stock materials.

Built-in uniforms & attributes

ShaderMaterial (not RawShaderMaterial) prepends these declarations:

NameTypeStageMeaning
modelMatrixmat4vertexobject → world
modelViewMatrixmat4vertexobject → camera
projectionMatrixmat4vertexcamera → clip
viewMatrixmat4bothworld → camera
normalMatrixmat3vertexobject normals → camera space
cameraPositionvec3bothcamera in world space
isOrthographicboolbothcamera type
positionvec3 attributevertex
normalvec3 attributevertex
uvvec2 attributevertexextra sets (uv1…) and color appear when used

Uniform value types map to GLSL as: number → float/int, Vector2/3/4 → vec2/3/4, Color → vec3, Matrix3/4 → mat3/4, Texture → sampler2D, CubeTexture → samplerCube, arrays → arrays. Instanced meshes add instanceMatrix (USE_INSTANCING); include <begin_vertex> / <project_vertex> chunks or multiply by it yourself.

Useful material options

OptionNotes
uniforms{ name: { value } }; mutate .value, never replace the object
defines{ COUNT: 4 } → #define COUNT 4; changing them needs needsUpdate = true
glslVersionnull (GLSL 1 style, auto-mapped) or GLSL3
lightstrue to receive three's light uniforms (merge UniformsLib.lights)
fogtrue + fog chunks
transparent, depthWrite, blending, sideas for any material
wireframe, clippingsupported
uniformsGroupsUBOs (UniformsGroup) shared across materials

Load .glsl files with your bundler (vite-plugin-glsl, or import src from "./a.frag?raw" in Vite) and declare the module type: declare module "*.frag?raw" { const s: string; export default s; }.

Patching stock materials

onBeforeCompile edits the generated GLSL of a built-in material, keeping all of its PBR, shadows and fog. You replace #include <chunk> markers with your own code.

const uTime = { value: 0 };               // shared ref
const mat = new MeshStandardMaterial({ color: "teal" });
 
mat.onBeforeCompile = (shader) => {
  shader.uniforms.uTime = uTime;
  shader.vertexShader = shader.vertexShader
    .replace("#include <common>", /* glsl */ `
      #include <common>
      uniform float uTime;`)
    .replace("#include <begin_vertex>", /* glsl */ `
      #include <begin_vertex>
      transformed.y += sin(position.x * 3.0 + uTime)
        * 0.2;`);
};
// distinct cache key if variants differ
mat.customProgramCacheKey = () => "wave-v1";
ChunkContains / use to hook
commonhelpers and constants; add declarations after it
begin_vertexvec3 transformed = vec3(position); edit vertex positions after it
beginnormal_vertexobjectNormal; fix normals after displacing
project_vertexmvPosition, gl_Position
worldpos_vertexworldPosition (shadows, env)
color_fragment / map_fragmentdiffuseColor: tint or replace base color
emissivemap_fragmenttotalEmissiveRadiance
normal_fragment_mapsperturb normal
opaque_fragmentwrites gl_FragColor from outgoingLight
tonemapping_fragment, colorspace_fragmentoutput transforms
fog_fragment, dithering_fragmentlast steps

Read a material's full source in THREE.ShaderLib.standard.vertexShader/fragmentShader or in src/renderers/shaders/ShaderChunk/. Chunk names change occasionally between releases (e.g. encodings_fragment became colorspace_fragment, output_fragment became opaque_fragment), so pin your three version. Libraries like three-custom-shader-material wrap this pattern.

EffectComposer (WebGL)

 RenderPass ─► rtA ─► UnrealBloomPass ─► rtB ─► … ─► OutputPass ─► canvas
 (linear HDR, HalfFloat targets)                    tone map + sRGB
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 { OutputPass } from
  "three/addons/postprocessing/OutputPass.js";
 
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
composer.addPass(new UnrealBloomPass(
  new Vector2(innerWidth, innerHeight),
  0.8,   // strength
  0.4,   // radius
  0.85,  // threshold (luminance)
));
composer.addPass(new OutputPass());
 
renderer.setAnimationLoop(() => composer.render());
// on resize: composer.setSize(w, h)
// and composer.setPixelRatio(renderer.getPixelRatio())

Why OutputPass. Passes work in linear HDR render targets; tone mapping and the sRGB conversion normally happen when three draws to the canvas. With a composer the last pass must do that work itself: OutputPass applies renderer.toneMapping, toneMappingExposure and outputColorSpace. Without it the image is too dark and highlights clip. It replaces the old ShaderPass(GammaCorrectionShader). Put FXAAPass after OutputPass (it expects sRGB input).

Pass (addons)Effect
RenderPassdraws the scene; first pass
UnrealBloomPassHDR bloom
OutputPasstone mapping + color space; last before AA
FXAAPass, SMAAPass, TAARenderPass, SSAARenderPassanti-aliasing (composer bypasses canvas MSAA)
GTAOPass, SAOPass, SSAOPassambient occlusion
SSRPassscreen-space reflections
OutlinePassselection outlines
BokehPassdepth of field
FilmPass, GlitchPass, AfterimagePass, DotScreenPass, HalftonePassstylised
LUTPasscolor grading with a 3D LUT
RenderPixelatedPasspixel-art look
ShaderPass(shader)your full-screen GLSL (tDiffuse is the input)

Anti-aliasing: canvas antialias does not apply to the composer's targets. Pass a WebGLRenderTarget with samples: 4 to new EffectComposer(renderer, rt), or add an AA pass. Alternatives: the postprocessing (opens in a new tab) library merges effects into fewer passes; r182+ also offers outputBufferType: HalfFloatType with renderer.setEffects([...]) for passes without a composer (no OutputPass needed there).

Render targets

const rt = new WebGLRenderTarget(512, 512, {
  samples: 4,              // MSAA inside the target
  type: HalfFloatType,     // HDR headroom
});
const screen = new Mesh(
  new PlaneGeometry(2, 1),
  new MeshBasicMaterial({ map: rt.texture }),
);
 
renderer.setAnimationLoop(() => {
  screen.visible = false;          // avoid feedback
  renderer.setRenderTarget(rt);
  renderer.render(monitorScene, monitorCam);
  renderer.setRenderTarget(null);  // back to canvas
  screen.visible = true;
  renderer.render(scene, camera);
});
APIUse
WebGLRenderTarget(w, h, opts)samples, type, format, depthBuffer, depthTexture, count (MRT)
rt.texturethe color output; linear data, no sRGB conversion applied
rt.depthTexture = new DepthTexture(w, h)sample depth later (soft particles, fog)
rt.setSize(w, h)on resize; also rebuilds MSAA buffers
WebGLCubeRenderTarget + CubeCameradynamic reflections / env maps
WebGL3DRenderTarget, WebGLArrayRenderTargetvolumes, layers
renderer.readRenderTargetPixelsAsync(rt, x, y, w, h, buf)GPU → CPU (picking, screenshots) without stalling
renderer.copyTextureToTextureblit regions
rt.dispose()free GPU memory

A mesh must not sample the target it is being rendered into: hide it for that pass or ping-pong between two targets.

WebGPURenderer

import * as THREE from "three/webgpu";
 
const renderer = new THREE.WebGPURenderer({
  antialias: true,
  // forceWebGL: true,  // test the WebGL2 backend
});
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
document.body.append(renderer.domElement);
 
await renderer.init();   // picks WebGPU or WebGL2
console.log(renderer.backend.isWebGPUBackend
  ? "WebGPU" : "WebGL2 fallback");
renderer.setAnimationLoop(() => {
  renderer.render(scene, camera);
});
RuleDetail
Import from three/webgpuit re-exports core (shared three.core.js since r171, so addons importing three still work)
TSL from three/tslFn, uniform, uv, time, color, …
Initialize firstrender() before init() throws; setAnimationLoop awaits init() itself
renderAsync(), clearAsync(), hasFeatureAsync()deprecated since r181: await init(), then use the sync versions
computeAsync()still fine when you need to await a compute pass
PCFSoftShadowMapremoved; PCFShadowMap with shadow.radius
Stock materialswork (converted to node materials)
ShaderMaterial, onBeforeCompile, EffectComposernot supported: port to TSL / RenderPipeline
Generated shaderawait renderer.debug.getShaderAsync(scene, camera, mesh)
ProfilingInspector addon (three/addons/inspector/Inspector.js), renderer.inspector = new Inspector()
Detect WebGPUnavigator.gpu / three/addons/capabilities/WebGPU.js; usually unnecessary thanks to the fallback

Node materials & TSL

TSL (Three Shading Language) builds shader graphs with JavaScript calls; the renderer compiles them to WGSL (WebGPU) or GLSL (WebGL2 fallback). You assign nodes to slots on *NodeMaterial classes.

SlotReplaces
colorNodebase color (vec3/vec4)
opacityNode, alphaTestNodealpha
positionNodelocal vertex position (displacement)
normalNodenormal
emissiveNode, roughnessNode, metalnessNode, aoNodePBR inputs
outputNodefinal color after lighting
fragmentNode, vertexNodewhole stage (unlit, full control)
castShadowNode, receivedShadowNode, depthNode, mrtNodeadvanced
import * as THREE from "three/webgpu";
import {
  color, mix, normalLocal, positionLocal, sin, time,
  uniform, uv,
} from "three/tsl";
 
const tint = uniform(new THREE.Color("#ff6a3d"));
const amp = uniform(0.05);
 
const mat = new THREE.MeshStandardNodeMaterial();
mat.colorNode = mix(color("#1b2a49"), tint, uv().y);
mat.positionNode = positionLocal.add(
  normalLocal.mul(
    sin(time.mul(2).add(positionLocal.y.mul(8))).mul(amp),
  ),
);
mat.roughnessNode = uv().x;  // 0..1 across the mesh
 
// runtime updates: no recompilation
tint.value.set("#3dffb0");
amp.value = 0.1;
TSL building blockExamples
Constants / constructorsfloat(1), int(2), vec2(), vec3(1, 0, 0), vec4(), color("#fff"), mat3()
Uniformsuniform(0.5), uniform(new Color()), uniform(new Vector3()); update .value
GeometrypositionLocal, positionWorld, normalLocal, normalWorld, normalView, uv(), attribute("name")
ScenecameraPosition, modelWorldMatrix, screenUV, instanceIndex
Timetime, deltaTime (seconds, updated per render)
Math (chainable).add(), .sub(), .mul(), .div(), sin, cos, mix, smoothstep, clamp, length, dot, normalize
Texturestexture(tex, uv()), texture(tex).sample(uv)
Noise / hashhash(seed), mx_noise_float(p) (MaterialX noise)
Variables.toVar(), .assign(), .addAssign(), varying(node)
Control flowIf(cond, () => {}), .ElseIf(), .Else(), Loop(n, ({ i }) => {}), select(c, a, b), Discard()
FunctionsFn(() => { … }) returns a callable node; Fn(([a, b]) => …) for parameters
import { Fn, If, float, uv, vec3, Discard } from "three/tsl";
 
// a reusable function: ring mask with discard
const ring = Fn(() => {
  const d = uv().sub(0.5).length();
  If(d.greaterThan(0.5), () => { Discard(); });
  const edge = d.smoothstep(0.35, 0.4).oneMinus()
    .mul(d.smoothstep(0.25, 0.3));
  return vec3(edge, edge.mul(0.6), float(0.2));
});
mat.colorNode = ring();

The TSL editor example (webgpu_tsl_editor) shows TSL next to the WGSL/GLSL it generates; the TSL docs page (opens in a new tab) lists every function.

WebGPU post-processing

PostProcessing was renamed RenderPipeline in r183 (the old name still works with a deprecation warning). Effects are TSL nodes combined into one outputNode.

import * as THREE from "three/webgpu";
import { pass } from "three/tsl";
import { bloom } from
  "three/addons/tsl/display/BloomNode.js";
 
const pipeline = new THREE.RenderPipeline(renderer);
const scenePass = pass(scene, camera);
const sceneColor = scenePass.getTextureNode("output");
const glow = bloom(sceneColor, 1.0, 0.4, 0.85);
pipeline.outputNode = sceneColor.add(glow);
 
renderer.setAnimationLoop(() => pipeline.render());
// tweak live: glow.strength.value = 1.5
PieceNotes
pass(scene, camera)renders the scene into textures; getTextureNode("output"), getTextureNode("depth")
mrt({ output, emissive, normal }) + scenePass.setMRT(...)multiple outputs (selective bloom, SSR, AO inputs)
pipeline.outputColorTransformtrue: tone mapping + sRGB applied at the end automatically
renderOutput(node)apply that transform yourself mid-chain (e.g. before FXAA) with outputColorTransform = false
Effect nodesthree/addons/tsl/display/*: bloom, fxaa, smaa, traa, ao (GTAO), ssr, dof, gaussianBlur, outline, film, lut3D, …
pipeline.needsUpdate = trueafter replacing outputNode

Check the addon file for each effect's exact function name and parameters; they are still evolving.

Compute with TSL

WebGPURenderer runs TSL compute shaders on storage buffers. On the WebGL2 fallback, compute is emulated with transform feedback, so workgroup-level features are unavailable; test both backends.

import * as THREE from "three/webgpu";
import {
  Fn, If, deltaTime, hash, instanceIndex, instancedArray,
  uniform, vec3,
} from "three/tsl";
 
const COUNT = 100_000;
const pos = instancedArray(COUNT, "vec3"); // GPU buffer
const vel = instancedArray(COUNT, "vec3");
const gravity = uniform(-9.8);
 
const init = Fn(() => {
  const p = pos.element(instanceIndex);
  p.assign(vec3(hash(instanceIndex).sub(0.5).mul(10),
    hash(instanceIndex.add(1)).mul(10),
    hash(instanceIndex.add(2)).sub(0.5).mul(10)));
})().compute(COUNT);
 
const step = Fn(() => {
  const p = pos.element(instanceIndex);
  const v = vel.element(instanceIndex);
  v.y.addAssign(gravity.mul(deltaTime));
  p.addAssign(v.mul(deltaTime));
  If(p.y.lessThan(0), () => {
    p.y.assign(0);
    v.y.assign(v.y.negate().mul(0.6)); // bounce
  });
})().compute(COUNT);

Draw the buffer without a CPU round trip: SpriteNodeMaterial with positionNode = pos.toAttribute(), and sprite.count = COUNT (see the recipe).

Recipes

Fresnel rim on a stock material

Use to add a rim glow to MeshStandardMaterial while keeping its lighting, shadows and fog (WebGL).

import { Color, MeshStandardMaterial } from "three";
 
export function withRim(
  mat: MeshStandardMaterial, rim = new Color("#6cf"),
  power = 2.5,
): MeshStandardMaterial {
  mat.onBeforeCompile = (s) => {
    s.uniforms.uRim = { value: rim };
    s.uniforms.uPow = { value: power };
    s.fragmentShader = s.fragmentShader
      .replace("#include <common>", `#include <common>
        uniform vec3 uRim; uniform float uPow;`)
      .replace("#include <emissivemap_fragment>",
        `#include <emissivemap_fragment>
        float f = pow(1.0 - saturate(dot(normal,
          normalize(vViewPosition))), uPow);
        totalEmissiveRadiance += uRim * f;`);
  };
  mat.customProgramCacheKey = () => `rim-${power}`;
  return mat;
}

normal and vViewPosition are view-space values the standard shader already defines; saturate comes from the common chunk.

Full-screen shader pass

Use for a custom post effect (vignette, grading) in an EffectComposer chain; place it before OutputPass.

import { ShaderPass } from
  "three/addons/postprocessing/ShaderPass.js";
 
const Vignette = {
  uniforms: {
    tDiffuse: { value: null },  // filled by the composer
    uStrength: { value: 0.5 },
  },
  vertexShader: /* glsl */ `
    varying vec2 vUv;
    void main() {
      vUv = uv;
      gl_Position = projectionMatrix * modelViewMatrix
        * vec4(position, 1.0);
    }`,
  fragmentShader: /* glsl */ `
    uniform sampler2D tDiffuse;
    uniform float uStrength;
    varying vec2 vUv;
    void main() {
      vec4 c = texture2D(tDiffuse, vUv);
      float d = distance(vUv, vec2(0.5));
      c.rgb *= 1.0 - smoothstep(0.3, 0.8, d) * uStrength;
      gl_FragColor = c;
    }`,
};
const vignette = new ShaderPass(Vignette);
composer.insertPass(vignette, composer.passes.length - 1);

WebGPU scene with TSL material

Use as the starting point for a WebGPU project: async init, fallback aware, animated node material.

import * as THREE from "three/webgpu";
import { color, mix, sin, time, uv } from "three/tsl";
 
const renderer = new THREE.WebGPURenderer({
  antialias: true,
});
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.toneMapping = THREE.AgXToneMapping;
document.body.append(renderer.domElement);
await renderer.init();
 
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
  50, innerWidth / innerHeight, 0.1, 50,
);
camera.position.set(0, 0, 3);
scene.add(new THREE.HemisphereLight("#fff", "#446", 2));
 
const mat = new THREE.MeshStandardNodeMaterial();
const pulse = sin(time.mul(3)).mul(0.5).add(0.5);
mat.colorNode = mix(color("#2244ff"), color("#ff4422"),
  uv().y.mul(pulse));
scene.add(new THREE.Mesh(
  new THREE.TorusKnotGeometry(0.6, 0.2, 128, 16), mat,
));
 
renderer.setAnimationLoop(() => {
  renderer.render(scene, camera);
});

GPU particles, compute to draw

Use for 100k+ particles simulated entirely on the GPU; builds on pos, init and step from the compute section.

import * as THREE from "three/webgpu";
import { color, uniform } from "three/tsl";
 
const mat = new THREE.SpriteNodeMaterial();
mat.positionNode = pos.toAttribute(); // per instance
mat.colorNode = color("#9cf");
mat.scaleNode = uniform(0.03);
mat.depthWrite = false;
 
const particles = new THREE.Sprite(mat);
particles.count = COUNT;        // instanced sprites
particles.frustumCulled = false; // bounds unknown
scene.add(particles);
 
await renderer.init();
renderer.compute(init);          // once
renderer.setAnimationLoop(() => {
  renderer.compute(step);        // simulate
  renderer.render(scene, camera);
});

Selective bloom in WebGPU

Use to make only emissive surfaces glow: render an emissive MRT output and bloom just that.

import * as THREE from "three/webgpu";
import { emissive, mrt, output, pass } from "three/tsl";
import { bloom } from
  "three/addons/tsl/display/BloomNode.js";
 
const scenePass = pass(scene, camera);
scenePass.setMRT(mrt({ output, emissive }));
const base = scenePass.getTextureNode("output");
const glowSrc = scenePass.getTextureNode("emissive");
 
const pipeline = new THREE.RenderPipeline(renderer);
pipeline.outputNode = base.add(bloom(glowSrc, 2, 0.5, 0));
renderer.setAnimationLoop(() => pipeline.render());
// give glowing meshes emissive + emissiveIntensity > 1

References