Post-process chain
Run full-screen passes over the finished frame, in order, each reading the last one's image.
How it works
Post-processing works on the picture, not the scene. First the scene is drawn into a
render targetA texture a draw writes into instead of the screen, so a later pass can read the result. Blur, bloom and every multi-pass effect use them. Render target A texture a draw writes into instead of the screen, so a later pass can read the result. Blur, bloom and every multi-pass effect use them. UV coordinates A 2D position on a surface or the screen, usually from 0 to 1 on both axes. Shaders draw shapes and patterns as functions of it.uv = gl_FragCoord.xy / resolution
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:
- Scene into a float target, in linear light.
- Temporal anti-aliasing (TAA), which blends each frame with the last few.
- Bloom: pick the pixels brighter than 1, blur them, add them back. It needs values above 1, so it runs before tone mapping.
- Tone mappingRendering keeps light values above 1 in float buffers (HDR), then a tone-mapping curve compresses them into the 0–1 a screen can show.Rendering
HDR & tone mapping
Rendering keeps light values above 1 in float buffers (HDR), then a tone-mapping curve compresses them into the 0–1 a screen can show.
Reinhard = c / (1 + c): squeeze the HDR range into 0–1. - Color grading: contrast, tints, a lookup table. It is tuned on the tone-mapped image.
- Grain and vignette.
- sRGB encodeThe curve screens and image files use to store color, with more steps for dark tones. Shaders do their math in linear light and encode to sRGB once, at the end.Rendering
sRGB encoding
The curve screens and image files use to store color, with more steps for dark tones. Shaders do their math in linear light and encode to sRGB once, at the end.
c ≤ 0.0031308 ? 12.92c : 1.055c^(1/2.4) − 0.055Sources.- sRGB (opens in a new tab)Reference
- Gamma Correction (opens in a new tab)Article
- DitherBanding is the stripes a smooth gradient breaks into when stored with too few levels. Dithering adds tiny per-pixel noise before rounding, so the stripes become fine grain.Rendering
Banding & dithering
Banding is the stripes a smooth gradient breaks into when stored with too few levels. Dithering adds tiny per-pixel noise before rounding, so the stripes become fine grain.
Sources. 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. - 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.