43
←→

Post-processing in React Three Fiber with TSL and WebGPU

How to set up post-processing in React Three Fiber on the WebGPURenderer: Three's PostProcessing class, a reusable Effects component, custom vignette and grain effects written in TSL, and stacking them with bloom.

Loading...
←How to build a flow field shader in Three.js with TSLWebGPU vs WebGL for creative coding→
←→

Ready to start learning?

What's included in the course ↓

Access to the course: master shader techniques, use workflow-enhancing utilities, learn from shader breakdowns with full code, and get downloadable R3F and vanilla projects.

One single payment. No subscription required. 30-day money-back guarantee. No questions asked.

Not sure which to pick? Most people choose Pro for the full collection and all future updates. Fundamentals is a focused, lower-cost starting point you can upgrade from any time.

“Knowing shaders is your unreasonable advantage in UI. Ben has worked super hard on this course, recommended!”
Joshua Crowley - Designer and Educator
Loading...

Fragments

Learn creative coding with shaders. For design engineers, creative coders and shader artists: techniques, tools, deep dives. Powered by ThreeJS and TSL.

New techniques, breakdowns and shader experiments — straight to your inbox.

2026 Phobon

phobon.ioartifice.shshadercraft

Pages

HomeTechniquesUtilitiesBreakdownsWorksWriting

Contact

X @thenoumenonhey@fragments.supplyOKAY DEV @phobon
All rights reserved.
1st anniversary sale: 33% off until Sept 17
Curriculum
Works171
Writing45
1st anniversary sale: 33% off until Sept 17

Post-processing is where a shader gets its finish

I wrote a while ago about entering my post-processing era. That was the moment I realised almost none of my pieces felt done until they'd been through a grain pass, a vignette, maybe some chromatic aberration. The scene is the drawing. Post-processing is the print.

If you're building in React Three Fiber on WebGPU, the way you do this has changed. @react-three/postprocessing and the postprocessing library it wraps are built around WebGL's EffectComposer. On the WebGPURenderer the equivalent is Three's own PostProcessing class, driven by TSL nodes. This is the R3F setup I use, and the shape an effect needs so it can stack as a pass.

The mental model

With TSL you don't configure a chain of passes and their render targets one by one. There's a single outputNode. Start from the rendered scene as a texture, transform it however you like, and hand that graph to PostProcessing. Three works out the intermediate targets.

The minimum vanilla version
import { PostProcessing } from 'three/webgpu'
import { pass } from 'three/tsl'
 
const postProcessing = new PostProcessing(renderer)
 
// Render the scene to a texture we can read from
const scenePass = pass(scene, camera)
const sceneTexture = scenePass.getTextureNode('output')
 
// Any TSL expression over sceneTexture is now your post-processing pipeline
postProcessing.outputNode = sceneTexture
 
// In the loop, instead of renderer.render(scene, camera):
postProcessing.render()

That's a no-op pipeline. The interesting part is whatever you do to sceneTexture before you assign it. You're working with a texture you can sample, not a colour.

Three shapes of function

In Fragments, three kinds of function all get called "effects". They aren't interchangeable.

Patterns are vec2 → float. Give them a UV, get a mask back. vignettePattern, grainTexturePattern, ledPattern. You can use them inside a material's colorNode, on a flat sketch, in a compute shader. Anywhere.

Adjustments are vec3 → vec3. Colour in, colour out. brightness, contrast, saturation, hueRotate. The image adjustments set. They don't care where the colour came from.

Effects are the post-processing shape. They take a texture and a UV, and return a vec4:

The effect signature
import { Fn, uv } from 'three/tsl'
 
export const someEffect = Fn((props) => {
  const { input, inputUV = uv, ...params } = props || {}
  const _uv = inputUV().toVar()
  // ...
  return input.sample(_uv) // vec4
})

input is a texture node, not a colour. That's the whole point. A distortion effect needs to read the scene at a different UV than the one it's shading. Bulge, swirl, chromatic aberration all work by remapping the coordinate you sample at. A vec3 → vec3 function can't do that, because by the time you have a colour the sampling has already happened. Effects sit on top of patterns and adjustments and turn them into passes.

Wiring it into React Three Fiber

