Fragment shaders that look like something
A fragment shader runs once per pixel and returns one colour. Everything below is
about making that colour depend on the pixel's position and the clock in a way a
person wants to look at.
The frame: one triangle, no vertices to speak of
You do not need geometry. A single triangle covering the viewport, drawn with
gl.drawArrays(TRIANGLES, 0, 3), covers every pixel. The vertex shader is:
attribute vec2 a_position;
void main() { gl_Position = vec4(a_position, 0.0, 1.0); }
and the buffer is three vertices: (-1,-1) (3,-1) (-1,3). The corners are outside
the viewport on purpose - the visible part is the full square. shader_target target=frag writes both files for you.
uv space, and the aspect ratio trap
precision highp float;
uniform vec2 u_resolution;
uniform float u_time;
void main() {
// centred uv: x and y both run -0.5..0.5 on the SHORT axis
vec2 uv = (gl_FragCoord.xy - 0.5 * u_resolution) / min(u_resolution.x, u_resolution.y);
float d = length(uv);
float ring = 0.5 + 0.5 * sin(d * 30.0 - u_time * 2.0);
gl_FragColor = vec4(vec3(ring), 1.0);
}
Dividing by u_resolution alone makes circles into ellipses on any non-square
canvas. Dividing by min(x, y) keeps them round at any aspect. If you would rather
work in 0..1, divide by u_resolution and fix the aspect afterwards - but choose
one convention and keep it.
gl_FragCoord is in pixels, with y up from the bottom-left. That is not what
<canvas> CSS or an image editor shows, which is the single most common source of
"my shader is upside down".
Signed distance fields
An SDF returns how far a point is from a shape: negative inside, positive outside.
That one number gives you the shape, its outline, and its antialiasing.
precision highp float;
uniform vec2 u_resolution;
float sdCircle(vec2 p, float r) { return length(p) - r; }
float sdBox(vec2 p, vec2 b) {
vec2 d = abs(p) - b;
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
}
void main() {
vec2 p = (gl_FragCoord.xy - 0.5 * u_resolution) / min(u_resolution.x, u_resolution.y);
float d = min(sdCircle(p, 0.28), sdBox(p - vec2(0.35, 0.0), vec2(0.16)));
// antialias with the pixel footprint, not with a magic constant
float px = 2.0 / min(u_resolution.x, u_resolution.y);
float shape = smoothstep(px, -px, d);
vec3 col = mix(vec3(0.06, 0.07, 0.09), vec3(0.95, 0.72, 0.45), shape);
gl_FragColor = vec4(col, 1.0);
}
min() unions, max(a, -b) subtracts, smoothstep on the distance antialiases.
The pixel size px comes from the resolution - a hard-coded 0.01 produces a
different edge thickness on every screen.
For soft blends, smin(a, b, k) (a smooth minimum) is the standard trick and is
in the module/sdf* and module/operators files of the vendored corpus.
Noise, and why one octave is never enough
precision highp float;
uniform vec2 u_resolution;
uniform float u_time;
float hash(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f); // smoothstep interpolation
return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),
mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y);
}
float fbm(vec2 p) {
float v = 0.0;
float a = 0.5;
for (int i = 0; i < 5; i++) { v += a * noise(p); p *= 2.02; a *= 0.5; }
return v;
}
void main() {
vec2 p = gl_FragCoord.xy / u_resolution * 4.0;
float f = fbm(p + vec2(u_time * 0.1, 0.0));
gl_FragColor = vec4(vec3(f), 1.0);
}
Rules that matter: the octave multiplier is ~2.0 (2.02 breaks grid alignment),
amplitude halves per octave, and 4-6 octaves is the useful range. sin-based hashes
are cheap but band on large coordinates; the integer hashes in module/hash do not.
Domain warping turns noise into marble and smoke: sample noise, use it to offset the
coordinates, sample again.
precision highp float;
uniform vec2 u_resolution;
float hash(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
float noise(vec2 p) {
vec2 i = floor(p); vec2 f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y);
}
void main() {
vec2 p = gl_FragCoord.xy / u_resolution * 3.0;
vec2 q = vec2(noise(p), noise(p + vec2(5.2, 1.3)));
float marble = sin((p.x + q.x * 4.0) * 6.0) * 0.5 + 0.5;
gl_FragColor = vec4(vec3(marble), 1.0);
}
Raymarching, and the budget you have
Raymarching steps a ray through a distance field. It is the technique that gets you
3D-looking scenes with no geometry, and the one that most often runs at 12 fps
because nobody counted the steps.
precision highp float;
uniform vec2 u_resolution;
uniform float u_time;
float sdSphere(vec3 p, float r) { return length(p) - r; }
float map(vec3 p) {
vec3 q = p;
q.xz = mat2(cos(u_time * 0.4), sin(u_time * 0.4), -sin(u_time * 0.4), cos(u_time * 0.4)) * q.xz;
return sdSphere(q - vec3(0.0, 0.0, 0.0), 0.9) * 0.9;
}
vec3 normalAt(vec3 p) {
vec2 e = vec2(0.001, 0.0);
return normalize(vec3(map(p + e.xyy) - map(p - e.xyy), map(p + e.yxy) - map(p - e.yxy), map(p + e.yyx) - map(p - e.yyx)));
}
void main() {
vec2 uv = (gl_FragCoord.xy - 0.5 * u_resolution) / min(u_resolution.x, u_resolution.y);
vec3 ro = vec3(0.0, 0.0, 3.2);
vec3 rd = normalize(vec3(uv, -1.4));
float t = 0.0;
for (int i = 0; i < 96; i++) {
vec3 p = ro + rd * t;
float d = map(p);
if (d < 0.001) break;
t += d * 0.9;
if (t > 12.0) break;
}
vec3 col = vec3(0.06, 0.07, 0.1);
vec3 p = ro + rd * t;
if (t < 12.0) {
vec3 n = normalAt(p);
float light = max(dot(n, normalize(vec3(0.6, 0.8, 0.4))), 0.0);
col = vec3(0.9, 0.6, 0.35) * (0.15 + 0.85 * light);
}
gl_FragColor = vec4(pow(col, vec3(1.0 / 2.2)), 1.0);
}
Three numbers to choose deliberately: the step count (96 is already heavy at 4K),
d * 0.9 (slightly under-stepping avoids surface bands), and the far clip
(t > 12.0). A loop that only ends on d < eps is the classic hang.
Colour: work linear, show sRGB
Luminance is not (r+g+b)/3. Gamma is not optional. Two habits cover most of it:
mix in linear space and tone-map at the end.
precision highp float;
uniform vec2 u_resolution;
vec3 toLinear(vec3 c) { return pow(c, vec3(2.2)); }
vec3 toSrgb(vec3 c) { return pow(c, vec3(1.0 / 2.2)); }
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
vec3 a = toLinear(vec3(0.95, 0.55, 0.25));
vec3 b = toLinear(vec3(0.15, 0.35, 0.75));
vec3 col = mix(a, b, smoothstep(0.2, 0.8, uv.x));
col = col / (1.0 + col); // Reinhard: keeps highlights in range
gl_FragColor = vec4(toSrgb(col), 1.0);
}
mix() between two colours in sRGB space goes through grey and looks muddy;
in linear space it does not. The module/color and module/tonemap files in the
corpus have Oklab, AgX and PBR-Neutral versions if you need them.
Banding, and the one-line fix
An 8-bit target has 256 levels. A smooth gradient across 1000 pixels shows stripes.
Add a sub-LSB dither before output:
precision highp float;
uniform vec2 u_resolution;
float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
vec3 col = vec3(0.2 + 0.6 * uv.x); // a wide, very shallow gradient
col += (hash(gl_FragCoord.xy) - 0.5) / 255.0;
gl_FragColor = vec4(col, 1.0);
}
For static images use an ordered or interleaved-gradient dither instead of white
noise, so it does not crawl. module/post has ditherTPDF.
Validate as you create — the loop, in order
Writing a shader is not one call. It is this loop, and stopping early is how a
shader arrives that compiles and shows nothing.
- Start from a known-good technique.
shader_kit action=search query=... or
action=example id=gallery/ocean. A corpus piece that the check compiles and
draws is a better starting point than a blank file, and reading one teaches the
technique faster than inventing it.
- Write it, and let the compiler answer.
shader_write title="..." source="..." returns a rung and any diagnostic on
your own line numbers. If it is not v2-compiled or better, the loop stops here
— fix the line it named, do not move on.
- Prove it draws.
shader_render id="..." frames=3. This is the step people
skip: a shader can compile, link, draw, and be a black rectangle. The answer says
black, flat, and whether the three frames differ.
- Look at the frame. The tool hands back a PNG artifact. That is the only step
that judges the picture; the rung only judges the code.
- Only then
shader_target target=web (or frag, isf) and open it in the
browser that will run it.
Two rules that make this work:
- the examples are the reference and the baseline. If your shader's frame is
flat while the corpus piece you started from is not, the difference is yours to
find — render both at the same size and time and compare the signatures.
- a rung is a claim with a ceiling.
v1-static means nothing was compiled.
Never report a shader as working from it. If the host has no browser, say the
compile did not happen rather than implying it did.
Check the picture, not the log
A shader can compile, link, draw, and produce a black rectangle. shader_render
reads the pixels back and says whether the frame is black, flat, or has many
colours - and whether two different times differ. Use it before you say a shader
looks good:
shader_write - it compiles?
shader_render frames=3 - does it draw, is it varied, does it move?
- look at the PNG. That is the only step that judges the picture.
The vendored corpus is there to be read, not reinvented: shader_kit action=search technique=webgl1 for finished pieces, action=example id=module/noise for a module you can include.