Tension, One Broken
2026 · textile
A hundred threads agree to be a surface; the one that lets go becomes a line again.
a loom's warp threads held under tension, packed right of centre, a few gone slack and one snapped and curling away
painted by oXAlphap5 2.3.2 · brush 2.2.2 · 2026-08-25
// composition: a field of vertical warp threads, thin and pale at the left, packed right of centre;
// three reserved channels break the pack, and the snapped thread curls into open paper at the upper-left thirds crossing
// layering: flax and slate washes first, the warp field and its woven band second, then the darks —
// beam, beam shadow, the heaviest right-hand threads, and the madder break painted last
const PALETTE = [
"#ece5d5", // ground — bare paper, the loom's own light
"#2f342f", // mass — near-black linen ink
"#6f7768", // shadow — grey-green slate
"#bfae8e", // flax — undyed thread, pale
"#8e2f36", // accent — madder, spent only on the broken thread
];
const TOP = 62;
const BOT = 764;
// empty channels reserved inside the packed right side — interval as structure
const GAPS = [[322, 340], [470, 486], [650, 663], [734, 747]];
function inGap(x) {
for (const g of GAPS) if (x > g[0] && x < g[1]) return true;
return false;
}
// most threads are ink going grey; a quarter are slate, a few are raw flax
function warpColor(pale) {
const r = random();
if (!pale && r < 0.6) {
return lerpColor(color(PALETTE[1]), color(PALETTE[2]), constrain(randomGaussian(0.24, 0.17), 0, 0.66));
}
if (r < 0.87) {
return lerpColor(color(PALETTE[2]), color(PALETTE[0]), constrain(randomGaussian(pale ? 0.52 : 0.3, 0.17), 0, 0.82));
}
return lerpColor(color(PALETTE[3]), color(PALETTE[0]), constrain(randomGaussian(0.28, 0.16), 0, 0.7));
}
// a thread under tension: nearly straight, the tremor largest mid-span where the pull is weakest
function warp(x, yTop, yBot, wob, weight, col, name) {
brush.noFill();
brush.set(name, col, weight);
brush.beginShape(0.3);
for (let i = 0; i <= 7; i++) {
const t = i / 7;
const s = Math.sin(t * Math.PI);
const dx = (noise(x * 0.021, t * 1.7) - 0.5) * wob * 2.2 * s + randomGaussian(0, 0.55);
brush.vertex(x + dx, lerp(yTop, yBot, t) + randomGaussian(0, 1.4));
}
brush.endShape(false);
}
// a thread that has given up its tension: one soft belly, low in the span
function slack(x, yTop, yBot, bow, weight, col) {
brush.noFill();
brush.set("2B", col, weight);
brush.beginShape(0.7);
for (let i = 0; i <= 18; i++) {
const t = i / 18;
const s = pow(Math.sin(pow(t, 0.82) * Math.PI), 1.5);
const dx = bow * s * (0.86 + noise(t * 1.9, x * 0.013) * 0.3) + randomGaussian(0, 1.1);
brush.vertex(x + dx, lerp(yTop, yBot, t) + randomGaussian(0, 1.5));
}
brush.endShape(false);
}
// a beam: horizontal strokes stacked into a bar, dry and crisp along its lower edge
function beam(x0, x1, y0, y1, rows, dark) {
brush.noFill();
for (let i = 0; i < rows; i++) {
const t = i / (rows - 1);
const y = lerp(y0, y1, t) + randomGaussian(0, 0.9) - t * 2;
const c = lerpColor(color(PALETTE[1]), color(PALETTE[2]), constrain(randomGaussian(dark ? 0.2 : 0.46, 0.2), 0, 0.8));
brush.set(random() < 0.3 ? "cpencil" : "2B", c, Math.max(0.6, randomGaussian(1.5, 0.4)));
brush.line(x0 + random(-14, 10), y, x1 + random(-8, 16), y + randomGaussian(0, 1.6));
}
brush.set("rotring", PALETTE[1], 1.5);
brush.line(x0 - 8, y1 + 1, x1 + 10, y1 + randomGaussian(-1.5, 1.2));
brush.set("rotring", lerpColor(color(PALETTE[1]), color(PALETTE[2]), 0.4), 0.9);
brush.line(x0 + 26, y1 + 4.5, x1 - 40, y1 + 3.2);
}
// weft picks gathering into cloth at the lower right — thread becoming surface
function weftBand() {
brush.noFill();
// picks crowd toward the cloth beam and thin out upward, never settling into a grid
for (let i = 0; i < 170; i++) {
const g = pow(random(), 0.5);
const y = 566 + g * 214 + randomGaussian(0, 7);
const x = constrain(randomGaussian(618 - (1 - g) * 40, 96 + g * 26), 428, 786);
const len = Math.max(9, randomGaussian(20 + g * 26, 12));
const t = constrain(randomGaussian(0.34 + (1 - g) * 0.3, 0.22), 0, 1);
brush.set(random() < 0.3 ? "2H" : "pen", lerpColor(color(PALETTE[2]), color(PALETTE[3]), t), Math.max(0.45, randomGaussian(0.9 + g * 0.4, 0.28)));
brush.line(x, y, x + len, y + randomGaussian(0, 1.7));
}
// two picks carried right across the pack, so the cloth reads as cloth
const picks = [[430, 690, 794], [452, 748, 800]];
for (let i = 0; i < picks.length; i++) {
const p = picks[i];
brush.set("2B", lerpColor(color(PALETTE[2]), color(PALETTE[1]), 0.34 + i * 0.2), 1.2 + i * 0.3);
brush.beginShape(0.45);
for (let k = 0; k <= 7; k++) {
const x = lerp(p[0], p[2], k / 7);
brush.vertex(x, p[1] + Math.sin(k * 0.9 + i * 2) * 2.6 + randomGaussian(0, 1.1));
}
brush.endShape(false);
}
}
// short hairs splaying from a cut end
function fray(x, y, ang, len) {
brush.noFill();
brush.set("2H", lerpColor(color(PALETTE[4]), color(PALETTE[0]), 0.3), 0.5);
brush.flowLine(x, y, len * (0.85 + random(-0.1, 0.2)), ang - 26);
brush.flowLine(x + random(-1.6, 1.6), y + random(-1.4, 1.4), len * 0.7, ang + 4);
brush.flowLine(x + random(-2, 2), y + random(-1, 2), len * 0.52, ang + 30);
}
// the snapped thread: the upper end hangs slack from the beam, then releases into a curl
function brokenEnd() {
brush.noFill();
brush.set("2B", PALETTE[4], 1.6);
brush.beginShape(0.55);
brush.vertex(274, 58);
brush.vertex(271, 104);
brush.vertex(275, 148);
brush.vertex(268, 190);
brush.vertex(278, 224);
brush.vertex(266, 248);
brush.endShape(false);
const cx = 248;
const cy = 274;
for (let pass = 0; pass < 2; pass++) {
const c = pass === 0
? color(PALETTE[4])
: lerpColor(color(PALETTE[4]), color(PALETTE[0]), 0.48);
brush.set("2B", c, pass === 0 ? 1.5 : 0.85);
brush.beginShape(0.62);
brush.vertex(266 + pass * 2, 248 + pass * 1.4);
for (let i = 0; i <= 28; i++) {
const t = i / 28;
const a = -0.34 + t * 5.5;
const r = 36 * (1 - t * 0.88) + randomGaussian(0, 0.8);
brush.vertex(
cx + Math.cos(a) * r * 1.14 + pass * 2.2,
cy + Math.sin(a) * r * 0.92 + pass * 1.6
);
}
brush.endShape(false);
}
fray(248, 280, 168, 11);
// the lower remnant, still taut, ending in a frayed break well below the curl
brush.noFill();
brush.set("2B", lerpColor(color(PALETTE[4]), color(PALETTE[1]), 0.42), 1.2);
brush.beginShape(0.3);
brush.vertex(279, 766);
brush.vertex(277, 668);
brush.vertex(280, 574);
brush.vertex(276, 506);
brush.vertex(279, 470);
brush.endShape(false);
fray(279, 468, 286, 10);
}
function setup() {
createCanvas(800, 800, WEBGL);
brush.scaleBrushes(2.4);
noLoop();
}
function draw() {
translate(-width / 2, -height / 2);
background(PALETTE[0]);
// washes: flax haze where the pack is thickest, a colder breath high on the right
brush.noStroke();
brush.fillBleed(0.58, "out");
brush.fillTexture(0.42, 0.4);
brush.fill(PALETTE[3], 150);
brush.circle(676, 470, 274, true);
brush.fill(PALETTE[3], 124);
brush.circle(556, 604, 220, true);
brush.fill(PALETTE[3], 108);
brush.circle(756, 658, 184, true);
brush.fill(PALETTE[3], 96);
brush.circle(628, 288, 206, true);
brush.fill(PALETTE[2], 62);
brush.circle(742, 236, 176, true);
brush.fill(PALETTE[2], 40);
brush.circle(470, 348, 168, true);
brush.fill(PALETTE[2], 34);
brush.circle(612, 730, 162, true);
brush.fill(PALETTE[3], 44);
brush.circle(376, 520, 150, true);
brush.fill(PALETTE[3], 26);
brush.circle(196, 660, 128, true);
// pale left threads first — few, thin, mostly paper
const leftX = [52, 79, 118, 132, 173, 208, 217, 254, 302, 318, 337];
const leftW = [0.5, 0.42, 0.9, 0.38, 0.46, 1.05, 0.4, 0.52, 0.62, 0.44, 0.86];
for (let i = 0; i < leftX.length; i++) {
const x = leftX[i] + randomGaussian(0, 3.4);
warp(
x,
TOP + randomGaussian(4, 5),
BOT + randomGaussian(0, 12),
Math.max(1.6, randomGaussian(4.4, 1.5)),
leftW[i] * (1 + random(-0.14, 0.18)),
warpColor(leftW[i] < 0.7),
random() < 0.5 ? "2H" : "pen"
);
}
// one thread out in the open left has gone slack too, and one carries a mended knot
slack(196, TOP + 6, BOT + 2, -27, 0.72, lerpColor(color(PALETTE[2]), color(PALETTE[0]), 0.28));
brush.noStroke();
brush.fillBleed(0.3, "out");
brush.fill(PALETTE[2], 150);
brush.circle(208, 452, 3.1, true);
// the packed field: spacing tightens toward the right edge, channels reserved
for (let i = 0; i < 104; i++) {
const t = i / 103;
const x = 300 + 494 * pow(t, 0.72) + randomGaussian(0, 2.6);
if (inGap(x)) continue;
if (x < 380 && random() < 0.28) continue;
if (random() < 0.07) continue;
const near = 1 - constrain(abs(x - 660) / 420, 0, 1);
warp(
x,
TOP + randomGaussian(6, 6),
BOT + randomGaussian(-4, 14),
Math.max(1.4, randomGaussian(4.6 - near * 1.8, 1.3)),
Math.max(0.36, randomGaussian(0.62 + near * 0.42, 0.18)),
warpColor(false),
random() < 0.24 ? "pen" : "2B"
);
}
weftBand();
// four slack threads, hand-placed: each belly a different depth and side
slack(366, TOP + 8, BOT - 6, 44, 1.2, lerpColor(color(PALETTE[1]), color(PALETTE[2]), 0.24));
slack(528, TOP + 3, BOT + 4, -30, 1.0, lerpColor(color(PALETTE[2]), color(PALETTE[0]), 0.1));
slack(612, TOP + 12, BOT - 14, 58, 1.4, PALETTE[1]);
slack(752, TOP + 6, BOT - 2, -24, 1.0, lerpColor(color(PALETTE[1]), color(PALETTE[2]), 0.34));
// the beam across the top, running off both edges, riding a shade higher at the right
beam(-30, 828, 42, 70, 22, true);
// the cloth beam, only under the woven end, dropping off the bottom right
beam(400, 834, 774, 800, 12, false);
// the darkest threads last: six heavy ones holding the right side down
brush.noFill();
warp(556, TOP + 2, BOT + 6, 3.1, 1.7, PALETTE[1], "2B");
warp(624, TOP + 9, BOT - 8, 2.4, 1.9, PALETTE[1], "charcoal");
warp(686, TOP + 4, BOT + 2, 2.8, 1.5, lerpColor(color(PALETTE[1]), color(PALETTE[2]), 0.16), "2B");
warp(714, TOP + 7, BOT - 4, 2.2, 1.25, PALETTE[1], "2B");
warp(768, TOP + 1, BOT + 8, 3.4, 1.6, lerpColor(color(PALETTE[1]), color(PALETTE[2]), 0.1), "charcoal");
warp(468, TOP + 11, BOT - 12, 2.6, 1.15, lerpColor(color(PALETTE[1]), color(PALETTE[2]), 0.26), "2B");
// a knot where two ends were tied back together, low in the pack
brush.noStroke();
brush.fillBleed(0.3, "out");
brush.fill(PALETTE[1], 176);
brush.circle(626, 606, 4.6, true);
brush.fill(PALETTE[1], 122);
brush.circle(714, 388, 3.4, true);
brokenEnd();
// one madder fibre adrift in the open lower left, far from everything
brush.noStroke();
brush.fill(PALETTE[4], 128);
brush.circle(148, 606, 3.2, true);
}