You can't printf a pixel - but you can make the pixel show you its variables. Shader debugging is three techniques and a catalogue of known culprits. Learn the techniques; the catalogue accumulates on its own.
The pixel shader's one superpower as a debugger: it already displays a value on screen every frame - you just choose which value. Suspect the mask? Wire the mask straight into Emissive/Base Color and look at it. The screen becomes your inspector:
return float4(mask.xxx, 1); // grayscale: is the mask what I think? return float4(uv, 0, 1); // UVs as red/green - the classic return float4(normalWS * 0.5 + 0.5, 1); // normals, remapped to visible range return float4(frac(worldPos / 100), 1); // world position as repeating bands
Read the diagnostics like an X-ray: a UV view that isn't a smooth red/green gradient means broken UVs, not a broken shader. A normal view with hard facets means the mesh normals are the problem, four layers before your fresnel. Half of "shader bugs" turn out to be input bugs, and this technique finds them in seconds. (Values can exceed the visible 0-1: multiply down or frac them to inspect big ranges.)
When the material is a hundred nodes and something's wrong somewhere: cut it in half. Output the value at the midpoint (technique 1). Looks right? The bug is downstream. Wrong? Upstream. Repeat. Seven checks find one bad node in a hundred - the same binary search from the interview bank story, because it's genuinely how working TAs debug. Corollary: build complex materials with checkpoints - named reroute nodes or function boundaries at sane midpoints make future bisection instant.
Bug survives bisection ambiguity, or appears only "sometimes"? Rebuild the smallest material that shows it: new material, the suspect feature alone, default cube. If the bug vanishes, the problem is an interaction - add pieces back one at a time. If it persists, you now hold a five-node repro instead of a hundred-node haystack - which is also precisely the artifact a rendering engineer needs if the answer turns out to be "engine bug." Attach it to the ticket; be the TA whose reports engineers love.
| Symptom | Prime suspect | The fix |
|---|---|---|
| Washed-out or too-dark data (roughness feels wrong, masks mushy) | sRGB on a data texture - gamma silently applied | Texture settings -> sRGB off; use linear formats for data. (The one-breath answer from the interview bank.) |
| Bumps look pushed in, not out | Normal map green channel convention (GL vs DX) | Flip Green Channel on import - verify with the PBR Map Generator's lit preview |
| Black splotches that spread - sometimes across the whole screen once bloom grabs it | NaN: sqrt/pow of a negative, or divide by zero | saturate() before sqrt/pow; max(d, 0.0001) on divisors. NaN spreads through every op that touches it, so guard at the source |
| Lighting has faceted seams along UV islands | Tangent space: mismatched tangents/binormals between bake and engine (or missing tangent import) | Re-import with correct tangent settings; bake and render must agree on tangent basis (MikkTSpace everywhere is the modern peace treaty) |
| Fine patterns shimmer/crawl at distance | No mips (or aliasing high-frequency detail in the shader) | Enable mips; move detail into textures (which mip) rather than procedural high-frequency math (which doesn't) |
| Banding in gradients, especially dark ones | 8-bit precision, often via a low-bit render path or texture format | Higher-precision format for the gradient source, or break the band edges with a hair of noise/dither |
| Works on PC, broken on mobile/Quest | Half-precision floats: mobile runs half where PC ran float | Big numbers (world pos, time) overflow half - frac time down, localize world coordinates before the math |
| Flickers only when the camera moves | Z-fighting (coplanar faces) or temporal AA eating a hard-edged effect | Separate the surfaces; for TAA shimmer, ease hard thresholds with smoothstep and mind dithered opacity |
Different discipline, same instrumentation instinct. In Unreal: Shader Complexity view (Alt+8) for the heat map - green is fine, red means this material or its overdraw needs a conversation; material stats (instruction counts, samplers, and the permutation count from the previous page); and the GPU profiler to confirm the material actually matters - a 300-instruction shader on a distant prop is noise, the same shader on fullscreen water is the frame. The order is always: see the cost, then optimize. The materials performance guide takes it from here.
The best shader bug I ever met: a character's eyes went black, but only at dusk, only on one console. Weeks of on-and-off hunting, until someone output the lighting term to base color - negative values, arriving from a cloud shadow function that could, at exactly the sun angle dusk produced, return -0.02. A pow() downstream turned that into NaN; the console's bloom pass smeared it across the iris. One saturate() fixed it. The lesson outlived the project: don't debug by staring at the final image - make the pixel show you its homework.