How I Debug GLSL Shaders (and Keep My Sanity)

No console.log in GLSL. No breakpoints. Just a black screen. Here are my practical tools and workflow for debugging vertex and fragment shaders — including the colour-as-value trick.

The Fundamental Problem

When a GLSL shader breaks, you usually don't get an error message. You get a black screen, or a completely uniform colour, or a pattern that doesn't look anything like what you expected. The shader runs on the GPU — there are no breakpoints, no console.log, no stack traces.

There is, however, one output you can inspect: the pixel colour. Almost everything in GLSL debugging comes down to encoding your suspect values as colours and looking at the result.

Technique 1: Colour-as-Value

The most useful technique. Encode whatever you're suspicious about as the fragment colour:

// Suspect: is vNormal correct?
// Normals are in [-1,1] — remap to [0,1] for display
gl_FragColor = vec4(vNormal * 0.5 + 0.5, 1.0);

// Suspect: is the UV correct?
gl_FragColor = vec4(vUv, 0.0, 1.0);

// Suspect: is a float in the right range?
// Encode as a green channel value
gl_FragColor = vec4(0.0, clamp(myFloat, 0.0, 1.0), 0.0, 1.0);

If your normals are pointing correctly on a sphere, you'll see red on the right side, green on top, and blue facing toward you. If you see uniform grey or magenta, the normals are broken.

Technique 2: Step-by-Step Commenting

A shader bug is rarely in the place you think it is. Work backwards: comment out the final effect, add a baseline colour, and gradually uncomment until it breaks:

// Step 1: known good — just white
gl_FragColor = vec4(1.0);

// Step 2: add lighting — still ok?
// gl_FragColor = vec4(vec3(diffuse), 1.0);

// Step 3: add texture — still ok?
// gl_FragColor = vec4(texture2D(map, vUv).rgb * diffuse, 1.0);

// Step 4: add fresnel — still ok?
// float f = fresnel(vViewDir, vNormal);
// gl_FragColor = vec4(mix(deep, sky, f), 1.0);

Technique 3: Check Compilation Errors

Three.js logs shader compile errors to the console, but only if the shader actually compiles. A typo that's valid GLSL syntax won't produce an error — it produces wrong output. Always check:

// Force a compile error check after program link
const gl = renderer.getContext();
const program = material.program?.program;
if (program && !gl.getProgramParameter(program, gl.LINK_STATUS)) {
  console.error('Shader link failed:', gl.getProgramInfoLog(program));
}

Technique 4: SpectorJS

SpectorJS is a browser extension that captures a WebGL frame and shows you every draw call, every uniform value, and every texture. It's the closest thing to a GPU debugger available in the browser.

I use it whenever the colour-as-value trick doesn't narrow it down fast enough. Being able to inspect the exact texture bound to a sampler uniform has saved me hours.

Technique 5: Precision

On mobile GPUs (and some desktop integrated graphics), mediump precision can cause unexpected clamping or loss of significance. If something works on desktop but breaks on mobile, add:

// Always declare precision in fragment shaders
precision highp float;
precision highp int;

Quickest debug loop: Add gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); (solid red) at the very end of your fragment shader. If you don't see red, the shader isn't being used at all — check your material assignment, not the shader code.