A shader that does not compile at least says where it fails. A shader that compiles and draws the wrong thing says nothing. It has no printf and no breakpoints in the browser, and it runs as millions of copies at once. Debugging it is a craft of its own: read the compiler's log well, turn the values you doubt into colours, cut the problem in half until it has nowhere to hide, and know which operations have no defined result.
By the end you can
- Read a compile log and find the line it means, even when code was added in front of yours.
- Turn any value into a colour that shows at a glance whether it is right.
- Find a bug by cutting the shader in half, and read one pixel's value as a number.
- Recognise the operations whose result is undefined, and guard them.
- Choose a tool that can step through a shader on your platform.
glGetShaderInfoLog returns the compiler's messages as text. Its format is not standardised, but most drivers start each message with the source string and the line. These are real messages from the compiler in Chrome and Edge (ANGLE):
1ERROR: 0:6: 'col' : undeclared identifier
2ERROR: 0:6: 'constructor' : not enough data provided for construction
3 │ │
4 │ └─ line 6
5 └─── source string 0 (glShaderSource can take several strings)Fix the first message first. Later messages are often consequences of it: above, vec4(col, 1.0) fails only because col does not exist. And a syntax error is reported where the parser noticed it, which is often the line after the mistake. A missing semicolon at the end of line 6 shows up as "'o' : syntax error" on line 7, at the first word that cannot continue the statement.
| Code | Message (ANGLE) | What it means |
|---|---|---|
| float x = 1; | cannot convert from 'const int' to 'highp float' | GLSL ES never converts int to float by itself (desktop GLSL does since 1.20). Write 1.0. |
| float x = 0.5 * i; | wrong operand types - no operation '*' exists that takes a left-hand operand of type 'const float' and a right operand of type 'highp int' | the same rule for operators: write 0.5 * float(i). |
| vec3 c = vec4(1.0); | dimension mismatch | a vec4 does not fit a vec3: take .rgb or .xyz. |
| a missing ; | 'o' : syntax error | reported on the next line, at the first word that cannot follow. |
| vec4(col, 1.0) | undeclared identifier | a typo, a variable declared inside a block that has ended, or one the stage does not have. |
When the line numbers do not match
Programs often put code in front of the shader before compiling it: a #version line, a list of #defines, a shared library of functions. Every line added shifts the numbers in the log, and "line 47" no longer means line 47 of your file. The #line directive fixes that: #line n says that the next line is line n. Put #line 1 right before your own code, and the log counts from your first line again. (The playground in this track gets the same result the other way round: it counts the lines of its prelude and subtracts them from every number in the log.)
1std::string src = "#version 330 core\n"
2 + defines // "#define SHADOWS 1\n#define LIGHTS 4\n"
3 + commonLib // shared functions, many lines
4 + "#line 1\n" // the log now counts from the next line
5 + readFile("lit.frag");Compile before you run
Each driver has its own GLSL compiler, and some accept code the specification forbids. A shader can then work on your computer and fail to compile on someone else's. The Khronos reference compiler is strict and runs without a GPU, so you can check every shader as part of your build: glslangValidator lit.frag prints the errors (the stage comes from the file extension: .vert, .frag, .comp), and for Vulkan, glslc compiles to SPIR-V (see Versions & Dialects).
There is no printf on the GPU, but there is a screen with millions of pixels. The universal technique is to output the value you doubt as a colour. Write FragColor = vec4(vec3(x), 1.0) for a scalar, or vec4(v * 0.5 + 0.5, 1.0) for a direction in [−1, 1]. Then check that it looks the way you expect: black where it should be 0, white where 1, a smooth ramp where it should be continuous. Out-of-range values clip to black and white, so use fract(x) to see large values as bands.
1FragColor = vec4(vec3(d), 1.0); // a scalar
2FragColor = vec4(normal * 0.5 + 0.5, 1.0); // a direction
3FragColor = vec4(fract(worldPos), 1.0); // large coordinates as repeating bands
4FragColor = vec4(d < 0.0 ? vec3(1, 0, 0) : vec3(0, 0, 1), 1.0); // a sign
5FragColor = vec4(any(isnan(c)) ? vec3(1, 0, 1) : vec3(0), 1.0); // NaN huntingWhy * 0.5 + 0.5 for directions: a unit normal's components run −1..1, but a colour channel can only show 0..1, so everything negative would be black. Halving and shifting maps −1 → 0, 0 → 0.5 and 1 → 1. A normal pointing straight up, (0, 1, 0), shows as (0.5, 1, 0.5), light green; one pointing at the camera along +Z shows as (0.5, 0.5, 1), the lavender colour of every normal map. With fract(worldPos), a position of 3.25 shows as 0.25: each whole unit becomes one band, so you can count units on screen.
Two habits make these views far more useful. Show the debug view on one side of the screen and the real image on the other, split at the mouse, so you can see which part of the picture each value belongs to. And for a final colour, do not look at it: test it. A range check paints every pixel below 0 blue, above 1 red and NaN magenta, and everything that is fine grey, so a problem of one pixel cannot hide in a busy image.
uView picks what the left side shows; the right side is always the real image. Drag across the canvas to move the split. 1: the normal as a colour. 2: the ball's texture coordinates as repeating bands. 3: N·L with its sign, negative in red. 4: a range check on the final colour: blue below 0, red above 1, magenta for NaN, grey where all is well.
When you have no idea where the bug is, halve the space it can be in, again and again. Each step takes one edit and one look, so even a long shader takes only a few steps:
| Step | If it is still wrong | If it is right |
|---|---|---|
| Write a constant colour on the first line of main and return. | the shader is not the problem: geometry, blending, depth test, or the wrong program bound (see Debugging OpenGL) | the bug is in the shader; go on |
| Replace a uniform or texture you doubt with the value it should have. | the bug is in the shader's own maths | the bug is on the CPU side: wrong location, glUniform1i for a float, a uniform the compiler removed |
| Output an intermediate value halfway down main and return. | the bug is above that line: move the early return up | the bug is below it: move the return down |
Sometimes a colour is not precise enough: you need to know whether a value is 0.5 or 0.503. Then pick one pixel and read its value as a number. Render to a 32-bit float target, write the value you want only for that pixel, and read it back with glReadPixels. That is a printf for one pixel, and it is how the figure in the next section gets its numbers.
1// Early return: everything below this line is not run
2FragColor = vec4(vec3(ndl), 1.0); return;
3
4// Pixel probe: write the exact numbers for pixel (200, 100) only.
5// Render into a GL_RGBA32F texture, then on the CPU:
6// glReadPixels(200, 100, 1, 1, GL_RGBA, GL_FLOAT, out);
7if (ivec2(gl_FragCoord.xy) == ivec2(200, 100)) {
8 FragColor = vec4(ndl, spec, length(N), 1.0);
9 return;
10}The specification lists, function by function, inputs for which the result is undefined: sqrt of a negative number, pow of a negative base, acos of a value above 1, and more. Undefined does not mean an error. The GPU returns something, often NaN (not a number), sometimes a plain number, and different GPUs return different things. The figure runs each case as a real shader on your GPU and reads the result back:
| expression | the spec says | RTX 4060 |
|---|---|---|
| sqrt(-1.0) | undefined for x < 0 | NaN |
| log(0.0) | undefined for x ≤ 0 | −Inf |
| 1.0 / 0.0 | +Inf in IEEE 754, which GLSL ES does not require | +Inf |
| x / x, with x = 0.0 | 0 / 0 is NaN in IEEE 754 | 1 |
| pow(-2.0, 2.0) | undefined for x < 0 | 4 |
| pow(-2.0, 2.2) | undefined for x < 0 | NaN |
| pow(0.0, -1.0) | undefined for x = 0 and y ≤ 0 | +Inf |
| normalize(vec3(0.0)).x | divides by a length of 0 | NaN |
| acos(1.000001) | undefined for |x| > 1 | NaN |
| atan(0.0, 0.0) | undefined when both are 0 | 0 |
| NaN * 0.0 | NaN in IEEE 754 | 0 |
| min(NaN, 1.0) | not specified | 1 |
| max(NaN, 0.0) | not specified | NaN |
| clamp(NaN, 0.0, 1.0) | not specified | 1 |
| isnan(NaN) ? 1.0 : 0.0 | 1, or always 0 if the GPU has no NaNs | 1 |
| -7 % 3 | undefined if an operand is negative | -1 |
| mod(-7.0, 3.0) | x − y · floor(x / y) = 2 | 2 |
| smoothstep(1.0, 0.0, 0.25) | undefined if edge0 ≥ edge1 | 0.84375 |
| float x; (never written) | undefined value | 0 |
| f() that misses its return | undefined value | 0 |
Every row is a real one-pixel shader. Pink is NaN, amber is infinity. Look at x / x: 0 / 0 should be NaN, but the compiler simplified x / x to 1 before the GPU ever saw a zero. NaN * 0.0 became 0 the same way, while min and clamp quietly turned NaN into a number and max kept it. None of this is guaranteed: another GPU, driver or browser may print a different column, and that is exactly why shaders must not rely on it.
NaN is the worst of these, because it spreads. Any arithmetic with a NaN gives NaN, so one bad value in a lighting term turns the whole colour into NaN. When the colour is written to a normal 8-bit target, NaN usually becomes 0: black pixels, or a black shape with crisp edges, where the bad input lies. Infinity spreads in a gentler way: it turns into NaN as soon as you compute ∞ − ∞, 0 · ∞ or ∞ / ∞.
The ambient term 0.12 should keep the whole ball dimly lit, yet a pure black crescent sweeps across its dark side. Turn on uFind: it paints every NaN pixel magenta and dims the rest. Which line makes NaN only there? (The answer is in the text below the playground.)
The answer: pow(dot(N, H), uShine). The half vector H points between the light and the viewer. On the side of the ball that faces away from it, dot(N, H) is negative, and pow of a negative base is undefined. GPUs compute pow(x, y) as exp2(y · log2(x)), and log2 of a negative number is NaN. NaN then flows through the sum and the gamma line into the colour, and the 0.12 of ambient light is lost with it. The fix is pow(max(dot(N, H), 0.0), uShine). Every pixel that showed magenta was a pixel where a negative number reached pow.
| Expression | Undefined when | Guarded version |
|---|---|---|
| sqrt(x) | x < 0 | sqrt(max(x, 0.0)) |
| pow(x, y) | x < 0, or x = 0 and y ≤ 0 | pow(max(x, 0.0), y) |
| log(x), log2(x) | x ≤ 0 | log(max(x, 1e-30)) |
| acos(x), asin(x) | |x| > 1 | acos(clamp(x, -1.0, 1.0)) |
| normalize(v) | v has length 0 | check dot(v, v) > 1e-12 first, or pick a fallback direction |
| a / b | b = 0 | a / max(b, 1e-6) when b ≥ 0 |
| atan(y, x) | x = y = 0 | test for the centre before calling it |
| smoothstep(e0, e1, x) | e0 ≥ e1 | 1.0 − smoothstep(e1, e0, x) for a falling edge |
| clamp(x, lo, hi) | lo > hi | make sure the bounds are in order |
| a % b (int) | a or b negative | mod() on floats, or unsigned ints |
Colours and probes work everywhere. On native platforms, tools can do more: capture a frame, pick a pixel, and step through the shader for that pixel line by line with every variable's value. Support depends on the API:
| Tool | What it does for shaders | Where |
|---|---|---|
| RenderDoc | frame capture, pixel history, and line-by-line shader debugging; for OpenGL it captures the frame but cannot step through shaders | Vulkan, D3D11, D3D12, OpenGL |
| debugPrintfEXT | printf from a shader (GL_EXT_debug_printf); the validation layer prints the output, and RenderDoc shows it per draw | Vulkan |
| Nsight Graphics | NVIDIA's frame debugger and profiler; its shader debugger works with Vulkan and D3D12 | Vulkan, D3D12, OpenGL |
| Xcode Metal debugger | steps through Metal shaders, which is what WebGL becomes on a Mac | macOS, iOS |
| Spector.js | a browser extension that captures every WebGL call of a frame with its state and shader source; no stepping | WebGL |
| glslangValidator, glslc | compile offline with the reference compiler, to catch errors before the driver sees them | any |
1#version 450
2#extension GL_EXT_debug_printf : enable
3...
4// Printed once per invocation that runs it, so limit it to one pixel
5if (ivec2(gl_FragCoord.xy) == ivec2(200, 100))
6 debugPrintfEXT("N = %v3f, ndl = %f", N, ndl);| Symptom | Cause | Fix |
|---|---|---|
| dozens of errors after one small edit | one mistake confuses the parser for everything that follows | fix only the first message, then compile again |
| the log points at a line that looks fine | code was added in front of the shader, or the mistake is at the end of the line before | #line 1 before your code; look one line up |
| black pixels with sharp edges | NaN from an unguarded sqrt, pow, acos or normalize | NaN view to find them, then guard the input |
| works in the browser, flickers in the native build | an uninitialised variable, zeroed by ANGLE but not by the driver | initialise everything |
| works on one GPU, fails to compile on another | a lenient driver accepted code outside the specification | check with glslangValidator in the build |
| a debug colour looks right but the result is still wrong | the value clipped to 0..1 on screen, hiding that it was 3 or −2 | use a range check, fract, or the pixel probe |
Key ideas
- Fix the first compile error first; a syntax error is reported where the parser noticed it, often one line late.
- #line 1 before your own code makes the log's line numbers yours again.
- Paint the value you doubt as a colour, split against the real image, and range-check final colours.
- Cut the shader in half with early returns and constants; read exact numbers with a one-pixel probe.
- sqrt, pow, log, acos, normalize, division and % have inputs with undefined results; NaN spreads and usually shows as black.
- The browser zeroes forgotten variables; native drivers do not. RenderDoc, debugPrintfEXT and vendor tools can step through shaders.