Shader post-processing in a hurry

April 6th, 2024

The example scene before and after post processing.

So you have written a cool raymarcher but for some reason it looks cheap. There are a few basic things you can always do to improve the perceived image quality: tone mapping, avoiding clipping color channels, color grading, adding glow, anti-aliasing and dithering.

These are not necessary for small ShaderToy experiments, but for example a demo or intro for a party that’s shown on a big screen you’ll want to avoid the common issues. There are more advanced approaches than what are used here but my goal is to show the absolute minimum you can do.

Below I apply small changes to the scene one step at a time. You can find the finished shader here.

The starting point

The original somewhat uninspired 3D rendering.

We start with a basic raymarched scene. It exhibits two main problems: clipped color channels and edge aliasing. By clipped color channels I mean the solid cyan-colored regions. Edge aliasing is very visible here due to high contrast, pulling attention away from 3D shapes to 2D edges, especially in motion. Not something we want.

Tone mapping

Simple tone mapping added.

With tone mapping we can improve the definition of bright regions. You can see that the gradients at bottom of the pillars don’t stop as suddenly as earlier. You can get really deep into this stuff but for now we’ll settle with a simple formula I picked up on ShaderToy:

// A very simple tone mapping curve.
// Modified from https://www.shadertoy.com/view/lstfR7
vec3 robobo1221sTonemap(vec3 x){
    return x / sqrt(x*x + 0.5); // Adjust the + 0.5 term to taste.
}

White saturation

Hacky white saturation makes it look a bit more “cinematic,” if you will.

Real film saturates to white when it receives a lot of light. Here we use “a phenomenological approach” (a hack that looks cool lol) to simulate this feature. I implemented it by adding white to the color when any of the channels reaches a high enough value.

// Make very bright colors to saturate to white like film does.
vec3 saturateToWhite(vec3 x) {
    // Compute a weighted channel-wise maximum, square it, add back.
    // Squaring makes 'white' blow up quickly when values go over 1.0
    float white = max(x.r * 0.3, max(x.g * 0.6, x.b * 0.1));
    x += vec3(white * white); 
    return x;
}

Weights are rounded constants of an RGB-to-luma conversion formula. They are rounded to make it clear this isn’t an exact science.

For a bigger project you might want to use something more robust, like AgX (ShaderToy). It does both tonemapping and saturates bright colors to white. I noticed its results seemed a bit washed out with its default settings, so I encourage you to familiarize yourself with its “look” control knobs.

Color grading

Color grading changes added.

When tweaking the colors at a post-processing stage it’s important to know what you’re after. Here I wanted the scene to look more like scifi and less like Minas Morgul so I turned up the blues.

// "Color grading"
color += 0.01 * vec3(1.0, 0.5, 0.1); // Lift blacks a tiny bit
color *= vec3(1.0, 1.0, 1.1);        // Add blue gain
color = pow(color, vec3(1.1));       // A subtle contrast boost

If you want to change color saturation then you can do it the same the AgX implementation linked above does. Subtract a greyscale value, amplify that difference, add it back in:

// From https://iolite-engine.com/blog_posts/minimal_agx_implementation
float luma = dot(val, vec3(0.2126, 0.7152, 0.0722));
float sat = 1.0; // Adjust this
vec3 saturated = luma + sat * (val - luma);

Glow

A small glow effect.
The glow effect is quite subtle.

Next is a glow effect done with a brute force 5x5 pixel Gaussian blur. This really isn’t great but I do think it’s still an improvement. I don’t want to call it a “bloom” effect since it’s so small.

Bigger glow is better and for that you should use some two-pass bloom code, for example of this shader by ferris.


Anti-aliasing

FXAA anti-aliasing addded in. I copypasted this code from the shader linked earlier.

Instead of supersampling the image, I just added an FXAA pass. It’s an improvement but can’t hide all aliasing in motion. In a perfect world this would come before glow so that jaggies don’t get amplified by the blur but it’s passable like this too.

Dithering

Banding is not a problem in a small ShaderToy preview window but it can become visible when showing your work full screen. So we’ll add dithering to break the color bands. The below image shows a brightened-up crop.

Hover with your mouse to change image.
Dithering hides color banding. Image changes on mouse hover (off, on).

I found the iqint2 noise function to work well enough for this purpose.

// Integer Hash - II
// - Inigo Quilez, Integer Hash - II, 2017
//   https://www.shadertoy.com/view/XlXcW4
uvec3 iqint2(uvec3 x)
{
    const uint k = 1103515245u;

    x = ((x>>8U)^x.yzx)*k;
    x = ((x>>8U)^x.yzx)*k;
    x = ((x>>8U)^x.yzx)*k;

    return x;
}

// Noise is uint so we scale it to [0, 1] range
vec3 noise =
    vec3(iqint2(uvec3(fragCoord.x, fragCoord.y, iTime*1000.)))
    / float(0xffffffffU);
// and then to [-1, -1].
color += (2./255.)*(-0.5+noise.x);
The end result after dithering. Its effect is imperceptible at this size. Noise is in range [-1, 1] here but I suppose [-0.5, 0.5] should already hide most banding.

The shader is now good enough!

Other things to check

You want to review your work on the biggest screen of the house. This will expose poor composition, low contrast and small artifacts that are easy to miss when working on a monitor, especially in a small preview window. If you’re making a demo that’s shown fullscreen then also make sure to hide the mouse cursor.