Skip to content
Buse Turgay Sönmez

Design Engineer, Belgrade

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
1.00×
Pointer
1.00×
Dot pitch
7 px

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);
}
--ground
oklch(0.985 0.003 85)
--surface
oklch(0.958 0.005 85)
--raised
oklch(1 0 0)
--ink
oklch(0.2 0.006 85)
--muted
oklch(0.43 0.008 85)
--faint
oklch(0.5 0.008 85)
--line
oklch(0.9 0.004 85)
--line-strong
oklch(0.6 0.006 85)
--accent
oklch(0.66 0.22 38)
--accent-ink
oklch(0.52 0.19 38)
--stage
oklch(0.152 0.004 85)
--radius-stage
36px
--radius-stage-sm
28px
--radius-media
24px
--radius-panel
16px
--radius-field
10px
--ease-out-expo
cubic-bezier(0.16, 1, 0.3, 1)

This week · W39One WebGL context, three leaksGeometry · 2026