diff --git a/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/buffer_b.glsl b/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/buffer_b.glsl new file mode 100644 index 0000000..f8f1b7b --- /dev/null +++ b/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/buffer_b.glsl @@ -0,0 +1,202 @@ +// Buffer B — poops and splats. iChannel0 = Body (A), iChannel1 = this buffer, iChannel2 = blobs. +// Layout: see common.glsl. Each texel reads the previous frame of every other texel; texels that +// depend on the same event (a drop, a hit) recompute it from the same inputs. +// Positions are worked in screen heights (sh) and stored as UV. + +#define BODY iChannel0 +#define B iChannel1 +#define BLOBS iChannel2 + +// 0–1, per frame: the rounding offset for tick(). A golden-ratio (Weyl) sequence, not a random +// hash: its running sum stays within about one AGE_Q of exact, where independent random +// offsets wander (≈ ±0.17 s over 15 s at 30 fps). +float DITHER; + +// x + dt, kept in multiples of AGE_Q (see common.glsl) and clamped at T_CLAMP. +float tick(float x, float dt) { return min(x + floor(dt / AGE_Q + DITHER) * AGE_Q, T_CLAMP); } +float quant(float x) { return floor(x / AGE_Q + 0.5) * AGE_Q; } + +// ── Who is there ────────────────────────────────────────────────────────────── +// The record in slot k carries a different person than SLOT k remembers (or none). +bool isFresh(int k) { + vec4 idn = recIdn(BLOBS, k); + return isState(idn.y, S_EMPTY) || abs(idn.x - sSlot(B, k).x) > 0.5; +} + +vec4 initial(ivec2 c) { + if (c.y == 0 && c.x < SLOTS) return vec4(0.0, FIRST_BOMB_S, 0.0, 0.0); // SLOT + if (c.y == 4 && c.x < SLOTS * SPLATS) return vec4(0.0, 0.0, T_CLAMP, 0.0); // SPLAT + if (c.y == 5 && c.x < GROUNDS) return vec4(0.0, 0.0, T_CLAMP, 0.0); // GROUND + return vec4(0.0); +} + +// ── Dropping ────────────────────────────────────────────────────────────────── +// Slot k's person is due a poop this frame: confirmed, overdue, and theirs isn't still falling. +bool drops(int k) { + return !isFresh(k) && isState(recIdn(BLOBS, k).y, S_CONFIRMED) + && sSlot(B, k).y <= 0.0 && sDrop(B, k).x < 0.5; +} + +// Where to drop it (UV x): over their centre of mass, ahead of them by the time it takes to +// fall to their head (homing does the rest). +float dropX(int k, float aim) { + vec4 box = recBox(BLOBS, k), an = sAnchor(B, k); + float fall = max(SPAWN_Y - box.w, 0.0) / FALL_SPEED; + float lead = clamp(toScreen(recMot(BLOBS, k).zw).x * fall, -LEAD_MAX, LEAD_MAX); + float x = toScreen(an.xy).x + aim * an.z + lead; + return clamp(toUv(vec2(x, 0.0)).x, 0.02, 0.98); // always seen falling +} + +// The falling poop's position (sh): a steady fall with a sway about its sway centre. +vec2 poopAt(vec4 poop, float age) { + vec2 c = toScreen(poop.xy); + return vec2(c.x + SWAY * sin(age * SWAY_W + poop.z), c.y); +} + +// The tracked person whose box holds `uv`, preferring `prefer`; −1 if none. +int slotAt(vec2 uv, int prefer) { + const float M = 0.02; // boxes ease; allow a little slack + int found = -1; + for (int k = 0; k < SLOTS; k++) { + if (!isHere(recIdn(BLOBS, k).y)) continue; + vec4 b = recBox(BLOBS, k); + if (uv.x < b.x - M || uv.x > b.z + M || uv.y < b.y - M || uv.y > b.w + M) continue; + if (k == prefer) return k; + if (found < 0) found = k; + } + return found; +} + +struct Poop { vec4 poop; vec4 drop; int hitSlot; bool ground; vec2 at; }; + +// Slot k's poop, one frame on: dropped, falling, landed on someone, or landed on the floor. +Poop poopStep(int k, float dt) { + Poop o; + o.poop = sPoop(B, k); o.drop = sDrop(B, k); o.hitSlot = -1; o.ground = false; o.at = vec2(0.0); + if (drops(k)) { + float aim = AIM_SPREAD * (2.0 * hash11(recIdn(BLOBS, k).x * 3.7 + iTime * 1.3) - 1.0); + o.poop = vec4(dropX(k, aim), SPAWN_Y, 6.2832 * hash11(iTime * 2.9 + float(k)), aim); + o.drop = vec4(1.0, recBox(BLOBS, k).y, recIdn(BLOBS, k).x, 0.0); + return o; + } + if (o.drop.x < 0.5) return o; // nothing falling + + // Homing: drift toward the person it was dropped on, while they're still in this slot. + vec4 idn = recIdn(BLOBS, k); + float vx = 0.0; + if (abs(idn.x - o.drop.z) < 0.5 && isHere(idn.y)) { + vec4 an = sAnchor(B, k); + float off = toScreen(an.xy).x + o.poop.w * an.z - toScreen(o.poop.xy).x; + vx = clamp(off * HOMING_GAIN, -HOMING_MAX, HOMING_MAX); + } + float h = dt / float(FALL_STEPS), age = o.drop.w; + for (int s = 0; s < FALL_STEPS; s++) { + o.poop.y -= FALL_SPEED * h; // UV y is sh + o.poop.x += toUv(vec2(vx * h, 0.0)).x; + age += h; + vec2 uv = toUv(poopAt(o.poop, age)); + bool onScreen = uv.x > 0.0 && uv.x < 1.0 && uv.y > 0.0 && uv.y < 1.0; + if (onScreen && texture(BODY, uv).r > 0.5) { + int m = slotAt(uv, k); + if (m >= 0) { o.hitSlot = m; o.at = uv; o.poop = vec4(0.0); o.drop = vec4(0.0); return o; } + } + if (uv.y <= o.drop.y) { + o.ground = uv.x > -0.05 && uv.x < 1.05; + o.at = vec2(uv.x, o.drop.y); o.poop = vec4(0.0); o.drop = vec4(0.0); return o; + } + } + o.drop.w = min(age, T_CLAMP); + return o; +} + +// ── Per person ──────────────────────────────────────────────────────────────── +vec4 slotNext(int k, float dt) { + vec4 idn = recIdn(BLOBS, k); + float here = isHere(idn.y) ? 1.0 : 0.0; + if (isFresh(k)) + return vec4(isState(idn.y, S_EMPTY) ? 0.0 : idn.x, quant(FIRST_BOMB_S + FIRST_JITTER * hash11(idn.x * 1.37)), here, 0.0); + vec4 s = sSlot(B, k); + if (drops(k)) s.y = quant(BOMB_EVERY_S + BOMB_JITTER_S * (2.0 * hash11(iTime * 7.7 + float(k)) - 1.0)); + else if (isState(idn.y, S_CONFIRMED)) s.y = max(-tick(-s.y, dt), -10.0); + s.z = here > 0.5 ? 1.0 : max(s.z - dt / LEAVE_FADE_S, 0.0); + return s; +} + +// The silhouette inside slot k's (widened) box, from this frame's Body: centre of mass (UV) and +// size (sh). Falls back to the box when it holds too little body to measure. +vec4 measure(int k) { + vec4 box = recBox(BLOBS, k); + vec4 b = clamp(box + vec4(-ANCHOR_MARGIN, -ANCHOR_MARGIN, ANCHOR_MARGIN, ANCHOR_MARGIN), 0.0, 1.0); + vec2 span = b.zw - b.xy, sum = vec2(0.0); + float n = 0.0; + for (int j = 0; j < ANCHOR_NY; j++) + for (int i = 0; i < ANCHOR_NX; i++) { + vec2 uv = b.xy + span * (vec2(i, j) + 0.5) / vec2(ANCHOR_NX, ANCHOR_NY); + float on = step(0.5, texture(BODY, uv).r); + sum += on * uv; n += on; + } + if (n < 6.0) return vec4((box.x + box.z) * 0.5, (box.y + box.w) * 0.5, max(box.w - box.y, 0.05), 0.0); + vec2 cell = toScreen(span) / vec2(ANCHOR_NX, ANCHOR_NY); + return vec4(sum / n, SIZE_K * sqrt(n * cell.x * cell.y), 0.0); +} + +vec4 anchorNext(int k, float dt) { + if (isFresh(k)) return measure(k); + if (!isHere(recIdn(BLOBS, k).y)) return sAnchor(B, k); // lost: hold where they were + return mix(sAnchor(B, k), measure(k), 1.0 - exp(-dt / ANCHOR_EMA_S)); +} + +vec4 splatNext(int k, int j, float dt) { + const vec4 DEAD = vec4(0.0, 0.0, T_CLAMP, 0.0); + bool fresh = isFresh(k); + for (int i = 0; i < SLOTS; i++) { // any person's poop can land on k + Poop p = poopStep(i, dt); + if (p.hitSlot != k) continue; + if (j > 0) { // shift the ring down + if (fresh) return DEAD; + vec4 s = sSplat(B, k, j - 1); + s.z = tick(s.z, dt); + return s; + } + vec4 an = anchorNext(k, dt); + vec2 q = (toScreen(p.at) - toScreen(an.xy)) / max(an.z, 0.05); + float seed = hash11(iTime * 13.7 + float(k) * 3.1 + float(i)); + return vec4(q.x, q.y - SPLAT_SINK - SINK_SPREAD * hash11(seed * 91.0), 0.0, seed); + } + if (fresh) return DEAD; + vec4 s = sSplat(B, k, j); + s.z = tick(s.z, dt); + return s; +} + +vec4 groundNext(int j, float dt) { + for (int i = 0; i < SLOTS; i++) { + Poop p = poopStep(i, dt); + if (!p.ground) continue; + if (j == 0) return vec4(p.at, 0.0, hash11(iTime * 5.3 + float(i))); + vec4 g = sGround(B, j - 1); + g.z = tick(g.z, dt); + return g; + } + vec4 g = sGround(B, j); + g.z = tick(g.z, dt); + return g; +} + +void mainImage(out vec4 o, in vec2 fc) { + ivec2 c = ivec2(fc); + o = vec4(0.0); + if (c.y > 5) return; + if (iFrame == 0) { o = initial(c); return; } + float dt = clamp(iTimeDelta, 1e-3, DT_MAX); + DITHER = float((uint(iFrame) * 2654435769u) >> 8u) / 16777216.0; // fract(iFrame · φ), exact in integers + + if (c.y == 0 && c.x < SLOTS) o = slotNext(c.x, dt); + else if (c.y == 1 && c.x < SLOTS) o = anchorNext(c.x, dt); + else if (c.y >= 2 && c.y <= 3 && c.x < SLOTS) { + Poop p = poopStep(c.x, dt); + o = c.y == 2 ? p.poop : p.drop; + } + else if (c.y == 4 && c.x < SLOTS * SPLATS) o = splatNext(c.x / SPLATS, c.x % SPLATS, dt); + else if (c.y == 5 && c.x < GROUNDS) o = groundNext(c.x, dt); +} diff --git a/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/common.glsl b/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/common.glsl new file mode 100644 index 0000000..29c41e0 --- /dev/null +++ b/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/common.glsl @@ -0,0 +1,130 @@ +// Common — every tunable for rainbow poop lives here (edit, save, press R). +// Lengths are in screen heights ("sh") unless noted. Spec: docs/superpowers/specs/2026-11-08-rainbow-poop-design.md +// +// State (Buffer B) layout, texel (x, y); k = person slot 0–7: +// (k, 0) SLOT id, seconds until this person's next poop, presence 0–1 (fades splats on loss), 0 +// (k, 1) ANCHOR the person's silhouette: centre of mass x, y (Body UV) and size (sh, ≈ their height; +// see SIZE_K), measured from this frame's Body, low-passed over ANCHOR_EMA_S; 0 +// (k, 2) POOP the poop dropped on this person: sway centre x, y (UV), sway phase, aim offset (sizes) +// (k, 3) DROP falling 0/1, floor y (UV), target id, seconds falling +// (k·SPLATS + j, 4) SPLAT centre in the person's frame x, y (sizes from their centre of mass), +// age s, seed 0–1 — j = 0 newest +// (j, 5) GROUND x, y (UV), age s, seed 0–1 — j = 0 newest + +const int SLOTS = 8, SPLATS = 8, GROUNDS = 8; +const float T_CLAMP = 99.0; // every "seconds since" counter clamps here +const float DT_MAX = 0.25; // a render hiccup longer than this is treated as this +// Ages and countdowns are kept in multiples of this, which a half float holds exactly up to 64 s; +// each frame's step is rounded to it with a golden-ratio offset, so the running error stays +// within about one step at any fps. (Plain f16 adds of 1/30 above 8 s drift by ~6%.) +const float AGE_Q = 1.0 / 32.0; + +// ── Cadence (per person) ────────────────────────────────────────────────────── +const float FIRST_BOMB_S = 1.2; // s after a person is confirmed before their first poop drops … +const float FIRST_JITTER = 0.8; // … plus up to this +const float BOMB_EVERY_S = 5.0; // then one every this many seconds … +const float BOMB_JITTER_S = 1.0; // … ± this, counted from the drop + +// ── The fall ────────────────────────────────────────────────────────────────── +// A poop appears just above the top of the screen, over where its person will be when it +// reaches their head, and drifts down at a steady, visible speed with a gentle sway. +const float SPAWN_Y = 1.04; // UV y the poop starts at (just off the top) +const float FALL_SPEED = 0.2; // sh/s +const float SWAY = 0.02; // sh, sideways sway amplitude +const float SWAY_W = 3.0; // rad/s +const float LEAD_MAX = 0.5; // sh: drop ahead of a moving person by at most this +const float AIM_SPREAD = 0.05; // aim within ± this × their size of their centre of mass +// While it falls, the poop drifts toward its person's centre of mass: quick enough to find someone +// standing or swaying, too slow to catch someone who walks or turns away (a walk is ~0.3 sh/s). +const float HOMING_GAIN = 3.0; // 1/s: sideways speed per sh off target … +const float HOMING_MAX = 0.18; // … up to this, sh/s +const int FALL_STEPS = 2; // body hit tests per frame along the fall +const float POOP_R = 0.022; // falling drop radius + +// ── Splats ──────────────────────────────────────────────────────────────────── +const float SPLAT_LIFE_S = 20.0; +const float SPLAT_FADE_S = 3.0; // the last part of the life +const float LEAVE_FADE_S = 0.6; // splats fade this fast when their person's track is lost +// Splats ride on the silhouette's centre of mass, scaled by its size: SIZE_K × √(area), about an +// upright adult's height. Both are measured from the Body each frame inside the tracker's box, so +// raised arms, strides and box jitter barely move them, and they don't lag the silhouette. +const float SIZE_K = 2.1; +const int ANCHOR_NX = 16, ANCHOR_NY = 24; // Body samples across and up the box +const float ANCHOR_MARGIN = 0.03; // UV: the box is widened by this (records ease behind the Body) +const float ANCHOR_EMA_S = 0.04; // just enough to soften sampling noise +const float SPLAT_R = 0.15; // × size +const float SPLAT_R_MIN = 0.04; // sh +const float SPLAT_SINK = 0.06; // × size: the splat centre sits this far below the impact … +const float SINK_SPREAD = 0.12; // … plus up to this, so splats spread down over head and shoulders +const float SPLAT_LUMP = 0.22; // edge lumpiness, × radius +const float POP_S = 0.18; // splat-in time +const float DRIP_LEN = 2.6; // longest drip, × splat radius +const float DRIP_S = 4.0; // drips reach full length after this +const float GROUND_R = 0.10; // sh, puddle radius before squashing +const float GROUND_LIFE_S = 8.0; +const float GROUND_FADE_S = 2.0; +const float GROUND_SQUASH = 2.6; // puddles are this many times wider than tall + +// ── Look ────────────────────────────────────────────────────────────────────── +const float CLOUD_Y = 0.86; +const float INK_W = 2.2 / 360.0, INK_W_HEAVY = 2.6 / 360.0, INK_W_LIGHT = 1.6 / 360.0; + +// ── Palette ─────────────────────────────────────────────────────────────────── +const vec3 PAPER = vec3(0.965, 0.945, 0.894); // #f6f1e4 +const vec3 INK = vec3(0.067); // #111111 +const vec3 WARM_WHITE = vec3(1.000, 0.980, 0.941); // #fffaf0 +const vec3 SLATE = vec3(0.443, 0.604, 0.678); // #719aad +const vec3 DEEP = vec3(0.125, 0.463, 0.639); // #2076a3 +const vec3 VIOLET = vec3(0.420, 0.357, 0.584); // #6b5b95 +const vec3 RUST = vec3(0.737, 0.298, 0.149); // #bc4c26 +const vec3 ORANGE = vec3(0.910, 0.525, 0.184); // #e8862f +const vec3 YELLOW = vec3(1.000, 0.949, 0.000); // #fff200 +// The rainbow, outside in: red, orange, yellow, green, blue, violet. +const vec3 RAINBOW[6] = vec3[6](vec3(0.878, 0.271, 0.227), ORANGE, YELLOW, + vec3(0.384, 0.698, 0.341), DEEP, VIOLET); +vec3 rainbow(int i) { return RAINBOW[((i % 6) + 6) % 6]; } + +// ── 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; } +bool isHere(float s) { return isState(s, S_TENTATIVE) || isState(s, S_CONFIRMED) || isState(s, S_COASTING); } + +// ── State accessors ─────────────────────────────────────────────────────────── +vec4 sSlot (sampler2D s, int k) { return texelFetch(s, ivec2(k, 0), 0); } +vec4 sAnchor(sampler2D s, int k) { return texelFetch(s, ivec2(k, 1), 0); } +vec4 sPoop (sampler2D s, int k) { return texelFetch(s, ivec2(k, 2), 0); } +vec4 sDrop (sampler2D s, int k) { return texelFetch(s, ivec2(k, 3), 0); } +vec4 sSplat (sampler2D s, int k, int j) { return texelFetch(s, ivec2(k * SPLATS + j, 4), 0); } +vec4 sGround(sampler2D s, int j) { return texelFetch(s, ivec2(j, 5), 0); } + +// UV ↔ screen heights. Macros: Shadertoy's Common tab can't see iResolution. +#define toScreen(uv) ((uv) * iResolution.xy / iResolution.y) +#define toUv(sh) ((sh) * iResolution.y / iResolution.xy) + +// ── Noise ───────────────────────────────────────────────────────────────────── +float hash11(float n) { return fract(sin(n * 127.1 + 311.7) * 43758.5453); } +float vnoise(float x) { // value noise, −1…1 + float i = floor(x), f = fract(x); + return mix(hash11(i), hash11(i + 1.0), f * f * (3.0 - 2.0 * f)) * 2.0 - 1.0; +} + +// ── Drawing helpers (lengths in screen heights) ─────────────────────────────── +float sdSegment(vec2 p, vec2 a, vec2 b) { + vec2 pa = p - a, ba = b - a; + return length(pa - ba * clamp(dot(pa, ba) / max(dot(ba, ba), 1e-9), 0.0, 1.0)); +} +// Coverage of a pen stroke `w` wide at distance `d`, antialiased over one pixel `px`. +float pen(float d, float w, float px) { + w = max(w, px); + return 1.0 - smoothstep(0.5 * (w - px), 0.5 * (w + px), d); +} +// Coverage of the inside of a signed distance `d`. +float fill(float d, float px) { return 1.0 - smoothstep(-0.5 * px, 0.5 * px, d); } +float smin(float a, float b, float k) { + float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0); + return mix(b, a, h) - k * h * (1.0 - h); +} diff --git a/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/image.glsl b/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/image.glsl new file mode 100644 index 0000000..0aa67cc --- /dev/null +++ b/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/image.glsl @@ -0,0 +1,154 @@ +// Image — rainbow poop. iChannel0 = Body (A), iChannel1 = state (B), iChannel2 = blobs. +// Back to front: paper and clouds → ground puddles → people → splats on people → falling poops. +// Positions: `p` is in screen heights (x 0…aspect, y 0…1); state and Body are in UV. + +#define BODY iChannel0 +#define B iChannel1 +#define BLOBS iChannel2 + +float PX; // one pixel, in screen heights +float ASPECT; + +vec2 uvToP(vec2 uv) { return uv * vec2(ASPECT, 1.0); } +vec2 pToUv(vec2 p) { return p / vec2(ASPECT, 1.0); } + +// Paint `c` over `col` with coverage `a`. +void over(inout vec3 col, vec3 c, float a) { col = mix(col, c, clamp(a, 0.0, 1.0)); } + +// ── Sky ─────────────────────────────────────────────────────────────────────── +void drawClouds(inout vec3 col, vec2 p) { + if (p.y < CLOUD_Y - 0.1) return; + for (int c = 0; c < 3; c++) { + float fc = float(c); + float span = ASPECT + 0.6; + vec2 at = vec2(mod(fc * 0.71 * span + iTime * (0.010 + 0.004 * fc), span) - 0.3, + CLOUD_Y + 0.05 * hash11(fc * 4.1)); + vec2 q = p - at; + if (abs(q.x) > 0.25 || abs(q.y) > 0.12) continue; + float d = 1e9; + for (int k = 0; k < 4; k++) { + float fk = float(k); + vec2 o = vec2((fk - 1.5) * 0.055, 0.012 * sin(fk * 2.3 + fc)); + d = smin(d, length(q - o) - (0.038 + 0.014 * hash11(fk + fc * 9.0)), 0.02); + } + d = max(d, -q.y - 0.015); // flat bottom + over(col, WARM_WHITE, fill(d, PX)); + over(col, INK, pen(abs(d), INK_W_LIGHT, PX)); + } +} + +// ── Splats ──────────────────────────────────────────────────────────────────── +// A rainbow splat of radius r around the origin: a lumpy blob in concentric bands (red outside, +// violet in the middle), satellite droplets and, if `drips`, drips running down. rgb + coverage. +vec4 splat(vec2 q, float r, float seed, float age, bool drips) { + float s = smoothstep(0.0, POP_S, age); + r *= max(s * (1.0 + 0.35 * sin(3.14159 * s)), 1e-3); // splat in with a little overshoot + float ang = atan(q.y, q.x); + float rr = r * (1.0 + SPLAT_LUMP * (0.6 * sin(5.0 * ang + seed * 40.0) + 0.4 * sin(3.0 * ang - seed * 23.0))); + float d = length(q) - rr; + vec3 c = rainbow(5 - int(clamp(length(q) / rr, 0.0, 0.999) * 6.0)); + int shift = int(seed * 6.0); + for (int i = 0; i < 4; i++) { + float fi = float(i), h = hash11(seed * 31.0 + fi); + float a = seed * 6.2832 + fi * 1.7 + h; + float ds = length(q - r * (1.35 + 0.35 * h) * vec2(cos(a), sin(a))) - r * (0.12 + 0.12 * h); + if (ds < d) { d = ds; c = rainbow(shift + i); } + } + if (drips) { + float grow = pow(smoothstep(0.0, DRIP_S, age), 0.6); + for (int i = 0; i < 3; i++) { + float fi = float(i), h = hash11(seed * 17.0 + fi * 5.0); + float x = r * (-0.55 + 0.55 * fi + 0.2 * (h - 0.5)); + float len = DRIP_LEN * r * (0.4 + 0.6 * h) * grow; + vec2 a = vec2(x, -0.3 * r), b = a - vec2(0.0, len); + float dd = min(sdSegment(q, a, b) - 0.15 * r, length(q - b) - 0.21 * r * grow); + if (dd < d) { d = dd; c = rainbow(shift + 2 * i + 1); } + } + } + float a = fill(d, PX); + float ink = pen(abs(d), INK_W, PX); + return vec4(mix(c, INK, ink / max(max(a, ink), 1e-4)), max(a, ink)); +} + +void drawSplats(inout vec3 col, vec2 p, vec2 uv) { + float clip = smoothstep(0.45, 0.55, texture(BODY, uv).r); // the people fill's own edge (0.5) + if (clip <= 0.0) return; // splats live on people only + for (int k = 0; k < SLOTS; k++) { + float presence = sSlot(B, k).z; + if (presence <= 0.0) continue; + vec4 an = sAnchor(B, k); + float h = an.z, r = max(SPLAT_R * h, SPLAT_R_MIN); + vec2 a = uvToP(an.xy); + float reach = r * (DRIP_LEN + 2.0); + for (int j = SPLATS - 1; j >= 0; j--) { // newest on top + vec4 s = sSplat(B, k, j); + if (s.z >= SPLAT_LIFE_S) continue; + vec2 q = p - (a + s.xy * h); + if (dot(q, q) > reach * reach) continue; + float alpha = presence * (1.0 - smoothstep(SPLAT_LIFE_S - SPLAT_FADE_S, SPLAT_LIFE_S, s.z)); + vec4 sp = splat(q, r, s.w, s.z, true); + over(col, sp.rgb, sp.a * alpha * clip); + } + } +} + +void drawGround(inout vec3 col, vec2 p) { + const float R = GROUND_R; + for (int j = GROUNDS - 1; j >= 0; j--) { + vec4 g = sGround(B, j); + if (g.z >= GROUND_LIFE_S) continue; + vec2 q = p - uvToP(g.xy); + if (abs(q.x) > 2.5 * R || abs(q.y) > R) continue; + q.y *= GROUND_SQUASH; + vec4 sp = splat(q, R, g.w, g.z, false); + over(col, sp.rgb, sp.a * (1.0 - smoothstep(GROUND_LIFE_S - GROUND_FADE_S, GROUND_LIFE_S, g.z))); + } +} + +// ── People ──────────────────────────────────────────────────────────────────── +float present(vec2 uv) { return step(0.5, texture(BODY, uv).r); } + +void drawPeople(inout vec3 col, vec2 p, vec2 uv) { + float r = max(INK_W_HEAVY, PX); + if (present(uv) > 0.5) { col = WARM_WHITE; return; } + float edge = 0.0; // outline: outside, within a heavy pen + for (int i = 0; i < 8; i++) { + float a = float(i) * 0.78539816; + edge = max(edge, present(pToUv(p + r * vec2(cos(a), sin(a))))); + } + over(col, INK, edge); +} + +// ── Falling poops ───────────────────────────────────────────────────────────── +void drawPoops(inout vec3 col, vec2 p) { + for (int k = 0; k < SLOTS; k++) { + vec4 dr = sDrop(B, k); + if (dr.x < 0.5) continue; + vec4 pp = sPoop(B, k); + float c = toScreen(pp.xy).x; + vec2 at = vec2(c + SWAY * sin(dr.w * SWAY_W + pp.z), pp.y); // as Buffer B's poopAt() + vec2 q = p - at; + if (dot(q, q) > 9.0 * POOP_R * POOP_R) continue; + // A drop falling straight down, leaning into its sway: the round end leads, the point trails. + vec2 fwd = normalize(vec2(SWAY * SWAY_W * cos(dr.w * SWAY_W + pp.z), -FALL_SPEED)); + float y = dot(q, -fwd), x = dot(q, vec2(-fwd.y, fwd.x)); + float d = y > 0.0 ? length(vec2(x, y * 0.45)) - POOP_R : length(q) - POOP_R; + over(col, rainbow(int(clamp((y / POOP_R + 1.0) * 1.5, 0.0, 5.0))), fill(d, PX)); + over(col, INK, pen(abs(d), INK_W, PX)); + } +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + ASPECT = iResolution.x / iResolution.y; + PX = 1.0 / iResolution.y; + vec2 uv = fragCoord / iResolution.xy; + vec2 p = uvToP(uv); + + vec3 col = PAPER; + drawClouds(col, p); + drawGround(col, p); + drawPeople(col, p, uv); + drawSplats(col, p, uv); + drawPoops(col, p); + fragColor = vec4(col, 1.0); +} diff --git a/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/setup.json b/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/setup.json new file mode 100644 index 0000000..c7807f1 --- /dev/null +++ b/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/setup.json @@ -0,0 +1,8 @@ +{ + "_comment": "Rainbow poop: rainbow poop falls from the sky onto visitors and the splats stick. Spec: docs/superpowers/specs/2026-11-08-rainbow-poop-design.md", + "duration_s": 120, + "channels": { + "buffer_b": ["A", "B", "blobs"], + "image": ["A", "B", "blobs"] + } +} diff --git a/ShowControl/FundingCAPTCHA/shaders/setups.json b/ShowControl/FundingCAPTCHA/shaders/setups.json index b75314f..c50bfc3 100644 --- a/ShowControl/FundingCAPTCHA/shaders/setups.json +++ b/ShowControl/FundingCAPTCHA/shaders/setups.json @@ -1 +1 @@ -["ripples", "endless_sunset", "hunter_bird", "watering_can"] +["ripples", "endless_sunset", "hunter_bird", "watering_can", "rainbow_poop"] diff --git a/ShowControl/FundingCAPTCHA/test_rainbow_poop.py b/ShowControl/FundingCAPTCHA/test_rainbow_poop.py new file mode 100644 index 0000000..418e26d --- /dev/null +++ b/ShowControl/FundingCAPTCHA/test_rainbow_poop.py @@ -0,0 +1,439 @@ +"""Behaviour tests for the rainbow_poop Setup (shaders/rainbow_poop/). + +Records come from the real BodyBlobs tracker run on synthetic masks with a +fake clock (camera 10 Hz, render 30 Hz), so record noise is realistic. +GL tests need a headless context (EGL / llvmpipe) and skip without one. +""" +from __future__ import annotations + +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" / "rainbow_poop" +SIZE = (128, 72) # render; ≥ 64 wide for the SPLAT row +BODY = (64, 40) # camera ROI (w, h) +SLABS = [{"near_mm": 1000, "far_mm": 4000}] +DT = 1 / 30 +BLOBS = BlobConfig(min_blob_pixels=20) # the shipped defaults, but for the small test Body + + +def const(name: str) -> float: + """A float constant from common.glsl, so tests follow retuning.""" + src = (SETUP / "common.glsl").read_text() + m = re.search(rf"\b{name}\s*=\s*([0-9.]+)", src) + assert m, name + return float(m.group(1)) + + +SPLAT_LIFE_S = const("SPLAT_LIFE_S") +GROUND_LIFE_S = const("GROUND_LIFE_S") +FIRST_BOMB_S, FIRST_JITTER = const("FIRST_BOMB_S"), const("FIRST_JITTER") +BOMB_EVERY_S, BOMB_JITTER_S = const("BOMB_EVERY_S"), const("BOMB_JITTER_S") +SPAWN_Y, FALL_SPEED = const("SPAWN_Y"), const("FALL_SPEED") +HEAD = 1 - 8 / 40 # UV y of the test people's heads +FALL_S = (SPAWN_Y - HEAD) / FALL_SPEED # seconds a poop takes to reach them + + +@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 30 Hz.""" + + def __init__(self, ctx, size=SIZE): + self.size = size + self.r = Renderer(ctx, size, body_mode="camera") + self.r.load(load_setup(SETUP)) + self.blobs = BodyBlobs(BLOBS) + self.t = 0.0 + self.hits: list[tuple[float, int]] = [] # (time, slot) of every new splat + self.misses: list[float] = [] # time of every new ground splat + + def run(self, masks, frames_per_camera=3, watch=True): + 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": BODY[0], "h": BODY[1]}, SLABS) + 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(self.blobs.records(now=self.t)) + self.r.render(DT, (0, 0, 0, 0)) + self.t += DT + if watch: + self._watch() + + def _watch(self): + b = self.b() + for k in range(8): + if b[4, k * 8, 2] == 0: # age 0: landed this frame + self.hits.append((self.t, k)) + if b[5, 0, 2] == 0: + self.misses.append(self.t) + + def b(self) -> np.ndarray: + """Buffer B as float32 (h, w, 4), row 0 = texel y 0.""" + t = self.r._buffers["buffer_b"].read_tex + return np.frombuffer(t.read(), dtype=np.float16).astype(np.float32).reshape(t.size[1], t.size[0], 4) + + def slot(self, k): return self.b()[0, k] # id, due s, presence + def anchor(self, k): return self.b()[1, k] # top-centre x, y, height + def poop(self, k): return self.b()[2, k] # sway centre x, y, phase + def drop(self, k): return self.b()[3, k] # falling, floor, -, age + def splats(self, k): return self.b()[4, k * 8:(k + 1) * 8] # x, y, age, seed + def grounds(self): return self.b()[5, :8] + def falling(self): return [k for k in range(8) if self.drop(k)[0] > 0.5] + + def live_splats(self, k): + s = self.splats(k) + return s[s[:, 2] < SPLAT_LIFE_S] + + 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 rect(x0, y0, x1, y1): + m = np.zeros(BODY[::-1], dtype=bool) + m[y0:y1, x0:x1] = True + return m + + +EMPTY = np.zeros(BODY[::-1], dtype=bool) + + +def standing(n, x=27): + return [rect(x, 8, x + 10, 36)] * n + + +def flickering(n, x=27, seed=0): + """A still person whose 1-px border toggles randomly each camera frame.""" + rng = np.random.default_rng(seed) + base = rect(x, 8, x + 10, 36) + edge = rect(x - 1, 7, x + 11, 37) & ~rect(x + 1, 9, x + 9, 35) + out = [] + for _ in range(n): + m = base.copy() + m[edge] = rng.random(edge.sum()) < 0.5 + out.append(m) + return out + + +# ── The empty room ──────────────────────────────────────────────────────────── + +def test_empty_room_drops_nothing(ctx): + rig = Rig(ctx) + for _ in range(40): # 12 s + rig.run([EMPTY] * 3) + assert not rig.falling() + assert not rig.hits and not rig.misses + + +# ── Bombing a standing person ───────────────────────────────────────────────── + +def test_standing_person_is_hit_soon_after_appearing(ctx): + rig = Rig(ctx) + rig.run(flickering(60)) # 6 s + assert rig.hits, "never hit" + first = rig.hits[0] + # confirmed after ~0.4 s, then the first countdown and the fall + assert first[0] < 0.4 + FIRST_BOMB_S + FIRST_JITTER + FALL_S + 0.5 + assert first[1] == 0 and not rig.misses + + +def splat_uv(rig, k, s): + """A splat's centre in Body UV, from its person's anchor (centre of mass, size).""" + an = rig.anchor(k) + aspect = rig.size[0] / rig.size[1] + return an[0] + s[0] * an[2] / aspect, an[1] + s[1] * an[2] + + +def test_splat_lands_on_the_head(ctx): + rig = Rig(ctx) + rig.run(flickering(60)) # rect(27, 8, 37, 36): UV x 0.42–0.58, y 0.1–0.8 + s = rig.live_splats(0) + assert len(s) >= 1 + u, v = splat_uv(rig, 0, s[0]) + assert 0.40 < u < 0.60 # over the body + assert 0.55 < v < 0.80 # on its upper part, below the top + + +def test_cadence_is_about_every_five_seconds(ctx): + rig = Rig(ctx) + rig.run(flickering(260)) # 26 s + t = [h[0] for h in rig.hits if h[1] == 0] + assert len(t) >= 4, t + gaps = np.diff(t) + assert (gaps > BOMB_EVERY_S - BOMB_JITTER_S - 0.5).all(), gaps + assert (gaps < BOMB_EVERY_S + BOMB_JITTER_S + 0.5).all(), gaps + assert not rig.misses + + +def test_poop_falls_slowly_from_the_top(ctx): + rig = Rig(ctx) + seen, ys = 0.0, [] + for _ in range(60): + rig.run(flickering(1)) + if rig.falling(): + seen = max(seen, rig.drop(0)[3]) + ys.append(rig.poop(0)[1]) + if rig.hits: + break + assert rig.hits and ys + assert ys[0] > 0.98 # it starts at the top of the sky + assert seen > 1.0 # and takes over a second to arrive + assert abs(seen - FALL_S) < 0.3 + + +def test_one_poop_per_person_at_a_time(ctx): + rig = Rig(ctx) + drops = 0 + was = False + for _ in range(150): + rig.run(flickering(1)) + now = bool(rig.falling()) + drops += now and not was + was = now + assert drops == len(rig.hits) or drops == len(rig.hits) + 1 # one in the air at a time + + +def test_two_people_both_get_hit(ctx): + rig = Rig(ctx) + two = [a | b for a, b in zip(flickering(80, x=8), flickering(80, x=46, seed=1))] + rig.run(two) + hit = {k for _, k in rig.hits} + assert hit == {0, 1} + + +# ── Sticking, fading, leaving ───────────────────────────────────────────────── + +def walk_from(x0, n, step=1): + return [rect(x0 + k * step, 8, x0 + k * step + 10, 36) for k in range(n)] + + +def test_splats_follow_a_walker(ctx): + rig = Rig(ctx) + rig.run(standing(45, x=10)) + before = rig.live_splats(0).copy() + assert len(before) + a0 = rig.anchor(0).copy() + rig.run(walk_from(10, 30) + standing(10, x=39), watch=False) + assert rig.anchor(0)[0] - a0[0] > 0.4 # the anchor moved with them … + after = {round(float(s[3]), 4): s for s in rig.live_splats(0)} + for s in before: # … and splats stayed put in it + np.testing.assert_allclose(after[round(float(s[3]), 4)][:2], s[:2], atol=1e-3) + + +def test_splats_fade_after_their_life(ctx): + rig = Rig(ctx) + rig.run(standing(45)) + first = rig.live_splats(0) + assert len(first) == 1 + seed, age = first[0, 3], first[0, 2] + rig.run(standing(int((SPLAT_LIFE_S - age) * 10) + 5)) + live = rig.live_splats(0) + assert seed not in live[:, 3] # the first splat has gone … + assert 2 <= len(live) <= 4 # … the later ones are still there + + +def test_splats_stay_put_on_the_body(ctx): # raised arms, arms out, walking + rig = Rig(ctx) + rig.run(standing(45, x=10)) + seed = rig.live_splats(0)[0, 3] + def where(): # the splat, relative to the torso's centre + s = next(s for s in rig.live_splats(0) if s[3] == seed) + u, v = splat_uv(rig, 0, s) + return u - (x + 5) / 64, v + x = 10 + u0, v0 = where() + worst = 0.0 + for k in range(40): + x = 10 + k // 2 # walking right at 0.5 px per camera frame + m = rect(x, 8, x + 10, 36) + if k % 4 == 1: m |= rect(x + 7, 1, x + 10, 8) # an arm straight up + if k % 4 == 3: m |= rect(x + 10, 12, x + 18, 15) # an arm out to the side + rig.run([m], watch=False) + u, v = where() + worst = max(worst, abs(u - u0), abs(v - v0)) + assert worst < 0.035, worst # ~2 Body px; the box moves ~7 px + + +def person(cx, arm, rng): + """A person, not a box: a head 3 px wide, a 7 px torso, two legs with a gap, an arm + sometimes out, and flickering edges. Most of the box is empty, so a loose aim misses.""" + m = np.zeros(BODY[::-1], bool) + c = int(round(cx)) + m[6:10, c - 1:c + 2] = True # head + m[10:22, c - 3:c + 4] = True # torso + m[22:36, c - 3:c - 1] = True # legs + m[22:36, c + 2:c + 4] = True + if arm: + m[11:13, c + 4:c + 4 + arm] = True + return m ^ ((rng.random(m.shape) < 0.08) & np.roll(m, 1, 1)) + + +@pytest.mark.parametrize("seed", [0, 3]) # both missed 3 of 8 before homing +def test_fidgeting_person_is_always_hit(ctx, seed): + rig = Rig(ctx) + rng = np.random.default_rng(seed) + x, v = 30.0, 0.0 + for k in range(400): # 40 s: a slow random walk, an arm in and out + v = 0.9 * v + rng.normal(0, 0.15) + x = float(np.clip(x + v, 10, 54)) + rig.run([person(x, 5 if (k // 7) % 3 == 0 else 0, rng)]) + assert len(rig.hits) >= 7 and not rig.misses + + +def test_ages_keep_time_in_half_floats(ctx): # f16 adds of 1/30 above 8 s drift ~6% + rig = Rig(ctx) + rig.run(standing(45)) + t0, age0 = rig.t, rig.live_splats(0)[-1, 2] + rig.run(standing(150)) # 15 s more + oldest = rig.live_splats(0)[-1, 2] + assert abs((oldest - age0) - (rig.t - t0)) < 0.1 + + +def test_leaving_fades_then_clears(ctx): + rig = Rig(ctx) + rig.run(standing(45)) + assert rig.live_splats(0).size + rig.run([EMPTY] * 9) # coasting, then lost: presence falls + assert rig.slot(0)[2] < 1.0 + rig.run([EMPTY] * 20) # the slot empties + assert rig.slot(0)[0] == 0 and rig.live_splats(0).size == 0 + + +def test_new_person_in_a_reused_slot_starts_clean(ctx): + rig = Rig(ctx) + rig.run(standing(45)) + assert rig.live_splats(0).size + rig.run([EMPTY] * 30) + rig.run(standing(5)) # someone new + assert rig.slot(0)[0] == 2 and rig.live_splats(0).size == 0 + + +# ── Dodging ─────────────────────────────────────────────────────────────────── + +def dodge(rig): + """Walk right at 1 px per camera frame until a poop drops, then back left at 2 px until it lands.""" + x = 4 + for _ in range(45): + x += 1 + rig.run([rect(x, 8, x + 10, 36)]) + if rig.falling(): + break + else: + pytest.fail("nothing dropped") + for _ in range(int((SPAWN_Y - 0.1) / FALL_SPEED * 10) + 15): # a miss falls to their feet + x = max(x - 2, 0) + rig.run([rect(x, 8, x + 10, 36)]) + if not rig.falling(): + break + + +def test_reversing_after_the_release_dodges(ctx): + rig = Rig(ctx) + dodge(rig) + assert rig.misses and not rig.hits + + +def test_steady_walker_is_hit(ctx): + rig = Rig(ctx) + x, v = 4, 1 + for _ in range(120): + x += v + if x > 50 or x < 4: + v = -v + rig.run([rect(x, 8, x + 10, 36)]) + assert rig.hits + + +# ── The picture ─────────────────────────────────────────────────────────────── + +BIG = (320, 180) +PAPER = np.array((246, 241, 228)) + + +def colourful(img) -> np.ndarray: + """Saturated pixels: splat colours, not paper, ink or warm white.""" + px = img[..., :3].astype(int) + return (px.max(axis=-1) - px.min(axis=-1)) > 90 + + +def test_splats_drawn_only_on_the_silhouette(ctx): + rig = Rig(ctx, BIG) + rig.run(standing(60)) + assert rig.live_splats(0).size + img = rig.image() + sat = colourful(img) + # The body rect(27, 8, 37, 36) in BIG pixels (row 0 = bottom). + c0, c1 = int(27 / 64 * BIG[0]), int(37 / 64 * BIG[0]) + r0, r1 = int((1 - 36 / 40) * BIG[1]), int((1 - 8 / 40) * BIG[1]) + on = sat[r0:r1, c0:c1].sum() + assert on > 20 + beside = sat[r0:r1 - 30, c1 + 3:c1 + 40].sum() + sat[r0:r1 - 30, c0 - 40:c0 - 3].sum() + assert beside == 0 # no splat colour off the body + + +def test_empty_room_is_paper_and_clouds(ctx): + rig = Rig(ctx, BIG) + rig.run([EMPTY] * 10) + img = rig.image() + low = img[:int(BIG[1] * 0.6)] # below the clouds: plain paper + assert (np.abs(low[..., :3].astype(int) - PAPER).max(axis=-1) < 6).all() + assert colourful(img).sum() == 0 # nothing in the sky + + +def test_falling_poop_is_drawn(ctx): + rig = Rig(ctx, BIG) + for _ in range(60): + rig.run(flickering(1)) + if rig.falling() and rig.drop(0)[3] > 0.4: + break + assert rig.falling() + u, v = rig.poop(0)[:2] + r, c = int(v * BIG[1]), int(u * BIG[0]) + assert colourful(rig.image()[r - 10:r + 10, max(c - 12, 0):c + 12]).sum() > 10 + + +def test_ground_puddle_is_drawn_at_the_feet(ctx): + rig = Rig(ctx, BIG) + dodge(rig) + assert rig.misses + rig.run([EMPTY] * 5) # past the splat-in + g = rig.grounds()[0] + r, c = int(g[1] * BIG[1]), int(g[0] * BIG[0]) + assert colourful(rig.image()[max(r - 8, 0):r + 8, max(c - 20, 0):c + 20]).sum() > 10 + + +# ── Robustness ──────────────────────────────────────────────────────────────── + +def test_state_stays_bounded_with_huge_steps(ctx): + rig = Rig(ctx) + rig.run(standing(45)) + rig.r.render(200.0, (0, 0, 0, 0)) + rig.run(standing(3)) + rig.r.render(200.0, (0, 0, 0, 0)) + rig.run([EMPTY] * 3) + b = rig.b() + assert np.isfinite(b).all() and np.abs(b).max() <= 99.0 diff --git a/docs/FundingCAPTCHA.md b/docs/FundingCAPTCHA.md index 40ad58b..6aca9e9 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`), `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 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`), `rainbow_poop` ([design](superpowers/specs/2026-11-08-rainbow-poop-design.md); about every 5 s a rainbow poop drifts down from the sky on to each visitor, and the splats stick to their silhouette, drip and fade over 20 s — step back the other way after it appears and it lands on the floor; tunables, including `BOMB_EVERY_S`, `FALL_SPEED`, `SPLAT_R` and `SPLAT_LIFE_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/specs/2026-11-08-rainbow-poop-design.md b/docs/superpowers/specs/2026-11-08-rainbow-poop-design.md new file mode 100644 index 0000000..88e5c98 --- /dev/null +++ b/docs/superpowers/specs/2026-11-08-rainbow-poop-design.md @@ -0,0 +1,78 @@ +# Rainbow Poop — shader player Setup + +A new FundingCAPTCHA shader player Setup (ADR-0021), from issue #161. **The birds were cut after the first build; see [Revisions](#revisions-after-the-first-build).** A flock of cartoon birds circles overhead. Every few seconds one of them picks a visitor, swoops down, and drops a rainbow poop that splats on their silhouette. The splats stick to the person, follow them as they move, drip, and fade. A visitor who dodges at the right moment makes the bird miss, and the poop splats on the ground instead. + +## Intent + +Slapstick. Standing in front of the screen gets you pooped on, which is funny the first time and funnier when it happens to your friend. The joke works because it's clearly aimed at *you*: the bird picks you, dives at you, and the splat stays on you as you walk around. Dodging is the game inside the joke. + +## Goals + +- **Empty room:** the flock circles lazily in the upper sky. The scene is never still. +- **Someone appears:** within about 2 s of the tracker confirming them, a bird peels off, swoops down above their head, releases a poop, and climbs back to the flock. +- **Cadence:** each person is bombed about every 5 s (4–6 s with jitter). *(Decided.)* +- **Splats stick:** a splat lands where the poop hits the silhouette, is drawn only on the silhouette, and travels with the person. Drips run down from it over the first few seconds. It fades out about 20 s after landing. *(Decided.)* +- **Dodging:** the bird leads its target, aiming where they'll be when the poop lands. Changing pace or direction after the release makes it miss. A miss splats on the ground at the target's feet and fades sooner. +- Runs on the show Pi 5 in the existing shader player, and can be prototyped in Shadertoy with the body painter and blob painter like every other Setup. + +## Non-goals + +- No uploaded art, textures, video or sound. +- Nearness (`.g`) isn't used. +- Splats don't survive a tracker re-ID (see Behaviour). + +## Anti-goals + +- **Noise.** The screen mustn't fill with poop. With one bomb per person per ~5 s and a 20 s life, a person carries about four splats at a time. +- **Splats floating off people.** A splat is never drawn outside the silhouette. +- **Popping.** Splats appear with a quick splat-in and leave with a fade, never a cut. +- **Realism.** Flat, ink-outlined storybook drawing, like the other Setups. + +## The look (decided) + +- **Style:** the shared line-drawn storybook style: black ink lines about 2–3 px at 1080p, flat fills, paper `#f6f1e4` background. +- **Sky:** paper with a few slow, ink-outlined clouds near the top. +- **People:** a flat warm-white fill with an ink outline (as `hunter_bird`), so you can see who's being bombed. +- **Birds:** a shared flock of four cartoon birds: a round slate body, a flapping wing, an orange beak and an eye, all ink-outlined. Wings beat faster while climbing and tuck back in the dive. +- **Poop in flight:** a small rainbow teardrop with an ink outline. +- **Splat:** a cartoon blob with lumpy edges, concentric rainbow bands (red outside to violet inside), a few satellite droplets, an ink outline, and two or three drips in rainbow colours running down from it. Everything is clipped to the silhouette. +- **Ground splat:** a flattened rainbow puddle at the target's feet. + +## Behaviour (decided) + +- **Flock:** `BIRDS` = 4, shared. Each circles its own slowly drifting orbit in the upper sky when idle. +- **Picking a target:** each confirmed person has a countdown, first `FIRST_BOMB_S` (about 2 s), then `BOMB_EVERY_S` ± `BOMB_JITTER_S`. When it runs out, the nearest idle bird is assigned to them, at most one bird per person at a time. If no bird is free, the person waits. +- **The dive:** a fixed-time swoop (`DIVE_S`) from the bird's position to a release point `DROP_ABOVE` above the target's head, arriving flying horizontally. The release point is updated every frame from the target's box and velocity, leading them by the poop's fall time. After the release the bird climbs away and rejoins its orbit. +- **The poop:** falls under gravity from the release point with the bird's horizontal velocity. It splats on the first silhouette pixel it reaches, on whichever tracked person's box holds that pixel (the target or a bystander). If it passes the target's feet without touching anyone, it splats on the ground there. +- **Sticking:** a splat is stored in its person's local frame (the low-passed box top-centre, scaled by box height). It follows them and scales as they step nearer or further away. Arms that move far from where the splat landed may carry it off the silhouette, where it's clipped. +- **Leaving, overlaps and slot swaps:** splats belong to a track. When the track is lost (the person left, merged into someone else, or got a new ID), its splats fade out over `LEAVE_FADE_S` (about 1 s). A new track starts clean. *(Decided: the simple rule. Inheriting splats across a re-ID is a possible later improvement.)* +- **Run length:** `duration_s` 120, like the other Setups. + +## Constraints + +- Shader player rules (`ShowControl/FundingCAPTCHA/shaders/README.md`): procedural only, Body read with `texture()` and UVs, blob records with `texelFetch`, 16-bit float buffers (positions as UV, bounded "seconds since" counters), `HW_PERFORMANCE` 0, half-resolution render. +- Leave Buffer D free for the blob painter, so the Setup can be prototyped in Shadertoy. +- Body and tracker run at about 10 fps, render at about 30. All timing is in seconds via `iTimeDelta`. +- Every tunable is a named constant at the top of `common.glsl`. + +## Testing + +Headless GL tests in the style of `test_hunter_bird.py`, with the real `BodyBlobs` tracker on synthetic masks and a fake clock: +- the empty room keeps the birds in the sky and drops nothing +- a standing person is bombed within about 3 s and then about every 5 s, and the splat is in their local frame on the body +- a splat follows a person who walks, and is clipped to the silhouette in the Image +- a person who reverses direction just after a release is missed, and the poop lands on the ground +- splats fade after their life, and fade when the track is lost +- a new track in a reused slot starts with no splats +- state stays bounded and finite through huge time steps + +Unverified until run with the real camera: how the cadence and dodge timing feel, and frame rate on the Pi 5. + +## Revisions after the first build + +- **No birds.** The flock, dives and climbs are gone. A poop appears just above the top of the screen and falls straight down at a steady `FALL_SPEED` (0.2 sh/s) with a gentle sideways sway, slow enough to watch the whole way. One poop per person is in the air at a time. +- **Aim and dodging are unchanged in spirit.** The drop point is over the person's head, led by their velocity × the time to fall to their head, and clamped on screen. Turning round after it appears makes it miss; a miss falls on to the floor at their feet. +- **Cadence** is the same per person (`FIRST_BOMB_S`, then `BOMB_EVERY_S` ± `BOMB_JITTER_S`), counted from each drop. +- **Bigger splats.** Splats are twice the radius (`SPLAT_R` 0.15 × box height), sit a little deeper on the body (`SPLAT_SINK`, `SINK_SPREAD`), and floor puddles double to match (`GROUND_R`). The falling poop is larger (`POOP_R`) so it reads at that scale. +- **Homing.** On site the poops often missed: the aim was fixed at the drop, from noisy tracker velocity over a 1.5–2.5 s fall, and spread over the whole box, which includes arms and empty space. Now the poop drifts toward its person's centre of mass as it falls (`HOMING_GAIN`, capped at `HOMING_MAX` 0.18 sh/s), and the aim spread is ± `AIM_SPREAD` (0.05) of their size. Standing, swaying or fidgeting gets you hit. Walking or turning away (about 0.3 sh/s and up) still dodges. +- **Splats anchored to the silhouette, not the box.** On site, splats slid around people as they moved, because they were placed relative to the box's top-centre and height, both eased. Raised arms, strides and box jitter moved them, and the easing made them lag. Now a splat's frame is the silhouette's centre of mass and size (`SIZE_K` × √area, about an adult's height), measured from this frame's Body inside the tracker's box (`ANCHOR_NX` × `ANCHOR_NY` samples). Its size and offsets are in that unit.