Skip to content
Rendering · Post chainIntermediate

Post-process chain

Run full-screen passes over the finished frame, in order, each reading the last one's image.

Live demo coming soon

How it works

Post-processing works on the picture, not the scene. First the scene is drawn into a render target. Then a fragment shader runs over every pixel of that image and writes a changed copy: blurred, brighter, recolored, with lines at its edges. The shader doesn’t know about meshes or lights any more. It sees a texture and a UV coordinate, like a photo filter.

A post-process chain is several of those passes in a row. Each one reads the image the previous pass wrote and writes a new one. The last pass writes to the screen.

One pass: a full-screen draw

A post pass needs a fragment for every pixel of the output, so it draws one shape that covers the whole target. The usual trick is a single triangle bigger than the screen, made in the vertex shader from gl_VertexID, with no vertex buffer at all:

#version 300 es
// vertex shader: draw with gl.drawArrays(gl.TRIANGLES, 0, 3)
out vec2 vUv;

void main() {
    // vertices at (0,0), (2,0), (0,2) in UV: the triangle covers the 0..1 square and spills past it
    vec2 uv = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
    vUv = uv;
    gl_Position = vec4(uv * 2.0 - 1.0, 0.0, 1.0);
}

The rasterizer clips away the part outside the screen. One triangle also avoids the diagonal seam of a two-triangle quad: the GPU shades pixels in 2×2 blocks, and the blocks along that seam get shaded once for each triangle.

The fragment shader then reads the previous image at vUv and writes the new color:

#version 300 es
precision highp float;

uniform sampler2D uPrevious;
in vec2 vUv;
out vec4 fragColor;

void main() {
    vec3 color = texture(uPrevious, vUv).rgb;
    float gray = dot(color, vec3(0.2126, 0.7152, 0.0722));
    fragColor = vec4(vec3(gray), 1.0); // a grayscale pass
}

The chain: two targets taking turns

A pass can’t read the target it is writing, so a chain needs at least two. Pass 1 reads A and writes B, pass 2 reads B and writes A, and so on. The last pass writes to the canvas instead. Passes that need an older image too, like bloom adding its blur back onto the original scene, keep that target alive until they’re done with it.

Order matters

Each pass assumes something about the image it receives, so the order is not free. A typical game frame runs:

  1. Scene into a float target, in linear light.
  2. Temporal anti-aliasing (TAA), which blends each frame with the last few.
  3. Bloom: pick the pixels brighter than 1, blur them, add them back. It needs values above 1, so it runs before tone mapping.
  4. Tone mapping: squeeze the HDR range into 0–1.
  5. Color grading: contrast, tints, a lookup table. It is tuned on the tone-mapped image.
  6. Grain and vignette.
  7. sRGB encode.
  8. Dither. It comes after the encode, because the encode changes the step size. Both run in the same final shader, just before it writes the 8-bit canvas.
  9. UI on top, so menus don’t blur or bloom.

Swap tone mapping and bloom and the bright pass finds nothing above 1. Grade before tone mapping and the look changes with the exposure.

Smaller is cheaper

Every pass touches every pixel of its target. A wide blur at full resolution is expensive, so chains often shrink first: render a half-size copy, then a quarter, then an eighth, blurring at each step, and scale back up while adding the layers together. A blur of a few pixels at an eighth of the size covers as much of the screen as a blur eight times wider at full size, over a sixty-fourth of the pixels. Bloom is usually built this way.

In Godot

The engine runs its own chain (glow, tone mapping, adjustments) from the Environment resource. For your own pass over the whole frame, the documented setup is a ColorRect filling a CanvasLayer, with a shader that reads the frame through a hint_screen_texture uniform at SCREEN_UV. Several passes stack several layers, or chain SubViewports so each one renders the previous one’s texture.

On this site

Screen-post cards draw a small test scene first, then run their passes over it. The scene’s color reaches the shader as t_screen, in linear HDR; extra passes in render.json read each other as t_<pass>, and effect is last.

Further reading