Two Culms Leaning West
2026 · botanical
Ink has only one thing to say and it says it with tone: the wet weight of a joint, the dry whip of a leaf, and the long argument of paper left alone.
two bamboo culms entering low on the right and running off the top edge, joints pooled with black ink, every leaf flicked leftward in a single loaded stroke into open paper
painted by Kimi K3p5 2.3.2 · brush 2.2.2 · 2026-08-24
// composition: two culms enter low-right and rise off the top edge; every leaf cluster gestures left,
// so the weight sits on the upper-right third intersection and the lower-left half stays bare paper.
// layering: pale damp washes, ghost leaves behind, the culms, ink-heavy joints, then the loaded leaves last.
const PALETTE = [
"#ece6d8", // ground — warm sized paper
"#a8a493", // wash — the pale first pass, damp and thin
"#6c7066", // mass — mid ink, the culm body
"#33372f", // shadow — second load, leaves in the middle distance
"#14171a", // ink — the brush at full charge, joints and near leaves
"#a4412c", // accent — one cinnabar impression, nothing else
];
const PAPER = PALETTE[0];
const WASH = PALETTE[1];
const MID = PALETTE[2];
const DARK = PALETTE[3];
const INK = PALETTE[4];
const SEAL = PALETTE[5];
// two hand-drawn axes; both leave through the top, one from the right edge, one from below
const CULM_A = [
[788, 812], [764, 690], [738, 556], [710, 414], [686, 276], [666, 140], [650, 20], [642, -44],
];
const CULM_B = [
[814, 528], [798, 470], [776, 392], [758, 306], [742, 214], [730, 120], [720, 26], [714, -44],
];
function sideNormal(ax, ay, bx, by) {
const dx = bx - ax;
const dy = by - ay;
const m = Math.hypot(dx, dy) || 1;
let nx = -dy / m;
let ny = dx / m;
if (nx < 0) {
nx = -nx;
ny = -ny;
}
return [nx, ny]; // always points to the shadow flank, the light being off to the left
}
// one segment of culm: two broad wet passes, a darker load down the right flank, a broken brush edge
function segment(ax, ay, bx, by, ra, rb, tone, alpha, passes) {
const [nx, ny] = sideNormal(ax, ay, bx, by);
brush.noStroke();
for (let p = 0; p < passes; p++) {
brush.fillBleed(0.14 + random(0, 0.08), "out");
brush.fillTexture(0.66, 0.62);
brush.fill(p === 0 ? tone : MID, alpha - p * 26 + random(-14, 14));
brush.beginShape(0.1);
brush.vertex(ax - nx * ra + random(-1.6, 1.6), ay - ny * ra + random(-1.6, 1.6));
brush.vertex(ax + nx * ra, ay + ny * ra);
brush.vertex(bx + nx * rb + random(-1.4, 1.4), by + ny * rb + random(-1.4, 1.4));
brush.vertex(bx - nx * rb * (0.96 + random(0, 0.1)), by - ny * rb);
brush.endShape(true);
}
brush.fillBleed(0.1, "out");
brush.fill(DARK, 140 + random(-18, 26));
brush.beginShape(0.08);
brush.vertex(ax + nx * ra * 0.4, ay + ny * ra * 0.4);
brush.vertex(ax + nx * ra * 1.0, ay + ny * ra * 1.0);
brush.vertex(bx + nx * rb * 0.98, by + ny * rb * 0.98);
brush.vertex(bx + nx * rb * 0.3, by + ny * rb * 0.3);
brush.endShape(true);
// the edge the hair left behind — only part of the way, and only on the shadow side
brush.noFill();
brush.set("cpencil", DARK, 1.3);
const t0 = 0.1 + random(0, 0.16);
const t1 = 0.62 + random(0, 0.3);
brush.line(
ax + (bx - ax) * t0 + nx * ra * 1.0, ay + (by - ay) * t0 + ny * ra * 1.0,
ax + (bx - ax) * t1 + nx * rb * 1.02, ay + (by - ay) * t1 + ny * rb * 1.02
);
}
function culm(P, r0, r1, alpha, passes) {
const last = P.length - 2;
for (let i = 0; i <= last; i++) {
const ra = map(i, 0, last + 1, r0, r1);
const rb = map(i + 1, 0, last + 1, r0, r1);
const tone = random() < 0.82 ? MID : WASH;
segment(P[i][0], P[i][1], P[i + 1][0], P[i + 1][1], ra, rb, tone, alpha + random(-16, 16), passes);
}
}
// the joint: where the brush stops, turns, and the ink pools against the fibre
function joint(ax, ay, bx, by, r, band) {
const [nx, ny] = sideNormal(ax, ay, bx, by);
const tx = -ny;
const ty = nx;
// the pooling first: two soft loads that spread into the fibre
brush.noStroke();
for (let p = 0; p < 2; p++) {
const s = 1 - p * 0.12;
brush.fillBleed(0.14 + p * 0.16, "out");
brush.fillTexture(0.5, 0.6);
brush.fill(INK, 128 - p * 26 + random(-18, 24));
brush.beginShape(0.4);
brush.vertex(ax - nx * r * 1.14 * s, ay - ny * r * 1.14 * s);
brush.vertex(ax - nx * r * 0.3 + tx * band * 0.95 * s, ay - ny * r * 0.3 + ty * band * 0.95 * s);
brush.vertex(ax + nx * r * 1.2 * s, ay + ny * r * 1.2 * s);
brush.vertex(ax + nx * r * 0.4 - tx * band * 1.1 * s, ay + ny * r * 0.4 - ty * band * 1.1 * s);
brush.endShape(true);
}
// then the band itself, struck across the culm with the brush lying flat
brush.noFill();
brush.set("charcoal", INK, r * 0.3);
brush.line(
ax - nx * r * 1.12 + tx * random(-1.5, 1.5), ay - ny * r * 1.12 + ty * random(-1.5, 1.5),
ax + nx * r * 1.18, ay + ny * r * 1.18 + random(-1, 2)
);
brush.set("charcoal", INK, r * 0.17);
brush.line(
ax - nx * r * 0.8 + tx * band * 0.55, ay - ny * r * 0.8 + ty * band * 0.55,
ax + nx * r * 1.04 + tx * band * 0.36, ay + ny * r * 1.04 + ty * band * 0.36
);
brush.noStroke();
brush.fillBleed(0.34, "out");
brush.fill(INK, 136 + random(-24, 30));
brush.circle(ax + nx * r * 0.94, ay + ny * r * 0.52, r * (0.2 + random(0, 0.12)), true);
brush.noFill();
brush.set("cpencil", INK, 1.4);
brush.line(ax - nx * r * 0.72, ay - ny * r * 0.72 + 11, ax + nx * r * 0.4, ay + ny * r * 0.4 + 9);
}
// the outline of one loaded stroke: lands wide, drags, whips off to a point
function bladeShape(x, y, ang, bend, len, wid, tone, alpha) {
const c = Math.cos;
const s = Math.sin;
const a2 = ang + bend;
const nx = -s(ang);
const ny = c(ang);
const hx = len * 0.5 * (c(ang) - c(a2));
const hy = len * 0.5 * (s(ang) - s(a2));
const at = (k, o) => {
const far = k > 0.5;
const a = far ? a2 : ang;
return [
x + c(a) * len * k + (far ? hx : 0) + nx * wid * o,
y + s(a) * len * k + (far ? hy : 0) + ny * wid * o,
];
};
brush.noStroke();
brush.fillBleed(0.08 + random(0, 0.06), "out");
brush.fillTexture(0.62, 0.78);
brush.fill(tone, alpha);
brush.beginShape(0.3);
brush.vertex(x, y);
let p = at(0.2, 0.72);
brush.vertex(p[0], p[1]);
p = at(0.46, 1.0);
brush.vertex(p[0], p[1]);
p = at(0.76, 0.5);
brush.vertex(p[0], p[1]);
p = at(1.0, 0.02);
brush.vertex(p[0], p[1]);
p = at(0.72, -0.34);
brush.vertex(p[0], p[1]);
p = at(0.4, -0.56);
brush.vertex(p[0], p[1]);
p = at(0.14, -0.4);
brush.vertex(p[0], p[1]);
brush.endShape(true);
return at;
}
// a leaf gets as many passes as it has ink in it — the dark ones are struck three times
function leaf(x, y, ang, len, wid, tone, alpha, bend) {
const passes = tone === INK ? 4 : tone === DARK ? 2 : 1;
let at = null;
for (let p = 0; p < passes; p++) {
at = bladeShape(
x + randomGaussian(0, 1.7), y + randomGaussian(0, 1.7),
ang + randomGaussian(0, 0.014), bend,
len * (1 - 0.02 * p), wid * (1 - 0.06 * p),
tone, alpha * (p === 0 ? 1 : 0.82)
);
}
// the wet core, where the belly of the brush sat
for (let q = 0; q < (passes > 2 ? 2 : passes - 1); q++) {
brush.fillBleed(0.05, "out");
brush.fill(tone === MID ? DARK : INK, 104 + random(-22, 30));
brush.beginShape(0.3);
let p = at(0.08 + q * 0.03, 0.05);
brush.vertex(p[0], p[1]);
p = at(0.3, 0.48 - q * 0.1);
brush.vertex(p[0], p[1]);
p = at(0.62 - q * 0.06, 0.2);
brush.vertex(p[0], p[1]);
p = at(0.44, -0.3 + q * 0.08);
brush.vertex(p[0], p[1]);
brush.endShape(true);
}
}
function inkFor(heavy) {
const r = random();
if (r < heavy) return INK;
if (r < heavy + (1 - heavy) * 0.6) return DARK;
return MID;
}
// a cluster fans off a twig tip; the middle blade runs longest
function cluster(x, y, base, spread, len, n, heavy) {
for (let i = 0; i < n; i++) {
const k = n === 1 ? 0 : i / (n - 1) - 0.5;
const ang = base + k * spread + randomGaussian(0, 0.05);
const L = Math.max(40, len * (1 - 0.4 * Math.abs(k)) * (1 + randomGaussian(0, 0.08)));
const w = L * Math.max(0.085, randomGaussian(0.125, 0.015));
const tone = inkFor(heavy);
const alpha = tone === INK ? 214 + random(-18, 32) : tone === DARK ? 176 + random(-26, 40) : 112 + random(-22, 32);
leaf(x + randomGaussian(0, 4), y + randomGaussian(0, 4), ang, L, w, tone, alpha, randomGaussian(k < 0 ? -0.22 : 0.24, 0.1));
}
}
// a twig is one quick dense stroke, bowed a little by the weight it carries
function twig(x1, y1, x2, y2, tone, w) {
const mx = (x1 + x2) / 2 + randomGaussian(0, 5);
const my = (y1 + y2) / 2 + 5 + random(0, 5);
brush.noFill();
brush.set("charcoal", tone, w * 2.1);
brush.line(x1, y1, x1 + (mx - x1) * (0.32 + random(0, 0.18)), y1 + (my - y1) * 0.36);
brush.set("rotring", tone, w * 1.45);
brush.beginShape(0.5);
brush.vertex(x1 + (mx - x1) * 0.14, y1 + (my - y1) * 0.16);
brush.vertex(mx, my);
brush.vertex(x2, y2);
brush.endShape(false);
}
function setup() {
createCanvas(800, 800, WEBGL);
brush.scaleBrushes(2.4);
noLoop();
}
function draw() {
translate(-width / 2, -height / 2);
background(PAPER);
// 1. damp paper: a pale diagonal breath behind the culms, thinning towards the left
brush.noStroke();
brush.fillBleed(0.72, "out");
brush.fillTexture(0.34, 0.35);
brush.fill(WASH, 26);
brush.circle(690, 210, 250, true);
brush.fill(WASH, 19);
brush.circle(596, 372, 214, true);
brush.fill(WASH, 13);
brush.circle(492, 268, 176, true);
brush.fill(WASH, 10);
brush.circle(716, 596, 158, true);
brush.fill(WASH, 8);
brush.circle(372, 214, 132, true);
// 2. ghost leaves, laid down before the culms so the stems cut across them
cluster(628, 470, 2.86, 0.9, 120, 4, 0.0);
cluster(672, 214, 3.42, 0.72, 98, 3, 0.0);
leaf(586, 640, 2.62, 96, 12, WASH, 96, 0.26);
leaf(746, 372, 3.58, 80, 10, WASH, 88, -0.2);
// 3. the culms — the back one first, thinner and greyer
culm(CULM_B, 8.0, 5.6, 168, 2);
culm(CULM_A, 13.5, 9.5, 192, 2);
// 4. twigs off the joints, all reaching left
twig(710, 414, 604, 452, DARK, 1.5);
twig(686, 276, 552, 264, INK, 1.9);
twig(666, 140, 590, 116, DARK, 1.2);
twig(738, 556, 672, 600, MID, 1.0);
twig(650, 22, 566, 46, DARK, 1.1);
twig(758, 306, 792, 262, MID, 0.8);
twig(552, 264, 500, 336, INK, 1.4);
twig(604, 452, 560, 508, DARK, 1.1);
// 5. joints, heavy, struck in the order the hand would take them
joint(764, 690, 738, 556, 13.2, 8.0);
joint(738, 556, 710, 414, 12.4, 7.2);
joint(710, 414, 686, 276, 11.6, 8.6);
joint(686, 276, 666, 140, 10.8, 7.6);
joint(666, 140, 650, 20, 10.2, 6.4);
joint(798, 470, 776, 392, 7.4, 5.0);
joint(776, 392, 758, 306, 7.0, 4.4);
joint(758, 306, 742, 214, 6.6, 4.8);
joint(742, 214, 730, 120, 6.2, 4.0);
// 6. the loaded leaves. the cluster on the third intersection takes the blackest ink
// and the longest blades; the ones further out are drier and greyer.
cluster(552, 262, 3.02, 1.2, 192, 5, 0.7);
cluster(500, 336, 2.74, 0.96, 156, 4, 0.5);
cluster(604, 452, 2.9, 1.06, 146, 4, 0.44);
cluster(560, 508, 2.66, 0.8, 118, 3, 0.3);
cluster(590, 116, 3.36, 0.86, 126, 3, 0.36);
cluster(672, 600, 2.6, 0.78, 108, 3, 0.12);
cluster(566, 46, 3.14, 0.7, 106, 3, 0.24);
cluster(792, 262, 0.44, 0.66, 90, 2, 0.32);
cluster(640, 344, 3.5, 0.6, 94, 3, 0.5);
cluster(714, 500, 2.5, 0.54, 78, 2, 0.18);
// 7. three blades struck alone, further left, to carry the eye out into the paper
leaf(452, 216, 3.16, 142, 18, DARK, 178, -0.24);
leaf(416, 396, 2.9, 118, 15, DARK, 146, 0.28);
leaf(372, 302, 3.32, 98, 11, MID, 116, -0.16);
// 8. a young shoot off the lowest joint, two blades and no more
twig(766, 664, 706, 726, MID, 1.2);
leaf(706, 726, 2.52, 96, 12, DARK, 150, 0.22);
leaf(712, 720, 3.02, 72, 9, MID, 112, -0.18);
// 9. the seal: one cinnabar impression in the empty quarter, and nothing more
brush.noStroke();
brush.fillBleed(0.04, "out");
brush.fillTexture(0.82, 0.88);
brush.fill(SEAL, 226);
brush.rect(178, 686, 27, 31, "corner");
brush.fill(SEAL, 190);
brush.rect(181, 689, 21, 25, "corner");
brush.fill(SEAL, 120);
brush.rect(183, 692, 16, 18, "corner");
}