Low Over the Shallows
2026 · fauna
Three strokes is the whole bird; the fourth would only slow it down.
swallows arcing low over pale water on a rising diagonal, wings caught at every phase of the beat, one skimming its own reflection
painted by Mistral Medium 3.1p5 2.3.2 · brush 2.2.2 · 2026-08-25
// composition: swallows scatter up a steep diagonal out of the lower left, low over pale water; the upper-right third is left as open air
// layering: the water plane and a few ripples first, then the birds back-to-front — far ones pale and small, the near ones dark and wide
// every bird is three strokes, two wings and a body, plus a forked tail; the skimmer at the lower-left third gets a fourth, its reflection
const PALETTE = [
"#ece7da", // paper, the air
"#93a7b0", // water, pale wash
"#4d6472", // slate, distance and ripples
"#20262b", // ink, the near birds
"#b0573a", // rust throat, the accent
];
let wet = 0.1; // how far pigment is allowed to creep past the edge of a stroke
// the body of a stroke: a ribbon laid along a spine, its half-width scaled by k
function paintRibbon(xs, ys, ws, k, bleed, col, alpha) {
const N = xs.length - 1;
brush.noStroke();
brush.fillBleed(bleed, "out");
brush.fillTexture(0.55, 0.7);
brush.fill(col, alpha);
brush.beginShape(0.6);
for (const side of [1, -1]) {
for (let i = 0; i <= N; i++) {
const j = side > 0 ? i : N - i;
const tx = xs[min(j + 1, N)] - xs[max(j - 1, 0)];
const ty = ys[min(j + 1, N)] - ys[max(j - 1, 0)];
const m = Math.hypot(tx, ty) || 1;
brush.vertex(xs[j] - (ty / m) * ws[j] * k * side, ys[j] + (tx / m) * ws[j] * k * side);
}
}
brush.endShape(true);
}
// one loaded stroke: a spine pulled from the base, curving as the wrist turns, width running out at the tip.
// a wet under-pass first, then a tighter darker pass — the pigment pools where the brush pressed hardest
function taperStroke(x0, y0, ang, len, bow, w0, w1, col, alpha) {
const N = 6;
const xs = [];
const ys = [];
const ws = [];
let x = x0;
let y = y0;
let a = ang;
for (let i = 0; i <= N; i++) {
const t = i / N;
xs.push(x);
ys.push(y);
ws.push(lerp(w0, w1, pow(t, 0.62)));
a += bow / N;
x += Math.cos(a) * (len / N);
y += Math.sin(a) * (len / N);
}
paintRibbon(xs, ys, ws, 1.16, wet + 0.34, col, alpha * 0.36);
paintRibbon(xs, ys, ws, 0.88, wet, col, alpha);
return { xs, ys };
}
// the dark core of a stroke, dragged along its loaded third only
function spine(s, col, weight) {
brush.noFill();
brush.set("2B", col, weight);
brush.beginShape(0.5);
for (let i = 0; i < 3; i++) brush.vertex(s.xs[i], s.ys[i]);
brush.endShape(false);
}
// a swallow: both wings swung back off one point. phase reads the beat: -1 wings down, 0 wide open, +1 wings up.
// the pair never closes onto the body — a swallow with shut wings is a dart, not a bird
function swallow(x, y, s, head, phase, col, alpha) {
const beat = abs(phase);
const spread = 0.72 + 0.34 * (1 - beat);
// the lift can never exceed the spread: a wing that lands on the body axis doubles it into a twig
const lift = phase * (spread - 0.44);
const jit = randomGaussian(0, 0.04);
const w = constrain(0.4 + s * 0.009, 0.4, 0.85);
// wings stay thin however large the bird: pigment width barely grows with span
const wing = 0.6 + s * 0.04;
// near wing — the long one, scything back. the curve is kept shallow: a deep hook lands the tip on the body
const a = taperStroke(
x, y, head + Math.PI - spread - lift + jit,
s * (1.18 + random(-0.08, 0.08)), 0.12 + beat * 0.16,
wing, 0.35, col, alpha,
);
// far wing — foreshortened by the roll of the body, and a shade weaker
const b = taperStroke(
x, y, head + Math.PI + spread - lift - jit,
s * (1.0 - beat * 0.26 + random(-0.07, 0.07)), -0.12 - beat * 0.16,
wing * 0.84, 0.3, col, alpha * 0.84,
);
// body and tail, one stroke pulled backwards off the head, its weight all in the breast
const hx = x + Math.cos(head) * s * 0.24;
const hy = y + Math.sin(head) * s * 0.24;
const c = taperStroke(
hx, hy, head + Math.PI, s * (0.6 + random(-0.05, 0.05)), phase * 0.12,
0.4 + s * 0.024, 0.28, col, alpha,
);
spine(a, col, w);
spine(b, col, w * 0.82);
// the fork, two thin flicks off the tail
const ex = c.xs[6];
const ey = c.ys[6];
brush.set("2B", col, max(0.35, s * 0.011));
const f = s * (0.32 + random(-0.04, 0.06));
brush.line(ex, ey, ex - Math.cos(head - 0.36) * f, ey - Math.sin(head - 0.36) * f);
brush.line(ex, ey, ex - Math.cos(head + 0.3) * f * 0.82, ey - Math.sin(head + 0.3) * f * 0.82);
}
// a chestnut throat, only on the birds close enough to have one
function throat(x, y, s, head) {
brush.noStroke();
brush.fillBleed(0.24, "out");
brush.fill("#b0573a", 175 + random(-18, 22));
brush.circle(x + Math.cos(head) * s * 0.24, y + Math.sin(head) * s * 0.24, s * 0.05, true);
}
function setup() {
createCanvas(800, 800, WEBGL);
brush.scaleBrushes(2.4);
noLoop();
}
function draw() {
translate(-width / 2, -height / 2);
background("#ece7da");
// --- water: one pale plane, heaviest at the lower left, thinning to bare paper on the right
brush.noStroke();
brush.fillTexture(0.4, 0.22);
brush.fillBleed(0.44, "out");
for (let gy = 0; gy < 4; gy++) {
for (let gx = 0; gx < 5; gx++) {
const x = -14 + gx * 98 + randomGaussian(0, 20);
const y = 506 + gy * 94 + randomGaussian(0, 20);
if (x > 378) continue;
if (random() > constrain(map(x, 0, 378, 1.05, 0.5), 0.42, 1)) continue;
brush.fill("#93a7b0", max(72, randomGaussian(104, 14)));
brush.circle(x, y, max(50, randomGaussian(68, 10)), true);
}
}
// a deeper pass in the near corner only, to weigh the bottom left down
brush.fillBleed(0.4, "out");
for (let i = 0; i < 11; i++) {
const x = randomGaussian(92, 86);
const y = randomGaussian(716, 60);
if (x > 320) continue;
brush.fill("#4d6472", max(44, randomGaussian(74, 13)));
brush.circle(x, y, max(30, randomGaussian(44, 8)), true);
}
// the darker seam just under the far edge, where the surface turns away from the light
brush.fillBleed(0.5, "out");
brush.fill("#4d6472", 74);
brush.circle(84, 492, 52, true);
brush.fill("#4d6472", 58);
brush.circle(214, 498, 44, true);
brush.fill("#93a7b0", 62);
brush.circle(324, 508, 40, true);
// the far edge of the water, said with three flat strokes and nothing else
brush.noFill();
brush.set("cpencil", "#4d6472", 1.3);
brush.line(-16, 471, 178, 468);
brush.set("cpencil", "#4d6472", 0.9);
brush.line(214, 475, 316, 472);
brush.set("cpencil", "#93a7b0", 1.5);
brush.line(62, 493, 206, 496);
// ripples: short and flat far off, longer as they come forward, each broken by a gap so none reads as a ruled line
brush.set("2B", "#4d6472", 1.2);
for (let i = 0; i < 10; i++) {
const t = i / 9;
const y = 520 + pow(t, 1.55) * 264 + randomGaussian(0, 14);
const x = randomGaussian(88 + t * 30, 88);
if (x > 330) continue;
// leave the band the skimmer occupies clear — its own surface strokes speak for it
if (y > 524 && y < 596) continue;
const len = (34 + t * 74) * (0.6 + random(0, 0.7));
const cut = 0.4 + random(0, 0.24);
brush.strokeWeight(0.8 + t * 1.3);
brush.line(x, y, x + len * cut, y + randomGaussian(0, 1.6 + t * 3));
brush.line(x + len * (cut + 0.16), y + randomGaussian(0, 2), x + len, y + randomGaussian(0, 2 + t * 3.6));
}
brush.set("cpencil", "#4d6472", 1.6);
brush.line(196, 736, 306, 740);
brush.line(52, 692, 148, 695);
brush.set("cpencil", "#93a7b0", 1.5);
brush.line(120, 776, 288, 772);
// --- the flight, back to front. the trail thins as it climbs and stops short of the top right
swallow(404, 190, 9, -0.42, 0.92, "#4d6472", 124);
swallow(448, 254, 11, -0.24, 0.30, "#4d6472", 132);
swallow(366, 258, 14, -0.36, -0.62, "#4d6472", 142);
swallow(436, 328, 17, -0.30, 0.70, "#4d6472", 152);
swallow(322, 338, 20, -0.52, -0.12, "#4d6472", 166);
swallow(458, 446, 22, -0.18, -0.88, "#4d6472", 180);
swallow(394, 378, 27, -0.42, 0.50, "#20262b", 152);
swallow(400, 506, 31, -0.10, -0.38, "#4d6472", 200);
swallow(296, 446, 38, -0.32, 0.95, "#20262b", 188);
swallow(334, 480, 24, -0.05, -0.20, "#4d6472", 190);
swallow(118, 512, 42, -0.30, 0.86, "#20262b", 210);
swallow(376, 692, 38, -0.62, -0.26, "#20262b", 206);
swallow(74, 726, 52, -0.46, 0.56, "#20262b", 230);
// the skimmer, largest, on the lower-left third — wings down, one tip in the surface
swallow(252, 556, 58, 0.14, -0.50, "#20262b", 238);
throat(252, 556, 58, 0.14);
throat(118, 512, 42, -0.30);
throat(74, 726, 52, -0.46);
// the surface it is skimming, two flat strokes drawn right under it
brush.noFill();
brush.set("cpencil", "#4d6472", 1.7);
brush.line(152, 604, 288, 602);
brush.set("cpencil", "#93a7b0", 1.3);
brush.line(304, 606, 388, 609);
// its reflection: the same bird, wetter and paler, upside down beneath that surface
wet = 0.54;
swallow(254, 650, 52, -0.15, 0.50, "#4d6472", 182);
wet = 0.1;
// the bloom where the wingtip breaks the water, and ripples laid back across the reflection
brush.noStroke();
brush.fillBleed(0.6, "out");
brush.fill("#93a7b0", 140);
brush.circle(196, 602, 20, true);
brush.fill("#ece7da", 180);
brush.circle(202, 599, 9, true);
brush.noFill();
brush.set("cpencil", "#ece7da", 2.4);
brush.line(196, 638, 302, 635);
brush.line(160, 684, 260, 681);
brush.set("2B", "#4d6472", 1.1);
brush.line(222, 648, 330, 652);
brush.line(178, 672, 268, 668);
// one feather adrift on the water, far from the flight
brush.noStroke();
brush.fillBleed(0.3, "out");
brush.fill("#4d6472", 120);
brush.circle(596, 736, 5, true);
}