Skip to content
Fundamental · Debugging & performanceBeginner

Output values as color

Write any value to the output to see it, and read what the colors tell you.

How it works

A fragment shader has no print. It runs millions of times per frame, on the GPU, and the only thing that comes back is a color per pixel. So the color is the debugger: when a value looks wrong, write it to the output and look at it. Every step preview on this site’s cards is exactly that, one variable shown as color.

One value, as gray

A single float between 0 and 1 reads best as gray: black is 0, white is 1. Put the same value in all three channels:

float d = length(p);              // the value you want to inspect
fragColor = vec4(vec3(d), 1.0);   // black at the center, brighter outward

A vec2 fits in red and green, as with UV: black at (0, 0), red where x is 1, green where y is 1, yellow where both are. A vec3 fills all three channels.

To read one component of a vector, isolate it: fragColor = vec4(vec3(n.y), 1.0); shows only y as gray.

Values outside 0 to 1

The display clamps the output, so anything below 0 shows as black and anything above 1 as white. A whole region of flat black can hide a value of -0.01 or of -500. Remap first:

  • A range of -1 to 1, such as a direction or a normal: v * 0.5 + 0.5 maps it to 0..1. Facing +x turns pinkish red, +y light green, +z light blue; a zero vector maps to 0.5 in every channel.
  • A large range, such as a distance in world units or a time in seconds: divide by the largest value you expect, or show fract(v), which repeats every 1 unit and draws contour lines across the field.
  • A sign that matters, such as a signed distance: give negative and positive values their own color. Mapping values to arbitrary colors to make a property visible is called false color.
// red where v is negative, green where it is positive, black at zero
vec3 signedView(float v) {
    return vec3(max(-v, 0.0), max(v, 0.0), 0.0);
}

// thin bands every 0.1 units: equal values share a band
float contours(float v) {
    return step(0.9, fract(v * 10.0));
}

Reading exact numbers

The eye is bad at judging brightness, and on this site the frame encodes the output to sRGB, so a value of 0.5 shows noticeably lighter than middle gray. To check a number, compare it instead of eyeballing it: step(0.5, v) turns white exactly where v reaches 0.5, and a split view shows two candidates side by side.

vec3 a = vec3(smoothstep(0.2, 0.8, x));
vec3 b = vec3(x * x * (3.0 - 2.0 * x));
vec3 color = uv.x < 0.5 ? a : b; // left half: a, right half: b

NaN and infinity

A division by zero, the square root of a negative number or normalize of a zero vector can produce NaN (not a number) or infinity. NaN spreads through every operation that touches it, and different GPUs display it differently: black, white, or random. GLSL ES 3.00 has isnan and isinf, so you can paint the bad pixels a color nothing else uses:

if (isnan(v) || isinf(v)) {
    fragColor = vec4(1.0, 0.0, 1.0, 1.0); // magenta: find where this starts
    return;
}

Some compilers optimize on the assumption that NaN never happens, so a clean result is not a proof. Once a pixel is flagged, walk backward through the chain and show each earlier value until the magenta disappears.

Walk the chain

Most shaders are a chain: coordinates, then a distance, then a mask, then a color. When the final image is wrong, show the first link, then the next, and stop at the first one that looks wrong. That one, or the input to it, holds the bug. The step previews on the cards follow the same order for the same reason.

In an engine, make sure the value reaches the screen unchanged. In Godot, writing it to ALBEDO lights and shades it; add render_mode unshaded; while you debug, and ALBEDO = value; then shows the raw value. Writing it to EMISSION with ALBEDO = vec3(0.0); is not enough: the default SPECULAR of 0.5 still adds light and sky reflections on top, so set SPECULAR = 0.0; too if you take that route.

Further reading