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.
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.
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; }

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)
);
}
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.
Here you can just output your value as an output colour, but keep in mind several things:
heat to make it more recognisable.
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.

For visualising signs of your values you can use gradients or just fixed 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; }

If you have branching in your Metal function, return different fixed colours for each one to see when you enter them.
Identifiers usually are integer values. You can use different approaches depending on density and range of their values:
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
);
}

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);
}

That could be useful because lots of shader bugs happen because of fast value changes across neighbouring pixels.
So for example you could use something like this:
return float4(10 * fwidth(uv), 0.5, 1);

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;
}

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:
