Debugging Shaders That Look Wrong
Start by displaying the value you suspect is wrong, then narrow down the graph or build a smaller reproduction. This page covers those three approaches and a few common causes. The aim is to test one assumption at a time.
On this page
Technique 1: Output The Suspect Technique 2: Bisect Technique 3: Minimal Repro The Wrong-Looks Catalogue When It's Not Wrong, Just SlowTechnique 1 - Output The Suspect
A useful feature of a pixel shader for debugging: 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. This helps distinguish incorrect input data from a problem in the shader logic. (Values can exceed the visible 0-1: multiply down or frac them to inspect big ranges.)
Technique 2 - Bisect The Graph
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. In a simple chain, repeatedly halving the search area can narrow a hundred nodes to one in about seven checks - 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.
Technique 3 - The Minimal Repro
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 artefact a rendering engineer needs if the answer turns out to be "engine bug." Attach it to the ticket; include the expected result, actual result, and steps to reproduce it.
The Catalogue Of Classic Wrong-Looks
| 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 our normal tool'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 (check whether both sides use MikkTSpace and matching settings) |
| 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 maths (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, localise world coordinates before the maths |
| 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 |
When It's Not Wrong, Just Slow
Different discipline, same instrumentation instinct. In Unreal: Shader Complexity view (Alt+8) for the heat map - green is fine, red suggests the material or overdraw needs closer inspection; 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 optimise. The materials performance guide takes it from here.
A useful test case is a lighting value that becomes slightly negative before reaching a square-root or power operation. Depending on the expression, the result may become NaN and affect later passes. Display the intermediate value, test the relevant range, and guard the operation where the invalid input first appears.
