Shader Programming skill for Claude Code

GPU code with named spaces and no NaN speckles. Claude writes shaders with every vector tagged by its coordinate space, lighting done in linear color, and procedural edges antialiased with derivatives. It extends engine lighting instead of rewriting it, ports between GLSL, HLSL, WGSL and Godot syntax, and debugs by rendering intermediate values as color and hunting NaNs before touching performance.

  • nameshader-programming
  • size209 lines · ≈2,300 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Writes and debugs GPU shaders in GLSL, HLSL, WGSL, and Godot shading language, covering vertex and fragment stages, coordinate spaces, normal mapping, Lambert, Blinn-Phong and Fresnel lighting, procedural noise and SDFs, color space correctness, precision, cross-compilation, and GPU performance. Use when writing a custom material or post effect, porting a shader between engines or from GLSL to WGSL, when output is black, banded, NaN-speckled, or too slow on mobile, or when building Shader Graph or Unity URP, three.js, Godot, or WebGPU shaders.

Try asking

  • Write a dissolve shader with a glowing edge for Unity URP
  • Port this GLSL water shader from WebGL to WGSL for WebGPU
  • My custom lighting looks wrong on scaled objects and has black speckles, debug it

Use it when

  • Writing a custom material, post effect or procedural pattern
  • Porting shaders between engines or shading languages
  • Output is black, banded, inverted or full of NaN pixels
  • A shader is too slow on mobile GPUs

Skip it when

How to check the result

The shader compiles on every target backend, debug-color passes show correct normals and uvs on scaled objects, and a frame capture on the weakest GPU meets the budget.

The common mistake it prevents

Transforming normals with the model matrix instead of the normal matrix, which skews lighting on any non-uniformly scaled object.

~/.claude/skills/shader-programming/SKILL.md

--- name: shader-programming description: Writes and debugs GPU shaders in GLSL, HLSL, WGSL, and Godot shading language, covering vertex and fragment stages, coordinate spaces,… license: MIT ---

Done well means: every vector is in a named, consistent space, lighting happens in linear color and the output is encoded once, edges are antialiased with screen-space derivatives, mobile precision and cost were considered, and every bug was found by visualizing the intermediate value rather than guessing.

Ground rules

  • Identify the target first: engine, graphics API, and shading language version. Writing GLSL 4.50 for a WebGL2 target or desktop HLSL for a mobile GLES device wastes the effort.
  • Extend the engine’s lighting model instead of rewriting PBR: Unity URP/HDRP shader includes or Shader Graph custom functions, Unreal material functions/custom nodes, Godot shader_type spatial with ALBEDO/ROUGHNESS, three.js onBeforeCompile or TSL node materials.
  • Name spaces in variable names: posOS (object), posWS (world), posVS (view), posCS (clip), nWS. Most shader bugs are mixed spaces.
  • Never ship without checking on the weakest target GPU; desktop drivers forgive things mobile drivers do not.

Workflow

- [ ] 1. Identify engine, API, language version, and the material/pass to change
- [ ] 2. Write the minimal shader that outputs a solid color; confirm it is the one rendering
- [ ] 3. Build the effect stage by stage, visualizing each intermediate as color
- [ ] 4. Check color space, alpha/blending mode, and depth/culling state
- [ ] 5. Test edge cases: grazing angles, zero-length vectors, uv seams, scaled objects
- [ ] 6. Profile on target hardware; simplify the fragment stage first

Which language and tool

TargetLanguageNotes
three.js WebGLRendererGLSL ES 3.00 via ShaderMaterial/RawShaderMaterialthree injects matrices and attributes for ShaderMaterial
three.js WebGPURendererTSL (node materials)GLSL materials are not supported there
Raw WebGPUWGSLStrict alignment rules for uniform/storage buffers
Unity URP/HDRPHLSL in ShaderLab, or Shader GraphInclude URP Core.hlsl; keywords create variants
UnrealMaterial Editor; HLSL in Custom nodes or .usfPrefer material functions over large custom nodes
Godot 4Godot shading language (GLSL-like)Built-ins: VERTEX, NORMAL, UV, ALBEDO, TIME
Vulkan / cross-platformGLSL or HLSL compiled to SPIR-Vglslang, DXC; SPIRV-Cross or naga/Tint to translate

Minimal shaders

three.js ShaderMaterial (three compiles this as GLSL ES 3.00 on WebGL2, mapping varying and gl_FragColor for you, and supplies position, uv, normal, modelViewMatrix, projectionMatrix, normalMatrix). Keep this style so three’s #include chunks work; with glslVersion: THREE.GLSL3 you declare your own in/out and lose that compatibility:

