diff --git a/ShowControl/FundingCAPTCHA/shaders/setups.json b/ShowControl/FundingCAPTCHA/shaders/setups.json index 0dba8b2..b75314f 100644 --- a/ShowControl/FundingCAPTCHA/shaders/setups.json +++ b/ShowControl/FundingCAPTCHA/shaders/setups.json @@ -1 +1 @@ -["ripples", "endless_sunset", "hunter_bird"] +["ripples", "endless_sunset", "hunter_bird", "watering_can"] diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_b.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_b.glsl new file mode 100644 index 0000000..e1427c3 --- /dev/null +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_b.glsl @@ -0,0 +1,107 @@ +// Buffer B — Body memory. iChannel0 = Buffer A (Body), iChannel1 = Buffer B (itself), iChannel2 = blobs +// .r presence · .g run bottom y (UV) · .b run top y (UV) · .a owner + 0.9 * hold +// A run is the vertical stretch of body through a pixel; a raised arm's run starts at its own lowest point. + +float softPresence(vec2 uv) { + vec2 t = BODY_BLUR_TEXELS / max(iChannelResolution[0].xy, vec2(1.0)); + float s = 0.0; + for (int y = -1; y <= 1; y++) + for (int x = -1; x <= 1; x++) + s += texture(iChannel0, uv + vec2(x, y) * t).r; + s /= 9.0; + // Fill depth-dropout holes: a pixel ringed by body counts as body. + vec2 h = HOLE_FILL_TEXELS / max(iChannelResolution[0].xy, vec2(1.0)); + float ring = 0.0; + for (int k = 0; k < 8; k++) { + float a = 0.7853982 * float(k); + ring += texture(iChannel0, uv + vec2(cos(a), sin(a)) * h).r; + } + return max(s, smoothstep(0.45, 0.7, ring / 8.0)); +} + +// The slot whose live-or-not (non-empty) box contains uv; nearest centre wins. 0 = none. +int ownerAt(vec2 uv) { + int best = 0; + float bestD = 1e9; + for (int k = 0; k < SLOTS; k++) { + if (isState(recIdn(iChannel2, k).y, S_EMPTY)) continue; + vec4 b = recBox(iChannel2, k); + if (uv.x < b.x || uv.x > b.z || uv.y < b.y || uv.y > b.w) continue; + float d = length(uv - 0.5 * (b.xy + b.zw)); + if (d < bestD) { bestD = d; best = k + 1; } + } + return best; +} + +// Is this texel body this frame? Decided from last frame's stored texel t alone, so the march below, +// probing its neighbours, judges each exactly as that texel judges itself: every texel of a run then +// finds the same ends, even while the body moves (a rule using this frame's presence can't be +// evaluated at a neighbour, and the two disagreed wherever the body had just arrived or left, tearing +// the plants into rows). The cost is a frame of latency. From BODY_ON, and once body, until below +// BODY_OFF: hysteresis, so a flickering edge holds still. (A held, fading texel isn't body.) +bool fullHold(vec4 t) { return fract(t.a) > floor(0.9 / HOLD_Q) * HOLD_Q - 0.5 * HOLD_Q; } +bool member(vec4 t) { + bool run = t.g > 0.0 || t.b > 0.0; + return t.r >= BODY_ON || (fullHold(t) && run && t.r >= BODY_OFF); +} +bool bodyAt(float x, float y) { + return member(texelFetch(iChannel1, ivec2(min(vec2(x, y) * iResolution.xy, iResolution.xy - 1.0)), 0)); +} + +// How far the run extends from uv in direction dir (±1, in UV y) while last frame's body continues. +// Every texel of a run must agree on its ends, or the plants tear into rows: the march steps on a +// grid fixed to the screen, so the texels of a run all probe the same points along a ragged edge, +// then bisects the last step to find the edge texel itself. +float march(vec2 uv, float dir) { + float y = uv.y, off = clamp(uv.y + dir * float(RUN_STEPS + 1) * RUN_STEP_H, 0.0, 1.0); + float k0 = dir > 0.0 ? floor(uv.y / RUN_STEP_H) : ceil(uv.y / RUN_STEP_H); + for (int i = 1; i <= RUN_STEPS; i++) { + float ny = (k0 + dir * float(i)) * RUN_STEP_H; + if (ny < 0.0 || ny > 1.0) { off = clamp(ny, 0.0, 1.0); break; } + if (!bodyAt(uv.x, ny)) { off = ny; break; } + y = ny; + } + for (int i = 0; i < RUN_REFINE; i++) { + float m = 0.5 * (y + off); + if (bodyAt(uv.x, m)) y = m; else off = m; + } + return y; +} + +void mainImage(out vec4 o, in vec2 fc) { + vec2 uv = fc / iResolution.xy; + vec4 prev = iFrame == 0 ? vec4(0.0) : texelFetch(iChannel1, ivec2(fc), 0); + + float target = softPresence(uv); + float pres = iFrame == 0 ? target : mix(prev.r, target, 1.0 - exp(-iTimeDelta / PRESENCE_EASE_S)); + + float g = 0.0, b = 0.0, a = 0.0; + // The body has gone from under a person whose track is missing: every texel of theirs lets go this + // frame, not as its eased presence crosses BODY_OFF (sooner at the edges), so their hold fades in step. + int po = bOwner(prev); + float pst = po > 0 ? recIdn(iChannel2, po - 1).y : S_EMPTY; + bool leaving = po > 0 && target < BODY_OFF && !isState(pst, S_TENTATIVE) && !isState(pst, S_CONFIRMED); + if (!leaving && iFrame > 0 && member(prev)) { + // Not eased: an eased end has a history per texel, and texels that joined the run at different + // times would disagree. The hysteresis above already holds a flickering edge still. + g = march(uv, -1.0); + b = march(uv, 1.0); + a = float(ownerAt(uv)) + floor(0.9 / HOLD_Q) * HOLD_Q; + } else { + float ph = bHold(prev); + if (po > 0 && ph > 0.0) { + // A missing body coasts first: keep its plants until the track is lost, then rewind them away. + if (!isState(pst, S_TENTATIVE) && !isState(pst, S_CONFIRMED)) { + // The step is below a half float's resolution at high render rates, so round it + // stochastically to HOLD_Q: the expected fade is exact at any rate. The offset depends on + // the frame only, so every texel of a person steps together and their vines don't tear + // into rows; a golden-ratio sequence, not a hash, so the count of steps stays near exact. + float f = fract(prev.a); + float d = 0.9 * iTimeDelta / LEAVE_S; + f -= floor(d / HOLD_Q + fract(float(iFrame % 4096) * 0.618034)) * HOLD_Q; + if (f > 0.5 * HOLD_Q) { g = prev.g; b = prev.b; a = float(po) + f; } + } + } + } + o = vec4(pres, g, b, a); +} diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl new file mode 100644 index 0000000..c9e002b --- /dev/null +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl @@ -0,0 +1,389 @@ +// Buffer C — Garden state. iChannel0 = Buffer B, iChannel1 = Buffer C (itself), iChannel2 = blobs +// Layout: see common.glsl (rows 0–3 garden state and footprints, rows 4+ the tree grid). Each texel reads the +// previous frame of every other texel; texels that need the same derived value (PERSON and BASE +// both need this frame's base) compute it from the same inputs. + +#define BB iChannel0 +#define C iChannel1 +#define BLOBS iChannel2 + +// The starting value of every texel: "seconds since" counters start long ago. +vec4 initial(ivec2 c) { + if (c.y == 1) return vec4(0.0, 0.0, T_CLAMP, S_EMPTY); // BASE + if (c.y == 2) return vec4(0.0, 0.0, 0.0, T_CLAMP); // GHOST + if (c.y == 3 && c.x > 0 && c.x <= FOOTPRINTS) return vec4(0.0, 0.0, T_CLAMP, 0.0); // FOOT: none + return vec4(0.0); +} + +// The record in slot k carries a different person than PERSON k remembers (or none). +bool fresh(int k) { + vec4 idn = recIdn(BLOBS, k); + return isState(idn.y, S_EMPTY) || abs(idn.x - cPerson(C, k).x) > 0.5; +} +bool live(float st) { return isState(st, S_TENTATIVE) || isState(st, S_CONFIRMED) || isState(st, S_COASTING); } +// The box follows a real detection (a coasting or lost box only drifts on its last velocity). +bool seen(float st) { return isState(st, S_TENTATIVE) || isState(st, S_CONFIRMED); } + +// A new track's feet: presence-weighted mean x of B across the box, just above its bottom; y = the box bottom. +vec2 baseTarget(vec4 box) { + float y = box.y + 0.5 * BASE_BAND; // screen heights = UV in y + float w = 0.0, wx = 0.0; + for (int i = 0; i < BASE_TAPS; i++) { + float x = mix(box.x, box.z, (float(i) + 0.5) / float(BASE_TAPS)); + float p = texture(BB, vec2(x, y)).r; + w += p; wx += p * x; + } + return vec2(w > 1e-3 ? wx / w : 0.5 * (box.x + box.z), box.y); +} + +// Gap between two boxes, in screen heights (0 when they overlap). +float boxGap(vec4 a, vec4 b) { + vec2 d = max(vec2(0.0), max(a.xy - b.zw, b.xy - a.zw)); + return length(toScreen(d)); +} + +// Presence-weighted mean x of B over [x0, x1], BASE_BAND / 2 above row y (.y: the total weight, in taps). +vec2 feetBand(float x0, float x1, float y) { + float w = 0.0, wx = 0.0; + for (int i = 0; i < BASE_TAPS; i++) { + float x = mix(x0, x1, (float(i) + 0.5) / float(BASE_TAPS)); + float p = texture(BB, vec2(x, y + 0.5 * BASE_BAND)).r; + w += p; wx += p * x; + } + return vec2(w > 1e-3 ? wx / w : 0.5 * (x0 + x1), w); +} + +// A tracked person's feet, measured near their last base so that only their own feet count: in a +// window BASE_WIN either side of it (and inside the box), the presence-weighted mean x of the feet +// band. The window then re-centres on that x and measures again (mean shift), so it keeps up with a +// walk but stays on the feet it started on: when the tracker merges a neighbour in, the box swells but +// this doesn't move. y is the box bottom while the window finds feet there; if it doesn't, the bottom +// is a merged neighbour's (nearer the camera), and y is this person's lowest body row in the window: +// the bottom of B's runs through their legs BASE_UP above the last base. The whole box if no body is +// near (they have moved off it faster than the window follows: that's a step). +vec2 baseNear(vec4 box, vec2 last) { + float hw = BASE_WIN * iResolution.y / iResolution.x; // screen heights → UV x + vec2 bRes = iChannelResolution[0].xy; + float up = min(last.y + BASE_UP, 1.0); + vec2 base = last; + for (int n = 0; n < BASE_ITERS; n++) { + float x0 = max(box.x, base.x - hw), x1 = min(box.z, base.x + hw); + if (x1 <= x0) return n == 0 ? baseTarget(box) : base; + vec2 f = feetBand(x0, x1, box.y); + if (f.y >= BASE_FEET) { base = vec2(f.x, box.y); continue; } + float lo = 2.0; + for (int i = 0; i < BASE_TAPS; i++) { + float x = mix(x0, x1, (float(i) + 0.5) / float(BASE_TAPS)); + vec4 t = texelFetch(BB, ivec2(min(vec2(x, up) * bRes, bRes - 1.0)), 0); + if (t.r >= BODY_ON && t.b > 0.0) lo = min(lo, t.g); + } + if (lo > 1.0) return n == 0 ? baseTarget(box) : base; + base = vec2(feetBand(x0, x1, lo).x, lo); + } + return base; +} + +// A neighbour within SPLIT_GAP changed tracker state this frame (it appeared, coasted, came back or +// was lost): the tracker is merging them into slot k's person or splitting them off, and slot k's box +// swells or shrinks with it. That isn't a step: slot k's person re-plants for REPLANT_S. +bool contactChange(int k) { + vec4 box = recBox(BLOBS, k); + for (int j = 0; j < SLOTS; j++) { + vec4 idn = recIdn(BLOBS, j); + if (j == k || isState(idn.y, S_EMPTY)) continue; + bool changed = fresh(j) || !isState(cBase(C, j).w, idn.y); + if (changed && boxGap(box, recBox(BLOBS, j)) <= SPLIT_GAP) return true; + } + return false; +} +// BASE .z, seconds since the last step, is negative while re-planting: -(seconds still to go). +bool replanting(int k) { return contactChange(k) || cBase(C, k).z < 0.0; } +bool walking(float since) { return since >= 0.0 && since < WALK_HOLD_S; } + +// Growth a new track at `base` starts from: a recent ghost standing there, or a live +// neighbour its box overlaps or all but touches (a split, a merge, or a part of a planted +// person). A newcomer further away starts bare, so nobody pops into bloom on arrival. +float inherit(int k, vec2 base) { + vec4 box = recBox(BLOBS, k); + float g = 0.0; + for (int j = 0; j < SLOTS; j++) { + vec4 gh = cGhost(C, j); + if (gh.w < INHERIT_S && length(toScreen(gh.xy - base)) < INHERIT_DIST) g = max(g, gh.z); + if (j == k || fresh(j) || !live(recIdn(BLOBS, j).y)) continue; + if (boxGap(box, recBox(BLOBS, j)) <= SPLIT_GAP) g = max(g, cPerson(C, j).y); + } + return g; +} + +// ── BASE ────────────────────────────────────────────────────────────────────── +// This frame's BASE for slot k: eased base, seconds since the last step (negative: re-planting), +// record state (for GHOST, and to see a neighbour's state change). +vec4 baseNext(int k, float dt) { + vec4 idn = recIdn(BLOBS, k); + if (isState(idn.y, S_EMPTY)) return vec4(0.0, 0.0, T_CLAMP, S_EMPTY); + if (fresh(k)) return vec4(baseTarget(recBox(BLOBS, k)), T_CLAMP, idn.y); + vec4 b = cBase(C, k); + bool rp = replanting(k); + if (contactChange(k)) b.z = -REPLANT_S; + // A coasting or lost box drifts without a body under it: hold the base, so a dropped + // camera frame can't read as a step. Re-planting, the base snaps to the feet (and the spot with it). + if (seen(idn.y)) { + vec2 t = baseNear(recBox(BLOBS, k), b.xy); + b.xy = rp ? t : mix(b.xy, t, 1.0 - exp(-dt / BASE_EASE_S)); + } + if (rp) b.z = b.z + dt < 0.0 ? b.z + dt : WALK_HOLD_S; // planted again: not walking + else b.z = length(toScreen(b.xy - cPerson(C, k).zw)) > WALK_DIST ? 0.0 : min(b.z + dt, T_CLAMP); + b.w = idn.y; + return b; +} + +// ── PERSON ──────────────────────────────────────────────────────────────────── +vec4 personNext(int k, float dt, vec2 fc) { + vec4 idn = recIdn(BLOBS, k); + if (isState(idn.y, S_EMPTY)) return vec4(0.0); + vec4 b = baseNext(k, dt); + if (fresh(k)) return vec4(idn.x, inherit(k, b.xy), b.xy); + vec4 p = cPerson(C, k); + if (b.z == 0.0 || replanting(k)) p.zw = b.xy; // a step or a re-plant: the new spot is here + // Tentative and coasting hold their level, but a coasting track the tracker has merged into a + // neighbour's blob shares that neighbour's walking or not, so merged people grow (or rewind) together. + float since = b.z; // seconds since the last step + bool moves = isState(idn.y, S_CONFIRMED); + if (isState(idn.y, S_COASTING)) { + vec4 box = recBox(BLOBS, k); + since = T_CLAMP; + for (int j = 0; j < SLOTS; j++) { + if (j == k || fresh(j) || !isState(recIdn(BLOBS, j).y, S_CONFIRMED)) continue; + if (boxGap(box, recBox(BLOBS, j)) <= SPLIT_GAP) { moves = true; since = min(since, cBase(C, j).z); } + } + } + if (moves) { + float grow = p.y < GROW_EARLY ? GROW_EARLY / GROW_EARLY_S : (1.0 - GROW_EARLY) / (GROW_S - GROW_EARLY_S); + float rate = walking(since) ? -1.0 / REWIND_S : grow; + // The step is a few half-float ulps: round it stochastically to GROW_Q, so the + // expected growth is exact at any render rate. + float h = hash12(fc + vec2(113.0, 57.0) * float(iFrame % 997)); + p.y += sign(rate) * floor(abs(rate) * dt / GROW_Q + h) * GROW_Q; + p.y = clamp(p.y, 0.0, 1.0); + } + return p; +} + +// ── GHOST ───────────────────────────────────────────────────────────────────── +// Written once when slot k's person goes: their track is lost, or the slot holds someone else. +vec4 ghostNext(int k, float dt) { + vec4 p = cPerson(C, k); + float st = recIdn(BLOBS, k).y, was = cBase(C, k).w; // was = the record state last frame + bool fr = fresh(k); + bool gone = !isState(was, S_LOST) && (fr || isState(st, S_LOST)); + vec4 g = cGhost(C, k); + // The slot is reused as soon as a lost record frees it: a short-lived fragment leaving + // must not wipe a ghost someone may still come back to. Replace a ghost only once it can + // no longer be inherited, or with one at least as grown. + bool stronger = g.w >= INHERIT_S || p.y >= g.z; + if (p.x > 0.5 && gone && stronger) return vec4(p.zw, p.y, 0.0); + g.w = min(g.w + dt, T_CLAMP); + return g; +} + +// ── FOOT ────────────────────────────────────────────────────────────────────── +// The slot of the walker who drops a footprint this frame, or -1. A seen person who is walking drops one +// where their feet are once they're FOOT_GAP from every live footprint: the first such slot, one a frame +// (another stepping the same frame drops next frame). Only into the ring's head, and only once the +// footprint there has gone, so a live one never pops. The SUN texel (which keeps the head) and every +// FOOT texel compute this from the same last frame, so they agree. +int footDropper() { + if (cFoot(C, int(cSun(C).z)).z < FOOT_LIFE) return -1; + for (int k = 0; k < SLOTS; k++) { + if (!seen(recIdn(BLOBS, k).y) || fresh(k)) continue; + vec4 b = cBase(C, k); + if (!walking(b.z)) continue; + bool clear = true; + for (int f = 0; f < FOOTPRINTS; f++) { + vec4 ft = cFoot(C, f); + if (ft.z < FOOT_LIFE && length(toScreen(ft.xy - b.xy)) < FOOT_GAP) { clear = false; break; } + } + if (clear) return k; + } + return -1; +} + +vec4 footNext(int f, float dt) { + int k = footDropper(); + if (k >= 0 && f == int(cSun(C).z)) { + vec2 at = cBase(C, k).xy; + return vec4(at, 0.0, fract(hash12(at * 1000.0 + float(iFrame % 997)) * 0.999) * 8.0); + } + vec4 ft = cFoot(C, f); + ft.z = min(ft.z + dt, T_CLAMP); + return ft; +} + +// ── TREE ────────────────────────────────────────────────────────────────────── +// The B texel under screen point sp (screen heights). +vec4 tapB(vec2 sp) { + float aspect = iResolution.x / iResolution.y; + if (sp.x < 0.0 || sp.y < 0.0 || sp.x >= aspect || sp.y >= 1.0) return vec4(0.0); + return texelFetch(BB, ivec2(vec2(sp.x / aspect, sp.y) * iChannelResolution[0].xy), 0); +} +bool onBody(vec4 t) { return bOwner(t) > 0 && bHold(t) > 0.0; } + +// Is anything a pixel in this (non-body) cell might draw near: a body cell within TREE_R (last frame's +// grid), or a footprint's plants? +float treeNear(ivec2 cell) { + for (int y = -TREE_R; y <= TREE_R; y++) + for (int x = -TREE_R; x <= TREE_R; x++) + if (cTree(C, cell + ivec2(x, y), iResolution.xy).x >= 0.0) return 1.0; + float aspect = iResolution.x / iResolution.y; + vec2 c0 = vec2(cell) * TREE_CELL, c1 = c0 + TREE_CELL; + for (int f = 0; f < FOOTPRINTS; f++) { + vec4 ft = cFoot(C, f); + if (ft.z >= FOOT_LIFE) continue; + vec2 at = vec2(ft.x * aspect, ft.y); + if (at.x + FOOT_REACH_X >= c0.x && at.x - FOOT_REACH_X <= c1.x + && at.y + FOOT_REACH_UP >= c0.y && at.y - FOOT_REACH_DOWN <= c1.y) return 1.0; + } + return 0.0; +} + +vec4 treeNext(ivec2 cell) { + float aspect = iResolution.x / iResolution.y; + vec2 lo = vec2(cell) * TREE_CELL; + // Which person's body covers the cell, how much, how firmly held, and where its body sits in the cell. + int own = 0; + float n = 0.0, hold = 0.0; + vec2 sum = vec2(0.0); + for (int y = 0; y < 4; y++) + for (int x = 0; x < 4; x++) { + vec2 sp = lo + (vec2(x, y) + 0.5) * 0.25 * TREE_CELL; + vec4 t = tapB(sp); + if (!onBody(t)) continue; + if (own == 0) own = bOwner(t); + if (bOwner(t) != own) continue; + n += 1.0; sum += sp; + hold = max(hold, t.r >= BODY_ON ? 1.0 : bHold(t)); + } + // Hysteresis: a cell joins the body at TREE_FILL, and stays until under half that, so a flickering edge + // doesn't take its branch (and sprig, or flower) with it every other frame. + vec4 was = cTree(C, cell, iResolution.xy); + float need = treeOwner(was) == own && own > 0 ? 0.5 * TREE_FILL : TREE_FILL; + if (n < need * 16.0 || own > SLOTS) return vec4(-1.0, treeNear(cell), 0.0, 0.0); + int k = own - 1; + vec2 node = sum / n; + vec2 q = clamp(floor(((node - lo) / TREE_CELL) * 16.0), 0.0, 15.0); + float nodeQ = q.x * 16.0 + q.y; + vec4 prev = cTree(C, cell, iResolution.xy); + vec4 b = cBase(C, k); + // A person whose track is gone has no base: their held shape keeps the tree it had, as it rewinds away. + if (isState(b.w, S_EMPTY) || b.w > S_COASTING + 0.5) { + if (treeOwner(prev) == own) return vec4(prev.x, prev.y, prev.z, nodeQ); + return vec4(TREE_FAR, 0.0, float(own), nodeQ); + } + // A step costs more near the body's edge, so paths keep to the middle. + // How central: the body on rings round the cell, near and further out, so a wide torso has a middle too. + float ring = 0.0; + for (int r = 1; r <= 2; r++) + for (int i = 0; i < 8; i++) { + float a = 0.7853982 * (float(i) + 0.5 * float(r)); + vec4 t = tapB(node + vec2(cos(a), sin(a)) * TREE_EDGE_R * float(r) * TREE_CELL); + ring += (onBody(t) && bOwner(t) == own) ? 1.0 : 0.0; + } + float cost = TREE_CELL * (1.0 + TREE_EDGE_COST * (1.0 - ring / 16.0)); + // The cheapest way back to the root: straight there along the floor, for a cell at the base, or via a neighbour. + float d = TREE_FAR; + int parent = -1; + vec4 tpk = cTreeP(C, k); + vec2 root = tpk.w > 0.0 ? tpk.wy : vec2(b.x * aspect, b.y); + if (lo.y <= root.y + TREE_ROOT_BAND && abs(node.x - root.x) < TREE_ROOT_WIN) { + d = length(node - root); + parent = 0; + } + float keep = TREE_FAR; // the way it went last frame, if still open + int keepP = treeOwner(prev) == own ? treeParent(prev) : -1; + for (int i = 0; i < 8; i++) { + vec4 t = cTree(C, cell + TREE_DIRS[i], iResolution.xy); + if (treeOwner(t) != own || t.x >= TREE_FAR) continue; + float cand = t.x + cost * length(vec2(TREE_DIRS[i])); + if (cand < d) { d = cand; parent = i + 1; } + if (i + 1 == keepP) keep = cand; + } + if (keepP == 0 && parent == 0) keep = d; + // Hysteresis: keep last frame's way unless another is clearly cheaper, so near-equal routes don't trade places. + if (keepP > 0 && keep < TREE_FAR && keep <= d * (1.0 + TREE_PARENT_HYST)) { d = keep; parent = keepP; } + if (parent < 0) return vec4(TREE_FAR, 0.0, float(own), nodeQ); + // Seeds whose paths run through this cell: its own, and its children's (last frame's, so a branch settles a + // cell a frame from its seed down). + float seeds = treeSeed(cell) ? 1.0 : 0.0; + for (int i = 0; i < 8; i++) { + vec4 t = cTree(C, cell + TREE_DIRS[i], iResolution.xy); + int pa = treeParent(t); + if (treeOwner(t) == own && t.x < TREE_FAR && pa > 0 && TREE_DIRS[pa - 1] == -TREE_DIRS[i]) seeds += t.y; + } + // Eased (TREE_WIDTH_EASE_S), so a seed flickering in and out at an edge doesn't ripple down the trunk's width. + // Children pass on their eased counts, so a settled tree still adds up exactly. + if (treeOwner(prev) == own && prev.x < TREE_FAR) seeds = mix(prev.y, seeds, 1.0 - exp(-iTimeDelta / TREE_WIDTH_EASE_S)); + return vec4(d, seeds, float(own) + float(parent) / 16.0, nodeQ); +} + +// TREE PERSON k: the farthest path distance on their body (eased), their root's y, their growth, and their +// root's x: the middle of their lowest cells, so the root sits between the feet and both legs join it. +// (BASE follows one foot when the feet are apart, which is right for telling a step, not for a root.) +vec4 treePersonNext(int k, float dt) { + float raw = 0.0, n = 0.0, sx = 0.0; + float baseY = cBase(C, k).y; + int cols = treeCols(iResolution.xy); + for (int j = 0; j < TREE_ROWS; j++) + for (int i = 0; i < TREE_COLS_MAX; i++) { + if (i >= cols) break; + vec4 t = cTree(C, ivec2(i, j), iResolution.xy); + if (treeOwner(t) != k + 1) continue; + if (t.x < TREE_FAR) raw = max(raw, t.x); + if (float(j) * TREE_CELL <= baseY + TREE_ROOT_BAND) { n += 1.0; sx += (float(i) + 0.5) * TREE_CELL; } + } + vec4 pr = cTreeP(C, k); + float farD = pr.x <= 0.0 ? raw : mix(pr.x, raw, 1.0 - exp(-dt / TREE_MAX_EASE_S)); + float g = growthOf(C, k); + // The root eases (TREE_ROOT_EASE_S), so a foot's flickering edge doesn't shuffle it and the trunk with it. + vec2 raw2 = vec2(n > 0.0 ? sx / n : cBase(C, k).x * iResolution.x / iResolution.y, baseY); + vec2 root = pr.w <= 0.0 ? raw2 : mix(pr.wy, raw2, 1.0 - exp(-dt / TREE_ROOT_EASE_S)); + return vec4(farD, root.y, g, root.x); +} + +// ── SUN ─────────────────────────────────────────────────────────────────────── +vec4 sunNext(float dt, vec2 fc) { + float raw = 0.0; + for (int k = 0; k < SLOTS; k++) { + if (live(recIdn(BLOBS, k).y) && !fresh(k)) raw += cPerson(C, k).y; + vec4 g = cGhost(C, k); + raw += g.z * max(0.0, 1.0 - g.w / LEAVE_S); + } + float lvl = cSun(C).x; + float step = (clamp(raw / SUN_FULL, 0.0, 1.0) - lvl) * (1.0 - exp(-dt / SUN_EASE_S)); + // Near the target the step is below a half float's resolution and the easing would stall short of + // full: round it stochastically to GROW_Q, as growth is, so the expected easing is exact. + float h = hash12(fc + vec2(113.0, 57.0) * float(iFrame % 997)); + lvl = clamp(lvl + sign(step) * floor(abs(step) / GROW_Q + h) * GROW_Q, 0.0, 1.0); + float head = cSun(C).z; // the footprint ring's head moves on past a drop + if (footDropper() >= 0) head = mod(head + 1.0, float(FOOTPRINTS)); + return vec4(lvl, raw, head, 0.0); +} + +void mainImage(out vec4 o, in vec2 fc) { + ivec2 c = ivec2(fc); + o = vec4(0.0); + if (c.y >= 4) { // the tree grid + if (c.y < 4 + TREE_ROWS && c.x < treeCols(iResolution.xy)) o = iFrame == 0 ? vec4(-1.0, 0.0, 0.0, 0.0) : treeNext(c - ivec2(0, 4)); + return; + } + bool treeP = c.y == 3 && c.x >= TREE_P_X && c.x < TREE_P_X + SLOTS; + if (!treeP && (c.y == 3 ? c.x > FOOTPRINTS : c.x >= SLOTS)) return; + if (iFrame == 0) { o = initial(c); return; } + float dt = clamp(iTimeDelta, 0.0, T_CLAMP); + + if (c.y == 0) o = personNext(c.x, dt, fc); + else if (c.y == 1) o = baseNext(c.x, dt); + else if (c.y == 2) o = ghostNext(c.x, dt); + else if (treeP) o = treePersonNext(c.x - TREE_P_X, dt); + else if (c.x == 0) o = sunNext(dt, fc); + else o = footNext(c.x - 1, dt); +} diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl new file mode 100644 index 0000000..7c4b57c --- /dev/null +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl @@ -0,0 +1,246 @@ +// Common — every tunable for the watering can lives here (edit, save, press R). +// Lengths are in screen heights unless noted; mockup px / 360 = screen heights. +// Spec: docs/superpowers/specs/2026-11-06-watering-can-design.md +// +// Body memory (Buffer B, per pixel): +// .r presence 0–1 (soft, hole-filled, eased) .g run bottom y (UV) .b run top y (UV) +// .a owner + 0.9 * hold (owner = slot + 1, 0 none; hold 0–1, 1 while the body is present) +// Garden state (Buffer C), texel (x, y), k = slot 0–7: +// (k, 0) PERSON id (0 none), growth 0–1, planted spot x, y (UV) +// (k, 1) BASE base x, base y (UV, smoothed), seconds since last walking step (negative: re-planting, +// -seconds to go), record state last frame +// (k, 2) GHOST spot x, y, growth at loss, seconds since loss (T_CLAMP = no ghost) +// (0, 3) SUN sun level 0–1 (eased), raw total growth, next footprint f (the ring's head), — +// (1 + f, 3) FOOT footprint f = 0–FOOTPRINTS-1: x, y (UV), seconds since dropped (T_CLAMP none), seed +// (TREE_P_X + k, 3) TREE PERSON farthest path distance on k's body (eased), root y, growth 0–1, root x +// (screen heights; the root is the middle of their lowest cells, eased) +// (i, 4 + j) TREE grid cell (i, j) = screen [i, i + 1] × [j, j + 1] · TREE_CELL (screen heights): +// on a body: path distance to its person's root, seeds whose paths run through it (0: no +// branch here), owner + parent / 16 (parent 0 the root, 1–8 TREE_DIRS), node offset in the +// cell (qx * 16 + qy, q 0–15); +// off a body: -1, 1 if a body or a footprint's plants are near else 0, 0, 0. See cTree(). + +const int SLOTS = 8; +const float T_CLAMP = 99.0; // every "seconds since" counter clamps here +// Body memory +const float BODY_BLUR_TEXELS = 1.5, HOLE_FILL_TEXELS = 2.5; // as endless_sunset +const float PRESENCE_EASE_S = 0.12; // eases presence so 1-px edge flicker averages out +const float BODY_ON = 0.5; // presence above this is body +const float BODY_OFF = 0.3; // ... and a texel stays body until below this, so a flickering edge holds still +const int RUN_STEPS = 64; // max march taps each way +const float RUN_STEP_H = 1.0/64.0; // march step, screen heights: RUN_STEPS of them cover a full screen height +const int RUN_REFINE = 6; // bisections of the last march step, down to the edge texel (to 1080p), so a run's texels agree +const float LEAVE_S = 2.0; // a lost person's plants rewind away over this +const float HOLD_Q = 1.0/128.0; // 0.9 * hold is stored in multiples of this, so it is exact in a half float for any owner +// Garden +const float GROW_S = 20.0, REWIND_S = 20.0; // empty → full bloom, full bloom → empty +const float GROW_EARLY = 0.3, GROW_EARLY_S = 2.0; // growth starts fast, reaching GROW_EARLY in GROW_EARLY_S, + // then slows to reach full bloom at GROW_S +const float GROW_Q = 1.0/2048.0; // growth is stored in multiples of this (a half float's step just below 1) +const float BASE_BAND = 0.04; // screen heights above the lowest body row that count as feet +const int BASE_TAPS = 16; +const float BASE_EASE_S = 0.3; +const float BASE_WIN = 0.14; // feet count only this far either side of the last base (screen heights; about + // a stance), so a neighbour the tracker merges into the box can't pull it +const float BASE_FEET = 1.0; // the box bottom is this person's feet if their window finds this much presence + // there (in taps' worth); else it is a merged neighbour's (nearer the camera) +const int BASE_ITERS = 3; // the window re-centres on the feet it finds this many times a frame, so it + // keeps up with a walk (each pass reaches BASE_WIN further) +const float BASE_UP = 0.1; // the window finds the lowest body row from the legs this far above the last + // base (screen heights): more than the eased base lags a step up the image +const float REPLANT_S = 0.5; // for this long after a neighbour within SPLIT_GAP changes tracker state (a + // merge or a split), a person re-plants where they stand instead of stepping +const float WALK_DIST = 0.04; // screen heights the base must move from the spot to count as a step +const float WALK_HOLD_S = 0.4; // walking lasts this long after the last step +const float INHERIT_S = 3.0, INHERIT_DIST = 0.15; // ghost lifetime; how near a new track must appear (screen heights) +const float SPLIT_GAP = 0.03; // a new track this close to a live one (box gap, screen heights) is a split of it and + // inherits its growth: the tracker merges touching people, so a gap this small means a split +const float SUN_FULL = 1.0, SUN_EASE_S = 1.5; // total growth that fully lights the sun +// Plants +const float VINE_SPACING = 8.0/360.0; // ~17 branches across an adult; their twigs fill between +const int VINE_NEIGHBOURS = 4; // vines evaluated each side of a pixel. Was 2: a twig or flower reaches + // ~4 vines sideways (wobble + sway + its size), and at 2 they were cut off + // in visible straight lines. Lower it first if the Pi falls short of frame rate. +const float VINE_WOBBLE = 2.5/360.0, SWAY_AMP = 2.0/360.0, SWAY_HZ = 0.25; +const float WOBBLE_FREQ = 21.6; // radians per screen height up a branch (the mockup's 0.06 / px) +const float KINK_AMP = 0.5, KINK_FREQ = 2.7; // a second, faster wobble (× VINE_WOBBLE, × WOBBLE_FREQ): gnarled, not wavy +const float INK_W = 0.8/360.0; +// Branches are drawn as the concept art's: a filled, ink-outlined trunk that tapers to its tip, with +// twigs forking off it (blended into it, so the outline runs round the fork) that end in sprigs of petals. +const float STEM_W = 4.0/360.0, STEM_TIP_W = 1.2/360.0; // trunk width at its base and at its tip +const float STEM_TAPER_H = 60.0/360.0; // the trunk tapers over this much branch below its tip +const float BARK_HATCH = 0.2, BARK_FREQ = 90.0; // a broken ink stroke down the trunk's shaded side: offset (× width), dash rate +const float TWIG_STEP = 18.5/360.0; // twigs fork off every this much trunk, on alternating sides +const float TWIG_LEN = 11.0/360.0, TWIG_W = 1.8/360.0; // twig length, and width where it leaves the trunk +const float TWIG_RISE = 0.6, TWIG_RISE_VAR = 0.35; // twig angle above horizontal, and its random spread (radians) +const float TWIG_FORK = 0.45; // the fraction of twigs that fork once more, partway along... +const float FORK_AT = 0.55, FORK_LEN = 0.5, FORK_ANG = 0.75; // ... at this fraction of the twig, this × as long, turned up this far +const float WOOD_FILLET = 1.5/360.0; // the trunk and its twigs blend over this, so a fork reads as one piece of wood +const float PAIR_H = 50.0/360.0; // branches rise in pairs from one root, parting over about this much branch +const float SPRIG_LEN = 7.5/360.0, SPRIG_WID = 1.5/360.0; // a sprig's petals: length, half-width +const float SPRIG_FAN = 0.85, SPRIG_LIFT = 0.6; // petals fan ± this (radians) round the twig, turned up by this much +const float FLOWER_FROM = 0.6, FLOWER_FULL = 0.9; // growth at which buds start, and are fully open +const float FLOWER_R = 8.0/360.0, BUD_R = 2.5/360.0; +const float SAT_MIN = 0.1, SAT_MAX = 0.95; +const float SPROUT_H = 18.0/360.0; // a trunk fades in, and a twig or bud grows in, over this much branch below it +// The flower is a daisy: DAISY_N long petals round an oval centre, hatched in ink near their roots. +const float PETAL_R = 0.8, HEART_R = 0.7; // petals reach FLOWER_R × (1 + PETAL_R); centre radius, × FLOWER_R +const int DAISY_N = 12; +const float DAISY_FRAC = 0.5; // the fraction of branch tips that open into a daisy; the rest end in a sprig +// Flowers bloom all through the growth: TWIG_FLOWER_FRAC of twigs end in a daisy instead of a sprig, opening +// once the growing tip has climbed TWIG_FLOWER_OPEN_H past it. Low on a run they are small and pale; higher +// (so later, as growth climbs and slows) larger and bolder. The tips' late daisies are the largest and boldest. +const float TWIG_FLOWER_FRAC = 0.5; +const float TWIG_FLOWER_OPEN_H = 30.0/360.0; +const float TWIG_FLOWER_R_MIN = 2.5/360.0, TWIG_FLOWER_R_MAX = 6.5/360.0; // radius (petals' ring) at a run's bottom, top +const float BOLD_MIN = 0.35; // how much colour a flower at a run's bottom has (the rest is paper); 1 at its top +const float BOLD_YOUNG = 0.6; // ... times this while the person's growth is young, rising to 1 at full bloom +const float DAISY_PETAL_W = 0.3; // a petal's half-width at its tip, × the flower radius +const float PEN_MIN = 1.5; // thinnest line, render pixels: thinner ones are drawn this wide but fainter +const float REACH = FLOWER_R * (1.0 + PETAL_R); // a flower's outer radius (FLOWER_R is its petals' ring, as the mockup's r); + // the Image taps B at ±REACH and ±REACH / 2 above and below each pixel +const float SHORT_BLOOM_FROM = 0.5, SHORT_BLOOM_FULL = 0.75; // a run shorter than these × REACH keeps a bud, not a flower + // (the taps are REACH / 2 apart: they don't find it from every row a flower covers) +// Footprints: a walker leaves plants behind on the floor where they step (Buffer C row 3, a ring). +const int FOOTPRINTS = 32; // live at once; when all are, walkers leave none until the oldest has gone +const float FOOT_GAP = 0.12; // a walker drops a footprint where their feet are this far (screen heights) + // from every live one +const float FOOT_GROW_S = 1.5, FOOT_HOLD_S = 4.0, FOOT_FADE_S = 2.0; // grows in fast, holds, rewinds away +const float FOOT_LIFE = FOOT_GROW_S + FOOT_HOLD_S + FOOT_FADE_S; +const int FOOT_SPROUTS = 3; // little branches in a footprint's clump +const float FOOT_H = 0.08; // the tallest stands this high (screen heights) +const float FOOT_SPREAD = 0.025; // they stand across this either side of the footprint +const float FOOT_LEAN = 0.35; // and lean up to this far sideways per unit height +const float FOOT_FLOWER_FROM = 0.5, FOOT_FLOWER_R = 5.0/360.0; // the middle one's daisy opens from this growth; its size +// How far a footprint's plants reach from it: sideways, below, above (for the near grid and the Image's bounds). +const float FOOT_REACH_X = FOOT_SPREAD * 1.2 + FOOT_LEAN * FOOT_H + 0.7 * TWIG_LEN + SPRIG_LEN + INK_W; +const float FOOT_REACH_DOWN = SPRIG_LEN + INK_W; +const float FOOT_REACH_UP = FOOT_H + max(SPRIG_LEN, FOOT_FLOWER_R * (1.0 + PETAL_R)) + INK_W; +// The tree (Buffer C rows 4+). Each person's plants are one tree, rooted on the floor at their base (feet). +// Every body cell's parent is its neighbour on the cheapest path back to the root through that person's body; +// paths cost more near the silhouette's edge, so they run up the middle of the legs, torso and arms. A cell is +// grown once its path distance is within growth × the farthest on the body: growth climbs from the root, up +// the legs, then out along the arms and up the neck, and never starts a new root. Only the paths of a +// scattering of seed cells (TREE_SEED_FRAC) are branches, so the tree is sparse; a branch's width comes from how +// many seeds lie above it (thick at the root, thin at the tips), and every seed carries a sprig or a daisy. +// The branches don't depend on growth, so growth runs along them: tips extend, nothing pops in. An arm's path through the shoulder +// doesn't change as it waves, so waving keeps its growth. +const float TREE_CELL = 1.0/48.0; // cell size; 4 + 1/TREE_CELL rows of C must fit the render height (52 ≤ 72 at test size) +const int TREE_ROWS = 48; // = 1 / TREE_CELL +const int TREE_COLS_MAX = 96; // widest grid (aspect ≤ 2) +const float TREE_FILL = 0.25; // a cell is body when this much of it is (of 16 samples); stays until half that +const float TREE_EDGE_COST = 8.0; // a step costs 1 + this × (1 − how much of a ring round it is body): stay central +const float TREE_EDGE_R = 1.6; // the rings' radii: this and twice this, in cells +const float TREE_ROOT_BAND = 1.5 * TREE_CELL; // cells this close above the base are the root's (they join it on the floor) +const float TREE_ROOT_WIN = 0.35; // ... if within this of it sideways (screen heights): both feet, however wide +const float TREE_FAR = 50.0; // the path distance of a body cell no path reaches +const float TREE_ROOT_EASE_S = 1.0; // the root point eases over this +const float TREE_WIDTH_EASE_S = 1.0; // branch widths (seed counts) ease over this +const float TREE_PARENT_HYST = 0.05; // a cell changes its way back to the root only for one this much cheaper +const float TREE_MAX_EASE_S = 1.0; // the farthest path distance eases over this (so a raised arm doesn't jolt the front) +const int TREE_P_X = 34; // TREE PERSON texels: row 3, from x = this (after the footprints) +const float TREE_SEED_FRAC = 0.35; // the share of body cells that are seeds: a branch runs from each to the root +const float TREE_ROOT_W = 7.0/360.0, TREE_W_K = 1.1/360.0; // branch width: TREE_W_K × sqrt(seeds above), up to TREE_ROOT_W +const float TREE_JITTER = 0.5; // nodes are jittered this much (cells) so the grid doesn't show +const float TREE_FILLET = 4.0/360.0; // the tree's branches blend over this where they meet (closes slits between near paths) +const int TREE_R = 3; // the Image draws the cells within this many of a pixel's +const int MAX_TIPS = 12; // sprigs and flowers a pixel can see at once +const float TREE_OPEN_D = 3.0 * TREE_CELL; // a flower opens as the growth front passes this far beyond it +const ivec2 TREE_DIRS[8] = ivec2[8](ivec2(-1, -1), ivec2(0, -1), ivec2(1, -1), ivec2(-1, 0), + ivec2(1, 0), ivec2(-1, 1), ivec2(0, 1), ivec2(1, 1)); +// Sun +const vec2 SUN_POS = vec2(-0.16, 0.84); // x from the right edge (screen heights), y as a fraction of height +const float SUN_R = 0.09, RAY_LEN = 0.07; +const int SUN_RAYS = 12; // crayon rays at full level +const float SUN_MUTE = 0.8; // paper mixed into the yellow at level 0 (0 = full yellow) +const float SUN_EDGE_WOBBLE = 0.07; // the disc's edge is broken by noise: ± this fraction of SUN_R +const float SUN_GRAIN = 45.0; // crayon grain scale, cycles per screen height +const float SUN_COVER = 0.4; // grain threshold: higher = more paper shows through the fill +const float SUN_RAY_W = 0.011; // ray half-width (screen heights), tapering to a point +const float SUN_RAY_FROM = 1.2; // rays start this many SUN_R from the centre +const float SUN_WOBBLE_RATE = 0.3; // the wobble's clock: noise offsets move this fast per second +const float SUN_DRIFT = 0.02; // rays turn this many ray-widths per wobble-second (slow drift) +const float SUN_EDGE_SCALE = 25.0; // scale of the noise that breaks the disc's edge, cycles per screen height +const float SUN_RIM_FROM = 0.75, SUN_RIM_WEIGHT = 0.25; // the pressed stroke starts at this fraction of the edge; extra grain it adds +const float SUN_FILL_SOFT = 0.03; // softness of the fill's grain threshold +const float SUN_GRAIN_A = 0.6; // weight of the round grain; the stretched (hatching) grain gets the rest +const vec2 SUN_HATCH = vec2(0.2, 1.3); // the hatching grain's scale along and across the stroke, × SUN_GRAIN +const float SUN_RAY_MIN = 0.003; // rays shorter than this (screen heights) aren't drawn +const float SUN_RAY_LEN_JITTER = 0.2; // each ray's length varies by up to this fraction below full +const float SUN_RAY_SKEW = 0.1; // each ray's angle varies by ± half this many ray spacings +const float SUN_RAY_TAPER = 0.6; // a ray's width shrinks by this fraction toward its tip +const float SUN_RAY_W_JITTER = 0.3; // width varies by ± half this fraction, along the ray (noise rate SUN_RAY_W_RATE) +const float SUN_RAY_W_RATE = 6.0; +const float SUN_RAY_GRAIN = 0.7, SUN_RAY_FADE = 0.25, SUN_RAY_BIAS = 0.3; // ray grain weight; extra fill near the root; base fill +const float SUN_RAY_SOFT = 0.1; // softness of the ray grain threshold + +// ── Palette ─────────────────────────────────────────────────────────────────── +const vec3 PAPER = vec3(0.965, 0.945, 0.894); // #f6f1e4 +const vec3 INK = vec3(0.067); // #111111 +const vec3 YELLOW = vec3(1.000, 0.949, 0.000); // #fff200 +const vec3 ORANGE = vec3(0.910, 0.525, 0.184); // #e8862f +const vec3 PALE = vec3(0.984, 0.914, 0.627); // #fbe9a0 pale yellow +const vec3 SKY = vec3(0.443, 0.604, 0.678); // #719aad light blue: the site's band, the concept art's sky + +// ── Blob records (ADR-0022) ─────────────────────────────────────────────────── +const float S_EMPTY = 0., S_TENTATIVE = 1., S_CONFIRMED = 2., S_COASTING = 3., S_LOST = 4.; + +vec4 recBox(sampler2D b, int k) { return texelFetch(b, ivec2(k, 0), 0); } // x0, y0, x1, y1 Body UV +vec4 recMot(sampler2D b, int k) { return texelFetch(b, ivec2(k, 1), 0); } // cx, cy, vx, vy UV, UV/s +vec4 recIdn(sampler2D b, int k) { return texelFetch(b, ivec2(k, 2), 0); } // id, state, age_s, near +bool isState(float s, float want) { return abs(s - want) < 0.5; } + +// ── Garden state accessors (Buffer C) ───────────────────────────────────────── +vec4 cPerson(sampler2D c, int k) { return texelFetch(c, ivec2(k, 0), 0); } +vec4 cBase (sampler2D c, int k) { return texelFetch(c, ivec2(k, 1), 0); } +vec4 cGhost (sampler2D c, int k) { return texelFetch(c, ivec2(k, 2), 0); } +vec4 cSun (sampler2D c) { return texelFetch(c, ivec2(0, 3), 0); } +vec4 cFoot (sampler2D c, int f) { return texelFetch(c, ivec2(1 + f, 3), 0); } +// A footprint's growth at `age` seconds: fast in (easing out), holding, then rewinding away; 0 when gone. +float footGrowth(float age) { + if (age >= FOOT_LIFE) return 0.0; + if (age < FOOT_GROW_S) { float u = 1.0 - age / FOOT_GROW_S; return 1.0 - u * u; } + return clamp((FOOT_LIFE - age) / FOOT_FADE_S, 0.0, 1.0); +} +vec4 cTreeP (sampler2D c, int k) { return texelFetch(c, ivec2(TREE_P_X + k, 3), 0); } +int treeCols(vec2 res) { return min(int(ceil(res.x / res.y / TREE_CELL)), TREE_COLS_MAX); } +vec4 cTree (sampler2D c, ivec2 cell, vec2 res) { // a cell off the grid is empty, far from anything + if (cell.x < 0 || cell.y < 0 || cell.x >= treeCols(res) || cell.y >= TREE_ROWS) return vec4(-1.0, 0.0, 0.0, 0.0); + return texelFetch(c, cell + ivec2(0, 4), 0); +} +int treeOwner (vec4 t) { return t.x < 0.0 ? 0 : int(floor(t.z)); } +int treeParent(vec4 t) { return int(floor(fract(t.z) * 16.0 + 0.5)); } +vec2 treeOffset(vec4 t) { return (vec2(floor(t.w / 16.0), mod(t.w, 16.0)) + 0.5) / 16.0 - 0.5; } // in cells +bool cNear(sampler2D c, vec2 screenPos, vec2 res) { // is a body, or a footprint's plants, near? + vec4 t = cTree(c, ivec2(floor(screenPos / TREE_CELL)), res); + return t.x >= 0.0 || t.y > 0.5; +} +// Growth for owner slot k: the live person's, or while their plants rewind away, their ghost's. +float growthOf(sampler2D c, int k) { + vec4 pr = cPerson(c, k); + if (abs(pr.x) > 0.5) return pr.y; + vec4 gh = cGhost(c, k); + return gh.w < LEAVE_S ? gh.z : 0.0; +} + +// ── Body memory accessors (Buffer B .a = owner + 0.9 * hold) ────────────────── +int bOwner(vec4 b) { return int(floor(b.a)); } +float bHold (vec4 b) { return clamp(fract(b.a) / 0.9, 0.0, 1.0); } + +// UV → screen-height units. A macro: Shadertoy's Common tab can't see iResolution. +#define toScreen(uv) ((uv) * iResolution.xy / iResolution.y) + +// ── Noise ───────────────────────────────────────────────────────────────────── +float hash12(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} +// Is tree cell `cell` a seed (a branch runs from it to the root, and it carries a sprig or a daisy)? +bool treeSeed(ivec2 cell) { return hash12(vec2(cell) * 1.618 + 0.37) < TREE_SEED_FRAC; } +float noise(vec2 p) { // value noise, 0…1 + vec2 i = floor(p), f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix(mix(hash12(i), hash12(i + vec2(1, 0)), f.x), + mix(hash12(i + vec2(0, 1)), hash12(i + vec2(1, 1)), f.x), f.y); +} diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl new file mode 100644 index 0000000..23c253d --- /dev/null +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl @@ -0,0 +1,327 @@ +// Image — paper, the crayon sun, footprints and each person's tree. iChannel0 = Buffer B, iChannel1 = Buffer C, +// iChannel2 = blobs. p is in screen heights, origin bottom left, y up; screen-height y and UV y are the same number. +// +// Buffer C grows each person's tree on a grid (see common.glsl, "The tree"): every body cell has a node and a +// parent on the way back to the root; the cells on a seed's path are branches. A pixel draws the branch cells +// within TREE_R of its own: the segment from each node to its parent's, as far as growth has reached, as ink +// wood (all of it one shape, inked once, so forks are seamless), and a sprig or a daisy on each seed. Flowers are small and pale near the +// root, and larger and bolder further along the tree (so later, as growth climbs and slows). + +const float TAU = 6.2831853; + +float AA; // one render pixel, in screen heights + +float fillA(float sd) { return 1.0 - smoothstep(-0.6 * AA, 0.6 * AA, sd); } +float lineA(float sd, float w) { // a line of width w along sd = 0 + float wf = max(w, PEN_MIN * AA); // never thinner than the pen: fainter instead + return (w / wf) * (1.0 - smoothstep(0.5 * wf - 0.6 * AA, 0.5 * wf + 0.6 * AA, abs(sd))); +} + +// Premultiplied "over": lay colour c at coverage a on top of what's drawn so far. +void over(inout vec4 acc, vec3 c, float a) { acc = vec4(c * a, a) + acc * (1.0 - a); } +// A flat fill with an ink outline; k fades a shape in as it grows from nothing. +void inked(inout vec4 acc, vec3 c, float sd, float k) { + over(acc, c, fillA(sd) * k); + over(acc, INK, lineA(sd, INK_W) * k); +} + +vec3 hsl(float h, float s, float l) { // h in degrees + vec3 k = mod(vec3(0.0, 8.0, 4.0) + h / 30.0, 12.0); + return l - s * min(l, 1.0 - l) * clamp(min(k - 3.0, 9.0 - k), -1.0, 1.0); +} +// The style guide's pale yellow, light blue and orange; and a centre that stands out from each. +vec3 flowerColour(float seed) { + float h = hash12(vec2(seed, 9.7)); + return h < 0.34 ? PALE : h < 0.67 ? SKY : ORANGE; +} +vec3 heartColour(float seed) { return hash12(vec2(seed, 9.7)) < 0.34 ? ORANGE : YELLOW; } // stands out from its petals +// A flower colour as drawn: paled toward the paper, keeping its hue, by how low it is (bold 0–1) and how +// young the person's growth is (sat, from SAT_MIN to SAT_MAX as it ripens): pale, never grey. +vec3 tint(vec3 c, float sat, float bold) { + float ripe = clamp((sat - SAT_MIN) / (SAT_MAX - SAT_MIN), 0.0, 1.0); + return mix(PAPER, c, bold * mix(BOLD_YOUNG, 1.0, ripe)); +} + +// iq's vesica: a pointed leaf along y, half-length sqrt(r² − d²), half-width r − d. +float sdVesica(vec2 p, float r, float d) { + p = abs(p); + float b = sqrt(r * r - d * d); + return ((p.y - b) * d > p.x * b) ? length(p - vec2(0.0, b)) : length(p - vec2(-d, 0.0)) - r; +} + +vec4 tapB(float x, float y) { // B under screen point (x, y); 0 off screen + float aspect = iResolution.x / iResolution.y; + if (y < 0.0 || y >= 1.0 || x < 0.0 || x >= aspect) return vec4(0.0); + return texelFetch(iChannel0, ivec2(vec2(x / aspect, y) * iChannelResolution[0].xy), 0); +} +// Is t on a body (or its held shape)? B's membership has hysteresis, so a flickering edge holds +// still; a softer test on raw presence here would bring the flicker back. +bool onBody(vec4 t) { return bOwner(t) > 0 && bHold(t) > 0.0; } + + +float smin(float a, float b, float k) { // polynomial smooth min: the fillet where a twig leaves the trunk + float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0); + return mix(b, a, h) - k * h * (1.0 - h); +} +// A segment a → b, radius ra at a tapering to rb at b. +float sdTaper(vec2 p, vec2 a, vec2 b, float ra, float rb) { + vec2 pa = p - a, ba = b - a; + float h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-10), 0.0, 1.0); + return length(pa - ba * h) - mix(ra, rb, h); +} +vec2 rot(vec2 v, float a) { float c = cos(a), s = sin(a); return vec2(c * v.x - s * v.y, s * v.x + c * v.y); } + +// A sprig: SPRIG petals fanned round direction d from point s, at scale k, with an ink vein each. +void sprig(inout vec4 acc, vec2 p, vec2 s, vec2 d, float k, float seed, vec3 col) { + if (length(p - s) > SPRIG_LEN * k + INK_W + 2.0 * AA) return; + d = normalize(d + vec2(0.0, SPRIG_LIFT)); // sprigs turn to the light + float half_len = 0.5 * SPRIG_LEN * k, wid = SPRIG_WID * k; + float rr = 0.5 * (half_len * half_len / max(wid, 1e-6) + wid); // vesica of half-length half_len, half-width wid + float dd = rr - wid; + int n = 3 + int(2.0 * hash12(vec2(seed, 2.9))); // 3 or 4 petals + float o = min(1.0, 3.0 * k); // solid long before it's full size: flat, not a wash + for (int m = 0; m < 4; m++) { + if (m >= n) break; + float a = SPRIG_FAN * (2.0 * float(m) / float(n - 1) - 1.0) + 0.25 * (hash12(vec2(seed, float(m))) - 0.5); + vec2 u = rot(d, a); + vec2 q = p - (s + u * half_len); + vec2 lq = vec2(dot(q, vec2(-u.y, u.x)), dot(q, u)); // across, along + float sd = sdVesica(lq, rr, dd); + if (sd > INK_W + 2.0 * AA) continue; + inked(acc, col, sd, o); + float vein = length(vec2(lq.x, max(abs(lq.y + 0.15 * half_len) - 0.45 * half_len, 0.0))); + over(acc, INK, lineA(vein, 0.6 * INK_W) * fillA(sd) * o); + } +} + +// A daisy at q = 0 of radius fr (its petals' ring; they reach fr × (1 + PETAL_R)), petals swollen e of +// the way out, petals pc and centre hc; seed picks its turn and petal jitter. +void daisy(inout vec4 acc, vec2 q, float fr, float e, vec3 pc, vec3 hc, float seed) { + float a0 = TAU * hash12(vec2(seed, 11.3)); + float da = TAU / float(DAISY_N); + float r0 = 0.55 * fr, r1 = mix(r0, fr * (1.0 + PETAL_R), e), pw = DAISY_PETAL_W * fr * e; + // Only the three petals nearest q's angle can cover it (the centre hides where more overlap). + // Even petals lie under odd ones, the same at every pixel, so the overlaps agree. + int nn = int(floor((atan(q.y, q.x) - a0) / da + 0.5)); + for (int pass = 0; pass < 2; pass++) + for (int m = nn - 1; m <= nn + 1; m++) { + float id = mod(float(m), float(DAISY_N)); + if (int(mod(id, 2.0)) != pass) continue; + float a = a0 + da * float(m) + 0.15 * (hash12(vec2(seed, id)) - 0.5); + vec2 u = vec2(cos(a), sin(a)); + vec2 lq = vec2(dot(q, vec2(-u.y, u.x)), dot(q, u)); // across, along + float sd = sdTaper(lq, vec2(0.0, r0), vec2(0.0, r1 - pw), 0.7 * pw, pw); + if (sd > INK_W + 2.0 * AA) continue; + inked(acc, pc, sd, 1.0); + float root = 1.0 - smoothstep(r0, mix(r0, r1, 0.55), lq.y); // hatched in ink near the centre + over(acc, INK, lineA(abs(lq.x) - 0.4 * pw, 0.5 * INK_W) * fillA(sd) * root * e); + } + // The centre: an oval, hatched on its shaded side. + vec2 rad = HEART_R * fr * e * vec2(0.8, 1.05); + float sd = (length(q / rad) - 1.0) * min(rad.x, rad.y); + inked(acc, hc, sd, 1.0); + float step_ = 0.22 * rad.y; + float stripe = abs(fract(q.y / step_) - 0.5) * step_; + over(acc, INK, lineA(stripe, 0.5 * INK_W) * fillA(sd + 0.15 * rad.x) * smoothstep(0.0, -0.4 * rad.x, q.x) * e); +} + +// ── The tree ───────────────────────────────────────────────────────────────── + +// A cell's node, in screen heights, swaying a little more the further it is along the tree (u 0–1). +vec2 nodeOf(ivec2 cell, vec4 t, float u) { + vec2 j = vec2(hash12(vec2(cell) + 5.1), hash12(vec2(cell) + 8.3)) - 0.5; // a fixed jitter: no grid shows + vec2 n = (vec2(cell) + 0.5 + treeOffset(t) + TREE_JITTER * j) * TREE_CELL; + n.x += SWAY_AMP * u * sin(TAU * SWAY_HZ * iTime + 3.0 * n.y + float(treeOwner(t))); + return n; +} +// Wood along A → B (path distances sA < sB, radii rA, rB), as far as the growth front has reached. +float woodSeg(vec2 p, vec2 A, vec2 B, float sA, float sB, float rA, float rB, float front) { + if (front <= sA) return 1e3; + float f = clamp((front - sA) / max(sB - sA, 1e-4), 0.0, 1.0); + return sdTaper(p, A, mix(A, B, f), rA, mix(rA, rB, f)); +} +float branchW(float seeds) { return clamp(TREE_W_K * sqrt(max(seeds, 1.0)), STEM_TIP_W, TREE_ROOT_W); } + +// Every tree near p, over transparent, premultiplied: the wood, then sprigs, then flowers. +vec4 tree(vec2 fc) { + vec2 p = fc / iResolution.y; + vec4 acc = vec4(0.0); + if (!cNear(iChannel1, p, iResolution.xy)) return acc; // nothing near: paper, one fetch + float aspect = iResolution.x / iResolution.y; + ivec2 c0 = ivec2(floor(p / TREE_CELL)); + float wood = 1e3; + float slack = 2.5 * TREE_CELL + FLOWER_R * (1.0 + PETAL_R) + INK_W; // a node's drawing reaches at most this far + int nt = 0; + vec4 tipA[MAX_TIPS], tipB[MAX_TIPS]; // pos, dir · tips: scale, u, open, sat + float tipS[MAX_TIPS]; // seed + for (int dy = -TREE_R; dy <= TREE_R; dy++) + for (int dx = -TREE_R; dx <= TREE_R; dx++) { + ivec2 cell = c0 + ivec2(dx, dy); + vec4 t = cTree(iChannel1, cell, iResolution.xy); + int own = treeOwner(t); + if (own == 0 || own > SLOTS || t.x >= TREE_FAR || t.y < 0.5) continue; // no branch here + vec4 tp = cTreeP(iChannel1, own - 1); + if (tp.x <= 0.0) continue; + float u = clamp(t.x / tp.x, 0.0, 1.0); + vec2 P = nodeOf(cell, t, u); + if (length(p - P) > slack) continue; + vec4 bt = tapB(P.x, P.y); + float front = tp.z * tp.x * (bt.r >= BODY_ON ? 1.0 : bHold(bt)); // growth × the farthest + // The branch runs P → parent Q → grandparent R. Corners are cut: this cell draws the curve from + // the middle of R–Q round Q to the middle of Q–P (its children's curves carry on from there), and + // a tip the straight half on to P. Path distance s along the way says how far the growth reaches. + vec2 Q, R; + float dQ, dR, sQ, sR; + vec4 b = cBase(iChannel1, own - 1); + vec2 root = tp.w > 0.0 ? tp.wy : vec2(b.x * aspect, b.y); + int pa = treeParent(t); + if (pa == 0) { Q = root; dQ = 0.0; sQ = t.y; R = root; dR = 0.0; sR = t.y; } + else { + ivec2 c2 = cell + TREE_DIRS[pa - 1]; + vec4 t2 = cTree(iChannel1, c2, iResolution.xy); + if (treeOwner(t2) != own || t2.x >= TREE_FAR) continue; // its parent just left the body: re-settling + Q = nodeOf(c2, t2, clamp(t2.x / tp.x, 0.0, 1.0)); dQ = t2.x; sQ = t2.y; + int pa2 = treeParent(t2); + if (pa2 == 0) { R = root; dR = 0.0; sR = sQ; } + else { + ivec2 c3 = c2 + TREE_DIRS[pa2 - 1]; + vec4 t3 = cTree(iChannel1, c3, iResolution.xy); + if (treeOwner(t3) != own || t3.x >= TREE_FAR) { R = Q; dR = dQ; sR = sQ; } // as above: no corner + else { R = nodeOf(c3, t3, clamp(t3.x / tp.x, 0.0, 1.0)); dR = t3.x; sR = t3.y; } + } + } + if (dQ >= front && pa != 0) continue; // the growth hasn't reached this branch + float rP = 0.5 * branchW(t.y), rQ = 0.5 * branchW(sQ), rR = 0.5 * branchW(sR); + vec2 M1 = 0.5 * (P + Q), M2 = 0.5 * (Q + R), C = 0.25 * M2 + 0.5 * Q + 0.25 * M1; + float s1 = 0.5 * (t.x + dQ), s2 = 0.5 * (dQ + dR); + if (pa == 0) wood = smin(wood, woodSeg(p, root, P, 0.0, t.x, rQ * 1.2, rP, front), TREE_FILLET); + else { + wood = smin(wood, woodSeg(p, M2, C, s2, dQ, 0.5 * (rQ + rR), rQ, front), TREE_FILLET); + wood = smin(wood, woodSeg(p, C, M1, dQ, s1, rQ, 0.5 * (rP + rQ), front), TREE_FILLET); + } + bool tip = t.y - (treeSeed(cell) ? 1.0 : 0.0) < 0.5; // no seed above: the branch ends here + if (tip && pa != 0) wood = smin(wood, woodSeg(p, M1, P, s1, t.x, 0.5 * (rP + rQ), rP, front), TREE_FILLET); + float k = smoothstep(t.x - TREE_CELL, t.x + 0.5 * TREE_CELL, front); // a seed's sprig grows in as growth reaches it + if (treeSeed(cell) && k > 0.0 && nt < MAX_TIPS && length(p - P) < FLOWER_R * (1.0 + PETAL_R) + SPRIG_LEN + INK_W) { + tipA[nt] = vec4(P, normalize(P - Q + vec2(0.0, 1e-4))); + tipB[nt] = vec4(k, u, smoothstep(0.0, TREE_OPEN_D, front - t.x), mix(SAT_MIN, SAT_MAX, tp.z)); + tipS[nt] = hash12(vec2(cell) + 0.37); + nt++; + } + } + if (wood < INK_W + 2.0 * AA) inked(acc, PAPER, wood, 1.0); // unfilled: ink on paper, as the art + for (int n = 0; n < MAX_TIPS; n++) { // sprigs, on the nodes without a flower + if (n >= nt) break; + if (tipS[n] < TWIG_FLOWER_FRAC && tipB[n].z > 0.0) continue; + sprig(acc, p, tipA[n].xy, tipA[n].zw, tipB[n].x, tipS[n] * 97.0, PAPER); + } + for (int n = 0; n < MAX_TIPS; n++) { // flowers on top + if (n >= nt) break; + if (tipS[n] >= TWIG_FLOWER_FRAC || tipB[n].z <= 0.0) continue; + float u = tipB[n].y, open = tipB[n].z; + float fr = mix(TWIG_FLOWER_R_MIN, FLOWER_R, u * u) * open; + vec2 q = p - tipA[n].xy; + if (length(q) > fr * (1.0 + PETAL_R) + INK_W + 2.0 * AA) continue; + float bold = mix(BOLD_MIN, 1.0, u * u), seed = tipS[n] * 97.0; + daisy(acc, q, fr, smoothstep(0.0, 0.4, open), tint(flowerColour(seed), tipB[n].w, bold), + tint(heartColour(seed), tipB[n].w, bold), seed); + } + return acc; +} + +// ── Footprints ──────────────────────────────────────────────────────────────── +// Where a walker steps, Buffer C leaves a footprint: a clump of FOOT_SPROUTS little branches grows up +// off the floor there, each with a twig and a sprig, the middle one ending in a small daisy. It grows +// fast, holds, then rewinds away (footGrowth). Drawn as the body's plants are, but stands on its own. + +void footprint(inout vec4 acc, vec2 p, vec2 at, float g, float seed) { + if (abs(p.x - at.x) > FOOT_REACH_X || p.y < at.y - FOOT_REACH_DOWN || p.y > at.y + FOOT_REACH_UP) return; + float sat = mix(SAT_MIN, SAT_MAX, g); + float wd = 1e3; + vec2 tips[FOOT_SPROUTS], twigs[FOOT_SPROUTS], dirs[FOOT_SPROUTS]; + float ks[FOOT_SPROUTS]; + for (int s = 0; s < FOOT_SPROUTS; s++) { // the wood: stems and twigs, one shape + float h = hash12(vec2(seed, float(s) + 0.5)); + float x = at.x + FOOT_SPREAD * (2.0 * (float(s) + 0.5) / float(FOOT_SPROUTS) - 1.0 + 0.4 * (h - 0.5)); + float ht = FOOT_H * (0.6 + 0.4 * hash12(vec2(seed, float(s) + 3.5))) * g; + float lean = FOOT_LEAN * (2.0 * hash12(vec2(seed, float(s) + 6.5)) - 1.0); + vec2 o = vec2(x, at.y); + tips[s] = o + vec2(lean * ht, ht); + float tw = 0.5 * STEM_W * min(1.0, 3.0 * g), tip = 0.5 * STEM_TIP_W * g, thin = (1.0 - min(1.0, 3.0 * g)) * 2.0 * INK_W; + wd = smin(wd, sdTaper(p, o, tips[s], tw, tip) + thin, WOOD_FILLET); + // One twig, halfway up, on alternating sides. + float side = mod(float(s), 2.0) < 0.5 ? 1.0 : -1.0; + vec2 n = mix(o, tips[s], 0.5); + dirs[s] = vec2(side * cos(TWIG_RISE), sin(TWIG_RISE)); + ks[s] = smoothstep(0.3, 0.7, g); + twigs[s] = n + dirs[s] * TWIG_LEN * 0.7 * ks[s]; + wd = smin(wd, sdTaper(p, n, twigs[s], 0.5 * TWIG_W * ks[s], tip) + (1.0 - min(1.0, 3.0 * ks[s])) * 2.0 * INK_W, WOOD_FILLET); + } + if (wd < INK_W + 2.0 * AA) inked(acc, PAPER, wd, 1.0); + for (int s = 0; s < FOOT_SPROUTS; s++) { // then sprigs, and the middle one's daisy + float sd = seed * 17.0 + float(s); + if (ks[s] > 0.0) sprig(acc, p, twigs[s], dirs[s], ks[s], sd, PAPER); + if (s == FOOT_SPROUTS / 2) { + float open = smoothstep(FOOT_FLOWER_FROM, 1.0, g); + if (open > 0.0 && length(p - tips[s]) < FOOT_FLOWER_R * (1.0 + PETAL_R) + INK_W + 2.0 * AA) + daisy(acc, p - tips[s], FOOT_FLOWER_R * open, smoothstep(0.0, 0.25, open), + tint(flowerColour(seed), sat, 1.0), tint(heartColour(seed), sat, 1.0), sd); + } else sprig(acc, p, tips[s], vec2(0.0, 1.0), g, sd + 0.5, PAPER); + } +} + +// Every live footprint near p, over transparent, premultiplied. +vec4 footprints(vec2 fc) { + vec2 p = fc / iResolution.y; + vec4 acc = vec4(0.0); + if (!cNear(iChannel1, p, iResolution.xy)) return acc; // the tree grid's near flag counts footprints too + float aspect = iResolution.x / iResolution.y; + for (int f = 0; f < FOOTPRINTS; f++) { + vec4 ft = cFoot(iChannel1, f); + float g = footGrowth(ft.z); + if (g > 0.0) footprint(acc, p, vec2(ft.x * aspect, ft.y), g, ft.w); + } + return acc; +} + +// The crayon sun: a waxy, grain-filled disc with a noise-broken edge, and rays in the same hand. +// Level 0 is a muted, paper-mixed yellow with no rays; level 1 is full yellow with its rays out. +vec3 sunColour(vec2 fc) { + vec2 d = fc / iResolution.y - vec2(iResolution.x / iResolution.y + SUN_POS.x, SUN_POS.y); + float r = length(d); + if (r > SUN_R * (1.0 + SUN_EDGE_WOBBLE) + RAY_LEN) return PAPER; + float lv = clamp(cSun(iChannel1).x, 0.0, 1.0); + float t = iTime * SUN_WOBBLE_RATE; // a slow wobble; nothing is stored + float grain = SUN_GRAIN_A * noise(d * SUN_GRAIN + t) + (1.0 - SUN_GRAIN_A) * noise(vec2(d.x + d.y, d.x - d.y) * SUN_GRAIN * SUN_HATCH); + float cov = 0.0; + float edge = SUN_R * (1.0 + SUN_EDGE_WOBBLE * (2.0 * noise(d * SUN_EDGE_SCALE + t) - 1.0)); + if (r < edge) { + float rim = smoothstep(SUN_RIM_FROM, 1.0, r / edge); // the stroke round the edge is pressed harder + cov = smoothstep(SUN_COVER - SUN_FILL_SOFT, SUN_COVER + SUN_FILL_SOFT, grain + SUN_RIM_WEIGHT * rim); + } + float len = RAY_LEN * lv; + float r0 = SUN_R * SUN_RAY_FROM; + if (len > SUN_RAY_MIN && r > r0 && r < SUN_R + len) { + float a = atan(d.y, d.x) / TAU * float(SUN_RAYS) + SUN_DRIFT * t; + float id = mod(floor(a + 0.5), float(SUN_RAYS)); // atan's branch cut splits one ray in two: wrap its id + float rl = (SUN_R + len - r0) * (1.0 - SUN_RAY_LEN_JITTER + SUN_RAY_LEN_JITTER * hash12(vec2(id, 3.1))); + float u = (r - r0) / rl; // 0 at the root, 1 at the tip + float perp = (a - floor(a + 0.5) + SUN_RAY_SKEW * (hash12(vec2(id, 7.7)) - 0.5)) * TAU * r / float(SUN_RAYS); + float w = SUN_RAY_W * (1.0 - SUN_RAY_TAPER * u) * (1.0 - 0.5 * SUN_RAY_W_JITTER + SUN_RAY_W_JITTER * noise(vec2(u * SUN_RAY_W_RATE, id))); + if (u < 1.0 && abs(perp) < w) { + float rg = smoothstep(SUN_COVER - 2.0 * SUN_RAY_SOFT, SUN_COVER - SUN_RAY_SOFT, SUN_RAY_GRAIN * noise(d * SUN_GRAIN + 9.0) + SUN_RAY_FADE * (1.0 - u) + SUN_RAY_BIAS); + cov = max(cov, rg); + } + } + vec3 c = mix(mix(YELLOW, PAPER, SUN_MUTE), YELLOW, lv); + return mix(PAPER, c, cov); +} + +void mainImage(out vec4 o, in vec2 fc) { + AA = 1.0 / iResolution.y; + vec3 col = sunColour(fc); // the sun, under the plants + vec4 fp = footprints(fc); // behind the people + col = fp.rgb + col * (1.0 - fp.a); + vec4 pl = tree(fc); + o = vec4(pl.rgb + col * (1.0 - pl.a), 1.0); +} diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/setup.json b/ShowControl/FundingCAPTCHA/shaders/watering_can/setup.json new file mode 100644 index 0000000..d24ee5f --- /dev/null +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/setup.json @@ -0,0 +1,9 @@ +{ + "_comment": "Watering can: stay put and vines grow up your silhouette and bloom; walking rewinds. Spec: docs/superpowers/specs/2026-11-06-watering-can-design.md", + "duration_s": 120, + "channels": { + "buffer_b": ["A", "B", "blobs"], + "buffer_c": ["B", "C", "blobs"], + "image": ["B", "C", "blobs"] + } +} diff --git a/ShowControl/FundingCAPTCHA/test_watering_can.py b/ShowControl/FundingCAPTCHA/test_watering_can.py new file mode 100644 index 0000000..7f5701e --- /dev/null +++ b/ShowControl/FundingCAPTCHA/test_watering_can.py @@ -0,0 +1,1097 @@ +"""Behaviour tests for the watering_can Setup (shaders/watering_can/). + +Records come from the real BodyBlobs tracker run on synthetic masks with a +fake clock (camera 10 Hz, render at DT), so record noise is realistic. +GL tests need a headless context (EGL / llvmpipe) and skip without one. +""" +from __future__ import annotations + +import math +import os +import re +from pathlib import Path + +import numpy as np +import pytest + +from body_blobs import BlobConfig, BodyBlobs +from shadertoy import Renderer, load_setup, pack_body + +SETUP = Path(__file__).parent / "shaders" / "watering_can" + + +def glsl_consts(path=SETUP / "common.glsl") -> dict: + """Every `const float|int|vec2|vec3 NAME = , ...;` in common.glsl, evaluated: simple arithmetic, + earlier constants, max/min/ceil, float(...)/int(...); vec2/vec3 become tuples.""" + src = re.sub(r"//[^\n]*", "", path.read_text()) + env = {"max": max, "min": min, "ceil": math.ceil, "float": float, "int": int, + "vec2": lambda *a: tuple(float(x) for x in a), "vec3": lambda *a: tuple(float(x) for x in a)} + out = {} + for m in re.finditer(r"\bconst\s+(?:float|int|vec2|vec3)\s+([^;]*);", src): + depth, start, decls = 0, 0, [] + body = m.group(1) + for i, ch in enumerate(body + ","): # split on top-level commas only + depth += (ch == "(") - (ch == ")") + if ch == "," and depth == 0: + decls.append(body[start:i]); start = i + 1 + for d in decls: + name, expr = (x.strip() for x in d.split("=", 1)) + out[name] = env[name] = eval(expr, {"__builtins__": {}}, env) + return out + + +class _Consts(dict): + def __getitem__(self, name): + if name not in self: + raise KeyError(f"{name} is not a constant in common.glsl") + return dict.__getitem__(self, name) + + +K = _Consts(glsl_consts()) +SIZE = (128, 72) # render +BODY = (64, 48) # camera ROI (w, h) +SLABS = [{"near_mm": 1000, "far_mm": 4000}] +DT = 1 / 30 +LEAVE_S = K["LEAVE_S"] +BLOBS = BlobConfig(min_blob_pixels=20) # the shipped defaults, but for the small test Body + + +@pytest.fixture(scope="module") +def ctx(): + moderngl = pytest.importorskip("moderngl") + for kw in ({"backend": "egl"}, {}): + try: + c = moderngl.create_standalone_context(**kw) + break + except Exception: + continue + else: + if os.environ.get("CI"): + pytest.fail("no headless OpenGL context in CI — GL tests would silently skip") + pytest.skip("no headless OpenGL context") + yield c + c.release() + + +class Rig: + """Renderer + the real BodyBlobs on a fake clock: camera at 10 Hz, render at `dt`.""" + + def __init__(self, ctx, size=SIZE, dt=DT, slot=0): + self.size, self.dt, self.slot = size, dt, slot # slot: move the tracker's records there (owner = slot + 1) + self.r = Renderer(ctx, size, body_mode="camera") + self.r.load(load_setup(SETUP)) + self.blobs = BodyBlobs(BLOBS) + self.t = 0.0 + + def run(self, masks, frames_per_camera=3): + for m in masks: # one mask per camera frame, row 0 = top + fg = np.where(m, 2000, 0).astype(np.uint16) + body = pack_body(fg, {"x": 0, "y": 0, "w": m.shape[1], "h": m.shape[0]}, SLABS) # usually BODY + self.r.upload_body(body) + self.blobs.update(body[..., 0] > 0, body[..., 1] / 255.0, now=self.t) + for _ in range(frames_per_camera): + self.r.upload_blobs(np.roll(self.blobs.records(now=self.t), self.slot, axis=1)) + self.r.render(self.dt, (0, 0, 0, 0)) + self.t += self.dt + + def buf(self, name) -> np.ndarray: + """A buffer as float32 (h, w, 4), row 0 = the GL bottom.""" + t = self.r._buffers[name].read_tex + return np.frombuffer(t.read(), dtype=np.float16).astype(np.float32).reshape(t.size[1], t.size[0], 4) + + def person(self, k): return self.buf("buffer_c")[0, k] # id, growth, spot x, y + def base(self, k): return self.buf("buffer_c")[1, k] # base x, y, since step + def ghost(self, k): return self.buf("buffer_c")[2, k] # spot x, y, growth, since loss + def sun(self): return self.buf("buffer_c")[3, 0] # level, raw total + + def image(self) -> np.ndarray: + """The Image as uint8 (h, w, 4), row 0 = bottom.""" + return np.frombuffer(self.r.output.read(components=4), np.uint8).reshape(self.size[1], self.size[0], 4) + + def release(self): + self.r.release() + + +def rect(x0, y0, x1, y1): + m = np.zeros(BODY[::-1], dtype=bool) + m[max(y0, 0):y1, max(x0, 0):x1] = True + return m + + +EMPTY = np.zeros(BODY[::-1], dtype=bool) + + +def person(x=26, feet=44, arm="down", lift=None): + """A figure 12 px wide, head to feet. x is its left edge; feet is the row below its feet. + arm: "down" (hanging at the sides), "out" (right arm horizontal), "up" (right arm overhead). + lift: "L" or "R" raises that foot 2 px (a weight shift).""" + top = feet - 38 + m = rect(x + 4, top, x + 8, top + 6) # head + m |= rect(x + 2, top + 6, x + 10, top + 20) # torso (shoulder top = top + 6) + m |= rect(x + 2, top + 20, x + 6, feet - (2 if lift == "L" else 0)) # left leg + m |= rect(x + 6, top + 20, x + 10, feet - (2 if lift == "R" else 0)) # right leg + m |= rect(x, top + 7, x + 3, top + 22) # left arm, hanging + if arm == "down": + m |= rect(x + 9, top + 7, x + 12, top + 22) + elif arm == "out": + m |= rect(x + 9, top + 7, x + 24, top + 10) + elif arm == "up": + m |= rect(x + 9, top - 6, x + 12, top + 7) + return m + + +def flickering(n, seed=0, **kw): + """person(**kw) with its 1-px border toggled randomly, plus 3 random 1-px dropout holes inside, each camera frame.""" + rng = np.random.default_rng(seed) + base = person(**kw) + + def grow(m): + g = m.copy() + g[1:] |= m[:-1]; g[:-1] |= m[1:]; g[:, 1:] |= m[:, :-1]; g[:, :-1] |= m[:, 1:] + return g + + def shrink(m): + return ~grow(~m) + + edge = grow(base) & ~shrink(base) + inner = np.argwhere(shrink(base)) + out = [] + for _ in range(n): + m = base.copy() + m[edge] = rng.random(edge.sum()) < 0.5 + for y, x in inner[rng.integers(0, len(inner), 3)]: + m[y, x] = False + out.append(m) + return out + + +def walking(n, step=1, x=26, bounce=(4, 48), **kw): + """A person starting at x, moving sideways `step` px per camera frame, bouncing within `bounce`.""" + out, d = [], 1 + for _ in range(n): + out.append(person(x=x, **kw)) + if not bounce[0] <= x + d * step <= bounce[1]: + d = -d + x += d * step + return out + + +def stepping_toward(n, **kw): + """Feet row increases 1 px per camera frame from 36 to 47 (moving down the image).""" + return [person(feet=min(36 + k, 47), **kw) for k in range(n)] + + +def both(a, b): + """Two people at once: the camera frames of two mask lists, overlaid.""" + return [x | y for x, y in zip(a, b)] + + +def owner(texel): + return int(np.floor(texel[3])) + + +def slot_of_live(rig): + """The one slot whose PERSON id != 0.""" + live = [k for k in range(8) if rig.person(k)[0] != 0] + assert len(live) == 1, live + return live[0] + + +# ── Task 1: Body memory (Buffer B) ──────────────────────────────────────────── + +def b_at(rig, col, row): + """B texel under Body pixel (col, row), row 0 = top.""" + w, h = rig.size + return rig.buf("buffer_b")[int((1 - (row + 0.5) / BODY[1]) * h), int((col + 0.5) / BODY[0] * w)] + + +def test_presence_ignores_edge_flicker(ctx): + rig = Rig(ctx) + rig.run(flickering(30)) + for _ in range(10): + rig.run(flickering(1, seed=_ + 5)) + assert b_at(rig, 32, 20)[0] > 0.95 # torso centre + assert b_at(rig, 22, 20)[0] < 0.05 # 3 px outside the flickering border + rig.release() + + +def test_run_extents_span_leg_to_shoulder(ctx): + rig = Rig(ctx) + rig.run([person()] * 10) + g, b = b_at(rig, 29, 40)[1:3] # in a leg + assert abs(g - (1 - 44 / 48)) < 1 / 48 + assert abs(b - (1 - 12 / 48)) < 2 / 48 # shoulder top: row 6 + 6 + rig.release() + + +def test_raised_arm_runs_start_at_its_lowest_point(ctx): + rig = Rig(ctx) + rig.run([person(arm="up")] * 10) + g = b_at(rig, 36, 3)[1] # in the raised arm, above the head + assert abs(g - (1 - 12 / 48)) < 2 / 48 # its lowest row (the shoulder), not the feet + assert g > 0.5 + rig.release() + + +def test_owner_is_record_slot(ctx): + rig = Rig(ctx) + rig.run([person()] * 10) + assert owner(b_at(rig, 30, 20)) == 1 and owner(b_at(rig, 2, 2)) == 0 + rig.release() + + +def test_walker_leaves_no_hold_trail(ctx): + rig = Rig(ctx) + rig.run(walking(20)) + b = rig.buf("buffer_b") + gone = b[..., 0] < 0.05 + assert gone.any() and (b[..., 3][gone] == 0).all() + body = b[..., 0] > 0.95 # under the walker now: owned, and held at ~1 (present + assert body.any() # texels decode to floor(0.9 / HOLD_Q) * HOLD_Q / 0.9) + assert (b[..., 3][body] >= 1).all() + assert (np.abs((b[..., 3][body] % 1.0) / 0.9 - 1) < 0.01).all(), np.unique(b[..., 3][body]) + rig.release() + + +def test_lost_person_holds_then_fades(ctx): + rig = Rig(ctx) + rig.run([person()] * 20) + rig.run([EMPTY] * 15) # 1.5 s: lost after 8 misses + h1 = b_at(rig, 32, 20) + hold1 = (h1[3] % 1.0) / 0.9 + g1, b1 = h1[1], h1[2] + assert 0 < hold1 < 1 and owner(h1) == 1 + rig.run([EMPTY] * 25) # LEAVE_S more + h2 = b_at(rig, 32, 20) + assert h2[3] == 0 + assert g1 > 0 and b1 > g1 # the extents were kept while hold > 0 + rig.release() + + +@pytest.mark.parametrize("slot", [0, 7]) # owner 8 has the coarsest half-float spacing +@pytest.mark.parametrize("dt, per_cam", [(1 / 30, 3), (1 / 240, 24)]) +def test_hold_fade_independent_of_render_rate(ctx, dt, per_cam, slot): + """A lost person's hold falls by 1/LEAVE_S per second, +/- 15%, at any render rate. + Measured as the slope of the mean hold over the torso between 0.5 s and 1.5 s into the fade.""" + rig = Rig(ctx, dt=dt, slot=slot) + rig.run([person()] * 20, frames_per_camera=per_cam) + + def hold(): # mean over a torso patch, so per-pixel noise averages out + a = np.array([b_at(rig, c, r)[3] for c in range(30, 35) for r in range(14, 22)]) + return float(np.mean((a % 1.0) / 0.9)) + + h = [] + for _ in range(16): + rig.run([EMPTY], frames_per_camera=per_cam) + h.append(hold()) + assert h[1] < 1.0 # fading has started + # camera frames are 0.1 s apart: frames 4 -> 14 span 1.0 s + fall = h[4] - h[14] + assert abs(fall - 1.0 / LEAVE_S) < 0.15 * (1.0 / LEAVE_S), fall + rig.release() + + +@pytest.mark.parametrize("dt, per_cam", [(1 / 30, 3), (1 / 60, 6)]) +def test_fading_hold_agrees_along_a_run(ctx, dt, per_cam): + """Every texel of a lost person's run fades in step, so the Image rebuilds each vine tip from one value: + the hold's peak-to-peak down a torso column stays within 2 * HOLD_Q at every point of the fade.""" + size = (640, 360) + rig = Rig(ctx, size=size, dt=dt) + rig.run([person()] * 20, frames_per_camera=per_cam) + seen = [] + for k in range(28): # coasting (0.8 s), then the whole LEAVE_S fade + rig.run([EMPTY], frames_per_camera=per_cam) + col = rig.buf("buffer_b")[:, int(32.5 / BODY[0] * size[0])] + held = col[col[:, 3] > 0] + if len(held) < 50: + continue + f = held[:, 3] % 1.0 + if f.max() < 0.89: # mid-fade + seen.append(round((k + 1) / 10, 1)) + assert np.ptp(f) <= 2 * K["HOLD_Q"] + 1e-6, ((k + 1) / 10, np.ptp(f) / K["HOLD_Q"]) + assert len(seen) >= 10, seen + rig.release() + + +# ── Task 2: Garden state (Buffer C) ─────────────────────────────────────────── + +GROW_S, INHERIT_S, SUN_EASE_S = K["GROW_S"], K["INHERIT_S"], K["SUN_EASE_S"] +EARLY, EARLY_S = K["GROW_EARLY"], K["GROW_EARLY_S"] +LATE_RATE = (1 - EARLY) / (GROW_S - EARLY_S) # growth per second once past GROW_EARLY + + +def grown(t): + """Growth after t seconds planted from bare: fast to GROW_EARLY, then slower to full bloom at GROW_S.""" + return min(1.0, EARLY / EARLY_S * t if t <= EARLY_S else EARLY + LATE_RATE * (t - EARLY_S)) + + +def seconds_to(g): + """Seconds planted from bare to reach growth g (the inverse of grown).""" + return g / EARLY * EARLY_S if g <= EARLY else EARLY_S + (g - EARLY) / LATE_RATE +COAST_S = 0.8 # BlobConfig max_miss_frames at 10 Hz: missing → lost +# Walks start at x=16 so 30 steps never reach walking()'s bounce at 48: a walker who turns +# back crosses their own last spot, and that stretch (~0.7 s) rightly isn't a step. +WALK_X = 16 + + +def live_slots(rig): + return [k for k in range(8) if rig.person(k)[0] != 0] + + +def slot_near(rig, x_px): + """The live slot whose base is nearest Body column x_px.""" + return min(live_slots(rig), key=lambda k: abs(rig.base(k)[0] - x_px / BODY[0])) + + +def test_planted_flickering_person_blooms_in_20s(ctx): # spec: ~20 s, never loses growth + rig = Rig(ctx) + levels = [] + for _ in range(22): + rig.run(flickering(10)) + levels.append(rig.person(0)[1]) + assert abs(levels[9] - grown(10)) < 0.06 and levels[-1] > 0.98, levels + assert all(b >= a - 1e-3 for a, b in zip(levels, levels[1:])), levels + rig.release() + + +def test_arm_waving_does_not_rewind(ctx): + rig = Rig(ctx) + rig.run([person(arm=("down", "out", "up")[k // 5 % 3]) for k in range(100)]) + assert abs(rig.person(0)[1] - grown(10)) < 0.06, rig.person(0) + rig.release() + + +def test_weight_shift_does_not_rewind(ctx): + rig = Rig(ctx) + rig.run([person(lift=(None, "L", None, "R")[k // 3 % 4]) for k in range(100)]) + assert abs(rig.person(0)[1] - grown(10)) < 0.06, rig.person(0) + rig.release() + + +def test_walking_sideways_rewinds(ctx): + rig = Rig(ctx) + rig.run([person(x=WALK_X)] * 100) + g0 = rig.person(0)[1] + rig.run(walking(30, x=WALK_X)) + assert abs((g0 - rig.person(0)[1]) - 3.0 / 20.0) < 0.05, (g0, rig.person(0)) + rig.release() + + +def test_stepping_toward_camera_rewinds(ctx): + # Toward the camera for 1.1 s, then back away for 1.1 s. Detecting a step takes ~2 px of + # movement plus easing (~0.4 s, still growing meanwhile), so 1.1 s alone nets only ~0.01. + rig = Rig(ctx) + rig.run([person(feet=36)] * 100) + g0 = rig.person(0)[1] + assert g0 > 0.4 + rig.run(stepping_toward(11) + [person(feet=46 - k) for k in range(11)]) + assert rig.person(0)[1] < g0 - 0.03, (g0, rig.person(0)) + rig.release() + + +def test_growth_resumes_after_walking(ctx): + rig = Rig(ctx) + rig.run([person(x=WALK_X)] * 100) + walk = walking(30, x=WALK_X) + rig.run(walk) + g1 = rig.person(0)[1] + assert abs(g1 - (grown(10) - 3.0 / K["REWIND_S"])) < 0.1, g1 # rewound, not reset + levels = [] + for _ in range(3): + rig.run([walk[-1]] * 10) + levels.append(rig.person(0)[1]) + # Walking lasts WALK_HOLD_S (plus easing) past the last step, so the first ~0.6 s still + # rewinds a little; then it grows at the full rate from where the rewind got to, not from 0. + assert all(g >= g1 - 0.05 for g in levels), (g1, levels) + assert abs((levels[-1] - levels[0]) - 2.0 * LATE_RATE) < 0.02, (g1, levels) + rig.release() + + +def test_neighbour_walking_does_not_rewind_still_person(ctx): + rig = Rig(ctx) + rig.run(both([person(x=46)] * 100, walking(100, x=4, bounce=(4, 20)))) + assert len(live_slots(rig)) == 2 + still, walker = slot_near(rig, 52), slot_near(rig, 4) + assert still != walker + assert abs(rig.person(still)[1] - grown(10)) < 0.06, rig.person(still) + assert rig.person(walker)[1] < 0.1, rig.person(walker) + rig.release() + + +def test_brief_loss_keeps_growth(ctx): + rig = Rig(ctx) + rig.run([person()] * 100) + rig.run([EMPTY] * 20) # lost, then emptied: the track is gone + assert rig.person(0)[0] == 0 + rig.run([person()] * 5) + assert rig.person(slot_of_live(rig))[1] > 0.45 # whichever slot and id it came back in + rig.release() + + +def test_merge_then_split_both_inherit(ctx): + # A has grown far more than B, so B starting from the merged level is told apart from B keeping its own. + rig = Rig(ctx) + rig.run([person(x=4)] * 150) # A alone, 15 s + rig.run(both([person(x=4)] * 30, [person(x=40)] * 30)) # B arrives, 3 s + assert len(live_slots(rig)) == 2 + ga, gb = rig.person(slot_near(rig, 10))[1], rig.person(slot_near(rig, 46))[1] + assert ga > 0.8 and gb < 0.45, (ga, gb) + # B slides left into A until their masks overlap (one blob), and they stand merged for 3 s + rig.run(both([person(x=4)] * 26, [person(x=max(40 - k, 14)) for k in range(26)])) + rig.run(both([person(x=4)] * 30, [person(x=14)] * 30)) + merged = max(rig.person(k)[1] for k in live_slots(rig)) + assert merged > ga + # then B jumps back out: two blobs again + rig.run(both([person(x=4)] * 8, [person(x=22)] * 8)) + live = live_slots(rig) + assert len(live) == 2, live + for k in live: + assert rig.person(k)[1] >= merged - 0.05, (merged, gb, [rig.person(j) for j in live]) + rig.release() + + +def confirmed_near(rig, x_px): + """The level of the confirmed track whose base is nearest Body column x_px (a coasting or lost record of + the same person, left over from a merge or split, doesn't count).""" + ks = [k for k in live_slots(rig) if rig.base(k)[3] == 2] + assert ks, [rig.base(k) for k in live_slots(rig)] + return float(rig.person(min(ks, key=lambda k: abs(rig.base(k)[0] - x_px / BODY[0])))[1]) + + +@pytest.mark.parametrize("contact", ["every 0.3 s", "every 1 s", "50% of frames"]) +def test_touching_still_people_keep_growing(ctx, contact): + # Two people planted side by side with a 1-px gap. The tracker merges them into one blob whenever the + # gap is bridged; neither moves, so neither may lose growth to the box swelling and shrinking. + rng = np.random.default_rng(1) + a, b = person(x=10), person(x=23) + rig = Rig(ctx) + rig.run([a | b] * 60) + before = confirmed_near(rig, 16), confirmed_near(rig, 29) + frames = [] + for k in range(100): + m = a | b + if {"every 0.3 s": k // 3 % 2 == 0, "every 1 s": k // 10 % 2 == 0, "50% of frames": rng.random() < 0.5}[contact]: + m[:, 22] = True # bridged: one blob + frames.append(m) + rig.run(frames + [a | b] * 8) + after = confirmed_near(rig, 16), confirmed_near(rig, 29) + want = 10.8 * LATE_RATE # still for 10.8 s, past the early spurt + for g0, g1 in zip(before, after): + assert abs((g1 - g0) - want) < 0.15 * want, (contact, before, after) + rig.release() + + +@pytest.mark.parametrize("feet", [44, 47]) # the passer-by level with A, or nearer the camera +def test_passer_by_costs_nothing_once_past(ctx, feet): + # Someone walks across in front of planted A (merging with them on the way). Merged, they share a level; + # once the passer-by is clear, A loses nothing more and grows at the full rate straight away. + rig = Rig(ctx) + a = person(x=26) # spans Body columns 26..38 + rig.run([a] * 100) + xs = list(range(-12, 64, 2)) # 2 px per camera frame: a brisk walk + clear = xs.index(40) # the passer-by's left edge 2 px past A's right edge + rig.run([a | person(x=x, feet=feet) for x in xs[:clear]]) + levels = [] + for m in [a | person(x=x, feet=feet) for x in xs[clear:]] + [a] * 20: + rig.run([m]) + levels.append(confirmed_near(rig, 32)) + assert all(y >= x - 1e-3 for x, y in zip(levels, levels[1:])), str(np.round(levels, 3)) + want = (len(levels) - 1) / 10 * LATE_RATE + assert levels[-1] - levels[0] > 0.85 * want, (want, levels) + rig.release() + + +def detached_arm(x=26, feet=44): + """person(arm="out") with a 1-px gap between the outstretched arm and the torso: two blobs.""" + m = person(x=x, feet=feet, arm="out") + m[:, x + 10] = False + return m + + +def test_split_off_part_inherits(ctx): # Review Focus 1 + from body_blobs import find_blobs + arm = detached_arm() + sizes = sorted(d.pixels for d in find_blobs(arm, np.zeros(arm.shape), BLOBS.min_blob_pixels)) + assert len(sizes) == 2 and sizes[0] >= BLOBS.min_blob_pixels, sizes # really a second blob + rig = Rig(ctx) + rig.run([person(arm="out")] * 100) + g = rig.person(0)[1] + assert g > 0.4 + rig.run([arm] * 20) + live = live_slots(rig) + assert len(live) == 2, live # a second live track exists + new = [k for k in live if k != 0][0] + assert rig.person(new)[1] >= rig.person(0)[1] - 0.05, (rig.person(0), rig.person(new)) + rig.release() + + +def test_feet_off_frame(ctx): # Review Focus 2 + rig = Rig(ctx) + for _ in range(10): + rig.run(flickering(10, x=WALK_X, feet=48)) + g0 = rig.person(0)[1] + assert abs(g0 - grown(10)) < 0.06, g0 + rig.run(walking(30, x=WALK_X, feet=48)) + assert abs((g0 - rig.person(0)[1]) - 3.0 / 20.0) < 0.05, (g0, rig.person(0)) + rig.release() + + +def test_new_person_far_from_ghosts_starts_bare(ctx): + rig = Rig(ctx) + rig.run([person(x=4)] * 100) + assert rig.person(0)[1] > 0.4 + rig.run([EMPTY] * 10) # A is lost; its ghost is fresh + gh = rig.ghost(0) + assert gh[2] > 0.4 and gh[3] < INHERIT_S, gh + rig.run([person(x=46)] * 10) + b = slot_near(rig, 52) + assert rig.person(b)[0] != 0 and rig.ghost(0)[3] < INHERIT_S + assert rig.person(b)[1] < grown(1.0) + 0.02, rig.person(b) # its own second of growth, nothing inherited + rig.release() + + +@pytest.mark.parametrize("frag_frames", [1, 6]) +def test_fragment_in_freed_slot_keeps_ghost(ctx, frag_frames): + # A is lost and their slot frees; a short fragment takes that slot and leaves; A comes + # back at their old spot within INHERIT_S and must still inherit their own ghost. + # 1 frame: a tentative fragment that expires bare. 6 frames: it confirms, grows a little, + # and is lost after the ghost has faded from the sun (since > LEAVE_S) but is still inheritable. + rig = Rig(ctx) + rig.run([person(x=4)] * 100) + g = rig.person(0)[1] + assert g > 0.4 + rig.run([EMPTY] * 19) # coasted, lost, and the slot freed + assert rig.person(0)[0] == 0 and rig.ghost(0)[2] > 0.4 + rig.run([person(x=46)] * frag_frames) # a fragment takes the freed slot 0 ... + assert rig.person(0)[0] != 0 and rig.person(0)[2] > 0.5 + frag_g = rig.person(0)[1] + for _ in range(12): # ... and leaves: lost or expired + rig.run([EMPTY]) + if rig.person(0)[0] == 0 or rig.base(0)[3] > 3.5: + break + assert rig.person(0)[0] == 0 or rig.base(0)[3] > 3.5 + since = rig.ghost(0)[3] + assert since < INHERIT_S + if frag_frames > 1: # the case R5 measured wrongly + assert 0 < frag_g < g and since > LEAVE_S, (frag_g, since) + rig.run([person(x=4)] * 3) + k = slot_near(rig, 10) + assert rig.person(k)[2] < 0.3 # A, back at their spot + assert abs(rig.person(k)[1] - g) < 0.05, (g, rig.person(k)) + rig.release() + + +def test_newcomer_beside_bloomed_person_starts_bare(ctx): + rig = Rig(ctx) + rig.run([person(x=4)] * 200) # A in full bloom + assert rig.person(0)[1] > 0.9 + rig.run(both([person(x=4)] * 10, [person(x=20)] * 10)) # B appears with a 4 px gap + live = live_slots(rig) + assert len(live) == 2, live # a live, separate track + a, b = slot_near(rig, 10), slot_near(rig, 26) + assert a != b + assert rig.person(a)[1] > 0.9 + assert rig.person(b)[1] < grown(1.0) + 0.02, rig.person(b) # its own second of growth, nothing inherited + rig.release() + + +@pytest.mark.parametrize("dt, per_cam", [(1 / 30, 3), (1 / 60, 6)]) # a half float stalls the easing short of full +def test_sun_tracks_growth(ctx, dt, per_cam): + rig = Rig(ctx, dt=dt) + rig.run([EMPTY] * 30, frames_per_camera=per_cam) + assert rig.sun()[0] < 0.02 + rig.run([person()] * 300, frames_per_camera=per_cam) # full bloom from ~21 s, then 6 SUN_EASE_S to catch up + assert rig.person(0)[1] == 1.0 and rig.sun()[0] > 0.99, rig.sun() + rig.run([EMPTY] * int(round((COAST_S + LEAVE_S + 3 * SUN_EASE_S) * 10)), frames_per_camera=per_cam) + assert rig.sun()[0] < 0.05, rig.sun() + rig.release() + + +@pytest.mark.parametrize("dt, per_cam", [(1 / 60, 6), (1 / 240, 24)]) # a half float stalls small steps +def test_growth_independent_of_render_rate(ctx, dt, per_cam): + a = Rig(ctx, dt=1 / 30) + a.run([person()] * 150, frames_per_camera=3) # 15 s: through the coarser steps above 0.5 + b = Rig(ctx, dt=dt) + b.run([person()] * 150, frames_per_camera=per_cam) + assert abs(a.person(0)[1] - grown(15)) < 0.06, a.person(0) + assert abs(a.person(0)[1] - b.person(0)[1]) < 0.02, (a.person(0), b.person(0)) + a.release(); b.release() + + +def test_state_stays_bounded_when_idle(ctx): # Review Focus 4 + rig = Rig(ctx) + rig.run([person()] * 10) + rig.r.render(200.0, (0, 0, 0, 0)) # one huge dt + rig.run([EMPTY] * 3) + rig.r.render(200.0, (0, 0, 0, 0)) + rig.run([EMPTY] * 3) + for name in ("buffer_b", "buffer_c"): + v = rig.buf(name) + assert np.isfinite(v).all(), name + assert np.abs(rig.buf("buffer_c")).max() <= 99.0 + assert rig.ghost(0)[3] > INHERIT_S # the counters ran: the ghost is long gone + rig.release() + + +# ── Task 3: plants (Image) ──────────────────────────────────────────────────── + +PAPER = np.array([246, 241, 228]) +FLOWER_R, REACH = K["FLOWER_R"], K["REACH"] +TWIG_REACH = (1 + K["FORK_LEN"]) * K["TWIG_LEN"] + K["SPRIG_LEN"] # a forked twig and its sprig, from its node +SUN_POS, SUN_R, RAY_LEN = K["SUN_POS"], K["SUN_R"], K["RAY_LEN"] +FLOWER_RGB = np.array([[251, 233, 160], [113, 154, 173], [232, 134, 47]]) # pale yellow, light blue, orange +IMG = (256, 144) # image tests: big enough that petals have pure-colour pixels + + +def screen_xy(size): + """Pixel centres in screen heights, (h, w) each, row 0 = bottom.""" + w, h = size + ys, xs = np.mgrid[0:h, 0:w] + return (xs + 0.5) / h, (ys + 0.5) / h + + +def sun_region(size): + """Pixels the sun (Task 4) may draw on: within SUN_R + RAY_LEN of its centre.""" + x, y = screen_xy(size) + cx = size[0] / size[1] + SUN_POS[0] + return np.hypot(x - cx, y - SUN_POS[1]) <= SUN_R + RAY_LEN + + +def drawn(img, region=None): + """Pixels differing from paper by > 12 in any channel, within region (default: all but the sun's corner).""" + d = (np.abs(img[..., :3].astype(int) - PAPER) > 12).any(axis=-1) + if region is None: + region = ~sun_region((img.shape[1], img.shape[0])) + return d & region + + +def on_screen(mask, size): + """A Body mask (row 0 = top) as screen pixels (row 0 = bottom): the camera view is stretched to the screen.""" + w, h = size + ys, xs = np.mgrid[0:h, 0:w] + col = ((xs + 0.5) / w * mask.shape[1]).astype(int) + row = ((1 - (ys + 0.5) / h) * mask.shape[0]).astype(int) + return mask[row, col] + + +def rows(r0, r1): + """Body rows r0..r1 (row 0 = top) as a Body mask.""" + return rect(0, r0, BODY[0], r1) + + +def saturation(px): + """HSV saturation of uint8 RGB pixels (n, 3).""" + px = px.astype(float) + mx, mn = px.max(axis=-1), px.min(axis=-1) + return np.where(mx > 0, (mx - mn) / np.maximum(mx, 1), 0.0) + + +def flower_pixels(img): + """Pixels within 30 per channel of a flower colour.""" + rgb = img[..., :3].astype(int) + return (np.abs(rgb[..., None, :] - FLOWER_RGB).max(axis=-1) <= 30).any(axis=-1) + + +def grown_rig(ctx, frames, size=IMG, **kw): + rig = Rig(ctx, size=size) + rig.run([person(**kw)] * frames) + return rig + + +def column_runs(b): + """Each vertical run of body texels in B (h, w, 4) as its texels' (bottom, top) arrays.""" + member = (b[..., 1] > 0) | (b[..., 2] > 0) + for c in range(b.shape[1]): + col, r = member[:, c], 0 + while r < len(col): + if not col[r]: + r += 1 + continue + s = r + while r < len(col) and col[r]: + r += 1 + yield b[s:r, c, 1], b[s:r, c, 2] + + +def swinging_arm(angle_deg, dx=0): + """person() on a 4x finer Body (256 x 192), right arm a 12 px bar from the shoulder at angle_deg + (0 hanging, 90 out, 180 up), shifted dx px sideways.""" + m = np.roll(np.kron(person(), np.ones((4, 4), dtype=bool)), dx, axis=1) + yy, xx = np.mgrid[0:m.shape[0], 0:m.shape[1]] + a = np.radians(angle_deg) + px, py = xx - (36 * 4 + dx), yy - 14 * 4 # from the right shoulder, y down + along = px * np.sin(a) + py * np.cos(a) + return m | ((along > 0) & (along < 60) & (np.abs(-px * np.cos(a) + py * np.sin(a)) < 6)) + + +def test_run_ends_agree_while_moving(ctx): # a waving arm, a swaying body: the plants don't tear + rig = Rig(ctx, size=(320, 240)) + rig.run([swinging_arm(20)] * 30) + worst = [] + for k in range(16): # down to up over 1.6 s, drifting a Body px a frame + rig.run([swinging_arm(20 + 160 * (0.5 - 0.5 * np.cos(np.pi * k / 15)), dx=k % 4)]) + spreads = [max(np.ptp(g), np.ptp(t)) for g, t in column_runs(rig.buf("buffer_b"))] + worst.append(max(spreads)) + assert max(worst) < 2.0 / 240, worst # every texel of a run finds the same ends, to a texel or two + rig.release() + + +def test_run_ends_agree_along_a_column(ctx): # else the plants tear into rows + size = (640, 360) # the march's step is 5.6 px here + rig = Rig(ctx, size=size) + rig.run(flickering(20)) + b = rig.buf("buffer_b") + for col in (29.5, 32.5, 34.5): # inside the legs and torso (an edge is ragged under flicker) + c = b[:, int(col / BODY[0] * size[0])] + run = c[(c[:, 0] > 0.9) & (c[:, 2] > 0)] + assert len(run) > 100 + assert (np.ptp(run[:, 1:3], axis=0) < 1.0 / size[1]).all(), (col, np.ptp(run[:, 1:3], axis=0) * size[1]) + rig.release() + + +TREE_CELL = K["TREE_CELL"] + + +def test_tree_grid_marks_body_and_neighbourhood(ctx): + rig = Rig(ctx) # SIZE: 4 + 48 grid rows fit in 72 + rig.run([person()] * 10) + c = rig.buf("buffer_c") + aspect = SIZE[0] / SIZE[1] + reach = K["TREE_R"] * TREE_CELL + + def cell(x_sh, y_sh): # the tree cell holding screen point (x, y), screen heights + return c[4 + int(y_sh / TREE_CELL), int(x_sh / TREE_CELL)] + + y = 1 - 18 / BODY[1] # torso height + edge = 38 / BODY[0] * aspect # the figure's right edge (arm hanging at x 35..38) + torso = cell(32 / BODY[0] * aspect, y) + assert torso[0] >= 0 and owner(torso[[0, 1, 3, 2]]) == 1 # on the body, owned by slot 0 + assert torso[0] < K["TREE_FAR"] # a path reaches it from the root + near = cell(edge + 0.5 * reach, y) + assert near[0] < 0 and near[1] == 1 # just outside: no body, but near one + far = cell(edge + reach + 2.5 * TREE_CELL, y) + assert far[0] < 0 and far[1] == 0 # beyond what a pixel there could draw + assert cell(0.05, 0.05)[1] == 0 and cell(aspect - 0.05, 0.5)[1] == 0 + rig.release() + + +def feet_apart_arm_up(): + """A figure standing feet apart (a gap between the legs to the crotch), right arm raised overhead.""" + m = rect(28, 4, 33, 11) # head, touching the torso + m |= rect(26, 10, 36, 26) # torso + m |= rect(24, 26, 28, 44) | rect(34, 26, 38, 44) # legs, 6 px apart + m |= rect(36, 0, 39, 12) # right arm, raised (hand at the top of the frame) + return m + + +def tree_cells(rig, mask): + """Path distances of slot 0's tree cells whose centres fall in Body mask region `mask`.""" + c = rig.buf("buffer_c")[4:4 + K["TREE_ROWS"]] + aspect = SIZE[0] / SIZE[1] + out = [] + for j in range(c.shape[0]): + for i in range(int(aspect / TREE_CELL)): + t = c[j, i] + if t[0] < 0 or int(np.floor(t[2])) != 1: + continue + x, y = (i + 0.5) * TREE_CELL / aspect, (j + 0.5) * TREE_CELL # UV + r, col = int((1 - y) * BODY[1]), int(x * BODY[0]) + if 0 <= r < BODY[1] and 0 <= col < BODY[0] and mask[r, col]: + out.append(t[0]) + return np.array(out) + + +def test_one_root_at_the_feet(ctx): # one tree from the base: no roots in the arms or elsewhere + rig = Rig(ctx) + rig.run([feet_apart_arm_up()] * 40) + far = rig.buf("buffer_c")[3, K["TREE_P_X"], 0] + left, right = tree_cells(rig, rect(24, 40, 28, 44)), tree_cells(rig, rect(34, 40, 38, 44)) + assert len(left) and len(right) + assert left.min() < 0.15 * far and right.min() < 0.15 * far, (left.min(), right.min(), far) # both feet root + above = tree_cells(rig, rect(0, 0, BODY[0], 38)) + assert above.min() > 0.15 * far, (above.min(), far) # nothing above the feet is a root + hand = tree_cells(rig, rect(36, 0, 39, 3)) + assert hand.min() > 0.8 * far, (hand.min(), far) # the raised hand is reached last, via the shoulder + tp = rig.buf("buffer_c")[3, K["TREE_P_X"]] + front = tp[2] * tp[0] # growth × the farthest path distance + assert left.max() < front < hand.min(), (left.max(), front, hand.min()) # mid growth: legs grown, hand not + rig.release() + + +def test_still_trunk_and_root_hold_steady_under_flicker(ctx): # no fidgeting root or trunk + rig = Rig(ctx, size=(240, 180)) + rig.run(flickering(150, seed=3)) + rows = K["TREE_ROWS"] + + def state(): + c = rig.buf("buffer_c") + return c[4:4 + rows, :int(240 / 180 / TREE_CELL)].copy(), c[3, K["TREE_P_X"]].copy() + + prev, root_moves, width_moves, flips = state(), [], [], [] + for k in range(20): + rig.run(flickering(1, seed=100 + k), frames_per_camera=1) + (g, tp), (pg, ptp) = state(), prev + trunk = (g[..., 0] >= 0) & (pg[..., 0] >= 0) & (pg[..., 1] >= 8) # 8+ seeds above: trunk and main branches + root_moves.append(np.hypot(tp[3] - ptp[3], tp[1] - ptp[1])) + width_moves.append(np.abs(g[..., 1] - pg[..., 1])[trunk].mean()) + flips.append((np.round(np.modf(g[..., 2])[0] * 16) != np.round(np.modf(pg[..., 2])[0] * 16))[trunk].sum()) + prev = (g, tp) + assert max(root_moves) < 0.5 / 180, root_moves # under half a pixel a frame + assert max(width_moves) < 0.2, width_moves # seed counts (widths) barely move + assert sum(flips) == 0, flips # the trunk keeps its way back to the root + rig.release() + + +def test_sun_region_is_top_right(): + r, (w, h) = sun_region(IMG), IMG + assert r[int(0.84 * h), int(w - 0.16 * h)] # its centre + assert not r[: h // 2].any() and not r[:, : w // 2].any() # only in the top right quarter + + +def test_nothing_drawn_before_growth(ctx): # anti-goal: never draw the silhouette + rig = grown_rig(ctx, 4) + assert 0 < rig.person(0)[1] < 0.03, rig.person(0) # growing, barely + assert drawn(rig.image()).mean() < 0.002 + rig.release() + + +def test_bloom_fills_the_silhouette(ctx): + rig = grown_rig(ctx, 220) + assert rig.person(0)[1] > 0.99 + d, fig = drawn(rig.image()), on_screen(person(), IMG) + assert d[fig].mean() >= 0.7, d[fig].mean() + # Overhang is twigs, sprigs and flowers only: within a twig's or a daisy's reach (plus sway and a pixel + # of antialiasing) of the figure. + x, y = screen_xy(IMG) + fx, fy = x[fig], y[fig] + out = d & ~fig + far = [np.hypot(fx - x[r, c], fy - y[r, c]).min() for r, c in np.argwhere(out)] + assert out.sum() > 0 and max(far) <= max(TWIG_REACH, REACH) + K["SWAY_AMP"] + 1.5 / IMG[1], max(far) + rig.release() + + +def test_growth_climbs_from_the_feet(ctx): + rig = grown_rig(ctx, round(10 * seconds_to(0.3))) + assert abs(rig.person(0)[1] - 0.3) < 0.05, rig.person(0) + d, fig = drawn(rig.image()), person() + top, bottom = on_screen(fig & rows(6, 19), IMG), on_screen(fig & rows(31, 44), IMG) # thirds of rows 6..44 + assert d[bottom].mean() > 0.25, d[bottom].mean() # a sparse tree, but clearly there ... + assert d[top].mean() < 0.05, d[top].mean() # ... and the top still bare + rig.release() + + +def test_colour_ripens(ctx): + sats = [] + for frames, g in ((round(10 * seconds_to(0.3)), 0.3), (220, 1.0)): + rig = grown_rig(ctx, frames) + assert abs(rig.person(0)[1] - g) < 0.05, rig.person(0) + img = rig.image() + sats.append(saturation(img[..., :3][drawn(img)]).mean()) + rig.release() + assert sats[1] > 1.5 * sats[0], sats # rises clearly: the palette is pastel and the wood is ink on paper + + +def coloured(img): + """Flower pixels: drawn and not grey. The wood and leaves are ink on paper; only flowers carry colour.""" + rgb = img[..., :3].astype(int) + return drawn(img) & (rgb.max(axis=-1) - rgb.min(axis=-1) > 20) + + +def test_flowers_bloom_pale_low_then_bold_high(ctx): + fig = on_screen(person(), IMG) + _, y = screen_xy(IMG) + top, bottom = y[fig].max(), y[fig].min() + low = y < bottom + (top - bottom) / 3 # the figure's lower and upper thirds + high = y > top - (top - bottom) / 3 + early = grown_rig(ctx, round(10 * seconds_to(0.3))) # 2 s in + img = early.image() + c = coloured(img) + assert c[low].sum() > 20, c[low].sum() # flowers already open, low on the body ... + assert (flower_pixels(img) & drawn(img)).sum() < 5 # ... and pale: hardly a pixel in full colour yet + early.release() + full = grown_rig(ctx, 220) + img = full.image() + bold = flower_pixels(img) & drawn(img) + assert bold.sum() > 30 # bold flowers at full bloom (only the outer ones: few at this size) + assert bold[high].sum() > 2 * bold[low].sum(), (bold[high].sum(), bold[low].sum()) # and mostly high up + full.release() + + +def test_vines_follow_the_arm(ctx): + rig = grown_rig(ctx, 220, arm="up") + # The raised arm above the head (arm-only columns, clear of the shoulders' flowers). + arm = on_screen(rect(36, 0, 38, 8), IMG) + assert drawn(rig.image())[arm].mean() > 0.5, drawn(rig.image())[arm].mean() + # 0.6 s: the rewinding tip clears in a few camera frames; twigs point up, so they outlast it by a frame or two + rig.run([person(arm="down")] * 6) + assert drawn(rig.image())[arm].mean() < 0.1, drawn(rig.image())[arm].mean() + rig.release() + + +@pytest.mark.parametrize("thick", [6, 4]) # Body rows of a 96-row Body: 0.0625 and 0.042 screen heights +def test_horizontal_arm_blooms(ctx, thick): # a thin run grows to full bloom too: no bare stencil hole + # A finer Body than BODY, so the arm can be thinner than a flower and still be body after B's blur. + m = np.kron(person(arm="out"), np.ones((2, 2), dtype=bool)) # 128 x 96; the arm is rows 26-31 + m[26 + thick:32, 72:] = False # thin it from below + rig = Rig(ctx, size=IMG) + rig.run([m] * 220) + assert rig.person(0)[1] > 0.99 + arm = rect(0, 0, 0, 0).repeat(2, 0).repeat(2, 1) + arm[26:26 + thick, 76:100] = True # the outstretched arm, clear of the torso + d = drawn(rig.image())[on_screen(arm, IMG)].mean() + assert d >= 0.5, d + rig.release() + + +def test_bloom_is_stable_under_flicker(ctx): # Review Focus 3 + rig = Rig(ctx, size=IMG) + rig.run(flickering(220)) + fig = on_screen(person(), IMG) + prev, deltas = rig.image()[..., :3].astype(int), [] + for k in range(10): + rig.run(flickering(1, seed=100 + k)) + img = rig.image()[..., :3].astype(int) + deltas.append(np.abs(img - prev)[fig].mean()) + prev = img + assert max(deltas) < 6, deltas + rig.release() + + +def test_lost_person_plants_rewind_without_popping(ctx): + rig = grown_rig(ctx, 220) + n0, counts = drawn(rig.image()).sum(), [] + for _ in range(40): + rig.run([EMPTY]) + counts.append(drawn(rig.image()).sum()) + end = int(round((1.5 + LEAVE_S + 0.5) * 10)) + assert counts[end - 1] == 0, counts + # No popping: no camera frame loses more than 30% of the previous count. A steady fade over + # LEAVE_S loses ~5% of the bloom a frame, so near its end that is more than 30% of what's + # left: drops of up to 10% of the full bloom (twice the steady rate) are allowed too. + prev = [n0] + counts[:-1] + assert all(p - c <= max(0.3 * p, 0.1 * n0) for p, c in zip(prev, counts)), (n0, counts) + assert counts[4] > 0.5 * n0, (n0, counts) # it rewinds: still mostly there half a second in + rig.release() + + +# ── Footprints: a walker leaves plants on the floor where they step ────────── + +FOOTPRINTS, FOOT_GAP, FOOT_LIFE = K["FOOTPRINTS"], K["FOOT_GAP"], K["FOOT_LIFE"] + + +def footprints(rig): + """The live footprints: (x, y UV, seconds since dropped) of each.""" + c = rig.buf("buffer_c") + return [tuple(c[3, 1 + f, :3]) for f in range(FOOTPRINTS) if c[3, 1 + f, 2] < FOOT_LIFE] + + +def test_walker_leaves_footprints_along_their_path(ctx): + rig = Rig(ctx) + rig.run([person(x=WALK_X)] * 30) + rig.run(walking(30, x=WALK_X)) # 30 px right in 3 s + fps = sorted(footprints(rig)) + assert len(fps) >= 4, fps + feet = 1 - 44 / BODY[1] # the feet's row, UV y + aspect = SIZE[0] / SIZE[1] + xs = [x for x, _, _ in fps] + assert all(abs(y - feet) < 0.04 for _, y, _ in fps), fps + assert WALK_X / BODY[0] - 0.05 < xs[0] and xs[-1] < (WALK_X + 42) / BODY[0], fps # on the path + assert all((b - a) * aspect >= FOOT_GAP - 1e-3 for a, b in zip(xs, xs[1:])), fps # spaced + ages = [t for _, _, t in sorted(fps)] + assert ages == sorted(ages, reverse=True), ages # dropped in walking order: oldest first + rig.release() + + +@pytest.mark.parametrize("kind", ["planted", "waving"]) +def test_no_footprints_without_walking(ctx, kind): + rig = Rig(ctx) + if kind == "planted": + rig.run(flickering(100)) + else: + rig.run([person(arm=("down", "out", "up")[k // 5 % 3]) for k in range(100)]) + assert footprints(rig) == [] + rig.release() + + +def test_footprints_grow_where_the_walker_was_then_rewind_away(ctx): + rig = Rig(ctx, size=IMG) + rig.run([person(x=WALK_X)] * 30) + rig.run(walking(30, x=WALK_X)) + rig.run([EMPTY] * 15) # the walker leaves; their own plants rewind away + assert footprints(rig) + # Off the body, where the walker passed: footprint plants, standing up off the floor at the feet's row. + x, y = screen_xy(IMG) + feet = 1 - 44 / BODY[1] + floor = (y > feet - 0.03) & (y < feet + 0.12) + n0 = drawn(rig.image(), floor).sum() + assert n0 > 200, n0 + counts = [] + for _ in range(int(FOOT_LIFE * 10)): # then they rewind away, without popping + rig.run([EMPTY]) + counts.append(drawn(rig.image()).sum()) + assert footprints(rig) == [] and counts[-1] == 0, counts + prev = [n0] + counts[:-1] + assert all(p - c <= max(0.3 * p, 0.1 * n0) for p, c in zip(prev, counts)), (n0, counts) + rig.release() + + +# ── Task 4: the crayon sun (Image) ──────────────────────────────────────────── + +def sun_patch(img): + """(disc, ring) masks: pixels inside the sun's disc, and in the ring where its rays reach.""" + size = (img.shape[1], img.shape[0]) + x, y = screen_xy(size) + r = np.hypot(x - (size[0] / size[1] + SUN_POS[0]), y - SUN_POS[1]) + return r <= 0.85 * SUN_R, (r >= 1.2 * SUN_R) & (r <= SUN_R + RAY_LEN) + + +def test_sun_is_muted_when_empty(ctx): + rig = Rig(ctx, size=IMG) + rig.run([EMPTY] * 30) + img = rig.image() + disc, ring = sun_patch(img) + d = drawn(img, disc) + assert d.sum() > 0.3 * disc.sum(), (d.sum(), disc.sum()) # the sun is drawn, not paper + assert saturation(img[..., :3][d]).mean() < 0.35 + assert drawn(img, ring).sum() == 0 # no rays + rig.release() + + +def test_sun_brightens_and_grows_rays_with_bloom(ctx): + rig = grown_rig(ctx, 220) + img = rig.image() + disc, ring = sun_patch(img) + d = drawn(img, disc) + assert saturation(img[..., :3][d]).mean() > 0.7 + assert drawn(img, ring).sum() > 0.15 * ring.sum(), (drawn(img, ring).sum(), ring.sum()) + rig.release() + + +def seam_extents(img): + """Farthest drawn radius from the sun's centre in the two pixel rows of the left-pointing ray that are just above + and just below atan's branch cut (the ray's axis when it hasn't drifted). 0 where the ray isn't drawn in a row.""" + size = (img.shape[1], img.shape[0]) + x, y = screen_xy(size) + dx, dy = x - (size[0] / size[1] + SUN_POS[0]), y - SUN_POS[1] + r = np.hypot(dx, dy) + ray = (dx < 0) & (r > 1.3 * SUN_R) & (r < SUN_R + RAY_LEN + 0.02) + d = (np.abs(img[..., :3].astype(int) - PAPER) > 12).any(axis=-1) + row = 1.0 / size[1] + far = lambda m: r[m].max(initial=0.0) + return far(ray & d & (dy > 0) & (dy < row)), far(ray & d & (dy <= 0) & (dy > -row)) + + +def test_sun_ray_has_no_seam_at_the_atan_cut(ctx): + # The ray pointing left lies on atan's branch cut, and the slow drift (one ray width in ~170 s) carries it across. + # It must stay one ray, not two halves with their own length. Drift is a function of iTime: sample it directly. + rig = grown_rig(ctx, 220, size=(1024, 576)) + straddled = 0 + for k in range(16): # iTime 0–3.75 s: the ray is within a pixel or two of the cut + rig.r.time = 0.25 * k + rig.r.render(1e-4, (0, 0, 0, 0)) + up, down = seam_extents(rig.image()) + if up and down: + straddled += 1 + assert abs(up - down) < 0.0025, (up, down, rig.r.time) # 0.0025 = 1.4 px of this render + assert straddled >= 4, straddled # the ray did sit on the cut + rig.release() diff --git a/docs/FundingCAPTCHA.md b/docs/FundingCAPTCHA.md index b5aceb9..40ad58b 100644 --- a/docs/FundingCAPTCHA.md +++ b/docs/FundingCAPTCHA.md @@ -58,7 +58,7 @@ Keys at runtime: Alternative show mode: instead of the Game Arc, cycles Shadertoy **Setups** from `shaders/` with the depth silhouette as Buffer A (ADR-0021). Same camera, calibration, and settings as `app.py`. Prototyping guide: [`shaders/README.md`](../ShowControl/FundingCAPTCHA/shaders/README.md). -Setups in rotation: `ripples` (example), `endless_sunset` ([design](superpowers/specs/2026-10-30-endless-sunset-design.md); tunables at the top of its `common.glsl`), `hunter_bird` ([design](superpowers/specs/2026-11-04-hunter-bird-design.md); a bird's-eye debug view of the blob tracker — tunables, including the motion noise floor, at the top of its `common.glsl`). +Setups in rotation: `ripples` (example), `endless_sunset` ([design](superpowers/specs/2026-10-30-endless-sunset-design.md); tunables at the top of its `common.glsl`), `hunter_bird` ([design](superpowers/specs/2026-11-04-hunter-bird-design.md); a bird's-eye debug view of the blob tracker — tunables, including the motion noise floor, at the top of its `common.glsl`), `watering_can` ([design](superpowers/specs/2026-11-06-watering-can-design.md); a visitor standing still grows vines, leaves and flowers over their silhouette in ~20 s, and stepping sideways rewinds them — tunables, including `WALK_DIST`, `GROW_S` and `REWIND_S`, at the top of its `common.glsl`). Setups can also read the tracker's **blob records** (boxes, stable IDs, lifecycle) through the `blobs` channel source (ADR-0022). `blob_debug` is the reference reader, outside the rotation: `python3 shader_app.py --mock-camera --windowed --setup blob_debug`. diff --git a/docs/superpowers/plans/2026-11-06-watering-can.md b/docs/superpowers/plans/2026-11-06-watering-can.md new file mode 100644 index 0000000..eb52bc9 --- /dev/null +++ b/docs/superpowers/plans/2026-11-06-watering-can.md @@ -0,0 +1,424 @@ +# Watering Can Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A new shader player Setup, `watering_can`. When a person stays in one spot, dense vines climb their silhouette from its lowest points and bloom over about 20 s. Waggling is free and the plants ride on the body. Walking rewinds the growth. A crayon sun brightens with the garden. + +**Architecture:** A Shadertoy-style Setup folder with three tabs. Buffer D stays free for the blob painter. + +- **Buffer B, "Body memory"** (per pixel). It holds: + - stabilised presence + - the run extents: the bottom and top of the vertical run of body this pixel sits in + - which tracked person owns the pixel + - a "hold" that lets a lost person's plants rewind away rather than pop +- **Buffer C, "Garden"** (a small texel grid). It holds: + - per slot: id, growth level, planted spot, smoothed base (feet), walk timer + - per slot: a ghost record of the last person lost from that slot, so a returning or split track inherits growth + - the sun level +- **Image** draws paper, the crayon sun, and the vines, leaves and flowers. All of them derive from B's run extents and C's growth levels every frame. + +Tests drive the Setup headlessly with moderngl. Blob records come from the real `BodyBlobs` tracker running on synthetic masks with a fake clock, like `test_hunter_bird.py`. + +**Tech Stack:** GLSL (Shadertoy dialect, wrapped by `shadertoy.py`), Python 3, pytest, moderngl, numpy. + +**Spec:** `docs/superpowers/specs/2026-11-06-watering-can-design.md`, plus the mockup `docs/superpowers/specs/2026-11-06-watering-can-mockup.html`. Use the "Dense" panel, and scrub its slider. Also read the blob contract in `docs/adr/0022-shader-player-blob-records.md` and `shaders/README.md`. Read all of these before starting. `shaders/hunter_bird/` is the reference for per-slot state, fresh-id handling and the test rig. `shaders/endless_sunset/buffer_b.glsl` is the reference for soft presence and hole filling. + +## Global Constraints + +- **Shader player rules** (`ShowControl/FundingCAPTCHA/shaders/README.md`): + - Read the Body (Buffer A) only with `texture()` and UVs. + - Read blob records only with `texelFetch(ch, ivec2(slot, row), 0)`. + - Buffers are RGBA16F. Store positions as UV, levels as 0–1, and time only as bounded "seconds since" counters clamped at `T_CLAMP`. Never store absolute `iTime`. + - `HW_PERFORMANCE` is 0, and there are no textures. +- **The `blobs` contract is read-only.** If you need more from the tracker, stop and raise it. +- **Shadertoy:** every tab must paste unchanged, with `body_painter.glsl` in Buffer A and `blob_painter.glsl` in Buffer D. In Shadertoy, B and C read D's previous frame. Behaviour must not depend on that one-frame difference. +- **Tunables:** every threshold, rate, size and timing is a named constant at the top of `common.glsl`, so it can be retuned on site with **R**. +- **Units:** sizes are in screen heights. Mockup value N px → `N / 360.0`. Records and B are in UV. `toScreen(uv) = uv * iResolution.xy / iResolution.y`. +- **Timing:** everything is in seconds via `iTimeDelta`. Growth must not depend on render rate. +- **Palette (exact):** + - paper `#f6f1e4` + - ink `#111111` + - yellow `#fff200` + - orange `#e8862f` + - pink `#e8957f` + - violet `#6b5b95` + - rust `#bc4c26` + - Stem and leaf greens are free choices, ripening from near-grey to saturated. +- **Never draw the silhouette.** No outline, fill or wash. Where nothing has grown, the pixel is paper. +- **Repo rules:** never write a show, artist or album name into any file. Never commit anything under `ShowControl/FundingCAPTCHA/images/private/`. +- **Branch:** `claude/watering-can` (the spec is committed there). +- **Tests:** + - Run from `ShowControl/FundingCAPTCHA/`, e.g. `python3 -m pytest test_watering_can.py -v`. + - In a fresh container, `apt-get install -y libegl1 libgl1-mesa-dri` provides a headless GL context. + - Baseline: `make test` is green, except `test_compile_error_reports_tab_line` on NVIDIA drivers. That failure already exists and is unrelated. + +## Review Focus + +1. **The tracker splits a still person.** For example, a raised arm breaks off as its own blob, or depth dropout cuts the torso. The new track would start bare, leaving part of the body unplanted. Expected: a new track overlapping, or next to, a planted person inherits their growth. Pinned by `test_split_off_part_inherits` (Task 2). +2. **Feet cut off by the bottom of the frame**, when someone stands close to the camera. The box bottom pins to the edge. Expected: standing still there doesn't rewind, and walking sideways still rewinds. Pinned by `test_feet_off_frame` (Task 2). +3. **Flicker shimmering the vines.** Run extents recomputed every frame from a flickering mask make vine tips and leaves jump. Expected: a planted, bloomed person's image is stable frame to frame. Pinned by `test_bloom_is_stable_under_flicker` (Task 3). +4. **Hours in 16-bit buffers.** Every counter stays bounded and finite. Pinned by `test_state_stays_bounded_when_idle` (Task 2). +5. **Frame rate on the Pi.** Per-pixel cost is bounded by early-outs: + - B marches only inside the body. + - Image looks at `VINE_NEIGHBOURS` vines and skips pixels with no body nearby. + - If fps falls short, cut in this order: leaf count, flower petal count, `VINE_NEIGHBOURS`, `RUN_STEPS`. + - Checked in Task 5. + +--- + +## File Structure + +All under `ShowControl/FundingCAPTCHA/`: + +| File | Responsibility | +|---|---| +| `shaders/watering_can/setup.json` | Channel bindings, `duration_s: 120` | +| `shaders/watering_can/common.glsl` | Constants, palette, record and state accessors, `toScreen`, hash and noise | +| `shaders/watering_can/buffer_b.glsl` | Body memory (per pixel) | +| `shaders/watering_can/buffer_c.glsl` | Garden state (texel grid) | +| `shaders/watering_can/image.glsl` | Paper, sun, plants | +| `shaders/setups.json` | Add `"watering_can"` | +| `test_watering_can.py` | GL behaviour tests | +| `docs/FundingCAPTCHA.md` | List `watering_can` in "Setups in rotation", with a link to the spec | + +**`setup.json`:** +```json +{ + "_comment": "Watering can: stay put and vines grow up your silhouette and bloom; walking rewinds. Spec: docs/superpowers/specs/2026-11-06-watering-can-design.md", + "duration_s": 120, + "channels": { + "buffer_b": ["A", "B", "blobs"], + "buffer_c": ["B", "C", "blobs"], + "image": ["B", "C", "blobs"] + } +} +``` + +**Buffer B, per pixel:** + +| `.r` | `.g` | `.b` | `.a` | +|---|---|---|---| +| presence 0–1 (soft, hole-filled, eased) | run bottom y (UV) | run top y (UV) | `owner + 0.9 * hold` | + +- `owner` = slot + 1, or 0 for none. +- `hold` = 0–1, and is 1 while the body is present. +- Outside any run, `.g` and `.b` are 0. + +**Buffer C, texel `(x, y)`, with `k` = slot 0–7.** Every texel that isn't listed writes 0. + +| Texel | `.x` | `.y` | `.z` | `.w` | +|---|---|---|---|---| +| `(k, 0)` PERSON | id (0 = none) | growth 0–1 | planted spot x | planted spot y (UV) | +| `(k, 1)` BASE | base x | base y (UV, smoothed) | seconds since last walking step (≤ `T_CLAMP`) | — | +| `(k, 2)` GHOST | spot x | spot y | growth at loss | seconds since loss (`T_CLAMP` = no ghost) | +| `(0, 3)` SUN | sun level 0–1 (eased) | raw total growth | — | — | + +--- + +### Task 1: Skeleton, Common, Body memory (Buffer B) + +**Files:** +- Create: `shaders/watering_can/setup.json` (above), `common.glsl`, `buffer_b.glsl` +- Create: `buffer_c.glsl` (stub: writes 0) +- Create: `image.glsl` (stub: paper) +- Modify: `shaders/setups.json` → `["ripples", "endless_sunset", "hunter_bird", "watering_can"]` +- Create: `test_watering_can.py` + +**Interfaces produced in `common.glsl`.** Use these exact names. Values are starting points, and Task 5 tunes them. +```glsl +const int SLOTS = 8; +const float T_CLAMP = 99.0; +// Body memory +const float BODY_BLUR_TEXELS = 1.5, HOLE_FILL_TEXELS = 2.5; // as endless_sunset +const float PRESENCE_EASE_S = 0.12; // eases presence so 1-px edge flicker averages out +const float BODY_ON = 0.5; // presence above this is body +const int RUN_STEPS = 64; // max march taps each way +const float RUN_STEP_H = 1.0/96.0; // march step, screen heights +const float RUN_EASE_S = 0.15; // eases run extents; snaps when they jump more than RUN_SNAP +const float RUN_SNAP = 0.08; // screen heights +const float LEAVE_S = 2.0; // a lost person's plants rewind away over this +// Garden +const float GROW_S = 20.0, REWIND_S = 20.0; // empty → full bloom, full bloom → empty +const float BASE_BAND = 0.04; // screen heights above the box bottom that count as feet +const int BASE_TAPS = 16; +const float BASE_EASE_S = 0.3; +const float WALK_DIST = 0.04; // screen heights the base must move from the spot to count as a step +const float WALK_HOLD_S = 0.4; // walking lasts this long after the last step +const float INHERIT_S = 3.0, INHERIT_DIST = 0.15; // ghost lifetime; how near a new track must appear (screen heights) +const float SUN_FULL = 1.0, SUN_EASE_S = 1.5; // total growth that fully lights the sun +// Plants (Task 3) +const float VINE_SPACING = 4.5/360.0; // ~30 vines across an adult, as the mockup's Dense panel +const int VINE_NEIGHBOURS = 2; // vines evaluated each side of a pixel +const float VINE_WOBBLE = 5.0/360.0, SWAY_AMP = 2.0/360.0, SWAY_HZ = 0.25; +const float STEM_W = 2.2/360.0, INK_W = 1.2/360.0; +const float LEAF_STEP = 18.0/360.0, LEAF_LEN = 9.0/360.0, LEAF_WID = 3.8/360.0; +const float FLOWER_FROM = 0.6, FLOWER_FULL = 0.9; // growth at which buds start, and are fully open +const float FLOWER_R = 8.0/360.0, BUD_R = 2.5/360.0; +const float SAT_MIN = 0.1, SAT_MAX = 0.95; +// Sun (Task 4) +const vec2 SUN_POS = vec2(-0.16, 0.84); // x from the right edge (screen heights), y as a fraction of height +const float SUN_R = 0.09, RAY_LEN = 0.07; +``` +Also in `common.glsl`: +- palette constants +- `S_EMPTY … S_LOST` and `isState` +- `recBox`, `recMot`, `recIdn` (as in `hunter_bird`) +- `cPerson(s,k)`, `cBase(s,k)`, `cGhost(s,k)`, `cSun(s)` +- `int bOwner(vec4 b)` and `float bHold(vec4 b)`, which decode B's `.a` +- `toScreen` (a `#define`, as in `hunter_bird`) +- `hash12` and `noise` + +**B rules (per pixel):** +- Presence: compute endless_sunset's soft and hole-filled presence from A, then ease it towards that target with `PRESENCE_EASE_S`. +- Owner: the slot whose record is not empty and whose box contains the pixel. If several boxes contain it, pick the nearest box centre. +- Run extents: only if presence ≥ `BODY_ON`. March B's own previous-frame `.r` down and up from this pixel in `RUN_STEP_H` steps, at most `RUN_STEPS` each way, and stop at the first tap below `BODY_ON`. + - The march uses previous-frame presence, so the extents lag by one frame. That's fine. + - The extents are eased with `RUN_EASE_S`, except that they snap when the new value differs by more than `RUN_SNAP`. +- Hold, when the body is absent: + - If the previous owner's record is empty or lost: keep the previous extents and owner, and decrease hold by `dt / LEAVE_S`. + - Otherwise (the owner is still live and simply moved off this pixel): set hold to 0 and the owner to 0. + - This is what stops walkers leaving trails. + +- [ ] **Step 1: Check `git status` is clean on `claude/watering-can`.** +- [ ] **Step 2: Write the rig and the failing tests.** + - Copy `ctx`, `Rig.run`, `rect`, `EMPTY` and `both` from `test_hunter_bird.py`. + - Use `SETUP = …/"watering_can"`, `SIZE = (128, 72)`, `BODY = (64, 48)`, `BLOBS = BlobConfig(min_blob_pixels=20)`. + - `Rig(ctx, size=SIZE, dt=DT)` takes the render `dt` as a parameter. + - Add `Rig.buf(name)`, which returns float32 `(h, w, 4)` with row 0 at the GL bottom. + - Add `Rig.person(k) = buf("buffer_c")[0, k]`, `Rig.base(k) = [1, k]`, `Rig.ghost(k) = [2, k]`, `Rig.sun() = [3, 0]`, and `Rig.image()`. + - Add these mask builders. Masks are bool `(48, 64)` with row 0 at the top: + +```python +def person(x=26, feet=44, arm="down", lift=None): + """A figure 12 px wide, head to feet. x is its left edge; feet is the row below its feet. + arm: "down" (hanging at the sides), "out" (right arm horizontal), "up" (right arm overhead). + lift: "L" or "R" raises that foot 2 px (a weight shift).""" + +def flickering(n, seed=0, **kw): + """person(**kw) with its 1-px border toggled randomly, plus 3 random 1-px dropout holes inside, each camera frame.""" + +def walking(n, step=1, x=26, bounce=(4, 48), **kw): # person starting at x, moving sideways step px per camera frame, bouncing within bounce +def owner(texel): # python mirror of bOwner: int(floor(texel[3])) +def slot_of_live(rig): # the one slot whose PERSON id != 0; asserts there is exactly one +def stepping_toward(n, **kw): # feet row increases 1 px per camera frame from 36 to 47 (moving down the image) +``` + +Tests for this task: +```python +def b_at(rig, col, row): # B texel under Body pixel (col, row), row 0 = top +def test_presence_ignores_edge_flicker(ctx): + rig = Rig(ctx); rig.run(flickering(30)) + # one Body px inside the border, over 10 more camera frames: + # presence at the torso centre stays > 0.95, and a pixel 3 px outside the figure stays < 0.05 +def test_run_extents_span_leg_to_shoulder(ctx): + rig = Rig(ctx); rig.run([person()] * 10) + g, b = b_at(rig, 28, 40)[1:3] # in a leg + # g ≈ UV of the feet (1 - 44/48) within 1/48; b ≈ UV of the shoulder top within 2/48 +def test_raised_arm_runs_start_at_its_lowest_point(ctx): + rig = Rig(ctx); rig.run([person(arm="up")] * 10) + # a pixel in the raised arm, above the head row: its run bottom is the arm's lowest row, not the feet +def test_owner_is_record_slot(ctx): + rig = Rig(ctx); rig.run([person()] * 10) + assert owner(b_at(rig, 30, 20)) == 1 and owner(b_at(rig, 2, 2)) == 0 +def test_walker_leaves_no_hold_trail(ctx): + rig = Rig(ctx); rig.run(walking(20)) + # every B texel with presence < 0.05 has hold == 0 +def test_lost_person_holds_then_fades(ctx): + rig = Rig(ctx); rig.run([person()] * 20); rig.run([EMPTY] * 15) # 1.5 s: lost after 8 misses + h1 = hold at the torso centre + rig.run([EMPTY] * 25) + # 0 < h1 < 1, and the hold is 0 after LEAVE_S more; the run extents were kept while hold > 0 +``` + +- [ ] **Step 3: Run the tests.** `python3 -m pytest test_watering_can.py -v` should FAIL (stub B), and `test_shadertoy.py::test_listed_setups_compile[watering_can]` should PASS. +- [ ] **Step 4: Implement `common.glsl` and `buffer_b.glsl`** following the rules above. +- [ ] **Step 5: Run the tests.** Expected: PASS. +- [ ] **Step 6: Commit** "Watering can: Setup skeleton and Body memory". + +### Task 2: Garden state (Buffer C) + +**Files:** `buffer_c.glsl` and `test_watering_can.py`. + +**Interfaces:** +- **Consumes:** B as in Task 1, through `bOwner`, `bHold` and `.r`; the record accessors. +- **Produces:** the C layout above, for Tasks 3 and 4. + +**C rules (per frame; each texel reads the previous frame of every other texel):** +- **Fresh slot.** The record's id differs from PERSON.id, or the record is empty. + - PERSON = `(record id or 0, inherit, base, base)`, where `base` = record box bottom-centre (x centre, y0). + - `inherit` = the max of: + - the growth of every GHOST with `since < INHERIT_S` whose spot is within `INHERIT_DIST` (screen units) of `base`; + - the growth of every other live PERSON whose record box overlaps this box, or whose box is within `INHERIT_DIST`. + - The second case covers splits and merges. + - Reset BASE: walk timer = `T_CLAMP`. +- **GHOST for slot `k`:** when PERSON.id ≠ 0 and the slot turns fresh or lost this frame, write `(PERSON spot, PERSON growth, 0)`. Otherwise `since += dt` (clamped). +- **BASE:** + - Sample B `.r` at `BASE_TAPS` x positions across the box, at `y0 + BASE_BAND/2`. + - Take the presence-weighted mean x. If the weight is 0, use the box centre. + - Base target = that x and `y0`. Ease toward it with `BASE_EASE_S`. +- **Walking.** If `length(toScreen(base - spot)) > WALK_DIST`: set spot = base and reset the walk timer to 0. Otherwise the timer advances by `dt`. `walking` = timer < `WALK_HOLD_S`. +- **Growth.** Only for confirmed records: + - walking: `-= dt / REWIND_S` + - otherwise: `+= dt / GROW_S` + - clamp to 0–1 + - Tentative and coasting records hold their level. +- **SUN.** + - raw = Σ live growth + Σ ghost growth × `max(0, 1 - since / LEAVE_S)` + - level eases toward `clamp(raw / SUN_FULL, 0, 1)` with `SUN_EASE_S` +- `iFrame == 0` resets everything (ghost `since` = `T_CLAMP`). + +- [ ] **Step 1: Write the failing tests.** +```python +def test_planted_flickering_person_blooms_in_20s(ctx): # spec: ~20 s, never loses growth + rig = Rig(ctx); levels = [] + for _ in range(22): rig.run(flickering(10)); levels.append(rig.person(0)[1]) + assert abs(levels[9] - 0.5) < 0.06 and levels[-1] > 0.98 + assert all(b >= a - 1e-3 for a, b in zip(levels, levels[1:])) +def test_arm_waving_does_not_rewind(ctx): + rig = Rig(ctx); rig.run([person(arm=("down", "out", "up")[k // 5 % 3]) for k in range(100)]) + assert abs(rig.person(0)[1] - 0.5) < 0.06 +def test_weight_shift_does_not_rewind(ctx): + rig = Rig(ctx); rig.run([person(lift=(None, "L", None, "R")[k // 3 % 4]) for k in range(100)]) + assert abs(rig.person(0)[1] - 0.5) < 0.06 +def test_walking_sideways_rewinds(ctx): + rig = Rig(ctx); rig.run([person()] * 100); g0 = rig.person(0)[1] + rig.run(walking(30, x=26)) + assert abs((g0 - rig.person(0)[1]) - 3.0 / 20.0) < 0.05 +def test_stepping_toward_camera_rewinds(ctx): + rig = Rig(ctx); rig.run([person(feet=36)] * 100); g0 = rig.person(0)[1] + rig.run(stepping_toward(11)); assert rig.person(0)[1] < g0 - 0.03 +def test_growth_resumes_after_walking(ctx): + # grow 10 s, walk 3 s, then stand 3 s: the level rises from the post-walk value by ~3/20, never resetting to 0 +def test_neighbour_walking_does_not_rewind_still_person(ctx): + rig = Rig(ctx); rig.run(both([person(x=46)] * 100, walking(100, x=4, bounce=(4, 20)))) + # the still person's slot has growth ≈ 0.5 ± 0.06 +def test_brief_loss_keeps_growth(ctx): + rig = Rig(ctx); rig.run([person()] * 100); rig.run([EMPTY] * 20); rig.run([person()] * 5) + assert rig.person(slot_of_live(rig))[1] > 0.45 # whichever slot and id it came back in +def test_merge_then_split_both_inherit(ctx): + # two planted people 10 s apart, slide together until their masks overlap (one blob), then apart: + # each resulting track's growth ≥ the merged level - 0.05 +def test_split_off_part_inherits(ctx): # Review Focus 1 + # a planted person (10 s); then their right arm is detached by a 1-px gap for 2 s (a second blob): + # the new track's growth ≥ the person's growth - 0.05 +def test_feet_off_frame(ctx): # Review Focus 2 + # person(feet=48, ...) cut by the bottom edge, flickering for 10 s: growth ≈ 0.5; then walking 3 s: it drops by ~0.15 +def test_new_person_far_from_ghosts_starts_bare(ctx): + # A planted 10 s at x=4 leaves; B appears at x=46 within INHERIT_S: B's growth < 0.05 after its first second +def test_sun_tracks_growth(ctx): + # empty 3 s: sun < 0.02; one person 22 s: sun > 0.95; leaves: sun < 0.05 within LEAVE_S + 3·SUN_EASE_S +def test_growth_independent_of_render_rate(ctx): + a = Rig(ctx, dt=1/30); a.run([person()] * 100, frames_per_camera=3) + b = Rig(ctx, dt=1/60); b.run([person()] * 100, frames_per_camera=6) + assert abs(a.person(0)[1] - b.person(0)[1]) < 0.02 +def test_state_stays_bounded_when_idle(ctx): # Review Focus 4 + # as hunter_bird: two renders with dt = 200 s between empty runs; every C and B value is finite, and |C| ≤ 99 +``` +- [ ] **Step 2: Run** `python3 -m pytest test_watering_can.py -v`. Expected: the Task 2 tests FAIL. +- [ ] **Step 3: Implement `buffer_c.glsl`** following the rules above. +- [ ] **Step 4: Run the tests.** Expected: all PASS. If a test only passes after changing a constant, change it in `common.glsl` and note why in a comment. +- [ ] **Step 5: Commit** "Watering can: growth, walking, inheritance and sun level". + +### Task 3: Image — plants + +**Files:** `image.glsl` and `test_watering_can.py`. + +**Interfaces:** +- **Consumes:** B (run extents, owner, hold), C PERSON growth and SUN. +- **Produces:** the final image, minus the sun. + +**Drawing rules:** +- **Vines.** Vine `i` sits at screen x `i * VINE_SPACING`, plus a wobble `VINE_WOBBLE * sin(height * f + hash(i))` and a sway that grows with height up the run (`SWAY_AMP`, `SWAY_HZ`). + - For each pixel, evaluate the vines within `VINE_NEIGHBOURS` either side. + - For each vine, sample B at (vine x, pixel y), and also at pixel y ± `FLOWER_R`, so that overhanging leaves and flowers find their run. +- **Reach.** For a run with bottom `g`, top `b`, owner growth `G` and hold `h`: the tip is at `g + G * h * (b - g)`. + - The stem is drawn from `g` to the tip, only where B presence ≥ `BODY_ON` or hold > 0. Stems stay inside. + - Leaves are drawn at heights `g + j * LEAF_STEP` below the tip, on alternating sides. They may extend outside the body. + - At the tip there's a bud below `FLOWER_FROM`. Between `FLOWER_FROM` and `FLOWER_FULL` it opens into a 5-petal flower of radius up to `FLOWER_R`. The flower may extend outside the body. + - Flower colour is chosen per vine by hash from pink, orange, violet and rust, with a yellow centre. +- **Colour.** Saturation = `mix(SAT_MIN, SAT_MAX, G)`, uniform for the whole person. It applies to the greens and to the flowers. + - The look is flat fills with `INK_W` ink outlines on leaves, petals and buds, as in the mockup. +- **Early-out.** If B presence and hold are both 0 at all sampled points, output paper. + +- [ ] **Step 1: Write the failing tests.** +```python +PAPER = np.array([246, 241, 228]) +def drawn(img, region=None): # bool mask of pixels differing from paper by > 12 in any channel +def test_nothing_drawn_before_growth(ctx): # anti-goal: never draw the silhouette + rig = Rig(ctx); rig.run([person()] * 5) + assert drawn(rig.image()).mean() < 0.002 # the sun corner is excluded via region +def test_bloom_fills_the_silhouette(ctx): + rig = Rig(ctx); rig.run([person()] * 220) + # ≥ 70% of the screen pixels inside the figure are drawn, and drawn pixels outside the figure lie within FLOWER_R + LEAF_LEN of it +def test_growth_climbs_from_the_feet(ctx): + rig = Rig(ctx); rig.run([person()] * 60) # growth ≈ 0.3 + # drawn fraction in the figure's bottom third > 0.5; in the top third < 0.1 +def test_colour_ripens(ctx): + # mean HSV saturation of drawn pixels at growth 0.3 < at growth 1.0 - 0.25 +def test_flowers_only_late(ctx): + # pixels near the flower hues (pink/orange/violet/rust ± 30) are absent at growth 0.4 and present at growth 1.0 +def test_vines_follow_the_arm(ctx): + rig = Rig(ctx); rig.run([person(arm="up")] * 220) + # drawn fraction inside the raised-arm rect > 0.5; then arm="down" for 3 camera frames: the arm rect drawn fraction < 0.1 +def test_bloom_is_stable_under_flicker(ctx): # Review Focus 3 + rig = Rig(ctx); rig.run(flickering(220)) + # over 10 more camera frames, the mean |Δ| between consecutive images inside the figure is < 6 (of 255) +def test_lost_person_plants_rewind_without_popping(ctx): + rig = Rig(ctx); rig.run([person()] * 220); counts = [] + for _ in range(40): rig.run([EMPTY]); counts.append(drawn(rig.image()).sum()) + # the count reaches 0 by 1.5 s (lost) + LEAVE_S + 0.5 s; no single camera frame loses more than 30% of the previous count +``` +- [ ] **Step 2: Run the tests.** Expected: FAIL. +- [ ] **Step 3: Implement `image.glsl`.** +- [ ] **Step 4: Run the tests.** Expected: all PASS. +- [ ] **Step 5: Commit** "Watering can: vines, leaves and flowers". + +### Task 4: Image — the crayon sun + +**Files:** `image.glsl` and `test_watering_can.py`. + +**Rules:** +- **Position.** The sun's centre is at `(iResolution.x/iResolution.y + SUN_POS.x, SUN_POS.y)` in screen units, which is the top right. +- **Disc.** Radius `SUN_R`, with a crayon look: the edge is broken by noise, and the fill is a grain or hatch of noise that leaves paper showing through. +- **Muted vs. lit.** + - At sun level 0, the colour is a desaturated, paper-mixed yellow and there are no rays. + - At level 1, the colour is `#fff200` with ~12 crayon rays of length `RAY_LEN`. + - Ray length and colour follow the level. +- **Order.** Plants draw over the sun. + +- [ ] **Step 1: Write the failing tests.** +```python +def sun_patch(img): # the pixels within SUN_R + RAY_LEN of the sun centre +def test_sun_is_muted_when_empty(ctx): + # empty 3 s: the sun disc is drawn (not paper), its mean saturation < 0.35, and nothing is drawn in the ray ring +def test_sun_brightens_and_grows_rays_with_bloom(ctx): + # one person 22 s: disc saturation > 0.7, ray-ring drawn fraction > 0.15 +``` +- [ ] **Step 2: Run the tests.** Expected: FAIL. **Step 3:** Implement. **Step 4:** Run. Expected: PASS. +- [ ] **Step 5: Commit** "Watering can: crayon sun". + +### Task 5: Look and feel, Shadertoy check, frame rate, camera tuning, docs + +**Files:** `common.glsl` (constants only) and `docs/FundingCAPTCHA.md`. + +- [ ] **Step 1: Check by eye with the mouse.** Run `python3 shader_app.py --test-input --windowed --setup watering_can`. + - Hold still and watch it grow to full bloom in ~20 s. + - Wiggle while holding the button: nothing should rewind. + - Drag across the screen: it should rewind. + - Release: the plants should rewind away. + - Compare against the mockup's Dense panel, and tune constants only, with **R**. +- [ ] **Step 2: Mock camera.** Run `python3 shader_app.py --mock-camera --windowed --setup watering_can` for 2 minutes and note the fps lines. If fps is short of the other Setups, cut in the Review Focus 5 order. +- [ ] **Step 3: Shadertoy.** + - Paste `body_painter.glsl` into A and `blob_painter.glsl` into D (channels: D, Keyboard). + - Paste `common.glsl`, `buffer_b.glsl` (channels: A, B, D), `buffer_c.glsl` (channels: B, C, D) and `image.glsl` (channels: B, C, D). + - Confirm it compiles and behaves the same. If no browser is available, say so in the hand-off. +- [ ] **Step 4: Docs.** In `docs/FundingCAPTCHA.md` → "Setups in rotation", add `watering_can` with a link to the design and a note that its tunables (including `WALK_DIST`, `GROW_S` and `REWIND_S`) are at the top of its `common.glsl`. +- [ ] **Step 5: Full suite.** Run `make test` from the repo root. It must be green, apart from the known NVIDIA-only failure. +- [ ] **Step 6: Commit** "Watering can: tuning pass and docs". The hand-off must state: + - the constants that changed; + - the fps seen with `--mock-camera`, and on the Pi 5 if it was run there; + - that the walking threshold, flicker immunity and re-ID inheritance have **not** been verified with the real camera, unless they were. + - That verification needs `--camera` on site: + - a visitor stands 25 s and reaches full bloom without any rewind; + - waving the arms doesn't rewind; + - one sideways step visibly rewinds. diff --git a/docs/superpowers/specs/2026-11-06-watering-can-design.md b/docs/superpowers/specs/2026-11-06-watering-can-design.md new file mode 100644 index 0000000..00dd04b --- /dev/null +++ b/docs/superpowers/specs/2026-11-06-watering-can-design.md @@ -0,0 +1,132 @@ +# Watering Can — shader player Setup + +A new FundingCAPTCHA shader player Setup (ADR-0021), from issue #163. People are watering cans: when someone stays in one spot, vines sprout from the lowest points of their silhouette and climb, then flowers bloom at the tips. Walking away from the spot rewinds the growth. Waving and waggling don't rewind: the vines ride along on the body, so a visitor can waggle their own garden around. + +## Intent + +The reward for staying put. A visitor who stops in one place watches themselves slowly turn into a garden. The silhouette itself is never drawn; the person is revealed only by what grows on them, so by full bloom their shape reads clearly as "that's me, made of flowers". Once they're growing, moving their arms and body is play, not punishment: the vines and flowers wave around with them. Only walking costs growth. Even then it's gentle: the growth plays backwards like a reversed film, and nothing vanishes or pops. + +## Goals + +- **Growth while planted.** Sprouts start at the lowest point of each part of the silhouette and climb upward. They become leafy vines, and flowers open at the growing tips near the end. Growth stays inside the silhouette. +- **Everything grows upward from its lowest point.** Legs grow from the feet, a hanging arm from the hand, a raised arm from its lowest point, as if sprouting from the ground. Nothing grows outward from the shoulder. +- **Full bloom holds** for as long as the person stays in their spot. +- **Waggling is free.** Arm waves, flailing, swaying, bending and dancing in place don't rewind anything, and growth keeps going. The plants are attached to the body and move with it. +- **Walking rewinds.** When the base of a person's silhouette (their feet) moves to a new spot, their whole growth runs backwards: flowers close, vines shrink back down, colour drains. This covers steps in any direction, including straight toward or away from the camera. When they plant themselves again, growth resumes from wherever the rewind got to, not from bare. +- **Timing.** Standing in one spot fills a person to full bloom in about **20 s**. Rewind runs at the same rate by default. The two rates are separate tunables. +- **Colour ripens with maturity.** The whole plant on a person ripens together: muted while young, saturating as it grows, sprout to vine to flower. Full bloom is the big, vivid payoff, and at full bloom the whole person is vivid. +- **The sun follows the garden.** The empty room is plain paper with a muted crayon-drawn sun in one corner. The sun gets brighter and grows rays in step with how much is growing and blooming across everyone in frame, and dims back as growth rewinds or people leave. +- Runs on the show Pi 5 in the existing shader player, and can be prototyped in Shadertoy with the mouse body painter and blob painter like every other Setup. + +## Non-goals + +- No uploaded art, textures or video (the player doesn't support them). Everything is procedural. +- No limb or skeleton detection. +- No sound. +- Nearness (`.g`) isn't used for drawing. *(My call.)* + +## Anti-goals + +- **A planted person losing growth because of the camera.** This would ruin the piece. That includes depth-edge flicker, dropout holes, grain, tracker jitter in the person's box, and the tracker briefly losing them or giving them a new ID. +- **Waggling rewinding.** If waving your arms costs you growth, the fun idea is lost. +- **Drawing the silhouette.** No outline, no fill and no ghost of the person. Before anything grows, nothing marks where they stand. +- **Popping.** Rewinding must look like growth playing backwards. Plants never vanish or cut out. +- **A hard cut-out edge.** Growth shouldn't look like a stencil of the body. +- **Realistic rendering** (gradients, glow, photographic plants). It must read as flat, line-drawn storybook. + +## Reference mockup + +[`2026-11-06-watering-can-mockup.html`](2026-11-06-watering-can-mockup.html) is a runnable 2D-canvas sketch with a growth slider and Play/Rewind buttons. **The "Dense" panel is the approved look**: density, whole-plant ripening, no silhouette drawn, stems inside, leaves and flowers overhanging the edge. It doesn't show the sun, movement or real camera input. It's a sketch for look and feel, not a spec for code structure, and the vines in the real thing should look better than its stand-ins. + +## The look (decided) + +- **Style:** the same line-drawn storybook style as the other Setups (see `endless_sunset`): black ink lines, about 2–3 px at 1080p, with flat colour fills from the shared palette in `endless_sunset/common.glsl`. +- **Background:** plain paper `#f6f1e4`. Nothing else: no ground line or horizon. +- **Sun:** muted and crayon-drawn, in one corner. "Crayon" means a waxy, slightly broken, hand-drawn stroke and fill rather than a clean ink outline. When the garden is empty it's dull and has no rays. As total growth rises it brightens toward the palette yellow `#fff200` and grows rays. *(Which corner is my call: top right. The ray style is left to the builder, in the same crayon hand.)* +- **Vines:** dense. The approved mockup has about 30 vines across one standing adult, so the person's shape fills in clearly by full bloom. Spacing should be set in screen units. + - Stems are ink-edged or dark green lines that stay **inside** the silhouette. + - Leaves are flat green with ink outlines. Like the flowers, they may **hang past the edge**, so the outline looks soft and overgrown. + - Vines sway slightly, so a still, fully bloomed person doesn't look frozen. *(Sway is my call.)* +- **Flowers:** they open at vine tips in the last part of the growth and grow from buds to open blooms. They use a mix of palette colours with yellow centres (pink `#e8957f`, orange `#e8862f`, violet `#6b5b95`, rust `#bc4c26`), so a full bloom looks like a mixed bouquet. *(The colour mix is my call.)* + +## Behaviour (decided) + +- **One growth level per person.** + - Each person has a single growth level, 0–1, which rises while they stay planted and falls while they walk. + - The drawing derives from that level and the person's *current* silhouette every frame. In each vertical run of their body pixels (a column of the silhouette between a bottom edge and a top edge), the vine reaches from the run's bottom up a fraction of the run equal to the growth level, as in the mockup's `tip = g * span`. + - So when an arm moves, its vines move with it. A raised arm grows from its new lowest point. + - Colour saturation and flower opening follow the same level. +- **Planted vs. walking:** decided by whether the person's **base** moves, not their whole outline. + - Use the per-person tracker: the `"blobs"` records from ADR-0022, with stable slots, boxes, velocity and lifecycle. + - The base means the feet, for example the box bottom together with the horizontal position of the lowest body pixels in the box. It does not mean the centroid or the whole box, because an outstretched or flailing arm moves those without the person walking. + - A person is walking when their base has moved more than a tunable distance from their planted spot, judged over a short window, so jitter and weight-shifting don't count. A step toward or away from the camera moves the base up or down the image, so it counts too. + - All thresholds are tunable. +- **Rewind:** while walking, the growth level falls at the rewind rate. When the person is planted again, their new spot becomes the planted spot and the level rises from wherever it is. +- **Several people:** each tracked person has their own level, so one person walking doesn't rewind a still neighbour. + - Where the tracker merges two overlapping people into one blob, they share one level for as long as they're merged. + - When they separate, each one starts from the merged level. *(My call.)* +- **Tracker hiccups don't cost growth.** If a person's track is briefly lost and comes back, possibly in a different slot or with a new ID, they keep their growth. That covers occlusion, a split or merge, or flicker. A new track that appears where a recently lost one was standing inherits its level. *(The approach is my call. The requirement is the anti-goal above.)* +- **Leaving:** a person's plants leave with them. Walking out of frame rewinds on the way. If their track is lost while they still have growth, for example when they step out quickly, their plants rewind away over a short tunable time, about 2 s by default, rather than vanishing. *(The default is my call.)* +- **Sun level:** follows the total growth across everyone, smoothed. One person in full bloom brings the sun to full brightness, and more people don't push it past full. *(The saturation point is my call. It's tunable.)* +- **Run length:** `duration_s` 120, like the other Setups. *(My call.)* + +## Constraints + +- **Shader player rules** (see `ShowControl/FundingCAPTCHA/shaders/README.md`): + - Procedural only. + - Read the Body with `texture()` and normalised UVs. Blob records are the exception: read them with `texelFetch`. + - Buffers are 16-bit float on the Pi. Store positions and levels as 0–1. + - `HW_PERFORMANCE` is 0 on the Pi. + - The Pi renders at half resolution. + - The camera's square view is stretched to the screen. +- **Blob limits:** at most eight people are tracked at once (slots). The player only runs tracking while a Setup binds `"blobs"`. With all of Buffers B–D in use, the Setup can't be prototyped with blobs in Shadertoy, so leave one buffer free for the blob painter. +- **Real camera input:** + - The Body and tracker run at about 10 fps, and the shader at about 30. + - Growth timing must be in seconds, independent of frame rate. + - The silhouette flickers. Per-run vine heights derived from it every frame must not visibly jitter, so smooth or stabilise them as needed. +- **Tunables:** every value lives as a named constant at the top of `common.glsl`, so it can be retuned on site with **R** reload. That includes: + - growth time, rewind rate and leave time + - walking distance and window + - inheritance radius and time for lost tracks + - vine spacing, sway, and leaf and flower sizes + - when flowers open + - saturation range + - sun saturation point and position +- **Performance:** + - Dense vines are drawn per pixel. Keep the per-pixel cost bounded, for example by evaluating only nearby vines. + - Aim for the same smoothness as the existing Setups on the show Pi 5. *(My call.)* + - The plan must include a frame-rate check on the Pi. If that couldn't happen, it must say so. + +## Testing (decided) + +Headless GL tests against fake camera Bodies and blob records, in the style of `test_endless_sunset.py` and `test_hunter_bird.py`, covering at least: +- **Reaching full bloom:** + - a planted person with realistic edge flicker, dropout holes and box jitter reaches full bloom in about 20 s and never loses growth + - waving an arm or flailing while planted doesn't rewind, and the vines follow the arm +- **Rewinding:** + - walking sideways rewinds + - stepping toward or away from the camera rewinds + - after walking stops, growth resumes from where it was, not from zero +- **Several people:** a still neighbour isn't rewound by someone walking next to them +- **Tracker hiccups:** + - a brief track loss or ID change for a planted person keeps their growth + - a lost track with growth rewinds away without popping +- **The sun:** its level tracks total growth +- **Timing:** it holds at Pi scale, with a 10 fps Body and tracker under a 30 fps render + +The walking threshold, and how often the real tracker drops or re-IDs a still person, are unverified until the Setup has been run with the camera (`--camera`, or `--mock-camera` as a first approximation). The plan must include that tuning pass. If it couldn't happen, the plan must say so. + +## Left to the builder + +- Buffer layout and how per-person state is stored, as long as the behaviour above holds. +- How the base (feet) position is measured from the Body and the box. +- How vines are generated procedurally: placement, wobble, leaf rhythm, flower shapes, bud-to-bloom animation, and how vines follow a moving silhouette without shimmering. +- The crayon texture technique for the sun, and the ray style. +- Exact line weights and default constants beyond those stated. + +## Revisions after the first build + +- **Look:** the vines are drawn after the concept art's branches. Trunks taper and are ink-outlined, and they rise in pairs and merge where they meet. Forking twigs end in sprigs of pointed petals. About half the branch tips open into hatched daisies. The palette and ripening are unchanged. Density is about three quarters of the mockup's Dense panel. +- **Growth curve:** growth starts fast, reaching `GROW_EARLY` in `GROW_EARLY_S`, then slows so full bloom still comes at about `GROW_S`. +- **Footprints:** a walker leaves plants on the floor where they step. Each footprint is a small clump of branches with a daisy that grows in fast, holds, then rewinds away (`FOOT_*` in `common.glsl`). This is the one exception to "a person's plants leave with them". A person's own plants still ride on their body and rewind while they walk. Live footprints are never overwritten. When all `FOOTPRINTS` are live, walkers leave none until the oldest has gone. +- **One tree per person:** the per-column vines are replaced by a single tree. It is rooted on the floor between the feet and grows up the silhouette along the cheapest paths through the body, which keep to the middle of each limb. Buffer C keeps a 1/48-screen-height grid: each body cell's parent on its way back to the root, and how many seed cells' paths run through it. Branches are the seeds' paths; their width comes from that count, and the seeds carry the sprigs and daisies. Growth is a front of path distance (growth × the farthest on the body). It climbs from the root up both legs, through the torso, then out along the arms and up the neck. Nothing roots anywhere else. An arm's path distance through the shoulder doesn't change as it waves, so waving keeps its growth. Flower size and boldness follow distance along the tree. diff --git a/docs/superpowers/specs/2026-11-06-watering-can-mockup.html b/docs/superpowers/specs/2026-11-06-watering-can-mockup.html new file mode 100644 index 0000000..337ed7a --- /dev/null +++ b/docs/superpowers/specs/2026-11-06-watering-can-mockup.html @@ -0,0 +1,96 @@ + +Watering Can mockup + + +

Watering Can: the plants reveal the person

+

Option B (the whole plant ripens together). There's no outline and no fill. The person is only visible through what grows. Stems stay inside the body, while leaves and flowers can hang a little past the edge.

+ +
+ + + +
+ +
+
+
Sparse: about 16 vines
+
+
+
+
Dense: about 30 vines
+
+
+
+ + + +