Dither Field
A metaball field drawn with two colours and no blending. Each dot compares the field under it with its own threshold from an 8 by 8 Bayer matrix, so a soft edge turns into a change in dot density. The field is sampled once per dot, which makes the pitch a real control. It is this site's resting scene.
- 2026
- Shader
- Dither
- Pointer
Controls
Speed
Pointer
Dot pitch
Origin
First written for canvas 2D on an earlier Align Scale site, after studying the dithered hero on aspensearch.com; this is the fragment shader port.
Source
scene.ts67 lines
precision highp float;
#define TRAIL 8
uniform vec2 uRes;
uniform float uScale;
uniform float uTime;
uniform vec2 uMouse;
uniform float uEnergy;
uniform vec3 uTrail[TRAIL];
uniform vec3 uGround;
uniform vec3 uInk;
uniform vec3 uAccent;
uniform float uCell;
uniform float uInteraction;
// The 8x8 Bayer threshold of a cell, built from the 2x2 pattern without a lookup table.
float bayer2(vec2 a) {
a = floor(a);
return fract(a.x * 0.5 + a.y * a.y * 0.75);
}
float bayer4(vec2 a) { return bayer2(0.5 * a) * 0.25 + bayer2(a); }
float bayer8(vec2 a) { return bayer4(0.5 * a) * 0.25 + bayer2(a); }
// Inverse-square falloff: summed, the blobs merge instead of overlapping as circles.
float ball(vec2 p, vec2 centre, float radius) {
vec2 d = p - centre;
return radius * radius / (dot(d, d) + 0.0008);
}
void main() {
float cell = max(2.0, floor(uCell * uScale + 0.5));
vec2 id = floor(gl_FragCoord.xy / cell);
vec2 uv = (id + 0.5) * cell / uRes;
float aspect = uRes.x / uRes.y;
vec2 p = vec2(uv.x * aspect, uv.y);
float t = uTime * 0.16;
float field = ball(p, vec2(uMouse.x * aspect, uMouse.y), 0.2 + 0.08 * uEnergy * uInteraction);
field += ball(p, vec2((0.5 + cos(t) * 0.3) * aspect, 0.48 + sin(t * 1.3) * 0.26), 0.2);
field += ball(p, vec2((0.5 + cos(t * 1.4 + 2.0) * 0.34) * aspect, 0.5 + sin(t + 1.0) * 0.3), 0.15);
field *= 0.075;
// What the pointer has touched lately.
float heat = 0.0;
for (int i = 0; i < TRAIL; i++) {
vec2 d = p - vec2(uTrail[i].x * aspect, uTrail[i].y);
heat = max(heat, exp(-dot(d, d) * 30.0) * uTrail[i].z);
}
heat *= uInteraction;
field += heat * 0.3;
// A slow tide under everything, so the empty part of the field is sparse, not blank.
field += 0.1 + 0.07 * sin(p.x * 2.1 + p.y * 1.7 - uTime * 0.11);
field = min(field, 1.0);
float threshold = bayer8(id);
float ink = step(0.42 + (threshold - 0.5) * 0.62, field);
float warm = ink * step(0.55 + (threshold - 0.5) * 0.5, heat);
// Round dots with a soft edge of about a pixel.
float r = length(fract(gl_FragCoord.xy / cell) - 0.5);
float dotShape = 1.0 - smoothstep(0.4 - 1.0 / cell, 0.4, r);
vec3 colour = mix(uInk, uAccent, warm);
gl_FragColor = vec4(mix(uGround, colour, ink * dotShape), 1.0);
}