How to debug a shader when you cannot print anything
Debugging ordinary code is easy: print a value, or stop at a line and look. A shader runs on the graphics card, on thousands of pixels at once, with no console. So how do you find out why it is black, or wrong, or flickering? The trick is that the output is your debugger. Every pixel can show you a value, as long as you turn the value into a color.
Technique 1: show a number as a gray level
To see what some value x is across the picture, return it as a gray color:
float4 frag(float2 uv)
{
float x = length(uv - 0.5); // whatever you want to look at
return float4(x, x, x, 1.0);
}
Dark means near 0, bright means near 1. If the picture is all black the value is at or below zero everywhere. If it is all white, it is at or above one. That tells you immediately whether your numbers are in the range you expected.
Technique 2: remap the range
Colors only show values between 0 and 1. A value that runs from -1 to 1 will look half black. Squeeze it into range first:
float shown = x * 0.5 + 0.5; // -1..1 becomes 0..1 return float4(shown, shown, shown, 1.0);
For a value that runs much higher, divide it down, or look at just its repeating part with frac(x), which shows the pattern as stripes you can read.
Technique 3: show a vector as a color
Two- and three-component values map naturally to red, green and blue. Output the position and you can see the coordinate system:
return float4(p.x * 0.5 + 0.5, p.y * 0.5 + 0.5, 0.0, 1.0);
Red grows to the right and green grows upwards. If the gradients point the wrong way, or the middle is not where you expected, your coordinates are the bug, and you have found it without reading a line of the rest of the shader. The same works for normals in lighting code: output n * 0.5 + 0.5 and a smooth sphere should look like a smooth, colorful ball.
Technique 4: find the threshold
When you want to know "is this value bigger than something?", turn it into a hard black-and-white picture:
float test = step(0.5, x); return float4(test, test, test, 1.0);
Slide the threshold around until the white region matches what you think should be there. It is a quick way to measure a value without printing it.
Technique 5: bisect
If the output is wrong and you do not know where, return early. Put a return after the first few steps and show that intermediate value. If it is right, move the return later. If it is wrong, the bug is in the part you just passed. Halving the code each time finds the faulty line in a handful of tries, however long the shader is.
Technique 6: look at one pixel
Sometimes the exact number matters, not an impression of it. ShaderBattle's editor has a pixel readout: hover the picture and it shows the color your function returned for that pixel, and the same pixel of the target next to it, with a chart of the whole row. You can also pin a pixel and add print("name", value) to your code to see a value's exact contents at that pixel. In other tools, GPU debuggers such as RenderDoc or the Frame Debugger in Unity let you click a pixel and inspect it.
Technique 7: change the resolution
Drop the picture to a tiny size (the editor has a resolution menu down to 8 by 6 pixels). With only 48 pixels you can see each one as a big block, so a per-pixel bug becomes obvious. A pattern that looks like noise at full size is often a clean repeating shape at low size.
The usual suspects
- Everything is black. A value is below zero or exactly zero. Check for a missing
1.0in the alpha, a color multiplied by 0, or a mask that is never 1. Show each factor on its own. - Everything is white. A value is above one.
saturateor divide it down and look again. - Black or white speckles that flicker. Probably NaN (not a number) from dividing by zero,
sqrtof a negative, ornormalizeof a zero vector. Guard the input:max(d, 0.0001)orsqrt(max(x, 0.0)). - It looks right at first, then goes wrong as time passes. Time grows without limit and floating-point precision shrinks as the number gets large. Use
frac(time), or a repeatingsin(time), instead of the raw value in anything that is sensitive. - A shape is stretched. Aspect ratio. See the guide on UV and aspect ratio.
- Banding in a smooth gradient. An 8-bit output can only show 256 levels per channel. Adding a tiny amount of noise (dither) before the output hides the steps.
- Integer problems. In strict GLSL
1is not1.0. Use float literals everywhere. - It works on my machine and not on a phone. Phones often use lower precision. Test on the real device and keep large numbers out of low-precision variables.
A habit worth building
Build a shader in small steps and look at each one before adding the next. Start with just the coordinates, check them, add the shape, check it, then add color. When something goes wrong, you will know the last thing you added is the cause. Debugging a shader is mostly about never being more than one step away from a picture you trust.
Practise reading values
The early lessons are built around exactly this skill: Colors are four numbers and UV: where am I? have you turn numbers into colors and read them back.
Keep reading
- What is a fragment shader? A plain explanation with code
- HLSL vs GLSL vs WGSL vs Metal: shader languages compared
- How to learn shader programming: a step-by-step roadmap
- What is ray marching? Distance fields explained simply
- Noise in shaders: value noise, fBm and what they are for
- How to make an ocean shader: waves, Fresnel and foam
- Why is my circle an oval? UV coordinates and aspect ratio in shaders
- step, lerp and smoothstep: the three functions behind most shader effects
- Signed distance functions explained: drawing shapes with math
- Porting a Shadertoy shader to Unity (GLSL to HLSL): a conversion guide
- Why is my shader slow? A practical guide to shader performance