Command Palette

Search for a command to run...

Hectal
Course outline
Topic 15.2Advanced ~22 min · step 2 of 6

The Effect Chain

In one line

Post-processing draws the scene into a picture, then changes the picture with a chain of passes. On WebGL that is EffectComposer, and the chain must end with OutputPass once. On WebGPU a node pipeline does the same.

0/6 · 0%

Think of it like this

A photo studio. The photographer takes the raw photo (the render pass), the editor brightens a window (bloom), draws an outline round the product (outline pass), and last the printer converts everything to the paper's colours (output). If the printer's conversion is skipped the print is dark; if it is done twice the print is pale.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

post-processing
Changing the picture after the scene is drawn, with full-screen shader passes.
pass
One step of the chain: it reads the previous picture and writes a new one.
tone mapping
Squeezing very bright values into the range a screen can show, with a pleasant curve.
sRGB curve
The brightness curve screens expect. Linear values are encoded with it just before the screen.
node pipeline
WebGPU's way to build effects: small connected nodes (TSL) instead of a list of passes.

Step by step

01Draw the scene without any effect

The first example paints an orange half and a grey half with flat colours. renderer.render gives pixels 255, 105, 45 and 130, 130, 130 (rounded to steps of 5, because GPUs differ by a step or two).

02Leave out OutputPass

Render + FXAA with no OutputPass gives 255, 35, 5 and 55, 55, 55. The renderer's RenderPass wrote linear values into the target and FXAA copied them to the canvas without the sRGB curve: the colours are dark and over-saturated.

03Add OutputPass once

Render + Output + FXAA matches the direct render exactly: 255, 105, 45. OutputPass did the tone mapping (none here) and the sRGB curve, and FXAA works on finished colours.

Add OutputPass oncediagram
Rendering diagram…

04Add it twice

Two OutputPasses apply the curve twice: grey 130 becomes 190 and the orange turns pale, 255, 175, 115. This is the washed-out look. It often appears when a helper already adds an output step.

Try it yourself

  1. 1

    Switch the tone mapping on

    In the first example add renderer.toneMapping = THREE.ACESFilmicToneMapping; after setSize. The direct render and the chain with one OutputPass still agree, now at 245, 130, 55 for the orange: OutputPass reads the renderer's tone mapping. Without OutputPass it stays 255, 35, 5: nothing applied it.

  2. 2

    Let the status bar count the whole frame

    In the second example delete renderer.info.autoReset = false;. The Render + Output and Render + Bloom + Output lines now say draw calls: 1, triangles: 1: only the last full-screen draw is counted.

Code & diagrams

Missing, correct and doubled OutputPass New tab

Pixel values are rounded to steps of 5. The last line is the washed-out look.

idle

Console

Run the scene to see its console.

Expected console output

renderer.render (direct)         orange: 255, 105, 45   grey: 130, 130, 130
Render + FXAA (no OutputPass)    orange: 255, 35, 5     grey: 55, 55, 55
Render + Output + FXAA           orange: 255, 105, 45   grey: 130, 130, 130
Render + Output + Output + FXAA  orange: 255, 175, 115  grey: 190, 190, 190
What a chain adds to a frame New tab

info.autoReset is off so the numbers cover the whole frame, not only the last pass.

idle

Console

Run the scene to see its console.

Expected console output

renderer.render            draw calls:  3 | triangles:  1584 | textures on the GPU: 1
Render pass only           draw calls:  3 | triangles:  1584 | textures on the GPU: 1
Render + Output            draw calls:  4 | triangles:  1585 | textures on the GPU: 2
Render + Bloom + Output    draw calls: 17 | triangles:  1598 | textures on the GPU: 14
passes: RenderPass > UnrealBloomPass > OutputPass
The same idea on WebGPU: pass, bloom, RenderPipeline New tab

The output depends on the browser. With WebGPU it logs `backend: WebGPU`. Without, it warns `THREE.WebGPURenderer: WebGPU is not available, running under WebGL2 backend.` and logs `backend: WebGL 2 (fallback)`. Both then log `... applied by the pipeline itself: true | scene pass samples: 4`.

idle

Console

Run the scene to see its console.

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Two OutputPasses

In the first example the last chain already has two. Compare it with Render + Output + FXAA.

terminal
$ Run
── what you'll see ──
Render + Output + Output + FXAA orange: 255, 175, 115 grey: 190, 190, 190

Break #2