R3F v9 supports the WebGPU renderer through the gl prop, which is what the WebGPU Scene utility wraps. For post-processing, put a component inside the Canvas that grabs gl, scene and camera, builds a PostProcessing once, and calls render() from a useFrame that replaces R3F's default render.

The trimmed shape of the Post-Processing Component in Fragments:

A PostProcessing component for R3F
import { useFrame, useThree } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
import { convertToTexture, mrt, output, pass, uniform } from 'three/tsl'
import * as THREE from 'three/webgpu'
 
type Layer = { effect: any; args?: Record<string, unknown> }
 
export const PostProcessing = ({ layers }: { layers: Layer[] }) => {
  const { gl, scene, camera } = useThree()
  const pipelineRef = useRef<THREE.PostProcessing | null>(null)
 
  useEffect(() => {
    const scenePass = pass(scene, camera)
    scenePass.setMRT(mrt({ output })) // add `emissive` here if your effects want it
 
    let node = scenePass.getTextureNode('output')
 
    layers.forEach((layer, i) => {
      // Numeric args become uniforms so slider changes don't recompile the shader
      const args: Record<string, unknown> = {}
      for (const [name, value] of Object.entries(layer.args ?? {})) {
        args[name] = typeof value === 'number' ? uniform(value) : value
      }
 
      const color = layer.effect({ input: node, ...args })
      // Every layer except the last is baked to a texture so the next one can .sample() it
      node = i === layers.length - 1 ? color : convertToTexture(color)
    })
 
    const pipeline = new THREE.PostProcessing(gl as any)
    pipeline.outputNode = node
    pipelineRef.current = pipeline
 
    return () => {
      pipelineRef.current = null
    }
  }, [gl, scene, camera, layers])
 
  // Priority 1 disables R3F's automatic render; we take over
  useFrame(() => {
    pipelineRef.current?.render()
  }, 1)
 
  return null
}

convertToTexture is what makes stacking work. Each effect wants a texture as input, so the previous effect's colour output has to be rendered to one first. Numeric args are wrapped in uniform() so a control panel can mutate them without a rebuild. The useFrame priority tells R3F you're handling rendering. Leave it at the default and you'll draw the scene twice.

The real component also sets texture wrap modes per layer, so a mirror distortion can MirroredRepeatWrapping its input. It keeps a map of the uniforms so value changes are pushed in place, and it handles the ping-pong render-target history that the feedback trails effect needs to sample the previous frame. Same skeleton though.

Writing an effect

Given the shape above, a vignette is a pattern wrapped in a sample:

vignetteEffect
import { Fn, pow, smoothstep, uv } from 'three/tsl'
import { sdSphere } from '@/tsl/utils/sdf/shapes'
 
export const vignetteEffect = Fn((props) => {
  const { input, inputUV = uv, smoothing = 0.25, exponent = 5 } = props || {}
 
  const _uv = inputUV().toVar()
  const centeredUV = _uv.sub(0.5).toVar()
  const vignette = smoothstep(smoothing, 1, sdSphere(centeredUV)).oneMinus()
  const vignetteMask = pow(vignette, exponent).toVar()
 
  const originalColor = input.sample(_uv)
  return originalColor.mul(vignetteMask)
})

Grain is the same idea. Sample the scene, add a per-pixel offset:

grainTextureEffect
import { dot, Fn, fract, sin, uv, vec2 } from 'three/tsl'
 
export const grainTextureEffect = Fn((props) => {
  const { input, inputUV = uv, intensity = 0.1, scale = 1.0 } = props || {}
 
  const _uv = inputUV().toVar()
  const grain = fract(sin(dot(_uv.mul(scale), vec2(12.9898, 78.233))).mul(43758.5453123)).toVar()
 
  const originalColor = input.sample(_uv)
  return originalColor.add(grain.sub(0.5).mul(intensity))
})

An adjustment wrapper is even thinner. Sample, run the vec3 → vec3 function, put the alpha back:

brightnessAdjustment
import { Fn, uv, vec4 } from 'three/tsl'
import { brightness } from '@/tsl/utils/color/adjustments/brightness'
 
export const brightnessAdjustment = Fn((props) => {
  const { input, inputUV = uv, amount = 1 } = props || {}
  const _uv = inputUV().toVar()
  const color = input.sample(_uv)
  return vec4(brightness(color.rgb, amount), color.a)
})

