From 80f3707d2fe5a3500047c290dd47092ec7377918 Mon Sep 17 00:00:00 2001 From: "Impy[bot]" <314283863+ii-impy@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:38:35 -0500 Subject: [PATCH 1/5] Rainbow poop: design spec (#161) Co-Authored-By: Claude Opus 5.5 --- .../specs/2026-11-08-rainbow-poop-design.md | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 docs/superpowers/specs/2026-11-08-rainbow-poop-design.md 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..5ddc9c6 --- /dev/null +++ b/docs/superpowers/specs/2026-11-08-rainbow-poop-design.md @@ -0,0 +1,69 @@ +# Rainbow Poop — shader player Setup + +A new FundingCAPTCHA shader player Setup (ADR-0021), from issue #161. 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. From 88070f077639c993d9b731480317f07040b1d422 Mon Sep 17 00:00:00 2001 From: "Impy[bot]" <314283863+ii-impy@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:38:35 -0500 Subject: [PATCH 2/5] Rainbow poop: a flock dive-bombs visitors with rainbow splats that stick (#161) - Buffer B keeps the flock (orbit, dive, climb), the poops in flight, a bomb countdown and low-passed anchor per person, a ring of splats per person in their own frame, and a ring of ground puddles. - One idle bird per overdue person: first bomb ~1.2-2 s after confirming, then every 5 +/- 1 s. The dive leads the target by the fall time, so reversing after the drop dodges and the poop splats at their feet. - Splats are clipped to the silhouette, drip, fade after 20 s, and fade over 0.6 s when the track is lost; a new track starts clean. - Ages and countdowns are stored in 1/32 s steps rounded stochastically, since plain f16 adds of 1/30 above 8 s ran ~6% slow. - Buffer D is left free for the blob painter. Co-Authored-By: Claude Opus 5.5 --- .../shaders/rainbow_poop/buffer_b.glsl | 281 ++++++++++++++ .../shaders/rainbow_poop/common.glsl | 163 ++++++++ .../shaders/rainbow_poop/image.glsl | 189 +++++++++ .../shaders/rainbow_poop/setup.json | 8 + .../FundingCAPTCHA/shaders/setups.json | 2 +- .../FundingCAPTCHA/test_rainbow_poop.py | 359 ++++++++++++++++++ docs/FundingCAPTCHA.md | 2 +- 7 files changed, 1002 insertions(+), 2 deletions(-) create mode 100644 ShowControl/FundingCAPTCHA/shaders/rainbow_poop/buffer_b.glsl create mode 100644 ShowControl/FundingCAPTCHA/shaders/rainbow_poop/common.glsl create mode 100644 ShowControl/FundingCAPTCHA/shaders/rainbow_poop/image.glsl create mode 100644 ShowControl/FundingCAPTCHA/shaders/rainbow_poop/setup.json create mode 100644 ShowControl/FundingCAPTCHA/test_rainbow_poop.py 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..b9829ee --- /dev/null +++ b/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/buffer_b.glsl @@ -0,0 +1,281 @@ +// Buffer B — flock, 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 (an assignment, a release, 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 + +float ASPECT; +float DITHER; // 0–1, per texel per frame: stochastic rounding for tick() + +// 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; +} +int planSlot(vec4 plan) { return int(abs(plan.z) + 0.5) - 1; } +// The person a dive is aimed at is still in their slot. +bool targetValid(vec4 plan) { + int k = planSlot(plan); + if (k < 0) return false; + vec4 idn = recIdn(BLOBS, k); + return abs(idn.x - plan.w) < 0.5 && isHere(idn.y); +} + +// ── Flock ───────────────────────────────────────────────────────────────────── +// Bird i's idle orbit point (sh): a slow loop around a centre that wanders across the sky. +vec2 orbitGoal(int i) { + float fi = float(i); + float cx = ASPECT * (0.5 + 0.36 * vnoise(iTime * ORBIT_DRIFT + fi * 7.3)); + float cy = ORBIT_Y + 0.04 * vnoise(iTime * ORBIT_DRIFT * 1.7 + fi * 3.1 + 50.0); + float a = (i % 2 == 0 ? 1.0 : -1.0) * iTime * ORBIT_W + fi * 1.9; + return vec2(cx, cy) + ORBIT_R * vec2(1.6 * cos(a), sin(a)); +} + +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 == 2 && c.x < BIRDS) return vec4(toUv(orbitGoal(c.x)), 0.0, 0.0); // BIRD + if (c.y == 7 && c.x < SLOTS * SPLATS) return vec4(0.0, 0.0, T_CLAMP, 0.0); // SPLAT + if (c.y == 8 && c.x < GROUNDS) return vec4(0.0, 0.0, T_CLAMP, 0.0); // GROUND + return vec4(0.0); +} + +// ── Assignment: which idle bird goes for which overdue person this frame ──── +bool targeted(int k) { + for (int i = 0; i < BIRDS; i++) { + vec4 plan = sPlan(B, i); + if (isState(plan.x, 1.0) && planSlot(plan) == k) return true; + } + return false; +} +// (bird, slot), or (-1, -1). At most one per frame: the most overdue person, the nearest idle bird. +ivec2 assignNext() { + int bk = -1; float bt = 0.0; + for (int k = 0; k < SLOTS; k++) { + float due = sSlot(B, k).y; + if (due > 0.0 || isFresh(k) || !isState(recIdn(BLOBS, k).y, S_CONFIRMED) || targeted(k)) continue; + if (bk < 0 || due < bt) { bk = k; bt = due; } + } + if (bk < 0) return ivec2(-1); + vec2 tp = toScreen(recMot(BLOBS, bk).xy); + int bi = -1; float bd = 1e9; + for (int i = 0; i < BIRDS; i++) { + if (sPlan(B, i).x > 0.5) continue; + float d = length(toScreen(sBird(B, i).xy) - tp); + if (d < bd) { bi = i; bd = d; } + } + return bi < 0 ? ivec2(-1) : ivec2(bi, bk); +} + +// ── Dive ────────────────────────────────────────────────────────────────────── +float releaseSpeed() { return 2.0 * APPROACH / DIVE_S; } // the approach curve's end speed (level) + +// Where to release (sh) over slot k's person, flying `dir`, with `left` s of dive to go: +// above their head, leading them by the time until the poop lands. +vec2 releasePoint(int k, float dir, float left, float aim) { + vec4 box = recBox(BLOBS, k); + float top = box.w; // UV y is sh + float ry = min(top + DROP_ABOVE, SKY_MAX); + float fall = sqrt(2.0 * max(ry - top, 0.01) / GRAVITY); + vec2 c = toScreen(vec2((box.x + box.z) * 0.5, top)); + float halfw = 0.5 * toScreen(vec2(box.z - box.x, 0.0)).x; + float lead = clamp(toScreen(recMot(BLOBS, k).zw).x * (left + fall), -LEAD_MAX, LEAD_MAX); + return vec2(c.x + aim * AIM_SPREAD * halfw + lead - dir * releaseSpeed() * fall, ry); +} +// A per-dive aim offset, −1…1, fixed by the dive's start point. +float aimOf(int i, vec4 plan, vec4 path) { + return 2.0 * hash11(plan.w * 3.7 + float(i) * 11.0 + floor(path.x * 997.0) + floor(path.y * 613.0)) - 1.0; +} + +struct Bird { vec4 bird; vec4 plan; vec4 path; bool released; }; + +Bird birdStep(int i, float dt) { + Bird o; + o.bird = sBird(B, i); o.plan = sPlan(B, i); o.path = sPath(B, i); o.released = false; + vec2 pos = toScreen(o.bird.xy), vel = o.bird.zw; + + if (o.plan.x < 0.5) { // orbiting + ivec2 a = assignNext(); + if (a.x == i) { // sent: start the dive from here + int k = a.y; + float dir = toScreen(recMot(BLOBS, k).xy).x >= pos.x ? 1.0 : -1.0; + o.plan = vec4(1.0, 0.0, dir * float(k + 1), recIdn(BLOBS, k).x); + o.path = vec4(o.bird.xy, 0.0, 0.0); + o.path.zw = toUv(releasePoint(k, dir, DIVE_S, aimOf(i, o.plan, o.path))); + return o; + } + vec2 want = (orbitGoal(i) - pos) * ORBIT_GAIN; + if (length(want) > CRUISE) want *= CRUISE / length(want); + vel += (want - vel) * (1.0 - exp(-dt * STEER_K)); + pos += vel * dt; + o.bird = vec4(toUv(pos), vel); + return o; + } + + float t = o.plan.y + dt, dir = sign(o.plan.z); + if (o.plan.x < 1.5) { // diving: the release point follows the target + vec2 R = targetValid(o.plan) + ? releasePoint(planSlot(o.plan), dir, max(DIVE_S - t, 0.0), aimOf(i, o.plan, o.path)) + : toScreen(o.path.zw); + o.path.zw = toUv(R); + if (t >= DIVE_S) { o.released = true; o.plan.x = 2.0; t -= DIVE_S; } + else { + vec2 S = toScreen(o.path.xy), C = vec2(R.x - dir * APPROACH, R.y); + float u = t / DIVE_S; + pos = bezier(S, C, R, u); vel = bezierD(S, C, R, u) / DIVE_S; + } + } + if (o.plan.x > 1.5) { // climbing away from the frozen release point + vec2 R = toScreen(o.path.zw); + vec2 C = vec2(R.x + dir * APPROACH, R.y), E = vec2(R.x + 2.0 * dir * APPROACH, CLIMB_TO_Y); + float u = min(t / CLIMB_S, 1.0); + pos = bezier(R, C, E, u); vel = bezierD(R, C, E, u) / CLIMB_S; + if (t >= CLIMB_S) { o.plan = vec4(0.0); t = 0.0; } + } + o.plan.y = min(t, T_CLAMP); + o.bird = vec4(toUv(pos), vel); + return o; +} + +// ── Poop ────────────────────────────────────────────────────────────────────── +// 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; }; + +Poop poopStep(int i, float dt) { + Poop o; + o.poop = sPoop(B, i); o.drop = sDrop(B, i); o.hitSlot = -1; o.ground = false; o.at = vec2(0.0); + Bird b = birdStep(i, dt); + if (b.released) { // dropped this frame, from the release point + bool valid = targetValid(b.plan); + float floorY = valid ? recBox(BLOBS, planSlot(b.plan)).y : 0.02; + o.poop = vec4(b.path.zw, sign(b.plan.z) * releaseSpeed(), 0.0); + o.drop = vec4(float(planSlot(b.plan) + 1), floorY, 0.0, 0.0); + return o; + } + if (o.drop.x < 0.5) return o; // nothing falling + + vec2 pos = toScreen(o.poop.xy), vel = o.poop.zw; + float h = dt / float(FALL_STEPS); + for (int s = 0; s < FALL_STEPS; s++) { + vel.y -= GRAVITY * h; + pos += vel * h; + vec2 uv = toUv(pos); + 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 k = slotAt(uv, int(o.drop.x + 0.5) - 1); + if (k >= 0) { o.hitSlot = k; 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; + } + if (uv.x < -0.3 || uv.x > 1.3) { o.poop = vec4(0.0); o.drop = vec4(0.0); return o; } + } + o.poop = vec4(toUv(pos), vel); + o.drop.w = min(o.drop.w + dt, 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 (isState(idn.y, S_CONFIRMED)) s.y = max(-tick(-s.y, dt), -10.0); + if (assignNext().y == k) s.y = quant(BOMB_EVERY_S + BOMB_JITTER_S * (2.0 * hash11(iTime * 7.7 + float(k)) - 1.0)); + s.z = here > 0.5 ? 1.0 : max(s.z - dt / LEAVE_FADE_S, 0.0); + return s; +} + +// Box top-centre (UV) and height (sh), low-passed so splats ride smoothly. +vec4 anchorNext(int k, float dt) { + vec4 box = recBox(BLOBS, k); + vec4 want = vec4((box.x + box.z) * 0.5, box.w, box.w - box.y, 0.0); + if (isFresh(k)) return want; + if (!isHere(recIdn(BLOBS, k).y)) return sAnchor(B, k); // lost: hold where they were + return mix(sAnchor(B, k), want, 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 < BIRDS; i++) { + 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 < BIRDS; 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 > 8) return; + ASPECT = iResolution.x / iResolution.y; + if (iFrame == 0) { o = initial(c); return; } + float dt = clamp(iTimeDelta, 1e-3, DT_MAX); + uint hx = uint(iFrame) * 747796405u + uint(c.x) * 2891336453u + uint(c.y) * 277803737u; // PCG-style + hx = ((hx >> ((hx >> 28u) + 4u)) ^ hx) * 277803737u; + DITHER = float((hx >> 22u) ^ hx) / 4294967296.0; + + 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 <= 4 && c.x < BIRDS) { + Bird b = birdStep(c.x, dt); + o = c.y == 2 ? b.bird : c.y == 3 ? b.plan : b.path; + } + else if (c.y >= 5 && c.y <= 6 && c.x < BIRDS) { + Poop p = poopStep(c.x, dt); + o = c.y == 5 ? p.poop : p.drop; + } + else if (c.y == 7 && c.x < SLOTS * SPLATS) o = splatNext(c.x / SPLATS, c.x % SPLATS, dt); + else if (c.y == 8 && 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..c7b8442 --- /dev/null +++ b/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/common.glsl @@ -0,0 +1,163 @@ +// 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, i = bird 0–3: +// (k, 0) SLOT id, seconds until this person's next bomb, presence 0–1 (fades splats on loss), 0 +// (k, 1) ANCHOR box top-centre x, y (Body UV) and box height (sh), low-passed over ANCHOR_EMA_S; 0 +// (i, 2) BIRD position x, y (UV), velocity x, y (sh/s) +// (i, 3) PLAN mode (0 orbit, 1 dive, 2 climb), seconds in mode, ±(target slot + 1) (sign = flight +// direction), target id +// (i, 4) PATH dive start x, y (UV), release point x, y (UV; frozen once released) +// (i, 5) POOP position x, y (UV), velocity x, y (sh/s) +// (i, 6) DROP target slot + 1 (0 = nothing falling), floor y (UV), 0, seconds falling +// (k·SPLATS + j, 7) SPLAT centre in the person's frame x, y (box heights from the box top-centre), +// age s, seed 0–1 — j = 0 newest +// (j, 8) GROUND x, y (UV), age s, seed 0–1 — j = 0 newest + +const int SLOTS = 8, BIRDS = 4, 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 stochastically, so the expected rate is exact at any fps. +// (Plain f16 adds of 1/30 above 8 s drift by ~6%.) +const float AGE_Q = 1.0 / 32.0; + +// ── Bombing cadence (per person) ────────────────────────────────────────────── +const float FIRST_BOMB_S = 1.2; // s after a person appears before a bird is sent … +const float FIRST_JITTER = 0.8; // … plus up to this +const float BOMB_EVERY_S = 5.0; // then one bird every this many seconds … +const float BOMB_JITTER_S = 1.0; // … ± this + +// ── Flock (idle orbits) ─────────────────────────────────────────────────────── +const float ORBIT_Y = 0.80; // orbit centres' height +const float ORBIT_R = 0.09; // orbit radius +const float ORBIT_W = 0.8; // rad/s +const float ORBIT_DRIFT = 0.04; // how fast orbit centres wander across the sky (noise rate) +const float CRUISE = 0.55; // top speed toward the orbit point, sh/s +const float ORBIT_GAIN = 2.0; // 1/s: speed toward the orbit point per sh of distance +const float STEER_K = 3.0; // 1/s: how fast velocity turns to the desired one + +// ── Dive (a fixed-time swoop to the release point, then a climb away) ───────── +const float DIVE_S = 1.4; +const float CLIMB_S = 1.4; +const float APPROACH = 0.35; // sh: arrives flying level from this far to the side +const float DROP_ABOVE = 0.20; // sh above the target's head to release +const float SKY_MAX = 0.94; // the release point is never higher than this +const float CLIMB_TO_Y = 0.84; +const float LEAD_MAX = 0.5; // sh: the bird leads a moving target by at most this +const float AIM_SPREAD = 0.5; // aim within ± this × half the box width of its centre + +// ── Poop ────────────────────────────────────────────────────────────────────── +const float GRAVITY = 1.6; // sh/s² +const int FALL_STEPS = 3; // body hit tests per frame along the fall +const float POOP_R = 0.012; // in-flight 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 +const float ANCHOR_EMA_S = 0.15; +const float SPLAT_R = 0.075; // × box height +const float SPLAT_R_MIN = 0.02; // sh +const float SPLAT_SINK = 0.04; // × box height: the splat centre sits this far below the impact … +const float SINK_SPREAD = 0.10; // … 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_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 BIRD_S = 0.045; // bird body half-length +const float FLAP_HZ = 2.2, FLAP_CLIMB_HZ = 4.0; +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 sBird (sampler2D s, int i) { return texelFetch(s, ivec2(i, 2), 0); } +vec4 sPlan (sampler2D s, int i) { return texelFetch(s, ivec2(i, 3), 0); } +vec4 sPath (sampler2D s, int i) { return texelFetch(s, ivec2(i, 4), 0); } +vec4 sPoop (sampler2D s, int i) { return texelFetch(s, ivec2(i, 5), 0); } +vec4 sDrop (sampler2D s, int i) { return texelFetch(s, ivec2(i, 6), 0); } +vec4 sSplat (sampler2D s, int k, int j) { return texelFetch(s, ivec2(k * SPLATS + j, 7), 0); } +vec4 sGround(sampler2D s, int j) { return texelFetch(s, ivec2(j, 8), 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; +} + +// ── Paths ───────────────────────────────────────────────────────────────────── +vec2 bezier(vec2 a, vec2 b, vec2 c, float u) { return mix(mix(a, b, u), mix(b, c, u), u); } +vec2 bezierD(vec2 a, vec2 b, vec2 c, float u) { return 2.0 * mix(b - a, c - b, u); } // d/du + +// ── 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); } +vec2 rot(vec2 p, float a) { float c = cos(a), s = sin(a); return vec2(c * p.x + s * p.y, -s * p.x + c * p.y); } +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); +} +// A leaf/wing: a lens from two quadratics (0,0)→(L/2, ±0.42L)→(L,0). Signed distance. +float sdLeaf(vec2 q, float L) { + float t = q.x / L; + if (t <= 0.0) return length(q); + if (t >= 1.0) return length(q - vec2(L, 0.0)); + float half_ = 0.84 * L * t * (1.0 - t), slope = 0.84 * (1.0 - 2.0 * t); + return (abs(q.y) - half_) / sqrt(1.0 + slope * slope); +} +float sdTriangle(vec2 p, vec2 a, vec2 b, vec2 c) { + vec2 e0 = b - a, e1 = c - b, e2 = a - c, v0 = p - a, v1 = p - b, v2 = p - c; + vec2 pq0 = v0 - e0 * clamp(dot(v0, e0) / dot(e0, e0), 0.0, 1.0); + vec2 pq1 = v1 - e1 * clamp(dot(v1, e1) / dot(e1, e1), 0.0, 1.0); + vec2 pq2 = v2 - e2 * clamp(dot(v2, e2) / dot(e2, e2), 0.0, 1.0); + float s = sign(e0.x * e2.y - e0.y * e2.x); + vec2 d = min(min(vec2(dot(pq0, pq0), s * (v0.x * e0.y - v0.y * e0.x)), + vec2(dot(pq1, pq1), s * (v1.x * e1.y - v1.y * e1.x))), + vec2(dot(pq2, pq2), s * (v2.x * e2.y - v2.y * e2.x))); + return -sqrt(d.x) * sign(d.y); +} diff --git a/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/image.glsl b/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/image.glsl new file mode 100644 index 0000000..ac8766b --- /dev/null +++ b/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/image.glsl @@ -0,0 +1,189 @@ +// 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 → birds. +// 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 = 0.05; + 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); +} + +// ── Poops in flight ─────────────────────────────────────────────────────────── +void drawPoops(inout vec3 col, vec2 p) { + for (int i = 0; i < BIRDS; i++) { + if (sDrop(B, i).x < 0.5) continue; + vec4 pp = sPoop(B, i); + vec2 q = p - uvToP(pp.xy); + if (dot(q, q) > 16.0 * POOP_R * POOP_R) continue; + vec2 fwd = normalize(pp.zw + vec2(0.0, -1e-3)); // the round end leads, the point trails + 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_LIGHT, PX)); + } +} + +// ── Birds ───────────────────────────────────────────────────────────────────── +const vec3 BIRD_COL[4] = vec3[4](SLATE, VIOLET, DEEP, RUST); + +void drawBird(inout vec3 col, vec2 p, int i) { + vec4 bd = sBird(B, i); + vec2 q = (p - uvToP(bd.xy)) / BIRD_S; + if (dot(q, q) > 9.0) return; + vec2 v = bd.zw; + if (v.x < 0.0) q.x = -q.x; // face the way it flies + q = rot(q, clamp(atan(v.y, abs(v.x) + 1e-3), -0.9, 0.9) * 0.7); + float px = PX / BIRD_S, ink = INK_W / BIRD_S; + vec3 c = BIRD_COL[i % 4]; + + float dK = sdTriangle(q, vec2(1.02, 0.50), vec2(1.55, 0.36), vec2(1.02, 0.20)); // beak + over(col, ORANGE, fill(dK, px)); + over(col, INK, pen(abs(dK), ink, px)); + + float dB = (length(q / vec2(1.0, 0.62)) - 1.0) * 0.62; // body + float dH = length(q - vec2(0.72, 0.38)) - 0.42; // head + float dT = sdTriangle(q, vec2(-0.75, 0.05), vec2(-1.45, 0.38), vec2(-1.38, -0.22)); // tail + float d = smin(smin(dB, dH, 0.15), dT, 0.08); + over(col, c, fill(d, px)); + over(col, INK, pen(abs(d), ink, px)); + + float dE = length(q - vec2(0.84, 0.48)) - 0.13; // eye + over(col, WARM_WHITE, fill(dE, px)); + over(col, INK, fill(length(q - vec2(0.88, 0.48)) - 0.06, px)); + over(col, INK, pen(abs(dE), ink * 0.7, px)); + + float mode = sPlan(B, i).x; // wing: lazy, tucked in the dive, fast climbing + float hz = mode > 1.5 ? FLAP_CLIMB_HZ : FLAP_HZ; + float flap = sin(iTime * 6.2832 * hz + float(i) * 1.7); + float ang = mode > 0.5 && mode < 1.5 ? 2.85 + 0.08 * flap : 2.2 + 0.75 * flap; + float dW = sdLeaf(rot(q - vec2(-0.05, 0.2), ang), 1.25); + over(col, mix(c, INK, 0.25), fill(dW, px)); + over(col, INK, pen(abs(dW), ink, 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); + for (int i = 0; i < BIRDS; i++) drawBird(col, p, i); + 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..a2a4131 --- /dev/null +++ b/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/setup.json @@ -0,0 +1,8 @@ +{ + "_comment": "Rainbow poop: a flock dive-bombs visitors with rainbow splats that 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..146b00a --- /dev/null +++ b/ShowControl/FundingCAPTCHA/test_rainbow_poop.py @@ -0,0 +1,359 @@ +"""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") +DIVE_S = const("DIVE_S") + + +@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[7, k * 8, 2] == 0: # age 0: landed this frame + self.hits.append((self.t, k)) + if b[8, 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 bird(self, i): return self.b()[2, i] # x, y, vx, vy + def plan(self, i): return self.b()[3, i] # mode, t, ±(slot+1), id + def drop(self, i): return self.b()[6, i] # slot+1, floor, -, age + def splats(self, k): return self.b()[7, k * 8:(k + 1) * 8] # x, y, age, seed + def grounds(self): return self.b()[8, :8] + + 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 flock ───────────────────────────────────────────────────────────────── + +def test_empty_room_birds_circle_in_the_sky(ctx): + rig = Rig(ctx) + ys = [] + for _ in range(40): # 12 s + rig.run([EMPTY] * 3) + ys += [rig.bird(i)[1] for i in range(4)] + assert min(ys) > 0.6 # never come down + assert all(rig.plan(i)[0] == 0 for i in range(4)) + assert not rig.hits and not rig.misses + xs = [rig.bird(i)[0] for i in range(4)] + assert max(xs) - min(xs) > 0.1 # spread out, not one clump + a = [rig.bird(i)[:2].copy() for i in range(4)] + rig.run([EMPTY] * 5) + assert all(np.linalg.norm(rig.bird(i)[:2] - a[i]) > 0.01 for i in range(4)) # moving + + +# ── 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, the dive and the fall + assert first[0] < 0.4 + FIRST_BOMB_S + FIRST_JITTER + DIVE_S + 0.8 + assert first[1] == 0 and not rig.misses + + +def test_splat_lands_on_the_head_in_the_persons_frame(ctx): + rig = Rig(ctx) + rig.run(flickering(60)) + s = rig.live_splats(0) + assert len(s) >= 1 + x, y = s[0, 0], s[0, 1] + half_w = 10 / 64 * (128 / 72) / 2 / (28 / 40) # half the body width, in box heights + assert abs(x) < half_w * 1.2 # over the body + assert -0.2 < y < 0.0 # just below the box 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_one_bird_per_person_at_a_time(ctx): + rig = Rig(ctx) + for _ in range(80): + rig.run(flickering(1)) + diving = [rig.plan(i) for i in range(4) if rig.plan(i)[0] == 1] + assert len(diving) <= 1 + + +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_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): + """Pace at 2 px per camera frame until a poop is falling, then turn round for 1.5 s.""" + x, v = 4, 2 + for _ in range(200): + x += v + if x > 46: v = -2 + if x < 4: v = 2 + rig.run([rect(x, 8, x + 10, 36)]) + if any(rig.drop(i)[0] for i in range(4)): + break + else: + pytest.fail("never released") + v = -v + for _ in range(15): + x = int(np.clip(x + v, 0, 54)) + rig.run([rect(x, 8, x + 10, 36)]) + + +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); birds can be anywhere above it. + 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 below the birds + + +def test_empty_room_is_paper_birds_and_clouds(ctx): + rig = Rig(ctx, BIG) + rig.run([EMPTY] * 10) + img = rig.image() + low = img[:int(BIG[1] * 0.5)] # the lower half is empty paper + assert (np.abs(low[..., :3].astype(int) - PAPER).max(axis=-1) < 6).all() + assert colourful(img[int(BIG[1] * 0.6):]).sum() > 20 # birds up there + + +def test_ground_puddle_is_drawn_at_the_feet(ctx): + rig = Rig(ctx, BIG) + dodge(rig) + assert rig.misses + 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..126c989 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); a flock of birds dive-bombs each visitor about every 5 s, and the rainbow splats stick to their silhouette, drip and fade over 20 s — dodge after the drop and the poop lands on the floor; tunables, including `BOMB_EVERY_S`, `SPLAT_LIFE_S` and `DROP_ABOVE`, 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`. From b4e93a5b52e0714883968611618c77cf3c99674d Mon Sep 17 00:00:00 2001 From: "Impy[bot]" <314283863+ii-impy@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:38:35 -0500 Subject: [PATCH 3/5] Rainbow poop: golden-ratio rounding so ages keep time on every GPU The per-texel random dither for tick() left ages wandering ~0.17 s (1 sigma) over 15 s, so test_ages_keep_time_in_half_floats passed locally by luck and failed in CI (0.125 s). A Weyl sequence, fract(iFrame * phi) in integers, keeps the running error within about one AGE_Q. Co-Authored-By: Claude Opus 5.5 --- .../FundingCAPTCHA/shaders/rainbow_poop/buffer_b.glsl | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/buffer_b.glsl b/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/buffer_b.glsl index b9829ee..268f31b 100644 --- a/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/buffer_b.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/buffer_b.glsl @@ -8,7 +8,10 @@ #define BLOBS iChannel2 float ASPECT; -float DITHER; // 0–1, per texel per frame: stochastic rounding for tick() +// 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); } @@ -262,9 +265,7 @@ void mainImage(out vec4 o, in vec2 fc) { ASPECT = iResolution.x / iResolution.y; if (iFrame == 0) { o = initial(c); return; } float dt = clamp(iTimeDelta, 1e-3, DT_MAX); - uint hx = uint(iFrame) * 747796405u + uint(c.x) * 2891336453u + uint(c.y) * 277803737u; // PCG-style - hx = ((hx >> ((hx >> 28u) + 4u)) ^ hx) * 277803737u; - DITHER = float((hx >> 22u) ^ hx) / 4294967296.0; + 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); From 4ab51a0f6071102c28f0648093bb1a3819d31781 Mon Sep 17 00:00:00 2001 From: "Impy[bot]" <314283863+ii-impy@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:47:05 -0500 Subject: [PATCH 4/5] Rainbow poop: no birds; poop drifts down from the sky, splats twice as big - The flock, dives and climbs are gone. Each person's poop appears just above the top of the screen, over where they'll be, and falls at a steady 0.2 sh/s with a gentle sway, so it can be watched the whole way. One per person in the air at a time; drop points are kept on screen. - Cadence, leading aim and dodging are unchanged; a miss falls to the person's feet and leaves a puddle. - Splats are twice the radius (SPLAT_R 0.15 x box height) and sit a little deeper; puddles double to match, and the falling drop is larger. - Tests: bird tests replaced with fall tests (starts at the top, takes over a second, one at a time, drawn while falling). - 30.2 fps (the cap) on the show machine with --mock-camera. Co-Authored-By: Claude Opus 5.5 --- .../shaders/rainbow_poop/buffer_b.glsl | 190 ++++-------------- .../shaders/rainbow_poop/common.glsl | 108 +++------- .../shaders/rainbow_poop/image.glsl | 63 ++---- .../shaders/rainbow_poop/setup.json | 2 +- .../FundingCAPTCHA/test_rainbow_poop.py | 110 ++++++---- docs/FundingCAPTCHA.md | 2 +- .../specs/2026-11-08-rainbow-poop-design.md | 9 +- 7 files changed, 167 insertions(+), 317 deletions(-) diff --git a/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/buffer_b.glsl b/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/buffer_b.glsl index 268f31b..58df2b5 100644 --- a/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/buffer_b.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/buffer_b.glsl @@ -1,13 +1,12 @@ -// Buffer B — flock, poops and splats. iChannel0 = Body (A), iChannel1 = this buffer, iChannel2 = blobs. +// 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 (an assignment, a release, a hit) recompute it from the same inputs. +// 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 -float ASPECT; // 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). @@ -23,131 +22,38 @@ bool isFresh(int k) { vec4 idn = recIdn(BLOBS, k); return isState(idn.y, S_EMPTY) || abs(idn.x - sSlot(B, k).x) > 0.5; } -int planSlot(vec4 plan) { return int(abs(plan.z) + 0.5) - 1; } -// The person a dive is aimed at is still in their slot. -bool targetValid(vec4 plan) { - int k = planSlot(plan); - if (k < 0) return false; - vec4 idn = recIdn(BLOBS, k); - return abs(idn.x - plan.w) < 0.5 && isHere(idn.y); -} - -// ── Flock ───────────────────────────────────────────────────────────────────── -// Bird i's idle orbit point (sh): a slow loop around a centre that wanders across the sky. -vec2 orbitGoal(int i) { - float fi = float(i); - float cx = ASPECT * (0.5 + 0.36 * vnoise(iTime * ORBIT_DRIFT + fi * 7.3)); - float cy = ORBIT_Y + 0.04 * vnoise(iTime * ORBIT_DRIFT * 1.7 + fi * 3.1 + 50.0); - float a = (i % 2 == 0 ? 1.0 : -1.0) * iTime * ORBIT_W + fi * 1.9; - return vec2(cx, cy) + ORBIT_R * vec2(1.6 * cos(a), sin(a)); -} 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 == 2 && c.x < BIRDS) return vec4(toUv(orbitGoal(c.x)), 0.0, 0.0); // BIRD - if (c.y == 7 && c.x < SLOTS * SPLATS) return vec4(0.0, 0.0, T_CLAMP, 0.0); // SPLAT - if (c.y == 8 && c.x < GROUNDS) return vec4(0.0, 0.0, T_CLAMP, 0.0); // GROUND + 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); } -// ── Assignment: which idle bird goes for which overdue person this frame ──── -bool targeted(int k) { - for (int i = 0; i < BIRDS; i++) { - vec4 plan = sPlan(B, i); - if (isState(plan.x, 1.0) && planSlot(plan) == k) return true; - } - return false; -} -// (bird, slot), or (-1, -1). At most one per frame: the most overdue person, the nearest idle bird. -ivec2 assignNext() { - int bk = -1; float bt = 0.0; - for (int k = 0; k < SLOTS; k++) { - float due = sSlot(B, k).y; - if (due > 0.0 || isFresh(k) || !isState(recIdn(BLOBS, k).y, S_CONFIRMED) || targeted(k)) continue; - if (bk < 0 || due < bt) { bk = k; bt = due; } - } - if (bk < 0) return ivec2(-1); - vec2 tp = toScreen(recMot(BLOBS, bk).xy); - int bi = -1; float bd = 1e9; - for (int i = 0; i < BIRDS; i++) { - if (sPlan(B, i).x > 0.5) continue; - float d = length(toScreen(sBird(B, i).xy) - tp); - if (d < bd) { bi = i; bd = d; } - } - return bi < 0 ? ivec2(-1) : ivec2(bi, bk); +// ── 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; } -// ── Dive ────────────────────────────────────────────────────────────────────── -float releaseSpeed() { return 2.0 * APPROACH / DIVE_S; } // the approach curve's end speed (level) - -// Where to release (sh) over slot k's person, flying `dir`, with `left` s of dive to go: -// above their head, leading them by the time until the poop lands. -vec2 releasePoint(int k, float dir, float left, float aim) { +// Where to drop it (UV x): over their head, ahead of them by the time it takes to fall there. +float dropX(int k) { vec4 box = recBox(BLOBS, k); - float top = box.w; // UV y is sh - float ry = min(top + DROP_ABOVE, SKY_MAX); - float fall = sqrt(2.0 * max(ry - top, 0.01) / GRAVITY); - vec2 c = toScreen(vec2((box.x + box.z) * 0.5, top)); + float fall = max(SPAWN_Y - box.w, 0.0) / FALL_SPEED; + float cx = toScreen(vec2((box.x + box.z) * 0.5, 0.0)).x; float halfw = 0.5 * toScreen(vec2(box.z - box.x, 0.0)).x; - float lead = clamp(toScreen(recMot(BLOBS, k).zw).x * (left + fall), -LEAD_MAX, LEAD_MAX); - return vec2(c.x + aim * AIM_SPREAD * halfw + lead - dir * releaseSpeed() * fall, ry); -} -// A per-dive aim offset, −1…1, fixed by the dive's start point. -float aimOf(int i, vec4 plan, vec4 path) { - return 2.0 * hash11(plan.w * 3.7 + float(i) * 11.0 + floor(path.x * 997.0) + floor(path.y * 613.0)) - 1.0; + float lead = clamp(toScreen(recMot(BLOBS, k).zw).x * fall, -LEAD_MAX, LEAD_MAX); + float aim = 2.0 * hash11(recIdn(BLOBS, k).x * 3.7 + iTime * 1.3) - 1.0; + return clamp(toUv(vec2(cx + aim * AIM_SPREAD * halfw + lead, 0.0)).x, 0.02, 0.98); // always seen falling } -struct Bird { vec4 bird; vec4 plan; vec4 path; bool released; }; - -Bird birdStep(int i, float dt) { - Bird o; - o.bird = sBird(B, i); o.plan = sPlan(B, i); o.path = sPath(B, i); o.released = false; - vec2 pos = toScreen(o.bird.xy), vel = o.bird.zw; - - if (o.plan.x < 0.5) { // orbiting - ivec2 a = assignNext(); - if (a.x == i) { // sent: start the dive from here - int k = a.y; - float dir = toScreen(recMot(BLOBS, k).xy).x >= pos.x ? 1.0 : -1.0; - o.plan = vec4(1.0, 0.0, dir * float(k + 1), recIdn(BLOBS, k).x); - o.path = vec4(o.bird.xy, 0.0, 0.0); - o.path.zw = toUv(releasePoint(k, dir, DIVE_S, aimOf(i, o.plan, o.path))); - return o; - } - vec2 want = (orbitGoal(i) - pos) * ORBIT_GAIN; - if (length(want) > CRUISE) want *= CRUISE / length(want); - vel += (want - vel) * (1.0 - exp(-dt * STEER_K)); - pos += vel * dt; - o.bird = vec4(toUv(pos), vel); - return o; - } - - float t = o.plan.y + dt, dir = sign(o.plan.z); - if (o.plan.x < 1.5) { // diving: the release point follows the target - vec2 R = targetValid(o.plan) - ? releasePoint(planSlot(o.plan), dir, max(DIVE_S - t, 0.0), aimOf(i, o.plan, o.path)) - : toScreen(o.path.zw); - o.path.zw = toUv(R); - if (t >= DIVE_S) { o.released = true; o.plan.x = 2.0; t -= DIVE_S; } - else { - vec2 S = toScreen(o.path.xy), C = vec2(R.x - dir * APPROACH, R.y); - float u = t / DIVE_S; - pos = bezier(S, C, R, u); vel = bezierD(S, C, R, u) / DIVE_S; - } - } - if (o.plan.x > 1.5) { // climbing away from the frozen release point - vec2 R = toScreen(o.path.zw); - vec2 C = vec2(R.x + dir * APPROACH, R.y), E = vec2(R.x + 2.0 * dir * APPROACH, CLIMB_TO_Y); - float u = min(t / CLIMB_S, 1.0); - pos = bezier(R, C, E, u); vel = bezierD(R, C, E, u) / CLIMB_S; - if (t >= CLIMB_S) { o.plan = vec4(0.0); t = 0.0; } - } - o.plan.y = min(t, T_CLAMP); - o.bird = vec4(toUv(pos), vel); - return o; +// 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); } -// ── Poop ────────────────────────────────────────────────────────────────────── // 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 @@ -164,38 +70,33 @@ int slotAt(vec2 uv, int prefer) { struct Poop { vec4 poop; vec4 drop; int hitSlot; bool ground; vec2 at; }; -Poop poopStep(int i, float dt) { +// 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, i); o.drop = sDrop(B, i); o.hitSlot = -1; o.ground = false; o.at = vec2(0.0); - Bird b = birdStep(i, dt); - if (b.released) { // dropped this frame, from the release point - bool valid = targetValid(b.plan); - float floorY = valid ? recBox(BLOBS, planSlot(b.plan)).y : 0.02; - o.poop = vec4(b.path.zw, sign(b.plan.z) * releaseSpeed(), 0.0); - o.drop = vec4(float(planSlot(b.plan) + 1), floorY, 0.0, 0.0); + o.poop = sPoop(B, k); o.drop = sDrop(B, k); o.hitSlot = -1; o.ground = false; o.at = vec2(0.0); + if (drops(k)) { + o.poop = vec4(dropX(k), SPAWN_Y, 6.2832 * hash11(iTime * 2.9 + float(k)), 0.0); + o.drop = vec4(1.0, recBox(BLOBS, k).y, 0.0, 0.0); return o; } if (o.drop.x < 0.5) return o; // nothing falling - vec2 pos = toScreen(o.poop.xy), vel = o.poop.zw; - float h = dt / float(FALL_STEPS); + float h = dt / float(FALL_STEPS), age = o.drop.w; for (int s = 0; s < FALL_STEPS; s++) { - vel.y -= GRAVITY * h; - pos += vel * h; - vec2 uv = toUv(pos); + o.poop.y -= FALL_SPEED * h; // UV y is sh + 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 k = slotAt(uv, int(o.drop.x + 0.5) - 1); - if (k >= 0) { o.hitSlot = k; o.at = uv; o.poop = vec4(0.0); o.drop = vec4(0.0); return o; } + 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; } - if (uv.x < -0.3 || uv.x > 1.3) { o.poop = vec4(0.0); o.drop = vec4(0.0); return o; } } - o.poop = vec4(toUv(pos), vel); - o.drop.w = min(o.drop.w + dt, T_CLAMP); + o.drop.w = min(age, T_CLAMP); return o; } @@ -206,8 +107,8 @@ vec4 slotNext(int k, float dt) { 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 (isState(idn.y, S_CONFIRMED)) s.y = max(-tick(-s.y, dt), -10.0); - if (assignNext().y == k) s.y = quant(BOMB_EVERY_S + BOMB_JITTER_S * (2.0 * hash11(iTime * 7.7 + float(k)) - 1.0)); + 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; } @@ -224,7 +125,7 @@ vec4 anchorNext(int k, float dt) { 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 < BIRDS; i++) { + 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 @@ -245,7 +146,7 @@ vec4 splatNext(int k, int j, float dt) { } vec4 groundNext(int j, float dt) { - for (int i = 0; i < BIRDS; i++) { + 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))); @@ -261,22 +162,17 @@ vec4 groundNext(int j, float dt) { void mainImage(out vec4 o, in vec2 fc) { ivec2 c = ivec2(fc); o = vec4(0.0); - if (c.y > 8) return; - ASPECT = iResolution.x / iResolution.y; + 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 <= 4 && c.x < BIRDS) { - Bird b = birdStep(c.x, dt); - o = c.y == 2 ? b.bird : c.y == 3 ? b.plan : b.path; - } - else if (c.y >= 5 && c.y <= 6 && c.x < BIRDS) { + else if (c.y >= 2 && c.y <= 3 && c.x < SLOTS) { Poop p = poopStep(c.x, dt); - o = c.y == 5 ? p.poop : p.drop; + o = c.y == 2 ? p.poop : p.drop; } - else if (c.y == 7 && c.x < SLOTS * SPLATS) o = splatNext(c.x / SPLATS, c.x % SPLATS, dt); - else if (c.y == 8 && c.x < GROUNDS) o = groundNext(c.x, dt); + 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 index c7b8442..77bf1c9 100644 --- a/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/common.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/common.glsl @@ -1,77 +1,60 @@ // 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, i = bird 0–3: -// (k, 0) SLOT id, seconds until this person's next bomb, presence 0–1 (fades splats on loss), 0 +// 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 box top-centre x, y (Body UV) and box height (sh), low-passed over ANCHOR_EMA_S; 0 -// (i, 2) BIRD position x, y (UV), velocity x, y (sh/s) -// (i, 3) PLAN mode (0 orbit, 1 dive, 2 climb), seconds in mode, ±(target slot + 1) (sign = flight -// direction), target id -// (i, 4) PATH dive start x, y (UV), release point x, y (UV; frozen once released) -// (i, 5) POOP position x, y (UV), velocity x, y (sh/s) -// (i, 6) DROP target slot + 1 (0 = nothing falling), floor y (UV), 0, seconds falling -// (k·SPLATS + j, 7) SPLAT centre in the person's frame x, y (box heights from the box top-centre), +// (k, 2) POOP the poop dropped on this person: sway centre x, y (UV), sway phase, 0 +// (k, 3) DROP falling 0/1, floor y (UV), 0, seconds falling +// (k·SPLATS + j, 4) SPLAT centre in the person's frame x, y (box heights from the box top-centre), // age s, seed 0–1 — j = 0 newest -// (j, 8) GROUND x, y (UV), 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, BIRDS = 4, SPLATS = 8, GROUNDS = 8; +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 stochastically, so the expected rate is exact at any fps. -// (Plain f16 adds of 1/30 above 8 s drift by ~6%.) +// 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; -// ── Bombing cadence (per person) ────────────────────────────────────────────── -const float FIRST_BOMB_S = 1.2; // s after a person appears before a bird is sent … +// ── 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 bird every this many seconds … -const float BOMB_JITTER_S = 1.0; // … ± 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 -// ── Flock (idle orbits) ─────────────────────────────────────────────────────── -const float ORBIT_Y = 0.80; // orbit centres' height -const float ORBIT_R = 0.09; // orbit radius -const float ORBIT_W = 0.8; // rad/s -const float ORBIT_DRIFT = 0.04; // how fast orbit centres wander across the sky (noise rate) -const float CRUISE = 0.55; // top speed toward the orbit point, sh/s -const float ORBIT_GAIN = 2.0; // 1/s: speed toward the orbit point per sh of distance -const float STEER_K = 3.0; // 1/s: how fast velocity turns to the desired one - -// ── Dive (a fixed-time swoop to the release point, then a climb away) ───────── -const float DIVE_S = 1.4; -const float CLIMB_S = 1.4; -const float APPROACH = 0.35; // sh: arrives flying level from this far to the side -const float DROP_ABOVE = 0.20; // sh above the target's head to release -const float SKY_MAX = 0.94; // the release point is never higher than this -const float CLIMB_TO_Y = 0.84; -const float LEAD_MAX = 0.5; // sh: the bird leads a moving target by at most this +// ── 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: aim ahead of a moving person by at most this const float AIM_SPREAD = 0.5; // aim within ± this × half the box width of its centre - -// ── Poop ────────────────────────────────────────────────────────────────────── -const float GRAVITY = 1.6; // sh/s² -const int FALL_STEPS = 3; // body hit tests per frame along the fall -const float POOP_R = 0.012; // in-flight drop radius +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 const float ANCHOR_EMA_S = 0.15; -const float SPLAT_R = 0.075; // × box height -const float SPLAT_R_MIN = 0.02; // sh -const float SPLAT_SINK = 0.04; // × box height: the splat centre sits this far below the impact … -const float SINK_SPREAD = 0.10; // … plus up to this, so splats spread down over head and shoulders +const float SPLAT_R = 0.15; // × box height +const float SPLAT_R_MIN = 0.04; // sh +const float SPLAT_SINK = 0.06; // × box height: 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 BIRD_S = 0.045; // bird body half-length -const float FLAP_HZ = 2.2, FLAP_CLIMB_HZ = 4.0; 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; @@ -102,13 +85,10 @@ bool isHere(float s) { return isState(s, S_TENTATIVE) || isState(s, S_CONFIRMED) // ── 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 sBird (sampler2D s, int i) { return texelFetch(s, ivec2(i, 2), 0); } -vec4 sPlan (sampler2D s, int i) { return texelFetch(s, ivec2(i, 3), 0); } -vec4 sPath (sampler2D s, int i) { return texelFetch(s, ivec2(i, 4), 0); } -vec4 sPoop (sampler2D s, int i) { return texelFetch(s, ivec2(i, 5), 0); } -vec4 sDrop (sampler2D s, int i) { return texelFetch(s, ivec2(i, 6), 0); } -vec4 sSplat (sampler2D s, int k, int j) { return texelFetch(s, ivec2(k * SPLATS + j, 7), 0); } -vec4 sGround(sampler2D s, int j) { return texelFetch(s, ivec2(j, 8), 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) @@ -121,10 +101,6 @@ float vnoise(float x) { // value noise, −1…1 return mix(hash11(i), hash11(i + 1.0), f * f * (3.0 - 2.0 * f)) * 2.0 - 1.0; } -// ── Paths ───────────────────────────────────────────────────────────────────── -vec2 bezier(vec2 a, vec2 b, vec2 c, float u) { return mix(mix(a, b, u), mix(b, c, u), u); } -vec2 bezierD(vec2 a, vec2 b, vec2 c, float u) { return 2.0 * mix(b - a, c - b, u); } // d/du - // ── Drawing helpers (lengths in screen heights) ─────────────────────────────── float sdSegment(vec2 p, vec2 a, vec2 b) { vec2 pa = p - a, ba = b - a; @@ -137,27 +113,7 @@ float pen(float d, float w, float px) { } // 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); } -vec2 rot(vec2 p, float a) { float c = cos(a), s = sin(a); return vec2(c * p.x + s * p.y, -s * p.x + c * p.y); } 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); } -// A leaf/wing: a lens from two quadratics (0,0)→(L/2, ±0.42L)→(L,0). Signed distance. -float sdLeaf(vec2 q, float L) { - float t = q.x / L; - if (t <= 0.0) return length(q); - if (t >= 1.0) return length(q - vec2(L, 0.0)); - float half_ = 0.84 * L * t * (1.0 - t), slope = 0.84 * (1.0 - 2.0 * t); - return (abs(q.y) - half_) / sqrt(1.0 + slope * slope); -} -float sdTriangle(vec2 p, vec2 a, vec2 b, vec2 c) { - vec2 e0 = b - a, e1 = c - b, e2 = a - c, v0 = p - a, v1 = p - b, v2 = p - c; - vec2 pq0 = v0 - e0 * clamp(dot(v0, e0) / dot(e0, e0), 0.0, 1.0); - vec2 pq1 = v1 - e1 * clamp(dot(v1, e1) / dot(e1, e1), 0.0, 1.0); - vec2 pq2 = v2 - e2 * clamp(dot(v2, e2) / dot(e2, e2), 0.0, 1.0); - float s = sign(e0.x * e2.y - e0.y * e2.x); - vec2 d = min(min(vec2(dot(pq0, pq0), s * (v0.x * e0.y - v0.y * e0.x)), - vec2(dot(pq1, pq1), s * (v1.x * e1.y - v1.y * e1.x))), - vec2(dot(pq2, pq2), s * (v2.x * e2.y - v2.y * e2.x))); - return -sqrt(d.x) * sign(d.y); -} diff --git a/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/image.glsl b/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/image.glsl index ac8766b..0aa67cc 100644 --- a/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/image.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/image.glsl @@ -1,5 +1,5 @@ // 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 → birds. +// 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 @@ -93,7 +93,7 @@ void drawSplats(inout vec3 col, vec2 p, vec2 uv) { } void drawGround(inout vec3 col, vec2 p) { - const float R = 0.05; + 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; @@ -119,59 +119,25 @@ void drawPeople(inout vec3 col, vec2 p, vec2 uv) { over(col, INK, edge); } -// ── Poops in flight ─────────────────────────────────────────────────────────── +// ── Falling poops ───────────────────────────────────────────────────────────── void drawPoops(inout vec3 col, vec2 p) { - for (int i = 0; i < BIRDS; i++) { - if (sDrop(B, i).x < 0.5) continue; - vec4 pp = sPoop(B, i); - vec2 q = p - uvToP(pp.xy); - if (dot(q, q) > 16.0 * POOP_R * POOP_R) continue; - vec2 fwd = normalize(pp.zw + vec2(0.0, -1e-3)); // the round end leads, the point trails + 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_LIGHT, PX)); + over(col, INK, pen(abs(d), INK_W, PX)); } } -// ── Birds ───────────────────────────────────────────────────────────────────── -const vec3 BIRD_COL[4] = vec3[4](SLATE, VIOLET, DEEP, RUST); - -void drawBird(inout vec3 col, vec2 p, int i) { - vec4 bd = sBird(B, i); - vec2 q = (p - uvToP(bd.xy)) / BIRD_S; - if (dot(q, q) > 9.0) return; - vec2 v = bd.zw; - if (v.x < 0.0) q.x = -q.x; // face the way it flies - q = rot(q, clamp(atan(v.y, abs(v.x) + 1e-3), -0.9, 0.9) * 0.7); - float px = PX / BIRD_S, ink = INK_W / BIRD_S; - vec3 c = BIRD_COL[i % 4]; - - float dK = sdTriangle(q, vec2(1.02, 0.50), vec2(1.55, 0.36), vec2(1.02, 0.20)); // beak - over(col, ORANGE, fill(dK, px)); - over(col, INK, pen(abs(dK), ink, px)); - - float dB = (length(q / vec2(1.0, 0.62)) - 1.0) * 0.62; // body - float dH = length(q - vec2(0.72, 0.38)) - 0.42; // head - float dT = sdTriangle(q, vec2(-0.75, 0.05), vec2(-1.45, 0.38), vec2(-1.38, -0.22)); // tail - float d = smin(smin(dB, dH, 0.15), dT, 0.08); - over(col, c, fill(d, px)); - over(col, INK, pen(abs(d), ink, px)); - - float dE = length(q - vec2(0.84, 0.48)) - 0.13; // eye - over(col, WARM_WHITE, fill(dE, px)); - over(col, INK, fill(length(q - vec2(0.88, 0.48)) - 0.06, px)); - over(col, INK, pen(abs(dE), ink * 0.7, px)); - - float mode = sPlan(B, i).x; // wing: lazy, tucked in the dive, fast climbing - float hz = mode > 1.5 ? FLAP_CLIMB_HZ : FLAP_HZ; - float flap = sin(iTime * 6.2832 * hz + float(i) * 1.7); - float ang = mode > 0.5 && mode < 1.5 ? 2.85 + 0.08 * flap : 2.2 + 0.75 * flap; - float dW = sdLeaf(rot(q - vec2(-0.05, 0.2), ang), 1.25); - over(col, mix(c, INK, 0.25), fill(dW, px)); - over(col, INK, pen(abs(dW), ink, px)); -} - void mainImage(out vec4 fragColor, in vec2 fragCoord) { ASPECT = iResolution.x / iResolution.y; PX = 1.0 / iResolution.y; @@ -184,6 +150,5 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) { drawPeople(col, p, uv); drawSplats(col, p, uv); drawPoops(col, p); - for (int i = 0; i < BIRDS; i++) drawBird(col, p, i); fragColor = vec4(col, 1.0); } diff --git a/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/setup.json b/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/setup.json index a2a4131..c7807f1 100644 --- a/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/setup.json +++ b/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/setup.json @@ -1,5 +1,5 @@ { - "_comment": "Rainbow poop: a flock dive-bombs visitors with rainbow splats that stick. Spec: docs/superpowers/specs/2026-11-08-rainbow-poop-design.md", + "_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"], diff --git a/ShowControl/FundingCAPTCHA/test_rainbow_poop.py b/ShowControl/FundingCAPTCHA/test_rainbow_poop.py index 146b00a..da7a6da 100644 --- a/ShowControl/FundingCAPTCHA/test_rainbow_poop.py +++ b/ShowControl/FundingCAPTCHA/test_rainbow_poop.py @@ -36,7 +36,9 @@ def const(name: str) -> float: 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") -DIVE_S = const("DIVE_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") @@ -84,9 +86,9 @@ def run(self, masks, frames_per_camera=3, watch=True): def _watch(self): b = self.b() for k in range(8): - if b[7, k * 8, 2] == 0: # age 0: landed this frame + if b[4, k * 8, 2] == 0: # age 0: landed this frame self.hits.append((self.t, k)) - if b[8, 0, 2] == 0: + if b[5, 0, 2] == 0: self.misses.append(self.t) def b(self) -> np.ndarray: @@ -96,11 +98,11 @@ def b(self) -> np.ndarray: 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 bird(self, i): return self.b()[2, i] # x, y, vx, vy - def plan(self, i): return self.b()[3, i] # mode, t, ±(slot+1), id - def drop(self, i): return self.b()[6, i] # slot+1, floor, -, age - def splats(self, k): return self.b()[7, k * 8:(k + 1) * 8] # x, y, age, seed - def grounds(self): return self.b()[8, :8] + 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) @@ -137,22 +139,14 @@ def flickering(n, x=27, seed=0): return out -# ── The flock ───────────────────────────────────────────────────────────────── +# ── The empty room ──────────────────────────────────────────────────────────── -def test_empty_room_birds_circle_in_the_sky(ctx): +def test_empty_room_drops_nothing(ctx): rig = Rig(ctx) - ys = [] for _ in range(40): # 12 s rig.run([EMPTY] * 3) - ys += [rig.bird(i)[1] for i in range(4)] - assert min(ys) > 0.6 # never come down - assert all(rig.plan(i)[0] == 0 for i in range(4)) + assert not rig.falling() assert not rig.hits and not rig.misses - xs = [rig.bird(i)[0] for i in range(4)] - assert max(xs) - min(xs) > 0.1 # spread out, not one clump - a = [rig.bird(i)[:2].copy() for i in range(4)] - rig.run([EMPTY] * 5) - assert all(np.linalg.norm(rig.bird(i)[:2] - a[i]) > 0.01 for i in range(4)) # moving # ── Bombing a standing person ───────────────────────────────────────────────── @@ -162,8 +156,8 @@ def test_standing_person_is_hit_soon_after_appearing(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, the dive and the fall - assert first[0] < 0.4 + FIRST_BOMB_S + FIRST_JITTER + DIVE_S + 0.8 + # 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 @@ -175,7 +169,7 @@ def test_splat_lands_on_the_head_in_the_persons_frame(ctx): x, y = s[0, 0], s[0, 1] half_w = 10 / 64 * (128 / 72) / 2 / (28 / 40) # half the body width, in box heights assert abs(x) < half_w * 1.2 # over the body - assert -0.2 < y < 0.0 # just below the box top + assert -0.25 < y < 0.0 # just below the box top def test_cadence_is_about_every_five_seconds(ctx): @@ -189,12 +183,32 @@ def test_cadence_is_about_every_five_seconds(ctx): assert not rig.misses -def test_one_bird_per_person_at_a_time(ctx): +def test_poop_falls_slowly_from_the_top(ctx): rig = Rig(ctx) - for _ in range(80): + seen, ys = 0.0, [] + for _ in range(60): rig.run(flickering(1)) - diving = [rig.plan(i) for i in range(4) if rig.plan(i)[0] == 1] - assert len(diving) <= 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): @@ -267,21 +281,20 @@ def test_new_person_in_a_reused_slot_starts_clean(ctx): # ── Dodging ─────────────────────────────────────────────────────────────────── def dodge(rig): - """Pace at 2 px per camera frame until a poop is falling, then turn round for 1.5 s.""" - x, v = 4, 2 - for _ in range(200): - x += v - if x > 46: v = -2 - if x < 4: v = 2 + """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 any(rig.drop(i)[0] for i in range(4)): + if rig.falling(): break else: - pytest.fail("never released") - v = -v - for _ in range(15): - x = int(np.clip(x + v, 0, 54)) + 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): @@ -319,28 +332,41 @@ def test_splats_drawn_only_on_the_silhouette(ctx): 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); birds can be anywhere above it. + # 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 below the birds + assert beside == 0 # no splat colour off the body -def test_empty_room_is_paper_birds_and_clouds(ctx): +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.5)] # the lower half is empty paper + 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[int(BIG[1] * 0.6):]).sum() > 20 # birds up there + 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 diff --git a/docs/FundingCAPTCHA.md b/docs/FundingCAPTCHA.md index 126c989..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`), `rainbow_poop` ([design](superpowers/specs/2026-11-08-rainbow-poop-design.md); a flock of birds dive-bombs each visitor about every 5 s, and the rainbow splats stick to their silhouette, drip and fade over 20 s — dodge after the drop and the poop lands on the floor; tunables, including `BOMB_EVERY_S`, `SPLAT_LIFE_S` and `DROP_ABOVE`, 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 index 5ddc9c6..37df0be 100644 --- a/docs/superpowers/specs/2026-11-08-rainbow-poop-design.md +++ b/docs/superpowers/specs/2026-11-08-rainbow-poop-design.md @@ -1,6 +1,6 @@ # Rainbow Poop — shader player Setup -A new FundingCAPTCHA shader player Setup (ADR-0021), from issue #161. 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. +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 @@ -67,3 +67,10 @@ Headless GL tests in the style of `test_hunter_bird.py`, with the real `BodyBlob - 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. From fb50c54f5bb2f1950707d277328baf8d7d165b63 Mon Sep 17 00:00:00 2001 From: "Impy[bot]" <314283863+ii-impy@users.noreply.github.com> Date: Sun, 8 Nov 2026 12:55:40 -0500 Subject: [PATCH 5/5] Rainbow poop: homing poops; splats anchored to the silhouette, not the box On site, poops often missed and splats slid around people as they moved. - Homing: a falling poop drifts toward its person's centre of mass, at up to HOMING_MAX (0.18 sh/s). Aim spread is now +/- 0.05 of their size, not half the box width. Fidgeting people are always hit (was 81% on a person-shaped test silhouette); walking or turning away still dodges. - Anchor: splats ride on the silhouette's centre of mass and size (SIZE_K x sqrt(area)), measured from this frame's Body inside the tracker's box, instead of the eased box top-centre and height. Raised arms, arms out and strides barely move them (0.055 -> < 0.035 UV in the test), and they no longer lag the silhouette. - Tests: splats stay put with arms up/out while walking; a fidgeting person-shaped silhouette is always hit. Both fail on the previous commit. - Still 30.2 fps (the cap) on the show machine with --mock-camera. Co-Authored-By: Claude Opus 5.5 --- .../shaders/rainbow_poop/buffer_b.glsl | 52 +++++++++++---- .../shaders/rainbow_poop/common.glsl | 29 +++++--- .../FundingCAPTCHA/test_rainbow_poop.py | 66 +++++++++++++++++-- .../specs/2026-11-08-rainbow-poop-design.md | 2 + 4 files changed, 120 insertions(+), 29 deletions(-) diff --git a/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/buffer_b.glsl b/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/buffer_b.glsl index 58df2b5..f8f1b7b 100644 --- a/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/buffer_b.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/buffer_b.glsl @@ -37,15 +37,14 @@ bool drops(int k) { && sSlot(B, k).y <= 0.0 && sDrop(B, k).x < 0.5; } -// Where to drop it (UV x): over their head, ahead of them by the time it takes to fall there. -float dropX(int k) { - vec4 box = recBox(BLOBS, k); +// 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 cx = toScreen(vec2((box.x + box.z) * 0.5, 0.0)).x; - float halfw = 0.5 * toScreen(vec2(box.z - box.x, 0.0)).x; float lead = clamp(toScreen(recMot(BLOBS, k).zw).x * fall, -LEAD_MAX, LEAD_MAX); - float aim = 2.0 * hash11(recIdn(BLOBS, k).x * 3.7 + iTime * 1.3) - 1.0; - return clamp(toUv(vec2(cx + aim * AIM_SPREAD * halfw + lead, 0.0)).x, 0.02, 0.98); // always seen falling + 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. @@ -75,15 +74,25 @@ 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)) { - o.poop = vec4(dropX(k), SPAWN_Y, 6.2832 * hash11(iTime * 2.9 + float(k)), 0.0); - o.drop = vec4(1.0, recBox(BLOBS, k).y, 0.0, 0.0); + 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; @@ -113,13 +122,28 @@ vec4 slotNext(int k, float dt) { return s; } -// Box top-centre (UV) and height (sh), low-passed so splats ride smoothly. -vec4 anchorNext(int k, float dt) { +// 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 want = vec4((box.x + box.z) * 0.5, box.w, box.w - box.y, 0.0); - if (isFresh(k)) return want; + 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), want, 1.0 - exp(-dt / ANCHOR_EMA_S)); + return mix(sAnchor(B, k), measure(k), 1.0 - exp(-dt / ANCHOR_EMA_S)); } vec4 splatNext(int k, int j, float dt) { diff --git a/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/common.glsl b/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/common.glsl index 77bf1c9..29c41e0 100644 --- a/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/common.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/rainbow_poop/common.glsl @@ -3,10 +3,11 @@ // // 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 box top-centre x, y (Body UV) and box height (sh), low-passed over ANCHOR_EMA_S; 0 -// (k, 2) POOP the poop dropped on this person: sway centre x, y (UV), sway phase, 0 -// (k, 3) DROP falling 0/1, floor y (UV), 0, seconds falling -// (k·SPLATS + j, 4) SPLAT centre in the person's frame x, y (box heights from the box top-centre), +// (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 @@ -31,8 +32,12 @@ const float SPAWN_Y = 1.04; // UV y the poop starts at (just off th 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: aim ahead of a moving person by at most this -const float AIM_SPREAD = 0.5; // aim within ± this × half the box width of its centre +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 @@ -40,10 +45,16 @@ const float POOP_R = 0.022; // falling drop radius 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 -const float ANCHOR_EMA_S = 0.15; -const float SPLAT_R = 0.15; // × box height +// 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; // × box height: the splat centre sits this far below the impact … +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 diff --git a/ShowControl/FundingCAPTCHA/test_rainbow_poop.py b/ShowControl/FundingCAPTCHA/test_rainbow_poop.py index da7a6da..418e26d 100644 --- a/ShowControl/FundingCAPTCHA/test_rainbow_poop.py +++ b/ShowControl/FundingCAPTCHA/test_rainbow_poop.py @@ -161,15 +161,21 @@ def test_standing_person_is_hit_soon_after_appearing(ctx): assert first[1] == 0 and not rig.misses -def test_splat_lands_on_the_head_in_the_persons_frame(ctx): +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)) + 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 - x, y = s[0, 0], s[0, 1] - half_w = 10 / 64 * (128 / 72) / 2 / (28 / 40) # half the body width, in box heights - assert abs(x) < half_w * 1.2 # over the body - assert -0.25 < y < 0.0 # just below the box top + 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): @@ -250,6 +256,54 @@ def test_splats_fade_after_their_life(ctx): 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)) diff --git a/docs/superpowers/specs/2026-11-08-rainbow-poop-design.md b/docs/superpowers/specs/2026-11-08-rainbow-poop-design.md index 37df0be..88e5c98 100644 --- a/docs/superpowers/specs/2026-11-08-rainbow-poop-design.md +++ b/docs/superpowers/specs/2026-11-08-rainbow-poop-design.md @@ -74,3 +74,5 @@ Unverified until run with the real camera: how the cadence and dodge timing feel - **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.