WebGPU Post-Processing
Post-processing means changing the whole image after the scene has been drawn.
Blur, glow, colour filters and old-film looks are all post-processing effects. They work like photo filters, but they run on the GPU every frame.
How It Works
Post-processing uses two passes:
- Scene pass: draw the scene into an offscreen texture instead of the canvas.
- Post pass: draw one triangle that covers the whole canvas. Its fragment shader reads the offscreen texture, changes the colour, and writes the result to the canvas.
Because the post pass runs once per pixel and can read any pixel of the scene, it can do things a normal shader cannot, such as blurring.
Rendering to a Texture
The offscreen texture needs two usages. RENDER_ATTACHMENT lets the scene pass draw into it. TEXTURE_BINDING lets the post pass read it, like the textures in Lesson 8.
const sceneTexture = device.createTexture({
size: [canvas.width, canvas.height],
format,
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING
});
The scene pass is a normal render pass. Only the view changes:
const scenePass = encoder.beginRenderPass({
colorAttachments: [{
view: sceneTexture.createView(),
clearValue: { r: 0.12, g: 0.16, b: 0.3, a: 1 },
loadOp: "clear",
storeOp: "store"
}]
});
format as the canvas means one pipeline can draw into both the offscreen texture and the canvas.
The Full-Screen Pass
The post pass uses the full-screen triangle from WGSL basics. One large triangle covers the whole canvas, and passes a uv coordinate to the fragment shader.
struct VertexOutput {
@builtin(position) position: vec4f,
@location(0) uv: vec2f,
}
@group(0) @binding(0) var sceneTexture: texture_2d<f32>;
@group(0) @binding(1) var sceneSampler: sampler;
@vertex
fn vertexMain(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
let corners = array(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0));
let corner = corners[vertexIndex];
var output: VertexOutput;
output.position = vec4f(corner, 0.0, 1.0);
output.uv = vec2f(corner.x * 0.5 + 0.5, 0.5 - corner.y * 0.5);
return output;
}
0.5 - corner.y * 0.5. If you forget, the picture is upside down.
Greyscale
The simplest effect turns colour into grey. Our eyes see green as brighter than red, and red as brighter than blue, so we use a weighted average called luminance.
Example
Show the left half in colour and the right half in greyscale:
@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
let color = textureSample(sceneTexture, sceneSampler, input.uv).rgb;
let brightness = dot(color, vec3f(0.2126, 0.7152, 0.0722));
let grey = vec3f(brightness);
let isRightHalf = input.uv.x > 0.5;
let isDivider = abs(input.uv.x - 0.5) < 0.002;
var result = select(color, grey, isRightHalf);
result = select(result, vec3f(1.0), isDivider);
return vec4f(result, 1.0);
}
Edit & Run »
select(a, b, condition) returns b when the condition is true and a otherwise. It is WGSL's version of condition ? b : a.
Each frame draws the scene into the texture, then runs the post pass into the canvas:
function frame(now) {
device.queue.writeBuffer(frameBuffer, 0, new Float32Array([now / 1000, aspect]));
const encoder = device.createCommandEncoder();
renderScene(encoder);
applyPostEffect(encoder);
device.queue.submit([encoder.finish()]);
requestAnimationFrame(frame);
}
Simple Colour Effects
Many effects only change one pixel at a time. Each one is a line or two of WGSL:
let inverted = 1.0 - color;
let tinted = color * vec3f(1.15, 1.0, 0.8);
let contrasted = (color - 0.5) * 1.3 + 0.5;
let saturated = mix(vec3f(brightness), color, 1.4);
let toneMapped = color / (color + 1.0);
- Invert makes a photo negative.
- Tint warms or cools the image. Together with contrast and saturation, this is called colour grading.
- Tone mapping squeezes very bright values (above 1.0) back into the 0 to 1 range, so bright lights do not turn flat white. The formula above is the simple Reinhard operator.
Vignette and Chromatic Aberration
A vignette darkens the corners of the image, like an old camera lens. It depends on the distance from the centre.
Chromatic aberration imitates a cheap lens: red, green and blue are read from slightly different places, giving coloured fringes near the edges.
Example
Combine chromatic aberration, colour grading and a vignette:
fn sampleWithAberration(uv: vec2f) -> vec3f {
let shift = (uv - 0.5) * ABERRATION_STRENGTH;
let red = textureSample(sceneTexture, sceneSampler, uv + shift).r;
let green = textureSample(sceneTexture, sceneSampler, uv).g;
let blue = textureSample(sceneTexture, sceneSampler, uv - shift).b;
return vec3f(red, green, blue);
}
fn gradeColor(color: vec3f) -> vec3f {
var graded = color * WARM_TINT;
graded = (graded - 0.5) * CONTRAST + 0.5;
let brightness = dot(graded, LUMINANCE_WEIGHTS);
graded = mix(vec3f(brightness), graded, SATURATION);
return clamp(graded, vec3f(0.0), vec3f(1.0));
}
fn vignette(uv: vec2f) -> f32 {
let distanceFromCenter = length(uv - 0.5);
return 1.0 - smoothstep(VIGNETTE_START, VIGNETTE_END, distanceFromCenter);
}
@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
let color = sampleWithAberration(input.uv);
let graded = gradeColor(color);
return vec4f(graded * vignette(input.uv), 1.0);
}
Edit & Run »
WARM_TINT and VIGNETTE_END are at the top of the shader in the full example. Change them in the Playground to find a look you like. In a real app you would send them in a uniform buffer.
Chaining Passes: Ping-Pong
Some effects need more than one pass. A blur is the classic example.
A good blur averages many pixels around each pixel. A 9×9 square would need 81 samples. Instead, blur horizontally first (9 samples), then blur that result vertically (9 more). The result looks the same, but costs only 18 samples.
A pass cannot read and write the same texture, so we use two textures and bounce between them. This is called ping-pong:
- Scene → texture A
- Horizontal blur: A → B
- Vertical blur: B → A (or → canvas on the last round)
- Repeat steps 2 and 3 for a stronger blur.
Example
A two-pass Gaussian blur with ping-pong textures:
const WEIGHTS = array(0.227027, 0.1945946, 0.1216216, 0.054054, 0.016216);
@fragment
fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
let texelSize = 1.0 / vec2f(textureDimensions(sourceTexture));
let step = blur.direction * texelSize * blur.radius;
var color = textureSample(sourceTexture, sourceSampler, input.uv).rgb * WEIGHTS[0];
for (var index = 1; index < 5; index++) {
let offset = step * f32(index);
color += textureSample(sourceTexture, sourceSampler, input.uv + offset).rgb * WEIGHTS[index];
color += textureSample(sourceTexture, sourceSampler, input.uv - offset).rgb * WEIGHTS[index];
}
return vec4f(color, 1.0);
}
renderScene(encoder);
for (let iteration = 0; iteration < BLUR_ITERATIONS; iteration++) {
const isLastIteration = iteration === BLUR_ITERATIONS - 1;
runBlurPass(encoder, horizontalBlurFromA, textureB.createView());
const verticalTarget = isLastIteration ? context.getCurrentTexture().createView() : textureA.createView();
runBlurPass(encoder, verticalBlurFromB, verticalTarget);
}
Edit & Run »
The same shader does both directions. A uniform direction of (1, 0) blurs sideways and (0, 1) blurs up and down. The weights come from a bell curve (a Gaussian), so near pixels count more than far ones. They add up to 1, so the image keeps its brightness.
horizontalBlurFromA always reads A, and verticalBlurFromB always reads B. Only the target view changes. You create the bind groups once, not every frame.
Common Effects
| Effect | The idea in WGSL |
|---|---|
| Greyscale | vec3f(dot(color, vec3f(0.2126, 0.7152, 0.0722))) |
| Invert | 1.0 - color |
| Sepia | Greyscale, then multiply by a brown tint such as vec3f(1.2, 1.0, 0.8) |
| Colour grading | Tint, contrast (color - 0.5) * k + 0.5 and saturation with mix() |
| Tone mapping | color / (color + 1.0) |
| Vignette | color * (1.0 - smoothstep(start, end, length(uv - 0.5))) |
| Chromatic aberration | Sample red, green and blue at slightly different uv values |
| Pixelate | floor(uv * blocks) / blocks before sampling |
| Blur | Weighted average of neighbours, horizontal then vertical |
| Bloom | Keep only bright pixels, blur them, add them back to the scene |
Exercises
1. Which two usage flags does an offscreen scene texture need?
Show answer
GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING
2. Change the greyscale example so the right half is inverted instead of grey.
Show answer
var result = select(color, 1.0 - color, isRightHalf);3. Why can the blur not read and write the same texture in one pass?
Show answer
WebGPU does not allow a texture to be a render attachment and a bound texture in the same pass. Also, pixels would read neighbours that were already blurred. Ping-pong between two textures solves both problems.
4. Make the vignette example pixelated, with 80 blocks across the screen.
Show answer
Snap the uv to a grid before sampling:
let blocks = 80.0;
let pixelatedUv = (floor(input.uv * blocks) + 0.5) / blocks;
let color = sampleWithAberration(pixelatedUv);Summary
- Draw the scene into an offscreen texture with
RENDER_ATTACHMENT | TEXTURE_BINDING. - A full-screen triangle pass samples that texture and writes the changed colours to the canvas.
- Greyscale, invert, colour grading, tone mapping and vignette each take only a few lines of WGSL.
- Chain passes with two ping-pong textures. A blur is a horizontal pass followed by a vertical pass.
What Next?
Congratulations! You have finished the WebGPU tutorial. You can now draw 3D scenes with cameras, textures, instancing, compute shaders, shadows and post-processing.
See all of these ideas working together in a real game: City Drive runs entirely on WebGPU in your browser. For deeper dives into specific topics, read the articles. And keep experimenting in the Playground: the best way to learn graphics is to change a number and see what happens.
Official documentation
Go deeper with the specifications and reference documentation:
- developer.mozilla.org
MDN: GPUTexture.createView()
Views that let one texture be rendered to, then sampled.
- developer.mozilla.org
MDN: GPUCommandEncoder.beginRenderPass()
Rendering into an offscreen texture instead of the canvas.
- w3.org
WebGPU specification: render passes
Attachments, load and store operations between passes.
- webgpu.github.io
WebGPU Samples
Official examples of image blur and other multi-pass effects.