Chain without OutputPass

In the first example read the line Render + FXAA (no OutputPass).

terminal
$ Run
── what you'll see ──
Render + FXAA (no OutputPass) orange: 255, 35, 5 grey: 55, 55, 55

Myth vs fact

Myth

Post-processing is a free filter on top of the scene.

Fact

Each pass draws the whole screen again and keeps its own pictures in memory: bloom added 13 draw calls and 12 textures above.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    UnrealBloomPass is a special case in r186: as the last pass it draws its input to the canvas through a MeshBasicMaterial, so tone mapping and encoding apply. A chain of only RenderPass and bloom can look right without OutputPass, and breaks when you add a pass after it. Rely on the rule.

  • ▸

    The postprocessing library (6.39.5) is the other well-known choice.Its EffectComposer merges many effects into one shader pass, so five effects cost fewer full-screen draws than five three.js passes, and multisampling gives MSAA: new EffectComposer(renderer, { multisampling: 4 }), addPass(new RenderPass(...)), addPass(new EffectPass(camera, new BloomEffect())).@react-three/postprocessing wraps it for React Three Fiber (Phase 11 teaches R3F itself).

  • ▸

    The background is part of the picture. renderer.render clears to the background colour without tone mapping. Inside the chain the background is drawn into the linear target and OutputPass tone-maps it with everything else. A scene with scene.background = new THREE.Color("#0b1118") and NeutralToneMapping reads 11, 17, 24 drawn directly and 0, 8, 18 through the chain (measured in a scratch scene; ACESFilmicToneMapping gives 1, 3, 6). With no tone mapping both agree. When you add a chain, pick the backdrop colour with the chain on. The build's showroom shows it.

  • ▸

    Resize and pixel ratio. EffectComposer reads renderer.getPixelRatio() once, when it is created, and composer.setSize(w, h) multiplies by that remembered ratio. If the ratio changes later (a window dragged to another screen), call composer.setPixelRatio(renderer.getPixelRatio()) too, or the chain stays soft.

Remember this

  1. 1

    The problem this solves: the canvas shows one finished picture, but many looks (glow, outlines, smoothing edges) are made by processing the whole picture after it is drawn. You need a chain that passes a picture from step to step, and a rule for when colours are converted for the screen.

  2. 2

    The WebGL chain. new EffectComposer(renderer) owns two render targets (half-float by default) and a list of passes. composer.addPass(...) adds them in order. composer.render() replaces renderer.render(): each pass reads one target and writes the other, and the last pass writes to the canvas. Typical order: RenderPass (draw the scene), your effects, OutputPass.

  3. 3

    Colours stay linear until the end. Inside the chain the values are linear and can go above 1 (a bright lamp). OutputPass applies the renderer's toneMapping and the sRGB curve, once. Passes that work on the finished picture, like FXAA or SMAA, belong after it.

  4. 4

    What a chain costs. Every pass is at least one full-screen draw. The second example (renderer.info.autoReset = false) shows a frame of three spheres: 3 draw calls plain, 4 with OutputPass, 17 with bloom added, and the textures on the GPU go from 1 to 14. With the default autoReset = true the status bar shows only the last pass: draw calls: 1.

  5. 5

    What if the GPU cannot do half-float? The chain draws into half-float pictures, which WebGL 2 supports through EXT_color_buffer_float or EXT_color_buffer_half_float. If neither exists, keep renderer.render(): no glow, but the page works. The build checks this.

  6. 6

    The WebGPU way. There is no composer. pass(scene, camera) makes a node for the scene picture, bloom(...) and other nodes read it, and a RenderPipeline draws the final node. Tone mapping and colour space are added by the pipeline itself (outputColorTransform is true), so there is no OutputPass. The third example runs it; on a browser without WebGPU it runs on WebGL 2 and warns.

Explain it without notes

01

Why must OutputPass come before FXAA, and why only once?

02

A teammate adds bloom to a page and the colours look washed out. List three things to check.

Practice

01

List the passes, in order, for a chain with an outline, bloom and FXAA.

02

Write a function createChain(renderer, scene, camera) that returns a plain renderer call when the GPU cannot draw into half-float targets.

Done when you can

  • Build an EffectComposer chain: RenderPass, effects, OutputPass

  • Explain why colours go dark or pale when OutputPass is missing or doubled

  • Count what a chain adds to a frame with renderer.info

  • Name the WebGPU equivalent of a composer and of OutputPass