Skip to content
The Technical ArtistArt. Code. Pipelines. Games.
Home/Shaders/Debugging Shaders
shaders - working
debugging
method beats memory

Debugging Shaders That Look Wrong

Workingtutorial~4 min read

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 Slow

Technique 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:

HLSL - visualising intermediates
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

SymptomPrime suspectThe fix
Washed-out or too-dark data (roughness feels wrong, masks mushy)sRGB on a data texture - gamma silently appliedTexture settings -> sRGB off; use linear formats for data. (The one-breath answer from the interview bank.)
Bumps look pushed in, not outNormal 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 itNaN: sqrt/pow of a negative, or divide by zerosaturate() 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 islandsTangent 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 distanceNo 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 ones8-bit precision, often via a low-bit render path or texture formatHigher-precision format for the gradient source, or break the band edges with a hair of noise/dither
Works on PC, broken on mobile/QuestHalf-precision floats: mobile runs half where PC ran floatBig numbers (world pos, time) overflow half - frac time down, localise world coordinates before the maths
Flickers only when the camera movesZ-fighting (coplanar faces) or temporal AA eating a hard-edged effectSeparate 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 practical example

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.