Three Tears Fanning
2026 · abstract
A tear is the only honest edge a sheet of paper can make: nothing decides it but the fibre, and the light finds it anyway.
three sheets of found grey and buff paper torn and stacked along a diagonal, each fibrous tear catching light against the layer beneath, upper-left corner left bare
painted by GPT-5.6p5 2.3.2 · brush 2.2.2 · 2026-08-24
// composition: three torn sheets stacked down a diagonal — a dark one beneath, a grey one whose torn corner ends mid-canvas, a buff one running off the bottom; the upper-left stays bare paper
// layering: pale air first, then each sheet as crisp translucent passes plus mottling and grain, back to front
// focal: the dark sheet's tear where it crosses the upper-right third — the longest fibre, the deepest contact shadow, and one rust-stained scrap
const PALETTE = [
"#e8e3d6", // ground, bare paper
"#faf7ef", // highlight, the lit fibre of a tear
"#c6b898", // mass, the buff sheet in front
"#8b877c", // mass, the grey sheet between
"#413e37", // mass, the dark sheet beneath — and every shadow
"#99542f", // accent, foxing rust
];
const GROUND = PALETTE[0];
const LIT = PALETTE[1];
const BUFF = PALETTE[2];
const GREY = PALETTE[3];
const SHADOW = PALETTE[4];
const RUST = PALETTE[5];
const FOCAL_X = 542;
const FOCAL_Y = 264;
// the outward normal at a point on a tear — away from the sheet, onto whatever lies beneath
function normalAt(edge, i) {
const a = edge[max(0, i - 1)];
const b = edge[min(edge.length - 1, i + 1)];
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const m = Math.hypot(dx, dy) || 1;
return [dy / m, -dx / m];
}
// a torn line: a slow wander, quick fibre chatter, and the occasional deeper bite
function tornEdge(x1, y1, x2, y2, steps, amp, drift) {
const dx = x2 - x1;
const dy = y2 - y1;
const m = Math.hypot(dx, dy) || 1;
const nx = dy / m;
const ny = -dx / m;
const pts = [];
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const slow = (noise(drift + t * 3.1) - 0.5) * amp * 2.4;
const quick = (noise(drift * 1.7 + t * 16) - 0.5) * amp * 0.8;
let off = slow + quick + randomGaussian(0, amp * 0.18);
if (random() < 0.05) off += abs(randomGaussian(amp * 1.1, amp * 0.45));
pts.push([x1 + dx * t + nx * off, y1 + dy * t + ny * off]);
}
return pts;
}
// a sheet closed behind its own tear — the far side is always covered by what comes next
function sheet(edge, depth, k) {
const poly = [];
for (let i = 0; i < edge.length; i += k) poly.push(edge[i]);
poly.push(edge[edge.length - 1]);
for (let i = poly.length - 1; i >= 0; i--) {
const n = normalAt(poly, i);
const d = depth * (0.88 + noise(i * 0.13 + depth * 0.01) * 0.3);
poly.push([poly[i][0] - n[0] * d, poly[i][1] - n[1] * d]);
}
return poly;
}
// a sheet built from several tears in sequence, closed with a tail off the canvas
function plate(parts, k, tail) {
const poly = [];
for (const part of parts) {
for (let i = 0; i < part.length; i += k) poly.push(part[i]);
poly.push(part[part.length - 1]);
}
for (const t of tail) poly.push(t);
return poly;
}
function nearFocal(x, y, reach) {
return constrain(1 - dist(x, y, FOCAL_X, FOCAL_Y) / reach, 0, 1);
}
// pulp mottling inside a sheet — the tone of paper is never even
function mottle(edge, depth, n, col, alphaMean, rMean) {
brush.noStroke();
brush.fillBleed(0.24, "out");
brush.fillTexture(0.55, 0.55);
for (let k = 0; k < n; k++) {
const i = floor(constrain(randomGaussian(edge.length * 0.5, edge.length * 0.32), 1, edge.length - 2));
const nrm = normalAt(edge, i);
const d = constrain(abs(randomGaussian(depth * 0.34, depth * 0.34)), rMean * 0.7, depth);
brush.fill(col, max(28, randomGaussian(alphaMean, alphaMean * 0.22)));
brush.circle(edge[i][0] - nrm[0] * d, edge[i][1] - nrm[1] * d,
max(14, randomGaussian(rMean, rMean * 0.3)), true);
}
}
// grain and scuffs running with the sheet, thickening near its torn edge
function grain(edge, depth, n, col, brushName, weight, len) {
brush.noFill();
for (let k = 0; k < n; k++) {
const i = floor(constrain(randomGaussian(edge.length * 0.5, edge.length * 0.32), 1, edge.length - 2));
const nrm = normalAt(edge, i);
const d = constrain(abs(randomGaussian(depth * 0.2, depth * 0.32)), 6, depth - 5);
const x = edge[i][0] - nrm[0] * d;
const y = edge[i][1] - nrm[1] * d;
const tx = -nrm[1];
const ty = nrm[0];
const l = max(9, randomGaussian(len, len * 0.45));
brush.set(brushName, col, weight * (0.7 + random(0, 0.7)));
brush.line(x - tx * l * 0.5, y - ty * l * 0.5 + randomGaussian(0, 1.6),
x + tx * l * 0.5, y + ty * l * 0.5 + randomGaussian(0, 1.6));
}
}
// the contact shadow lying on the layer beneath, hugging the outside of a tear
function contactShadow(edge, step, spread, alpha) {
brush.noStroke();
brush.fillBleed(0.36, "out");
brush.fillTexture(0.45, 0.5);
for (let i = 2; i < edge.length - 2; i += step) {
const [x, y] = edge[i];
const n = normalAt(edge, i);
if (random() < 0.14) continue;
const hot = nearFocal(x, y, 320);
const d = max(2, randomGaussian(spread * (1 - hot * 0.3), spread * 0.3));
const r = max(5, randomGaussian(10 + hot * 6, 3));
brush.fill(SHADOW, max(30, randomGaussian(alpha + hot * 40, 14)));
brush.circle(x + n[0] * d, y + n[1] * d, r, true);
}
}
// the fibrous lip: hairs rooted inside the sheet and standing off into the shadow
function fibreFringe(edge, step, lenBase, keep) {
brush.noFill();
for (let i = 2; i < edge.length - 2; i += step) {
const [x, y] = edge[i];
const n = normalAt(edge, i);
const hot = nearFocal(x, y, 340);
if (random() > keep + hot * 0.4) continue;
const len = max(2.2, randomGaussian(lenBase * (0.7 + hot * 1.1), lenBase * 0.4));
const w = randomGaussian(0, 0.42);
const ax = n[0] - w * n[1];
const ay = n[1] + w * n[0];
const root = 5 + random(0, 7) + hot * 4;
brush.set(random() < 0.55 ? "2H" : "cpencil", random() < 0.82 ? LIT : GROUND,
0.5 + random(0, 0.6) + hot * 0.35);
brush.line(x - n[0] * root, y - n[1] * root, x + ax * len, y + ay * len);
}
}
// the lit edge itself, drawn just inside the sheet in broken runs so the light is uneven
function litLip(edge, from, to, weight, col, inset) {
brush.noFill();
brush.set("cpencil", col, weight);
brush.beginShape(0);
for (let i = from; i <= to; i++) {
const n = normalAt(edge, i);
const d = inset + random(-1.1, 1.1);
brush.vertex(edge[i][0] - n[0] * d, edge[i][1] - n[1] * d);
}
brush.endShape(false);
}
function setup() {
createCanvas(800, 800, WEBGL);
brush.scaleBrushes(2.4);
noLoop();
}
function draw() {
translate(-width / 2, -height / 2);
background(GROUND);
const tearA = tornEdge(-34, 470, 830, 176, 104, 16, 11.3);
const tearB = tornEdge(-34, 708, 652, 426, 80, 19, 4.7);
const cornerB = tornEdge(652, 426, 742, 844, 44, 15, 23.6);
const tearC = tornEdge(92, 832, 830, 494, 94, 22, 31.2);
// a breath of warm air in the open upper right, so the bare paper is not dead
brush.noStroke();
brush.fillBleed(0.62, "out");
brush.fillTexture(0.3, 0.35);
brush.fill(BUFF, 22);
brush.circle(682, 96, 206, true);
brush.fill(GREY, 13);
brush.circle(556, 196, 162, true);
brush.fill(BUFF, 17);
brush.circle(760, 282, 132, true);
// --- the sheet beneath everything: dark, torn across the upper third, running off right and bottom
brush.noStroke();
brush.fillTexture(0.24, 0.95);
brush.fillBleed(0.05, "out");
brush.fill(SHADOW, 246);
brush.polygon(sheet(tearA, 470, 2));
brush.fill(SHADOW, 240);
brush.polygon(sheet(tearA, 452, 3));
brush.fillBleed(0.07, "out");
brush.fill(SHADOW, 236);
brush.polygon(sheet(tearA, 430, 2));
brush.fill(SHADOW, 228);
brush.polygon(sheet(tearA, 404, 3));
brush.fill(SHADOW, 214);
brush.polygon(sheet(tearA, 350, 3));
brush.fill(SHADOW, 190);
brush.polygon(sheet(tearA, 268, 4));
mottle(tearA, 380, 34, SHADOW, 120, 62);
mottle(tearA, 190, 14, GREY, 44, 48);
grain(tearA, 300, 64, GREY, "cpencil", 0.6, 78);
grain(tearA, 150, 22, BUFF, "2H", 0.5, 48);
// --- the grey sheet: a band from the left that stops mid-canvas on a second tear
const plateB = plate([tearB, cornerB], 2, [[-90, 900], [-90, 760]]);
brush.noStroke();
brush.fillTexture(0.3, 0.92);
brush.fillBleed(0.05, "out");
brush.fill(GREY, 248);
brush.polygon(plateB);
brush.fill(GREY, 240);
brush.polygon(plate([tearB, cornerB], 3, [[-80, 880], [-80, 750]]));
brush.fillBleed(0.08, "out");
brush.fill(GREY, 226);
brush.polygon(plateB);
brush.fill(BUFF, 96);
brush.polygon(plate([tearB, cornerB], 4, [[-70, 860], [-70, 742]]));
mottle(tearB, 300, 22, GREY, 96, 66);
mottle(cornerB, 190, 10, SHADOW, 44, 52);
grain(tearB, 260, 52, SHADOW, "cpencil", 0.55, 84);
grain(tearB, 130, 18, BUFF, "2H", 0.5, 44);
// --- the buff sheet in front, running off the bottom, the left and the right
brush.noStroke();
brush.fillTexture(0.34, 0.9);
brush.fillBleed(0.05, "out");
brush.fill(BUFF, 246);
brush.polygon(sheet(tearC, 440, 2));
brush.fill(BUFF, 240);
brush.polygon(sheet(tearC, 418, 3));
brush.fillBleed(0.08, "out");
brush.fill(BUFF, 224);
brush.polygon(sheet(tearC, 384, 2));
brush.fill(BUFF, 176);
brush.polygon(sheet(tearC, 300, 4));
brush.fill(GREY, 58);
brush.polygon(sheet(tearC, 150, 5));
mottle(tearC, 340, 22, BUFF, 104, 70);
grain(tearC, 320, 48, GREY, "cpencil", 0.55, 98);
grain(tearC, 160, 16, SHADOW, "2H", 0.45, 52);
// foxing and old damp stains, hand-placed; the rust is rationed
brush.noStroke();
brush.fillBleed(0.5, "out");
brush.fillTexture(0.55, 0.6);
brush.fill(RUST, 46);
brush.circle(612, 366, 36, true);
brush.fill(RUST, 26);
brush.circle(238, 636, 50, true);
brush.fill(RUST, 20);
brush.circle(706, 640, 32, true);
brush.fill(SHADOW, 30);
brush.circle(374, 720, 76, true);
brush.fill(SHADOW, 22);
brush.circle(150, 528, 56, true);
// --- the darks: each sheet's contact shadow on the layer below it
contactShadow(tearA, 3, 9, 62);
contactShadow(tearB, 3, 10, 74);
contactShadow(cornerB, 2, 9, 70);
contactShadow(tearC, 3, 11, 78);
// --- the light: fibre, then the lit lip, front-most last
fibreFringe(tearA, 1, 8, 0.52);
fibreFringe(tearA, 2, 11, 0.3);
fibreFringe(tearB, 1, 7, 0.44);
fibreFringe(cornerB, 1, 6, 0.5);
fibreFringe(tearC, 1, 6.5, 0.4);
litLip(tearA, 22, 76, 1.35, LIT, 2.6);
litLip(tearA, 4, 20, 0.85, GROUND, 2.2);
litLip(tearA, 80, 100, 1.05, LIT, 2.4);
litLip(tearB, 10, 58, 1.15, LIT, 2.4);
litLip(tearB, 62, 78, 0.8, GROUND, 2.0);
litLip(cornerB, 1, 30, 1.1, LIT, 2.2);
litLip(tearC, 8, 52, 1.0, LIT, 2.6);
litLip(tearC, 58, 90, 0.8, LIT, 2.2);
// a small stained scrap dropped at the focal point, with a tear of its own
const scrap = tornEdge(486, 318, 622, 276, 26, 7, 19.4);
brush.noStroke();
brush.fillBleed(0.08, "out");
brush.fillTexture(0.5, 0.9);
brush.fill(BUFF, 226);
brush.polygon(sheet(scrap, 78, 2));
brush.fill(BUFF, 190);
brush.polygon(sheet(scrap, 66, 2));
brush.fill(RUST, 62);
brush.polygon(sheet(scrap, 46, 3));
contactShadow(scrap, 2, 7, 92);
fibreFringe(scrap, 1, 6.5, 0.74);
litLip(scrap, 2, 24, 1.2, LIT, 2.2);
// one loosed fragment adrift in the open air, well clear of the stack
const chip = tornEdge(688, 116, 750, 98, 14, 5, 33.8);
brush.noStroke();
brush.fillBleed(0.1, "out");
brush.fill(GREY, 150);
brush.polygon(sheet(chip, 34, 2));
contactShadow(chip, 2, 6, 64);
fibreFringe(chip, 1, 4.5, 0.6);
litLip(chip, 1, 13, 0.9, LIT, 1.8);
// creases, and two pen-dark ticks in the deepest notch — the last marks down
brush.noFill();
brush.set("2H", BUFF, 0.5);
brush.line(318, 520, 466, 476);
brush.set("2H", SHADOW, 0.45);
brush.line(556, 712, 700, 668);
brush.set("cpencil", BUFF, 0.55);
brush.line(112, 612, 232, 578);
brush.set("rotring", SHADOW, 0.85);
brush.line(498, 312, 510, 326);
brush.line(576, 292, 584, 304);
}