// vertex
varying vec2 vUv;
varying vec3 vNormalVS;
void main() {
  vUv = uv;
  vNormalVS = normalize(normalMatrix * normal);
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
// fragment
uniform vec3 uColor;
uniform float uTime;
varying vec2 vUv;
varying vec3 vNormalVS;
void main() {
  float rim = pow(1.0 - max(dot(normalize(vNormalVS), vec3(0.0, 0.0, 1.0)), 0.0), 3.0);
  vec3 col = uColor * (0.3 + 0.7 * rim);
  gl_FragColor = vec4(col, 1.0);
  #include <tonemapping_fragment>
  #include <colorspace_fragment>   // three's output conversion; expects gl_FragColor
}

WGSL equivalent of a fullscreen-triangle post pass:

struct VsOut { @builtin(position) pos: vec4f, @location(0) uv: vec2f };

@vertex fn vs(@builtin(vertex_index) i: u32) -> VsOut {
  let p = vec2f(f32((i << 1u) & 2u), f32(i & 2u));        // (0,0) (2,0) (0,2)
  var o: VsOut;
  o.pos = vec4f(p * 2.0 - 1.0, 0.0, 1.0);
  o.uv = vec2f(p.x, 1.0 - p.y);                             // flip: WebGPU NDC y is up, texture v is down
  return o;
}

@group(0) @binding(0) var src: texture_2d<f32>;
@group(0) @binding(1) var samp: sampler;

@fragment fn fs(in: VsOut) -> @location(0) vec4f {
  let c = textureSample(src, samp, in.uv).rgb;
  let luma = dot(c, vec3f(0.2126, 0.7152, 0.0722));
  return vec4f(vec3f(luma), 1.0);
}

Godot spatial shader:

shader_type spatial;
uniform vec4 tint : source_color = vec4(1.0);
uniform sampler2D noise_tex : hint_default_white, filter_linear_mipmap;
void fragment() {
  float n = texture(noise_tex, UV * 4.0 + vec2(TIME * 0.05)).r;
  ALBEDO = tint.rgb * mix(0.6, 1.0, n);
  ROUGHNESS = 0.7;
}

source_color marks the uniform as sRGB so Godot linearizes it; omit it for data values.

Spaces and transforms

From -> ToMatrix
Object -> Worldmodel matrix (unity_ObjectToWorld, modelMatrix, MODEL_MATRIX)
World -> Viewview matrix
View -> Clipprojection matrix
Normals object -> worldinverse-transpose of the model matrix (normal matrix), then normalize
  • Under non-uniform scale, transforming normals by the model matrix skews them; always use the normal matrix (engines provide helpers such as URP TransformObjectToWorldNormal).
  • Re-normalize interpolated normals in the fragment shader.
  • Normal maps: sample, remap n = tex * 2 - 1, build TBN from interpolated normal and tangent (with handedness tangent.w), transform to the lighting space. Normal map textures must be sampled as linear data, never sRGB.
  • DirectX-style vs OpenGL-style normal maps differ in green channel sign; flip n.y if bumps look inverted.

Lighting essentials

  • Lambert diffuse: max(dot(N, L), 0).
  • Blinn-Phong specular: H = normalize(L + V), pow(max(dot(N, H), 0), shininess).
  • Fresnel (Schlick): F0 + (1 - F0) * pow(1 - max(dot(N, V), 0), 5).
  • All lighting math in linear space; encode to sRGB once at output (most engines do it for you, so do not add a manual pow(c, 1/2.2) on top).

For hash, value noise, fBm, SDF primitives, smooth min, cosine palettes, dithering, and Fresnel/Blinn-Phong helper functions in GLSL and WGSL, read references/shader-snippets.md when the effect needs procedural patterns or analytic shapes.

Antialiasing procedural edges

float d = sdCircle(p, 0.5);                  // signed distance
float w = fwidth(d);                         // pixel footprint
float alpha = 1.0 - smoothstep(-w, w, d);    // crisp at any zoom

Derivatives (fwidth, dFdx, dpdx in WGSL) are undefined inside non-uniform control flow; compute them before branching.

Performance

CostFix
Heavy per-pixel mathMove to vertex shader or precompute into a texture (LUT)
Many texture samplesPack channels (roughness/metal/AO in one texture), lower resolution
Overdraw from transparent layersFewer layers, smaller quads, alpha-tested cutouts
discard/alpha test on mobile tile-based GPUsBreaks early depth; use sparingly, prefer opaque geometry
Divergent branchesBranch on uniforms (coherent); replace per-pixel branches with step/mix when both sides are cheap
Unity shader variant explosionshader_feature for material toggles (stripped if unused), multi_compile only for runtime switches
Full-precision everywhere on mobilemediump/half for colors and UVs in small ranges; keep highp for positions and large UVs

Debugging

  • Output the suspect value as color: fragColor = vec4(N * 0.5 + 0.5, 1) for normals, vec4(vec3(fract(uv * 10.0)), 1) for uv layout, vec4(vec3(d), 1) for distances.
  • NaN check: if (any(isnan(v))) fragColor = vec4(1, 0, 1, 1); (magenta). Usual sources: normalize(vec3(0)), pow(negative, x), sqrt(negative), division by zero.
  • Frame capture: RenderDoc (desktop, Android), Spector.js (WebGL), PIX (DirectX), Xcode GPU frame capture (Metal), browser WebGPU devtools or GPUDevice error scopes.
  • Compile errors: read the exact line in the driver log; engines prepend code, so map line numbers back (three logs the full source with line numbers on failure).

Gotchas

  • GLSL ES fragment shaders need a default float precision (precision highp float;) unless the engine injects it (three.js does for ShaderMaterial).
  • WGSL uniform buffers: vec3f is 16-byte aligned; pad CPU-side structs or use vec4f.
  • Texture V origin differs (OpenGL bottom-left, DirectX/Metal/WebGPU top-left); flip in one place only.
  • Banding in smooth gradients on 8-bit targets: add a tiny blue-noise or ordered dither before output.
  • TIME grows forever; sin(TIME * big) loses precision after hours. Wrap time on the CPU (time % period) for long-running sessions.
  • Integer and float literals: 1 / 2 is integer zero in GLSL; WGSL refuses implicit conversion, so write 1.0 / 2.0.

Verification

  1. Shader compiles on every target backend (WebGL2/WebGPU, Vulkan/Metal/DX, GLES) without warnings.
  2. Debug-color passes confirm normals, uvs, and intermediate values look right, including on non-uniformly scaled objects and at grazing angles.
  3. No magenta NaN pixels in a sweep of camera angles and parameter ranges.
  4. Frame capture on the weakest target shows the pass within its time budget; compare before and after for optimizations.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/shader-snippets.md 200 lines · 6.5 KB
# Shader snippets

Small, self-contained helpers. GLSL first; WGSL versions follow for the ones that differ
beyond syntax. HLSL: replace `vec` with `float2/3/4`, `mix` with `lerp`, `fract` with
`frac`, `mod(x, y)` with `x - y * floor(x / y)` (HLSL `fmod` truncates toward zero).

## Hashes

Prefer integer hashes where the target supports `uint` (GLSL ES 3.00, WGSL, HLSL SM4+).
The classic `fract(sin(dot(p, k)) * 43758.5)` hash breaks on mobile GPUs with low-precision
`sin` and shows visible patterns at large coordinates.

```glsl
uint pcg(uint v) {                       // PCG-style integer hash
  uint state = v * 747796405u + 2891336453u;
  uint word = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u;
  return (word >> 22u) ^ word;
}
float hash21(vec2 p) {                   // 2D -> [0,1)
  uvec2 q = uvec2(ivec2(floor(p)));
  return float(pcg(q.x ^ pcg(q.y))) / 4294967296.0;
}
```

```wgsl
fn pcg(v: u32) -> u32 {
  let state = v * 747796405u + 2891336453u;
  let word = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u;
  return (word >> 22u) ^ word;
}
fn hash21(p: vec2f) -> f32 {
  let q = vec2u(vec2i(floor(p)));
  return f32(pcg(q.x ^ pcg(q.y))) / 4294967296.0;
}
```

## Value noise and fBm

```glsl
float valueNoise(vec2 p) {
  vec2 i = floor(p), f = fract(p);
  vec2 u = f * f * (3.0 - 2.0 * f);       // smoothstep fade; use quintic for smoother derivatives
  float a = hash21(i);
  float b = hash21(i + vec2(1.0, 0.0));
  float c = hash21(i + vec2(0.0, 1.0));
  float d = hash21(i + vec2(1.0, 1.0));
  return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}

float fbm(vec2 p) {
  float sum = 0.0, amp = 0.5;
  mat2 rot = mat2(0.8, -0.6, 0.6, 0.8);   // rotate octaves to hide grid alignment
  for (int i = 0; i < 5; i++) {           // constant loop count: unrollable
    sum += amp * valueNoise(p);
    p = rot * p * 2.0;                    // lacunarity 2
    amp *= 0.5;                           // gain 0.5
  }
  return sum;                             // roughly [0, 1)
}
```

- Value noise is cheap but blocky; gradient (Perlin) or simplex noise looks more natural.
  Use a well-known simplex implementation or a noise texture rather than writing one.
- A tiling noise texture sampled with mipmaps is often faster than ALU noise on mobile.
- Domain warping: `fbm(p + 4.0 * vec2(fbm(p), fbm(p + 5.2)))` for organic marble/clouds.

## Signed distance functions (2D)

Negative inside, positive outside, magnitude = distance to the edge.

```glsl
float sdCircle(vec2 p, float r) { return length(p) - r; }

float sdBox(vec2 p, vec2 halfSize) {
  vec2 d = abs(p) - halfSize;
  return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
}

float sdRoundBox(vec2 p, vec2 halfSize, float r) {
  return sdBox(p, halfSize - vec2(r)) - r;
}

float sdSegment(vec2 p, vec2 a, vec2 b) {
  vec2 pa = p - a, ba = b - a;
  float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
  return length(pa - ba * h);
}

// boolean ops
float opUnion(float a, float b)     { return min(a, b); }
float opSubtract(float a, float b)  { return max(a, -b); }   // a minus b
float opIntersect(float a, float b) { return max(a, b); }
float smin(float a, float b, float k) {                       // smooth union, k = blend radius
  float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
  return mix(b, a, h) - k * h * (1.0 - h);
}
```

Render with derivative-based antialiasing:

```glsl
float d = sdRoundBox(uv - 0.5, vec2(0.3, 0.2), 0.05);
float aa = fwidth(d);
float fill = 1.0 - smoothstep(-aa, aa, d);
float outline = 1.0 - smoothstep(0.0, aa, abs(d) - 0.004);
```

Scale `p` uniformly only; non-uniform scaling breaks the distance property (outline widths
vary). Divide the result by the scale factor when scaling.

## Lighting helpers

```glsl
float lambert(vec3 N, vec3 L) { return max(dot(N, L), 0.0); }

float blinnPhong(vec3 N, vec3 L, vec3 V, float shininess) {
  vec3 H = normalize(L + V);
  return pow(max(dot(N, H), 0.0), shininess) * step(0.0, dot(N, L));  // no spec on back side
}

vec3 fresnelSchlick(float cosTheta, vec3 F0) {
  return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}

// half-Lambert wrap for soft stylized shading
float wrapDiffuse(vec3 N, vec3 L, float wrap) {
  return max((dot(N, L) + wrap) / (1.0 + wrap), 0.0);
}

// toon ramp: quantize diffuse into bands with soft edges
float toon(float ndl, float bands) {
  float x = ndl * bands;
  float aa = fwidth(x);
  return (floor(x) + smoothstep(0.5 - aa, 0.5 + aa, fract(x))) / bands;
}
```

`F0` is about 0.04 for dielectrics; metals use their albedo color as F0.

## Normal mapping

```glsl
vec3 perturbNormal(vec3 nWS, vec4 tangentWS, vec3 mapSample /* texture rgb */) {
  vec3 n = normalize(nWS);
  vec3 t = normalize(tangentWS.xyz - n * dot(n, tangentWS.xyz));   // Gram-Schmidt
  vec3 b = cross(n, t) * tangentWS.w;                               // handedness
  vec3 tsN = mapSample * 2.0 - 1.0;                                 // linear texture!
  // tsN.y = -tsN.y;  // uncomment for DirectX-convention normal maps in an OpenGL-convention engine
  return normalize(mat3(t, b, n) * tsN);
}
```

## Color utilities

```glsl
// cosine palette: a + b * cos(2*pi*(c*t + d)); tweak the four vec3s for gradients
vec3 palette(float t, vec3 a, vec3 b, vec3 c, vec3 d) {
  return a + b * cos(6.2831853 * (c * t + d));
}

vec3 srgbToLinear(vec3 c) {   // exact piecewise transfer
  return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(0.04045, c));
}
vec3 linearToSrgb(vec3 c) {
  return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));
}

float luminance(vec3 linearRgb) { return dot(linearRgb, vec3(0.2126, 0.7152, 0.0722)); }

// ordered 4x4 Bayer dither to break banding before 8-bit output
float bayer4(vec2 fragCoord) {
  ivec2 p = ivec2(mod(fragCoord, 4.0));
  int idx = p.x + p.y * 4;
  const float m[16] = float[16](0., 8., 2., 10., 12., 4., 14., 6., 3., 11., 1., 9., 15., 7., 13., 5.);
  return m[idx] / 16.0 - 0.5;
}
// color += bayer4(gl_FragCoord.xy) / 255.0;
```

Only convert manually when the engine does not; double conversion is the most common
"washed out" or "too dark" cause.

## UV tricks

```glsl
vec2 rotate2d(vec2 p, float a) { float c = cos(a), s = sin(a); return mat2(c, s, -s, c) * p; }

// tile with per-cell random id
vec2 cell = floor(uv * 8.0);
vec2 local = fract(uv * 8.0) - 0.5;
float rnd = hash21(cell);

// polar coordinates for radial effects
vec2 q = uv - 0.5;
float r = length(q);
float theta = atan(q.y, q.x);      // WGSL: atan2(q.y, q.x)
```

`fract(uv * n)` sampling a mipmapped texture creates seams at cell borders (derivatives
jump); pass explicit gradients (`textureGrad`) computed from the unwrapped uv.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc