<Prev | Content | Next>

09. Manual Diagnostics: pt.1 Value-level

There could be cases in your project when GPU Frame Capture may not work (Xcode can crash on it) or may take too much time. And usually you don't have any opportunity to get values from shader code on the CPU side. Actually, you do, because a shader produces some result - a texture (and kernels can write into buffers or textures). So you can output your internal shader data as a colour. In this article I'll show a bunch of examples of how to output different values to the output image. Obviously this can't cover all existing cases in the world, but I think it shows the main idea and its basic application.

Output as colour

The main idea is to visualise your internal shader state as a colour. For this purpose let's use a couple of basic tools: visual asserts and debug parameters.

Visual asserts

This small macro allows you to return early from your main fragment shader function with a given colour depending on a condition.

#define DEBUG_ASSERT(cond, color) if (!(cond)) { return color; }

Heat map

Visually, a heat map is easier to recognise than a flat grayscale, so I would recommend using some basic gradient:

float3 heat(float x) {
    x = clamp(x, 0.0, 1.0);
    return float3(
        smoothstep(0.5, 1.0, x),
        1.0 - abs(x - 0.5) * 2.0,
        smoothstep(0.5, 0.0, x)
    );
}

debugMode parameter

You can use some debugMode (or any other) parameter which you can send from the CPU side for convenient switching of debug output without rebuilding the app for every change.

switch(uniform.debugMode) {
    case 0: return float4(heat(in.v_texcoord.x), 1);
    case 1: return float4(heat(in.v_texcoord.y), 1);
    default: break;
}

In this example, we visualise the value of x or y texture coordinate depending on debugMode and use a heat map instead of simple grayscale.

Values

Scalars

Here you can just output your value as an output colour, but keep in mind several things:

  • Don't output the value into alpha channel as it can make your output less readable.
  • Normalise your scalar because you can only see [0; 1] in colours.
  • Use heat to make it more recognisable.

Vectors

Colour output allows visualising up to 3 components of a vector. If you need to visualise higher dimensions, visualise partly or project to 3D.

Same as with scalars, you need to put values into a valid range or map to a gradient.

Signs

For visualising signs of your values you can use gradients or just fixed values.

Bad values

Some operations may produce invalid values (NaN, inf) which may lead to unexpected results, so you need to catch them as early as possible. Just use a DEBUG_ASSERT-style macro for colour output.

#define DEBUG_INVALID(value, color) if (any(isnan(value)) || any(isinf(value))) { return color; }

Visualisations

Branch visualisation

If you have branching in your Metal function, return different fixed colours for each one to see when you enter them.

ID visualisation

Identifiers usually are integer values. You can use different approaches depending on density and range of their values:

  • Just an array of colours if the IDs are dense and range is small enough.
  • Complex gradient and normalised value of your ID.
  • Hashed colours
float3 hashColor(uint id) {
    uint x = id * 1664525u + 1013904223u;
    return float3(
        float((x >>  0) & 255u) / 255.0,
        float((x >>  8) & 255u) / 255.0,
        float((x >> 16) & 255u) / 255.0
    );
}

Checkerboards and grids

There could be a situation when continuous values don't allow visualising some issues (for example broken UV). In these cases you can draw a grid or a checkerboard:

float grid(float2 uv, float scale) {
    float2 g = abs(fract(uv * scale - 0.5) - 0.5) / fwidth(uv * scale);
    float line = min(g.x, g.y);
    return 1.0 - min(line, 1.0);
}

float checker(float2 uv, float scale) {
    float2 cell = floor(uv * scale);
    return fmod(cell.x + cell.y, 2.0);
}

Derivatives visualisation

That could be useful because lots of shader bugs happen because of fast value changes across neighbouring pixels.

  • UV seams, discontinuities, and bad unwraps
  • Wrong mip level / blurry or noisy textures
  • Broken derivatives due to divergent control flow
  • Normal maps looking wrong at grazing angles or seams
  • Screen-space effects
  • Geometric or interpolation problems

So for example you could use something like this:

return float4(10 * fwidth(uv), 0.5, 1);

SDF visualisation

You can visualise SDF with isolines to check if your SDF is really an SDF or starts warping, for example. Also you can combine isolines with sign.

float4 sdfPlot(float v, float maxDist) {
    float center = smoothstep(-3, 0, v) - smoothstep(0, 3, v);
    float positive = smoothstep(0.0, maxDist, v);
    float negative = smoothstep(0.0, -maxDist, v);
    float isoline = (sin(v) * 0.5 + 0.5);
    float x = v / maxDist;
    return float4(float3(
        smoothstep(-0.5, 1.0, x),
        1.0 - abs(x) * 2.0,
        smoothstep(0.5, -1.0, x)
    ), 1) * isoline + center;
}

Barycentric coordinates

If you're able to pass barycentric coordinates from vertex shader, you can visualise geometry, topology, triangles, wrong winding, etc.

IMPORTANT: If you use indexed model, you must pass the barycentric coordinates with vertices, because otherwise if you calculate them in vertex shader from vertex index, you'll get something like this:

Conclusion

  • You can debug your shaders even without GPU frame capturing, but interactively in your app.
  • Write values you need to diagnose as output colours.
  • Don't forget to normalise these values to [0; 1]
  • Build your own library for quick interactive visual diagnostics.

<Prev | Content | Next>