The one that justifies the whole shape is a distortion, where the coordinate changes before the sample:

bulgeEffect
import { Fn, float, uv, vec2 } from 'three/tsl'
import { bulgeDistortion } from '@/tsl/distortion/bulge_distortion'
 
export const bulgeEffect = Fn((props) => {
  const { input, inputUV = uv, strength = 0.5, radius = 0.3, power = 1.0, center = vec2(0.5) } = props || {}
 
  const _uv = inputUV().toVar()
  const distortedUV = bulgeDistortion(_uv, { strength: float(strength), radius: float(radius), power: float(power), center })
 
  return input.sample(distortedUV)
})

None of these declare a uniform, touch a render target or know what a pass is. Defaults are plain numbers. When the component hands in a uniform() instead, the same code runs. The inputUV parameter is there so a caller can pre-remap the UV before the effect gets it. That's how the kaleidoscope and mirror effects compose with others.

Stacking effects

With the component above, a look is an ordered array:

A look
import { bulgeEffect } from '@/tsl/post_processing/bulge_distortion_effect'
import { grainTextureEffect } from '@/tsl/post_processing/grain_texture_effect'
import { vignetteEffect } from '@/tsl/post_processing/vignette_effect'
 
const layers = [
  { effect: bulgeEffect, args: { strength: 0.15, radius: 0.6 } },
  { effect: vignetteEffect, args: { smoothing: 0.3, exponent: 3 } },
  { effect: grainTextureEffect, args: { intensity: 0.06 } },
]
 
<PostProcessing layers={layers} />

Order matters. Distortions first, because they move pixels and you want everything after them to be sharp. Grain last, so it sits on the image rather than getting smeared by anything after it. Vignette anywhere in between.

Define layers outside the component or useMemo it. A fresh array each render rebuilds the pipeline.

The effects in Fragments all follow this exact shape, so they stack freely: LED, CRT scanlines, dither, halftone, chromatic aberration, pixellation, the blurs, the distortions, and the image adjustments. Each page has the TSL source and a live sketch.

Things that bit me

  • Sampling vs. colour. If an effect's input is a colour node rather than a texture node, .sample() doesn't exist and you get a confusing error. That's what convertToTexture between layers is for.
  • Colour space. The scene pass output is linear. Washed-out results after post usually mean a double conversion or none. The Color Space Correction utility is the fix I reach for.
  • Resolution. uv() inside an effect is screen space, so grain and dither scale with the canvas. Multiply by a resolution uniform if you want fixed-size texels.
  • Rebuilding. Changing an effect's parameters should mutate uniform() values, not rebuild outputNode. Rebuilding recompiles the shader and you'll see a hitch.
  • Emissive-driven bloom. Bloom looks far better reading an emissive channel than thresholding the whole image. Add emissive to the mrt() and pass scenePass.getTextureNode('emissive') into the effect's args.

Why bother, when the WebGL stack works?

The ergonomics. In the old stack a custom effect meant a class, a GLSL string, a uniform declaration block and a pass registration. Here it's a function with a known signature. I write a pattern once, wrap it in an effect in six lines, test it on a flat sketch, and drop it in front of a full scene without changing anything. Pattern, then adjustment, then effect. That's why the Fragments post-processing collection could grow to forty-odd effects without turning into a mess.

The post-processing era post is the companion if you want the why of each effect. What grain does to perception, why dither reads as tactile. If you're still setting up, the boilerplate has the component wired already.

ProMost popularFull access to the entire Fragments collection. Includes all techniques, utilities, breakdowns and all future updates
  • ✓ 12 long-form technique lessons
  • ✓ 7 fundamentals lessons
  • ✓ 60 workflow enhancing utilities
  • ✓ 171+ full shader breakdowns
  • ✓ Downloadable R3F and Vanilla projects
  • ✓ Access to community Discord
Get Pro →$199$133USD
FundamentalsAccess to foundational shader techniques and utilities
  • ✓ 5 foundational long-form technique lessons
  • ✓ 7 fundamentals lessons
  • ✓ 24 foundational workflow enhancing utilities
  • ✓ 71 full shader breakdowns
  • ✓ Downloadable R3F and Vanilla projects
  • ✓ Access to community Discord
Get Fundamentals →$99$66USD
You'll be redirected to our secured payment platform and get instant access.