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.
- name
shader-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
- Scene setup, glTF loading and renderer configuration in Three.js — use threejs-scenes
- Generating level layouts or terrain data on the CPU — use procedural-generation
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.
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 spatialwithALBEDO/ROUGHNESS, three.jsonBeforeCompileor 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
| Target | Language | Notes |
|---|---|---|
| three.js WebGLRenderer | GLSL ES 3.00 via ShaderMaterial/RawShaderMaterial | three injects matrices and attributes for ShaderMaterial |
| three.js WebGPURenderer | TSL (node materials) | GLSL materials are not supported there |
| Raw WebGPU | WGSL | Strict alignment rules for uniform/storage buffers |
| Unity URP/HDRP | HLSL in ShaderLab, or Shader Graph | Include URP Core.hlsl; keywords create variants |
| Unreal | Material Editor; HLSL in Custom nodes or .usf | Prefer material functions over large custom nodes |
| Godot 4 | Godot shading language (GLSL-like) | Built-ins: VERTEX, NORMAL, UV, ALBEDO, TIME |
| Vulkan / cross-platform | GLSL or HLSL compiled to SPIR-V | glslang, 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 -> To | Matrix |
|---|---|
| Object -> World | model matrix (unity_ObjectToWorld, modelMatrix, MODEL_MATRIX) |
| World -> View | view matrix |
| View -> Clip | projection matrix |
| Normals object -> world | inverse-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 handednesstangent.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.yif 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
| Cost | Fix |
|---|---|
| Heavy per-pixel math | Move to vertex shader or precompute into a texture (LUT) |
| Many texture samples | Pack channels (roughness/metal/AO in one texture), lower resolution |
| Overdraw from transparent layers | Fewer layers, smaller quads, alpha-tested cutouts |
discard/alpha test on mobile tile-based GPUs | Breaks early depth; use sparingly, prefer opaque geometry |
| Divergent branches | Branch on uniforms (coherent); replace per-pixel branches with step/mix when both sides are cheap |
| Unity shader variant explosion | shader_feature for material toggles (stripped if unused), multi_compile only for runtime switches |
| Full-precision everywhere on mobile | mediump/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
GPUDeviceerror 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 forShaderMaterial). - WGSL uniform buffers:
vec3fis 16-byte aligned; pad CPU-side structs or usevec4f. - 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.
TIMEgrows forever;sin(TIME * big)loses precision after hours. Wrap time on the CPU (time % period) for long-running sessions.- Integer and float literals:
1 / 2is integer zero in GLSL; WGSL refuses implicit conversion, so write1.0 / 2.0.
Verification
- Shader compiles on every target backend (WebGL2/WebGPU, Vulkan/Metal/DX, GLES) without warnings.
- Debug-color passes confirm normals, uvs, and intermediate values look right, including on non-uniformly scaled objects and at grazing angles.
- No magenta NaN pixels in a sweep of camera angles and parameter ranges.
- 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
# 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.