From e4e0aad6ecc8b781434eafb8fcb0405622716a02 Mon Sep 17 00:00:00 2001 From: ii-impy <314283863+ii-impy@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:53:36 -0500 Subject: [PATCH 01/22] Watering can: design spec and approved mockup (#163) Co-Authored-By: Claude Opus 5.5 --- .../specs/2026-11-06-watering-can-design.md | 125 ++++++++++++++++++ .../specs/2026-11-06-watering-can-mockup.html | 96 ++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 docs/superpowers/specs/2026-11-06-watering-can-design.md create mode 100644 docs/superpowers/specs/2026-11-06-watering-can-mockup.html diff --git a/docs/superpowers/specs/2026-11-06-watering-can-design.md b/docs/superpowers/specs/2026-11-06-watering-can-design.md new file mode 100644 index 0000000..01ec02d --- /dev/null +++ b/docs/superpowers/specs/2026-11-06-watering-can-design.md @@ -0,0 +1,125 @@ +# Watering Can — shader player Setup + +A new FundingCAPTCHA shader player Setup (ADR-0021), from issue #163. People are watering cans: when someone stays in one spot, vines sprout from the lowest points of their silhouette and climb, then flowers bloom at the tips. Walking away from the spot rewinds the growth. Waving and waggling don't rewind: the vines ride along on the body, so a visitor can waggle their own garden around. + +## Intent + +The reward for staying put. A visitor who stops in one place watches themselves slowly turn into a garden. The silhouette itself is never drawn; the person is revealed only by what grows on them, so by full bloom their shape reads clearly as "that's me, made of flowers". Once they're growing, moving their arms and body is play, not punishment: the vines and flowers wave around with them. Only walking costs growth. Even then it's gentle: the growth plays backwards like a reversed film, and nothing vanishes or pops. + +## Goals + +- **Growth while planted.** Sprouts start at the lowest point of each part of the silhouette and climb upward. They become leafy vines, and flowers open at the growing tips near the end. Growth stays inside the silhouette. +- **Everything grows upward from its lowest point.** Legs grow from the feet, a hanging arm from the hand, a raised arm from its lowest point, as if sprouting from the ground. Nothing grows outward from the shoulder. +- **Full bloom holds** for as long as the person stays in their spot. +- **Waggling is free.** Arm waves, flailing, swaying, bending and dancing in place don't rewind anything, and growth keeps going. The plants are attached to the body and move with it. +- **Walking rewinds.** When the base of a person's silhouette (their feet) moves to a new spot, their whole growth runs backwards: flowers close, vines shrink back down, colour drains. This covers steps in any direction, including straight toward or away from the camera. When they plant themselves again, growth resumes from wherever the rewind got to, not from bare. +- **Timing.** Standing in one spot fills a person to full bloom in about **20 s**. Rewind runs at the same rate by default. The two rates are separate tunables. +- **Colour ripens with maturity.** The whole plant on a person ripens together: muted while young, saturating as it grows, sprout to vine to flower. Full bloom is the big, vivid payoff, and at full bloom the whole person is vivid. +- **The sun follows the garden.** The empty room is plain paper with a muted crayon-drawn sun in one corner. The sun gets brighter and grows rays in step with how much is growing and blooming across everyone in frame, and dims back as growth rewinds or people leave. +- Runs on the show Pi 5 in the existing shader player, and can be prototyped in Shadertoy with the mouse body painter and blob painter like every other Setup. + +## Non-goals + +- No uploaded art, textures or video (the player doesn't support them). Everything is procedural. +- No limb or skeleton detection. +- No sound. +- Nearness (`.g`) isn't used for drawing. *(My call.)* + +## Anti-goals + +- **A planted person losing growth because of the camera.** This would ruin the piece. That includes depth-edge flicker, dropout holes, grain, tracker jitter in the person's box, and the tracker briefly losing them or giving them a new ID. +- **Waggling rewinding.** If waving your arms costs you growth, the fun idea is lost. +- **Drawing the silhouette.** No outline, no fill and no ghost of the person. Before anything grows, nothing marks where they stand. +- **Popping.** Rewinding must look like growth playing backwards. Plants never vanish or cut out. +- **A hard cut-out edge.** Growth shouldn't look like a stencil of the body. +- **Realistic rendering** (gradients, glow, photographic plants). It must read as flat, line-drawn storybook. + +## Reference mockup + +[`2026-11-06-watering-can-mockup.html`](2026-11-06-watering-can-mockup.html) is a runnable 2D-canvas sketch with a growth slider and Play/Rewind buttons. **The "Dense" panel is the approved look**: density, whole-plant ripening, no silhouette drawn, stems inside, leaves and flowers overhanging the edge. It doesn't show the sun, movement or real camera input. It's a sketch for look and feel, not a spec for code structure, and the vines in the real thing should look better than its stand-ins. + +## The look (decided) + +- **Style:** the same line-drawn storybook style as the other Setups (see `endless_sunset`): black ink lines, about 2–3 px at 1080p, with flat colour fills from the shared palette in `endless_sunset/common.glsl`. +- **Background:** plain paper `#f6f1e4`. Nothing else: no ground line or horizon. +- **Sun:** muted and crayon-drawn, in one corner. "Crayon" means a waxy, slightly broken, hand-drawn stroke and fill rather than a clean ink outline. When the garden is empty it's dull and has no rays. As total growth rises it brightens toward the palette yellow `#fff200` and grows rays. *(Which corner is my call: top right. The ray style is left to the builder, in the same crayon hand.)* +- **Vines:** dense. The approved mockup has about 30 vines across one standing adult, so the person's shape fills in clearly by full bloom. Spacing should be set in screen units. + - Stems are ink-edged or dark green lines that stay **inside** the silhouette. + - Leaves are flat green with ink outlines. Like the flowers, they may **hang past the edge**, so the outline looks soft and overgrown. + - Vines sway slightly, so a still, fully bloomed person doesn't look frozen. *(Sway is my call.)* +- **Flowers:** they open at vine tips in the last part of the growth and grow from buds to open blooms. They use a mix of palette colours with yellow centres (pink `#e8957f`, orange `#e8862f`, violet `#6b5b95`, rust `#bc4c26`), so a full bloom looks like a mixed bouquet. *(The colour mix is my call.)* + +## Behaviour (decided) + +- **One growth level per person.** + - Each person has a single growth level, 0–1, which rises while they stay planted and falls while they walk. + - The drawing derives from that level and the person's *current* silhouette every frame. In each vertical run of their body pixels (a column of the silhouette between a bottom edge and a top edge), the vine reaches from the run's bottom up a fraction of the run equal to the growth level, as in the mockup's `tip = g * span`. + - So when an arm moves, its vines move with it. A raised arm grows from its new lowest point. + - Colour saturation and flower opening follow the same level. +- **Planted vs. walking:** decided by whether the person's **base** moves, not their whole outline. + - Use the per-person tracker: the `"blobs"` records from ADR-0022, with stable slots, boxes, velocity and lifecycle. + - The base means the feet, for example the box bottom together with the horizontal position of the lowest body pixels in the box. It does not mean the centroid or the whole box, because an outstretched or flailing arm moves those without the person walking. + - A person is walking when their base has moved more than a tunable distance from their planted spot, judged over a short window, so jitter and weight-shifting don't count. A step toward or away from the camera moves the base up or down the image, so it counts too. + - All thresholds are tunable. +- **Rewind:** while walking, the growth level falls at the rewind rate. When the person is planted again, their new spot becomes the planted spot and the level rises from wherever it is. +- **Several people:** each tracked person has their own level, so one person walking doesn't rewind a still neighbour. + - Where the tracker merges two overlapping people into one blob, they share one level for as long as they're merged. + - When they separate, each one starts from the merged level. *(My call.)* +- **Tracker hiccups don't cost growth.** If a person's track is briefly lost and comes back, possibly in a different slot or with a new ID, they keep their growth. That covers occlusion, a split or merge, or flicker. A new track that appears where a recently lost one was standing inherits its level. *(The approach is my call. The requirement is the anti-goal above.)* +- **Leaving:** a person's plants leave with them. Walking out of frame rewinds on the way. If their track is lost while they still have growth, for example when they step out quickly, their plants rewind away over a short tunable time, about 2 s by default, rather than vanishing. *(The default is my call.)* +- **Sun level:** follows the total growth across everyone, smoothed. One person in full bloom brings the sun to full brightness, and more people don't push it past full. *(The saturation point is my call. It's tunable.)* +- **Run length:** `duration_s` 120, like the other Setups. *(My call.)* + +## Constraints + +- **Shader player rules** (see `ShowControl/FundingCAPTCHA/shaders/README.md`): + - Procedural only. + - Read the Body with `texture()` and normalised UVs. Blob records are the exception: read them with `texelFetch`. + - Buffers are 16-bit float on the Pi. Store positions and levels as 0–1. + - `HW_PERFORMANCE` is 0 on the Pi. + - The Pi renders at half resolution. + - The camera's square view is stretched to the screen. +- **Blob limits:** at most eight people are tracked at once (slots). The player only runs tracking while a Setup binds `"blobs"`. With all of Buffers B–D in use, the Setup can't be prototyped with blobs in Shadertoy, so leave one buffer free for the blob painter. +- **Real camera input:** + - The Body and tracker run at about 10 fps, and the shader at about 30. + - Growth timing must be in seconds, independent of frame rate. + - The silhouette flickers. Per-run vine heights derived from it every frame must not visibly jitter, so smooth or stabilise them as needed. +- **Tunables:** every value lives as a named constant at the top of `common.glsl`, so it can be retuned on site with **R** reload. That includes: + - growth time, rewind rate and leave time + - walking distance and window + - inheritance radius and time for lost tracks + - vine spacing, sway, and leaf and flower sizes + - when flowers open + - saturation range + - sun saturation point and position +- **Performance:** + - Dense vines are drawn per pixel. Keep the per-pixel cost bounded, for example by evaluating only nearby vines. + - Aim for the same smoothness as the existing Setups on the show Pi 5. *(My call.)* + - The plan must include a frame-rate check on the Pi. If that couldn't happen, it must say so. + +## Testing (decided) + +Headless GL tests against fake camera Bodies and blob records, in the style of `test_endless_sunset.py` and `test_hunter_bird.py`, covering at least: +- **Reaching full bloom:** + - a planted person with realistic edge flicker, dropout holes and box jitter reaches full bloom in about 20 s and never loses growth + - waving an arm or flailing while planted doesn't rewind, and the vines follow the arm +- **Rewinding:** + - walking sideways rewinds + - stepping toward or away from the camera rewinds + - after walking stops, growth resumes from where it was, not from zero +- **Several people:** a still neighbour isn't rewound by someone walking next to them +- **Tracker hiccups:** + - a brief track loss or ID change for a planted person keeps their growth + - a lost track with growth rewinds away without popping +- **The sun:** its level tracks total growth +- **Timing:** it holds at Pi scale, with a 10 fps Body and tracker under a 30 fps render + +The walking threshold, and how often the real tracker drops or re-IDs a still person, are unverified until the Setup has been run with the camera (`--camera`, or `--mock-camera` as a first approximation). The plan must include that tuning pass. If it couldn't happen, the plan must say so. + +## Left to the builder + +- Buffer layout and how per-person state is stored, as long as the behaviour above holds. +- How the base (feet) position is measured from the Body and the box. +- How vines are generated procedurally: placement, wobble, leaf rhythm, flower shapes, bud-to-bloom animation, and how vines follow a moving silhouette without shimmering. +- The crayon texture technique for the sun, and the ray style. +- Exact line weights and default constants beyond those stated. diff --git a/docs/superpowers/specs/2026-11-06-watering-can-mockup.html b/docs/superpowers/specs/2026-11-06-watering-can-mockup.html new file mode 100644 index 0000000..337ed7a --- /dev/null +++ b/docs/superpowers/specs/2026-11-06-watering-can-mockup.html @@ -0,0 +1,96 @@ + +Watering Can mockup + + +

Watering Can: the plants reveal the person

+

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

+ +
+ + + +
+ +
+
+
Sparse: about 16 vines
+
+
+
+
Dense: about 30 vines
+
+
+
+ + + + From fa1cef23564d5f3a430cb89d3563befa3d45b350 Mon Sep 17 00:00:00 2001 From: ii-impy <314283863+ii-impy@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:57:21 -0500 Subject: [PATCH 02/22] Watering can: build plan (#163) Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-11-06-watering-can.md | 424 ++++++++++++++++++ 1 file changed, 424 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-06-watering-can.md diff --git a/docs/superpowers/plans/2026-11-06-watering-can.md b/docs/superpowers/plans/2026-11-06-watering-can.md new file mode 100644 index 0000000..eb52bc9 --- /dev/null +++ b/docs/superpowers/plans/2026-11-06-watering-can.md @@ -0,0 +1,424 @@ +# Watering Can Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A new shader player Setup, `watering_can`. When a person stays in one spot, dense vines climb their silhouette from its lowest points and bloom over about 20 s. Waggling is free and the plants ride on the body. Walking rewinds the growth. A crayon sun brightens with the garden. + +**Architecture:** A Shadertoy-style Setup folder with three tabs. Buffer D stays free for the blob painter. + +- **Buffer B, "Body memory"** (per pixel). It holds: + - stabilised presence + - the run extents: the bottom and top of the vertical run of body this pixel sits in + - which tracked person owns the pixel + - a "hold" that lets a lost person's plants rewind away rather than pop +- **Buffer C, "Garden"** (a small texel grid). It holds: + - per slot: id, growth level, planted spot, smoothed base (feet), walk timer + - per slot: a ghost record of the last person lost from that slot, so a returning or split track inherits growth + - the sun level +- **Image** draws paper, the crayon sun, and the vines, leaves and flowers. All of them derive from B's run extents and C's growth levels every frame. + +Tests drive the Setup headlessly with moderngl. Blob records come from the real `BodyBlobs` tracker running on synthetic masks with a fake clock, like `test_hunter_bird.py`. + +**Tech Stack:** GLSL (Shadertoy dialect, wrapped by `shadertoy.py`), Python 3, pytest, moderngl, numpy. + +**Spec:** `docs/superpowers/specs/2026-11-06-watering-can-design.md`, plus the mockup `docs/superpowers/specs/2026-11-06-watering-can-mockup.html`. Use the "Dense" panel, and scrub its slider. Also read the blob contract in `docs/adr/0022-shader-player-blob-records.md` and `shaders/README.md`. Read all of these before starting. `shaders/hunter_bird/` is the reference for per-slot state, fresh-id handling and the test rig. `shaders/endless_sunset/buffer_b.glsl` is the reference for soft presence and hole filling. + +## Global Constraints + +- **Shader player rules** (`ShowControl/FundingCAPTCHA/shaders/README.md`): + - Read the Body (Buffer A) only with `texture()` and UVs. + - Read blob records only with `texelFetch(ch, ivec2(slot, row), 0)`. + - Buffers are RGBA16F. Store positions as UV, levels as 0–1, and time only as bounded "seconds since" counters clamped at `T_CLAMP`. Never store absolute `iTime`. + - `HW_PERFORMANCE` is 0, and there are no textures. +- **The `blobs` contract is read-only.** If you need more from the tracker, stop and raise it. +- **Shadertoy:** every tab must paste unchanged, with `body_painter.glsl` in Buffer A and `blob_painter.glsl` in Buffer D. In Shadertoy, B and C read D's previous frame. Behaviour must not depend on that one-frame difference. +- **Tunables:** every threshold, rate, size and timing is a named constant at the top of `common.glsl`, so it can be retuned on site with **R**. +- **Units:** sizes are in screen heights. Mockup value N px → `N / 360.0`. Records and B are in UV. `toScreen(uv) = uv * iResolution.xy / iResolution.y`. +- **Timing:** everything is in seconds via `iTimeDelta`. Growth must not depend on render rate. +- **Palette (exact):** + - paper `#f6f1e4` + - ink `#111111` + - yellow `#fff200` + - orange `#e8862f` + - pink `#e8957f` + - violet `#6b5b95` + - rust `#bc4c26` + - Stem and leaf greens are free choices, ripening from near-grey to saturated. +- **Never draw the silhouette.** No outline, fill or wash. Where nothing has grown, the pixel is paper. +- **Repo rules:** never write a show, artist or album name into any file. Never commit anything under `ShowControl/FundingCAPTCHA/images/private/`. +- **Branch:** `claude/watering-can` (the spec is committed there). +- **Tests:** + - Run from `ShowControl/FundingCAPTCHA/`, e.g. `python3 -m pytest test_watering_can.py -v`. + - In a fresh container, `apt-get install -y libegl1 libgl1-mesa-dri` provides a headless GL context. + - Baseline: `make test` is green, except `test_compile_error_reports_tab_line` on NVIDIA drivers. That failure already exists and is unrelated. + +## Review Focus + +1. **The tracker splits a still person.** For example, a raised arm breaks off as its own blob, or depth dropout cuts the torso. The new track would start bare, leaving part of the body unplanted. Expected: a new track overlapping, or next to, a planted person inherits their growth. Pinned by `test_split_off_part_inherits` (Task 2). +2. **Feet cut off by the bottom of the frame**, when someone stands close to the camera. The box bottom pins to the edge. Expected: standing still there doesn't rewind, and walking sideways still rewinds. Pinned by `test_feet_off_frame` (Task 2). +3. **Flicker shimmering the vines.** Run extents recomputed every frame from a flickering mask make vine tips and leaves jump. Expected: a planted, bloomed person's image is stable frame to frame. Pinned by `test_bloom_is_stable_under_flicker` (Task 3). +4. **Hours in 16-bit buffers.** Every counter stays bounded and finite. Pinned by `test_state_stays_bounded_when_idle` (Task 2). +5. **Frame rate on the Pi.** Per-pixel cost is bounded by early-outs: + - B marches only inside the body. + - Image looks at `VINE_NEIGHBOURS` vines and skips pixels with no body nearby. + - If fps falls short, cut in this order: leaf count, flower petal count, `VINE_NEIGHBOURS`, `RUN_STEPS`. + - Checked in Task 5. + +--- + +## File Structure + +All under `ShowControl/FundingCAPTCHA/`: + +| File | Responsibility | +|---|---| +| `shaders/watering_can/setup.json` | Channel bindings, `duration_s: 120` | +| `shaders/watering_can/common.glsl` | Constants, palette, record and state accessors, `toScreen`, hash and noise | +| `shaders/watering_can/buffer_b.glsl` | Body memory (per pixel) | +| `shaders/watering_can/buffer_c.glsl` | Garden state (texel grid) | +| `shaders/watering_can/image.glsl` | Paper, sun, plants | +| `shaders/setups.json` | Add `"watering_can"` | +| `test_watering_can.py` | GL behaviour tests | +| `docs/FundingCAPTCHA.md` | List `watering_can` in "Setups in rotation", with a link to the spec | + +**`setup.json`:** +```json +{ + "_comment": "Watering can: stay put and vines grow up your silhouette and bloom; walking rewinds. Spec: docs/superpowers/specs/2026-11-06-watering-can-design.md", + "duration_s": 120, + "channels": { + "buffer_b": ["A", "B", "blobs"], + "buffer_c": ["B", "C", "blobs"], + "image": ["B", "C", "blobs"] + } +} +``` + +**Buffer B, per pixel:** + +| `.r` | `.g` | `.b` | `.a` | +|---|---|---|---| +| presence 0–1 (soft, hole-filled, eased) | run bottom y (UV) | run top y (UV) | `owner + 0.9 * hold` | + +- `owner` = slot + 1, or 0 for none. +- `hold` = 0–1, and is 1 while the body is present. +- Outside any run, `.g` and `.b` are 0. + +**Buffer C, texel `(x, y)`, with `k` = slot 0–7.** Every texel that isn't listed writes 0. + +| Texel | `.x` | `.y` | `.z` | `.w` | +|---|---|---|---|---| +| `(k, 0)` PERSON | id (0 = none) | growth 0–1 | planted spot x | planted spot y (UV) | +| `(k, 1)` BASE | base x | base y (UV, smoothed) | seconds since last walking step (≤ `T_CLAMP`) | — | +| `(k, 2)` GHOST | spot x | spot y | growth at loss | seconds since loss (`T_CLAMP` = no ghost) | +| `(0, 3)` SUN | sun level 0–1 (eased) | raw total growth | — | — | + +--- + +### Task 1: Skeleton, Common, Body memory (Buffer B) + +**Files:** +- Create: `shaders/watering_can/setup.json` (above), `common.glsl`, `buffer_b.glsl` +- Create: `buffer_c.glsl` (stub: writes 0) +- Create: `image.glsl` (stub: paper) +- Modify: `shaders/setups.json` → `["ripples", "endless_sunset", "hunter_bird", "watering_can"]` +- Create: `test_watering_can.py` + +**Interfaces produced in `common.glsl`.** Use these exact names. Values are starting points, and Task 5 tunes them. +```glsl +const int SLOTS = 8; +const float T_CLAMP = 99.0; +// Body memory +const float BODY_BLUR_TEXELS = 1.5, HOLE_FILL_TEXELS = 2.5; // as endless_sunset +const float PRESENCE_EASE_S = 0.12; // eases presence so 1-px edge flicker averages out +const float BODY_ON = 0.5; // presence above this is body +const int RUN_STEPS = 64; // max march taps each way +const float RUN_STEP_H = 1.0/96.0; // march step, screen heights +const float RUN_EASE_S = 0.15; // eases run extents; snaps when they jump more than RUN_SNAP +const float RUN_SNAP = 0.08; // screen heights +const float LEAVE_S = 2.0; // a lost person's plants rewind away over this +// Garden +const float GROW_S = 20.0, REWIND_S = 20.0; // empty → full bloom, full bloom → empty +const float BASE_BAND = 0.04; // screen heights above the box bottom that count as feet +const int BASE_TAPS = 16; +const float BASE_EASE_S = 0.3; +const float WALK_DIST = 0.04; // screen heights the base must move from the spot to count as a step +const float WALK_HOLD_S = 0.4; // walking lasts this long after the last step +const float INHERIT_S = 3.0, INHERIT_DIST = 0.15; // ghost lifetime; how near a new track must appear (screen heights) +const float SUN_FULL = 1.0, SUN_EASE_S = 1.5; // total growth that fully lights the sun +// Plants (Task 3) +const float VINE_SPACING = 4.5/360.0; // ~30 vines across an adult, as the mockup's Dense panel +const int VINE_NEIGHBOURS = 2; // vines evaluated each side of a pixel +const float VINE_WOBBLE = 5.0/360.0, SWAY_AMP = 2.0/360.0, SWAY_HZ = 0.25; +const float STEM_W = 2.2/360.0, INK_W = 1.2/360.0; +const float LEAF_STEP = 18.0/360.0, LEAF_LEN = 9.0/360.0, LEAF_WID = 3.8/360.0; +const float FLOWER_FROM = 0.6, FLOWER_FULL = 0.9; // growth at which buds start, and are fully open +const float FLOWER_R = 8.0/360.0, BUD_R = 2.5/360.0; +const float SAT_MIN = 0.1, SAT_MAX = 0.95; +// Sun (Task 4) +const vec2 SUN_POS = vec2(-0.16, 0.84); // x from the right edge (screen heights), y as a fraction of height +const float SUN_R = 0.09, RAY_LEN = 0.07; +``` +Also in `common.glsl`: +- palette constants +- `S_EMPTY … S_LOST` and `isState` +- `recBox`, `recMot`, `recIdn` (as in `hunter_bird`) +- `cPerson(s,k)`, `cBase(s,k)`, `cGhost(s,k)`, `cSun(s)` +- `int bOwner(vec4 b)` and `float bHold(vec4 b)`, which decode B's `.a` +- `toScreen` (a `#define`, as in `hunter_bird`) +- `hash12` and `noise` + +**B rules (per pixel):** +- Presence: compute endless_sunset's soft and hole-filled presence from A, then ease it towards that target with `PRESENCE_EASE_S`. +- Owner: the slot whose record is not empty and whose box contains the pixel. If several boxes contain it, pick the nearest box centre. +- Run extents: only if presence ≥ `BODY_ON`. March B's own previous-frame `.r` down and up from this pixel in `RUN_STEP_H` steps, at most `RUN_STEPS` each way, and stop at the first tap below `BODY_ON`. + - The march uses previous-frame presence, so the extents lag by one frame. That's fine. + - The extents are eased with `RUN_EASE_S`, except that they snap when the new value differs by more than `RUN_SNAP`. +- Hold, when the body is absent: + - If the previous owner's record is empty or lost: keep the previous extents and owner, and decrease hold by `dt / LEAVE_S`. + - Otherwise (the owner is still live and simply moved off this pixel): set hold to 0 and the owner to 0. + - This is what stops walkers leaving trails. + +- [ ] **Step 1: Check `git status` is clean on `claude/watering-can`.** +- [ ] **Step 2: Write the rig and the failing tests.** + - Copy `ctx`, `Rig.run`, `rect`, `EMPTY` and `both` from `test_hunter_bird.py`. + - Use `SETUP = …/"watering_can"`, `SIZE = (128, 72)`, `BODY = (64, 48)`, `BLOBS = BlobConfig(min_blob_pixels=20)`. + - `Rig(ctx, size=SIZE, dt=DT)` takes the render `dt` as a parameter. + - Add `Rig.buf(name)`, which returns float32 `(h, w, 4)` with row 0 at the GL bottom. + - Add `Rig.person(k) = buf("buffer_c")[0, k]`, `Rig.base(k) = [1, k]`, `Rig.ghost(k) = [2, k]`, `Rig.sun() = [3, 0]`, and `Rig.image()`. + - Add these mask builders. Masks are bool `(48, 64)` with row 0 at the top: + +```python +def person(x=26, feet=44, arm="down", lift=None): + """A figure 12 px wide, head to feet. x is its left edge; feet is the row below its feet. + arm: "down" (hanging at the sides), "out" (right arm horizontal), "up" (right arm overhead). + lift: "L" or "R" raises that foot 2 px (a weight shift).""" + +def flickering(n, seed=0, **kw): + """person(**kw) with its 1-px border toggled randomly, plus 3 random 1-px dropout holes inside, each camera frame.""" + +def walking(n, step=1, x=26, bounce=(4, 48), **kw): # person starting at x, moving sideways step px per camera frame, bouncing within bounce +def owner(texel): # python mirror of bOwner: int(floor(texel[3])) +def slot_of_live(rig): # the one slot whose PERSON id != 0; asserts there is exactly one +def stepping_toward(n, **kw): # feet row increases 1 px per camera frame from 36 to 47 (moving down the image) +``` + +Tests for this task: +```python +def b_at(rig, col, row): # B texel under Body pixel (col, row), row 0 = top +def test_presence_ignores_edge_flicker(ctx): + rig = Rig(ctx); rig.run(flickering(30)) + # one Body px inside the border, over 10 more camera frames: + # presence at the torso centre stays > 0.95, and a pixel 3 px outside the figure stays < 0.05 +def test_run_extents_span_leg_to_shoulder(ctx): + rig = Rig(ctx); rig.run([person()] * 10) + g, b = b_at(rig, 28, 40)[1:3] # in a leg + # g ≈ UV of the feet (1 - 44/48) within 1/48; b ≈ UV of the shoulder top within 2/48 +def test_raised_arm_runs_start_at_its_lowest_point(ctx): + rig = Rig(ctx); rig.run([person(arm="up")] * 10) + # a pixel in the raised arm, above the head row: its run bottom is the arm's lowest row, not the feet +def test_owner_is_record_slot(ctx): + rig = Rig(ctx); rig.run([person()] * 10) + assert owner(b_at(rig, 30, 20)) == 1 and owner(b_at(rig, 2, 2)) == 0 +def test_walker_leaves_no_hold_trail(ctx): + rig = Rig(ctx); rig.run(walking(20)) + # every B texel with presence < 0.05 has hold == 0 +def test_lost_person_holds_then_fades(ctx): + rig = Rig(ctx); rig.run([person()] * 20); rig.run([EMPTY] * 15) # 1.5 s: lost after 8 misses + h1 = hold at the torso centre + rig.run([EMPTY] * 25) + # 0 < h1 < 1, and the hold is 0 after LEAVE_S more; the run extents were kept while hold > 0 +``` + +- [ ] **Step 3: Run the tests.** `python3 -m pytest test_watering_can.py -v` should FAIL (stub B), and `test_shadertoy.py::test_listed_setups_compile[watering_can]` should PASS. +- [ ] **Step 4: Implement `common.glsl` and `buffer_b.glsl`** following the rules above. +- [ ] **Step 5: Run the tests.** Expected: PASS. +- [ ] **Step 6: Commit** "Watering can: Setup skeleton and Body memory". + +### Task 2: Garden state (Buffer C) + +**Files:** `buffer_c.glsl` and `test_watering_can.py`. + +**Interfaces:** +- **Consumes:** B as in Task 1, through `bOwner`, `bHold` and `.r`; the record accessors. +- **Produces:** the C layout above, for Tasks 3 and 4. + +**C rules (per frame; each texel reads the previous frame of every other texel):** +- **Fresh slot.** The record's id differs from PERSON.id, or the record is empty. + - PERSON = `(record id or 0, inherit, base, base)`, where `base` = record box bottom-centre (x centre, y0). + - `inherit` = the max of: + - the growth of every GHOST with `since < INHERIT_S` whose spot is within `INHERIT_DIST` (screen units) of `base`; + - the growth of every other live PERSON whose record box overlaps this box, or whose box is within `INHERIT_DIST`. + - The second case covers splits and merges. + - Reset BASE: walk timer = `T_CLAMP`. +- **GHOST for slot `k`:** when PERSON.id ≠ 0 and the slot turns fresh or lost this frame, write `(PERSON spot, PERSON growth, 0)`. Otherwise `since += dt` (clamped). +- **BASE:** + - Sample B `.r` at `BASE_TAPS` x positions across the box, at `y0 + BASE_BAND/2`. + - Take the presence-weighted mean x. If the weight is 0, use the box centre. + - Base target = that x and `y0`. Ease toward it with `BASE_EASE_S`. +- **Walking.** If `length(toScreen(base - spot)) > WALK_DIST`: set spot = base and reset the walk timer to 0. Otherwise the timer advances by `dt`. `walking` = timer < `WALK_HOLD_S`. +- **Growth.** Only for confirmed records: + - walking: `-= dt / REWIND_S` + - otherwise: `+= dt / GROW_S` + - clamp to 0–1 + - Tentative and coasting records hold their level. +- **SUN.** + - raw = Σ live growth + Σ ghost growth × `max(0, 1 - since / LEAVE_S)` + - level eases toward `clamp(raw / SUN_FULL, 0, 1)` with `SUN_EASE_S` +- `iFrame == 0` resets everything (ghost `since` = `T_CLAMP`). + +- [ ] **Step 1: Write the failing tests.** +```python +def test_planted_flickering_person_blooms_in_20s(ctx): # spec: ~20 s, never loses growth + rig = Rig(ctx); levels = [] + for _ in range(22): rig.run(flickering(10)); levels.append(rig.person(0)[1]) + assert abs(levels[9] - 0.5) < 0.06 and levels[-1] > 0.98 + assert all(b >= a - 1e-3 for a, b in zip(levels, levels[1:])) +def test_arm_waving_does_not_rewind(ctx): + rig = Rig(ctx); rig.run([person(arm=("down", "out", "up")[k // 5 % 3]) for k in range(100)]) + assert abs(rig.person(0)[1] - 0.5) < 0.06 +def test_weight_shift_does_not_rewind(ctx): + rig = Rig(ctx); rig.run([person(lift=(None, "L", None, "R")[k // 3 % 4]) for k in range(100)]) + assert abs(rig.person(0)[1] - 0.5) < 0.06 +def test_walking_sideways_rewinds(ctx): + rig = Rig(ctx); rig.run([person()] * 100); g0 = rig.person(0)[1] + rig.run(walking(30, x=26)) + assert abs((g0 - rig.person(0)[1]) - 3.0 / 20.0) < 0.05 +def test_stepping_toward_camera_rewinds(ctx): + rig = Rig(ctx); rig.run([person(feet=36)] * 100); g0 = rig.person(0)[1] + rig.run(stepping_toward(11)); assert rig.person(0)[1] < g0 - 0.03 +def test_growth_resumes_after_walking(ctx): + # grow 10 s, walk 3 s, then stand 3 s: the level rises from the post-walk value by ~3/20, never resetting to 0 +def test_neighbour_walking_does_not_rewind_still_person(ctx): + rig = Rig(ctx); rig.run(both([person(x=46)] * 100, walking(100, x=4, bounce=(4, 20)))) + # the still person's slot has growth ≈ 0.5 ± 0.06 +def test_brief_loss_keeps_growth(ctx): + rig = Rig(ctx); rig.run([person()] * 100); rig.run([EMPTY] * 20); rig.run([person()] * 5) + assert rig.person(slot_of_live(rig))[1] > 0.45 # whichever slot and id it came back in +def test_merge_then_split_both_inherit(ctx): + # two planted people 10 s apart, slide together until their masks overlap (one blob), then apart: + # each resulting track's growth ≥ the merged level - 0.05 +def test_split_off_part_inherits(ctx): # Review Focus 1 + # a planted person (10 s); then their right arm is detached by a 1-px gap for 2 s (a second blob): + # the new track's growth ≥ the person's growth - 0.05 +def test_feet_off_frame(ctx): # Review Focus 2 + # person(feet=48, ...) cut by the bottom edge, flickering for 10 s: growth ≈ 0.5; then walking 3 s: it drops by ~0.15 +def test_new_person_far_from_ghosts_starts_bare(ctx): + # A planted 10 s at x=4 leaves; B appears at x=46 within INHERIT_S: B's growth < 0.05 after its first second +def test_sun_tracks_growth(ctx): + # empty 3 s: sun < 0.02; one person 22 s: sun > 0.95; leaves: sun < 0.05 within LEAVE_S + 3·SUN_EASE_S +def test_growth_independent_of_render_rate(ctx): + a = Rig(ctx, dt=1/30); a.run([person()] * 100, frames_per_camera=3) + b = Rig(ctx, dt=1/60); b.run([person()] * 100, frames_per_camera=6) + assert abs(a.person(0)[1] - b.person(0)[1]) < 0.02 +def test_state_stays_bounded_when_idle(ctx): # Review Focus 4 + # as hunter_bird: two renders with dt = 200 s between empty runs; every C and B value is finite, and |C| ≤ 99 +``` +- [ ] **Step 2: Run** `python3 -m pytest test_watering_can.py -v`. Expected: the Task 2 tests FAIL. +- [ ] **Step 3: Implement `buffer_c.glsl`** following the rules above. +- [ ] **Step 4: Run the tests.** Expected: all PASS. If a test only passes after changing a constant, change it in `common.glsl` and note why in a comment. +- [ ] **Step 5: Commit** "Watering can: growth, walking, inheritance and sun level". + +### Task 3: Image — plants + +**Files:** `image.glsl` and `test_watering_can.py`. + +**Interfaces:** +- **Consumes:** B (run extents, owner, hold), C PERSON growth and SUN. +- **Produces:** the final image, minus the sun. + +**Drawing rules:** +- **Vines.** Vine `i` sits at screen x `i * VINE_SPACING`, plus a wobble `VINE_WOBBLE * sin(height * f + hash(i))` and a sway that grows with height up the run (`SWAY_AMP`, `SWAY_HZ`). + - For each pixel, evaluate the vines within `VINE_NEIGHBOURS` either side. + - For each vine, sample B at (vine x, pixel y), and also at pixel y ± `FLOWER_R`, so that overhanging leaves and flowers find their run. +- **Reach.** For a run with bottom `g`, top `b`, owner growth `G` and hold `h`: the tip is at `g + G * h * (b - g)`. + - The stem is drawn from `g` to the tip, only where B presence ≥ `BODY_ON` or hold > 0. Stems stay inside. + - Leaves are drawn at heights `g + j * LEAF_STEP` below the tip, on alternating sides. They may extend outside the body. + - At the tip there's a bud below `FLOWER_FROM`. Between `FLOWER_FROM` and `FLOWER_FULL` it opens into a 5-petal flower of radius up to `FLOWER_R`. The flower may extend outside the body. + - Flower colour is chosen per vine by hash from pink, orange, violet and rust, with a yellow centre. +- **Colour.** Saturation = `mix(SAT_MIN, SAT_MAX, G)`, uniform for the whole person. It applies to the greens and to the flowers. + - The look is flat fills with `INK_W` ink outlines on leaves, petals and buds, as in the mockup. +- **Early-out.** If B presence and hold are both 0 at all sampled points, output paper. + +- [ ] **Step 1: Write the failing tests.** +```python +PAPER = np.array([246, 241, 228]) +def drawn(img, region=None): # bool mask of pixels differing from paper by > 12 in any channel +def test_nothing_drawn_before_growth(ctx): # anti-goal: never draw the silhouette + rig = Rig(ctx); rig.run([person()] * 5) + assert drawn(rig.image()).mean() < 0.002 # the sun corner is excluded via region +def test_bloom_fills_the_silhouette(ctx): + rig = Rig(ctx); rig.run([person()] * 220) + # ≥ 70% of the screen pixels inside the figure are drawn, and drawn pixels outside the figure lie within FLOWER_R + LEAF_LEN of it +def test_growth_climbs_from_the_feet(ctx): + rig = Rig(ctx); rig.run([person()] * 60) # growth ≈ 0.3 + # drawn fraction in the figure's bottom third > 0.5; in the top third < 0.1 +def test_colour_ripens(ctx): + # mean HSV saturation of drawn pixels at growth 0.3 < at growth 1.0 - 0.25 +def test_flowers_only_late(ctx): + # pixels near the flower hues (pink/orange/violet/rust ± 30) are absent at growth 0.4 and present at growth 1.0 +def test_vines_follow_the_arm(ctx): + rig = Rig(ctx); rig.run([person(arm="up")] * 220) + # drawn fraction inside the raised-arm rect > 0.5; then arm="down" for 3 camera frames: the arm rect drawn fraction < 0.1 +def test_bloom_is_stable_under_flicker(ctx): # Review Focus 3 + rig = Rig(ctx); rig.run(flickering(220)) + # over 10 more camera frames, the mean |Δ| between consecutive images inside the figure is < 6 (of 255) +def test_lost_person_plants_rewind_without_popping(ctx): + rig = Rig(ctx); rig.run([person()] * 220); counts = [] + for _ in range(40): rig.run([EMPTY]); counts.append(drawn(rig.image()).sum()) + # the count reaches 0 by 1.5 s (lost) + LEAVE_S + 0.5 s; no single camera frame loses more than 30% of the previous count +``` +- [ ] **Step 2: Run the tests.** Expected: FAIL. +- [ ] **Step 3: Implement `image.glsl`.** +- [ ] **Step 4: Run the tests.** Expected: all PASS. +- [ ] **Step 5: Commit** "Watering can: vines, leaves and flowers". + +### Task 4: Image — the crayon sun + +**Files:** `image.glsl` and `test_watering_can.py`. + +**Rules:** +- **Position.** The sun's centre is at `(iResolution.x/iResolution.y + SUN_POS.x, SUN_POS.y)` in screen units, which is the top right. +- **Disc.** Radius `SUN_R`, with a crayon look: the edge is broken by noise, and the fill is a grain or hatch of noise that leaves paper showing through. +- **Muted vs. lit.** + - At sun level 0, the colour is a desaturated, paper-mixed yellow and there are no rays. + - At level 1, the colour is `#fff200` with ~12 crayon rays of length `RAY_LEN`. + - Ray length and colour follow the level. +- **Order.** Plants draw over the sun. + +- [ ] **Step 1: Write the failing tests.** +```python +def sun_patch(img): # the pixels within SUN_R + RAY_LEN of the sun centre +def test_sun_is_muted_when_empty(ctx): + # empty 3 s: the sun disc is drawn (not paper), its mean saturation < 0.35, and nothing is drawn in the ray ring +def test_sun_brightens_and_grows_rays_with_bloom(ctx): + # one person 22 s: disc saturation > 0.7, ray-ring drawn fraction > 0.15 +``` +- [ ] **Step 2: Run the tests.** Expected: FAIL. **Step 3:** Implement. **Step 4:** Run. Expected: PASS. +- [ ] **Step 5: Commit** "Watering can: crayon sun". + +### Task 5: Look and feel, Shadertoy check, frame rate, camera tuning, docs + +**Files:** `common.glsl` (constants only) and `docs/FundingCAPTCHA.md`. + +- [ ] **Step 1: Check by eye with the mouse.** Run `python3 shader_app.py --test-input --windowed --setup watering_can`. + - Hold still and watch it grow to full bloom in ~20 s. + - Wiggle while holding the button: nothing should rewind. + - Drag across the screen: it should rewind. + - Release: the plants should rewind away. + - Compare against the mockup's Dense panel, and tune constants only, with **R**. +- [ ] **Step 2: Mock camera.** Run `python3 shader_app.py --mock-camera --windowed --setup watering_can` for 2 minutes and note the fps lines. If fps is short of the other Setups, cut in the Review Focus 5 order. +- [ ] **Step 3: Shadertoy.** + - Paste `body_painter.glsl` into A and `blob_painter.glsl` into D (channels: D, Keyboard). + - Paste `common.glsl`, `buffer_b.glsl` (channels: A, B, D), `buffer_c.glsl` (channels: B, C, D) and `image.glsl` (channels: B, C, D). + - Confirm it compiles and behaves the same. If no browser is available, say so in the hand-off. +- [ ] **Step 4: Docs.** In `docs/FundingCAPTCHA.md` → "Setups in rotation", add `watering_can` with a link to the design and a note that its tunables (including `WALK_DIST`, `GROW_S` and `REWIND_S`) are at the top of its `common.glsl`. +- [ ] **Step 5: Full suite.** Run `make test` from the repo root. It must be green, apart from the known NVIDIA-only failure. +- [ ] **Step 6: Commit** "Watering can: tuning pass and docs". The hand-off must state: + - the constants that changed; + - the fps seen with `--mock-camera`, and on the Pi 5 if it was run there; + - that the walking threshold, flicker immunity and re-ID inheritance have **not** been verified with the real camera, unless they were. + - That verification needs `--camera` on site: + - a visitor stands 25 s and reaches full bloom without any rewind; + - waving the arms doesn't rewind; + - one sideways step visibly rewinds. From be331aaabf6ba6a63d4e694b58a4f42a5d994fda Mon Sep 17 00:00:00 2001 From: ii-impy <314283863+ii-impy@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:18:20 -0500 Subject: [PATCH 03/22] Watering can: Setup skeleton and Body memory Co-Authored-By: Claude Opus 5.5 --- .../FundingCAPTCHA/shaders/setups.json | 2 +- .../shaders/watering_can/buffer_b.glsl | 77 ++++++ .../shaders/watering_can/buffer_c.glsl | 4 + .../shaders/watering_can/common.glsl | 88 +++++++ .../shaders/watering_can/image.glsl | 4 + .../shaders/watering_can/setup.json | 9 + .../FundingCAPTCHA/test_watering_can.py | 226 ++++++++++++++++++ 7 files changed, 409 insertions(+), 1 deletion(-) create mode 100644 ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_b.glsl create mode 100644 ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl create mode 100644 ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl create mode 100644 ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl create mode 100644 ShowControl/FundingCAPTCHA/shaders/watering_can/setup.json create mode 100644 ShowControl/FundingCAPTCHA/test_watering_can.py diff --git a/ShowControl/FundingCAPTCHA/shaders/setups.json b/ShowControl/FundingCAPTCHA/shaders/setups.json index 0dba8b2..b75314f 100644 --- a/ShowControl/FundingCAPTCHA/shaders/setups.json +++ b/ShowControl/FundingCAPTCHA/shaders/setups.json @@ -1 +1 @@ -["ripples", "endless_sunset", "hunter_bird"] +["ripples", "endless_sunset", "hunter_bird", "watering_can"] diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_b.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_b.glsl new file mode 100644 index 0000000..c20f7cd --- /dev/null +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_b.glsl @@ -0,0 +1,77 @@ +// Buffer B — Body memory. iChannel0 = Buffer A (Body), iChannel1 = Buffer B (itself), iChannel2 = blobs +// .r presence · .g run bottom y (UV) · .b run top y (UV) · .a owner + 0.9 * hold +// A run is the vertical stretch of body through a pixel; a raised arm's run starts at its own lowest point. + +float softPresence(vec2 uv) { + vec2 t = BODY_BLUR_TEXELS / max(iChannelResolution[0].xy, vec2(1.0)); + float s = 0.0; + for (int y = -1; y <= 1; y++) + for (int x = -1; x <= 1; x++) + s += texture(iChannel0, uv + vec2(x, y) * t).r; + s /= 9.0; + // Fill depth-dropout holes: a pixel ringed by body counts as body. + vec2 h = HOLE_FILL_TEXELS / max(iChannelResolution[0].xy, vec2(1.0)); + float ring = 0.0; + for (int k = 0; k < 8; k++) { + float a = 0.7853982 * float(k); + ring += texture(iChannel0, uv + vec2(cos(a), sin(a)) * h).r; + } + return max(s, smoothstep(0.45, 0.7, ring / 8.0)); +} + +// The slot whose live-or-not (non-empty) box contains uv; nearest centre wins. 0 = none. +int ownerAt(vec2 uv) { + int best = 0; + float bestD = 1e9; + for (int k = 0; k < SLOTS; k++) { + if (isState(recIdn(iChannel2, k).y, S_EMPTY)) continue; + vec4 b = recBox(iChannel2, k); + if (uv.x < b.x || uv.x > b.z || uv.y < b.y || uv.y > b.w) continue; + float d = length(uv - 0.5 * (b.xy + b.zw)); + if (d < bestD) { bestD = d; best = k + 1; } + } + return best; +} + +// How far the run extends from uv in direction dir (±1, in UV y) while previous presence stays on body. +float march(vec2 uv, float dir) { + float y = uv.y; + for (int i = 1; i <= RUN_STEPS; i++) { + float ny = uv.y + dir * float(i) * RUN_STEP_H; + if (ny < 0.0 || ny > 1.0) break; + if (texture(iChannel1, vec2(uv.x, ny)).r < BODY_ON) break; + y = ny; + } + return y; +} + +void mainImage(out vec4 o, in vec2 fc) { + vec2 uv = fc / iResolution.xy; + vec4 prev = iFrame == 0 ? vec4(0.0) : texelFetch(iChannel1, ivec2(fc), 0); + + float target = softPresence(uv); + float pres = iFrame == 0 ? target : mix(prev.r, target, 1.0 - exp(-iTimeDelta / PRESENCE_EASE_S)); + + float g = 0.0, b = 0.0, a = 0.0; + if (pres >= BODY_ON) { + int own = ownerAt(uv); + float lo = march(uv, -1.0), hi = march(uv, 1.0); + bool had = prev.g > 0.0 || prev.b > 0.0; + float ease = 1.0 - exp(-iTimeDelta / RUN_EASE_S); + g = (!had || abs(lo - prev.g) > RUN_SNAP) ? lo : mix(prev.g, lo, ease); + b = (!had || abs(hi - prev.b) > RUN_SNAP) ? hi : mix(prev.b, hi, ease); + a = float(own) + 0.9; + } else { + int po = bOwner(prev); + float ph = bHold(prev); + if (po > 0 && ph > 0.0) { + float st = recIdn(iChannel2, po - 1).y; + // A missing body coasts first: keep its plants until the track is lost, then rewind them away. + if (!isState(st, S_TENTATIVE) && !isState(st, S_CONFIRMED)) { + float h = ph - iTimeDelta / LEAVE_S; + if (h > 0.0) { g = prev.g; b = prev.b; a = float(po) + 0.9 * h; } + } + } + } + o = vec4(pres, g, b, a); +} diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl new file mode 100644 index 0000000..04451db --- /dev/null +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl @@ -0,0 +1,4 @@ +// Buffer C — Garden state (stub; Task 2). iChannel0 = Buffer B, iChannel1 = Buffer C, iChannel2 = blobs +void mainImage(out vec4 o, in vec2 fc) { + o = vec4(0.0); +} diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl new file mode 100644 index 0000000..639213a --- /dev/null +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl @@ -0,0 +1,88 @@ +// Common — every tunable for the watering can lives here (edit, save, press R). +// Lengths are in screen heights unless noted; mockup px / 360 = screen heights. +// Spec: docs/superpowers/specs/2026-11-06-watering-can-design.md +// +// Body memory (Buffer B, per pixel): +// .r presence 0–1 (soft, hole-filled, eased) .g run bottom y (UV) .b run top y (UV) +// .a owner + 0.9 * hold (owner = slot + 1, 0 none; hold 0–1, 1 while the body is present) +// Garden state (Buffer C), texel (x, y), k = slot 0–7: +// (k, 0) PERSON id (0 none), growth 0–1, planted spot x, y (UV) +// (k, 1) BASE base x, base y (UV, smoothed), seconds since last walking step, — +// (k, 2) GHOST spot x, y, growth at loss, seconds since loss (T_CLAMP = no ghost) +// (0, 3) SUN sun level 0–1 (eased), raw total growth, —, — + +const int SLOTS = 8; +const float T_CLAMP = 99.0; // every "seconds since" counter clamps here +// Body memory +const float BODY_BLUR_TEXELS = 1.5, HOLE_FILL_TEXELS = 2.5; // as endless_sunset +const float PRESENCE_EASE_S = 0.12; // eases presence so 1-px edge flicker averages out +const float BODY_ON = 0.5; // presence above this is body +const int RUN_STEPS = 64; // max march taps each way +const float RUN_STEP_H = 1.0/64.0; // march step, screen heights: RUN_STEPS of them cover a full screen height +const float RUN_EASE_S = 0.15; // eases run extents; snaps when they jump more than RUN_SNAP +const float RUN_SNAP = 0.08; // screen heights +const float LEAVE_S = 2.0; // a lost person's plants rewind away over this +// Garden +const float GROW_S = 20.0, REWIND_S = 20.0; // empty → full bloom, full bloom → empty +const float BASE_BAND = 0.04; // screen heights above the box bottom that count as feet +const int BASE_TAPS = 16; +const float BASE_EASE_S = 0.3; +const float WALK_DIST = 0.04; // screen heights the base must move from the spot to count as a step +const float WALK_HOLD_S = 0.4; // walking lasts this long after the last step +const float INHERIT_S = 3.0, INHERIT_DIST = 0.15; // ghost lifetime; how near a new track must appear (screen heights) +const float SUN_FULL = 1.0, SUN_EASE_S = 1.5; // total growth that fully lights the sun +// Plants +const float VINE_SPACING = 4.5/360.0; // ~30 vines across an adult, as the mockup's Dense panel +const int VINE_NEIGHBOURS = 2; // vines evaluated each side of a pixel +const float VINE_WOBBLE = 5.0/360.0, SWAY_AMP = 2.0/360.0, SWAY_HZ = 0.25; +const float STEM_W = 2.2/360.0, INK_W = 1.2/360.0; +const float LEAF_STEP = 18.0/360.0, LEAF_LEN = 9.0/360.0, LEAF_WID = 3.8/360.0; +const float FLOWER_FROM = 0.6, FLOWER_FULL = 0.9; // growth at which buds start, and are fully open +const float FLOWER_R = 8.0/360.0, BUD_R = 2.5/360.0; +const float SAT_MIN = 0.1, SAT_MAX = 0.95; +// Sun +const vec2 SUN_POS = vec2(-0.16, 0.84); // x from the right edge (screen heights), y as a fraction of height +const float SUN_R = 0.09, RAY_LEN = 0.07; + +// ── Palette ─────────────────────────────────────────────────────────────────── +const vec3 PAPER = vec3(0.965, 0.945, 0.894); // #f6f1e4 +const vec3 INK = vec3(0.067); // #111111 +const vec3 YELLOW = vec3(1.000, 0.949, 0.000); // #fff200 +const vec3 ORANGE = vec3(0.910, 0.525, 0.184); // #e8862f +const vec3 PINK = vec3(0.910, 0.584, 0.498); // #e8957f +const vec3 VIOLET = vec3(0.420, 0.357, 0.584); // #6b5b95 +const vec3 RUST = vec3(0.737, 0.298, 0.149); // #bc4c26 + +// ── Blob records (ADR-0022) ─────────────────────────────────────────────────── +const float S_EMPTY = 0., S_TENTATIVE = 1., S_CONFIRMED = 2., S_COASTING = 3., S_LOST = 4.; + +vec4 recBox(sampler2D b, int k) { return texelFetch(b, ivec2(k, 0), 0); } // x0, y0, x1, y1 Body UV +vec4 recMot(sampler2D b, int k) { return texelFetch(b, ivec2(k, 1), 0); } // cx, cy, vx, vy UV, UV/s +vec4 recIdn(sampler2D b, int k) { return texelFetch(b, ivec2(k, 2), 0); } // id, state, age_s, near +bool isState(float s, float want) { return abs(s - want) < 0.5; } + +// ── Garden state accessors (Buffer C) ───────────────────────────────────────── +vec4 cPerson(sampler2D c, int k) { return texelFetch(c, ivec2(k, 0), 0); } +vec4 cBase (sampler2D c, int k) { return texelFetch(c, ivec2(k, 1), 0); } +vec4 cGhost (sampler2D c, int k) { return texelFetch(c, ivec2(k, 2), 0); } +vec4 cSun (sampler2D c) { return texelFetch(c, ivec2(0, 3), 0); } + +// ── Body memory accessors (Buffer B .a = owner + 0.9 * hold) ────────────────── +int bOwner(vec4 b) { return int(floor(b.a)); } +float bHold (vec4 b) { return clamp(fract(b.a) / 0.9, 0.0, 1.0); } + +// UV → screen-height units. A macro: Shadertoy's Common tab can't see iResolution. +#define toScreen(uv) ((uv) * iResolution.xy / iResolution.y) + +// ── Noise ───────────────────────────────────────────────────────────────────── +float hash12(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} +float noise(vec2 p) { // value noise, 0…1 + vec2 i = floor(p), f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix(mix(hash12(i), hash12(i + vec2(1, 0)), f.x), + mix(hash12(i + vec2(0, 1)), hash12(i + vec2(1, 1)), f.x), f.y); +} diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl new file mode 100644 index 0000000..f6478bc --- /dev/null +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl @@ -0,0 +1,4 @@ +// Image — paper (stub; Tasks 3–4). iChannel0 = Buffer B, iChannel1 = Buffer C, iChannel2 = blobs +void mainImage(out vec4 o, in vec2 fc) { + o = vec4(PAPER, 1.0); +} diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/setup.json b/ShowControl/FundingCAPTCHA/shaders/watering_can/setup.json new file mode 100644 index 0000000..d24ee5f --- /dev/null +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/setup.json @@ -0,0 +1,9 @@ +{ + "_comment": "Watering can: stay put and vines grow up your silhouette and bloom; walking rewinds. Spec: docs/superpowers/specs/2026-11-06-watering-can-design.md", + "duration_s": 120, + "channels": { + "buffer_b": ["A", "B", "blobs"], + "buffer_c": ["B", "C", "blobs"], + "image": ["B", "C", "blobs"] + } +} diff --git a/ShowControl/FundingCAPTCHA/test_watering_can.py b/ShowControl/FundingCAPTCHA/test_watering_can.py new file mode 100644 index 0000000..af5d39e --- /dev/null +++ b/ShowControl/FundingCAPTCHA/test_watering_can.py @@ -0,0 +1,226 @@ +"""Behaviour tests for the watering_can Setup (shaders/watering_can/). + +Records come from the real BodyBlobs tracker run on synthetic masks with a +fake clock (camera 10 Hz, render at DT), so record noise is realistic. +GL tests need a headless context (EGL / llvmpipe) and skip without one. +""" +from __future__ import annotations + +import os +from pathlib import Path + +import numpy as np +import pytest + +from body_blobs import BlobConfig, BodyBlobs +from shadertoy import Renderer, load_setup, pack_body + +SETUP = Path(__file__).parent / "shaders" / "watering_can" +SIZE = (128, 72) # render +BODY = (64, 48) # 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 + + +@pytest.fixture(scope="module") +def ctx(): + moderngl = pytest.importorskip("moderngl") + for kw in ({"backend": "egl"}, {}): + try: + c = moderngl.create_standalone_context(**kw) + break + except Exception: + continue + else: + if os.environ.get("CI"): + pytest.fail("no headless OpenGL context in CI — GL tests would silently skip") + pytest.skip("no headless OpenGL context") + yield c + c.release() + + +class Rig: + """Renderer + the real BodyBlobs on a fake clock: camera at 10 Hz, render at `dt`.""" + + def __init__(self, ctx, size=SIZE, dt=DT): + self.size, self.dt = size, dt + self.r = Renderer(ctx, size, body_mode="camera") + self.r.load(load_setup(SETUP)) + self.blobs = BodyBlobs(BLOBS) + self.t = 0.0 + + def run(self, masks, frames_per_camera=3): + for m in masks: # one mask per camera frame, row 0 = top + fg = np.where(m, 2000, 0).astype(np.uint16) + body = pack_body(fg, {"x": 0, "y": 0, "w": 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(self.dt, (0, 0, 0, 0)) + self.t += self.dt + + def buf(self, name) -> np.ndarray: + """A buffer as float32 (h, w, 4), row 0 = the GL bottom.""" + t = self.r._buffers[name].read_tex + return np.frombuffer(t.read(), dtype=np.float16).astype(np.float32).reshape(t.size[1], t.size[0], 4) + + def person(self, k): return self.buf("buffer_c")[0, k] # id, growth, spot x, y + def base(self, k): return self.buf("buffer_c")[1, k] # base x, y, since step + def ghost(self, k): return self.buf("buffer_c")[2, k] # spot x, y, growth, since loss + def sun(self): return self.buf("buffer_c")[3, 0] # level, raw total + + def image(self) -> np.ndarray: + """The Image as uint8 (h, w, 4), row 0 = bottom.""" + return np.frombuffer(self.r.output.read(components=4), np.uint8).reshape(self.size[1], self.size[0], 4) + + def release(self): + self.r.release() + + +def rect(x0, y0, x1, y1): + m = np.zeros(BODY[::-1], dtype=bool) + m[max(y0, 0):y1, max(x0, 0):x1] = True + return m + + +EMPTY = np.zeros(BODY[::-1], dtype=bool) + + +def person(x=26, feet=44, arm="down", lift=None): + """A figure 12 px wide, head to feet. x is its left edge; feet is the row below its feet. + arm: "down" (hanging at the sides), "out" (right arm horizontal), "up" (right arm overhead). + lift: "L" or "R" raises that foot 2 px (a weight shift).""" + top = feet - 38 + m = rect(x + 4, top, x + 8, top + 6) # head + m |= rect(x + 2, top + 6, x + 10, top + 20) # torso (shoulder top = top + 6) + m |= rect(x + 2, top + 20, x + 6, feet - (2 if lift == "L" else 0)) # left leg + m |= rect(x + 6, top + 20, x + 10, feet - (2 if lift == "R" else 0)) # right leg + m |= rect(x, top + 7, x + 3, top + 22) # left arm, hanging + if arm == "down": + m |= rect(x + 9, top + 7, x + 12, top + 22) + elif arm == "out": + m |= rect(x + 9, top + 7, x + 24, top + 10) + elif arm == "up": + m |= rect(x + 9, top - 6, x + 12, top + 7) + return m + + +def flickering(n, seed=0, **kw): + """person(**kw) with its 1-px border toggled randomly, plus 3 random 1-px dropout holes inside, each camera frame.""" + rng = np.random.default_rng(seed) + base = person(**kw) + + def grow(m): + g = m.copy() + g[1:] |= m[:-1]; g[:-1] |= m[1:]; g[:, 1:] |= m[:, :-1]; g[:, :-1] |= m[:, 1:] + return g + + def shrink(m): + return ~grow(~m) + + edge = grow(base) & ~shrink(base) + inner = np.argwhere(shrink(base)) + out = [] + for _ in range(n): + m = base.copy() + m[edge] = rng.random(edge.sum()) < 0.5 + for y, x in inner[rng.integers(0, len(inner), 3)]: + m[y, x] = False + out.append(m) + return out + + +def walking(n, step=1, x=26, bounce=(4, 48), **kw): + """A person starting at x, moving sideways `step` px per camera frame, bouncing within `bounce`.""" + out, d = [], 1 + for _ in range(n): + out.append(person(x=x, **kw)) + if not bounce[0] <= x + d * step <= bounce[1]: + d = -d + x += d * step + return out + + +def stepping_toward(n, **kw): + """Feet row increases 1 px per camera frame from 36 to 47 (moving down the image).""" + return [person(feet=min(36 + k, 47), **kw) for k in range(n)] + + +def owner(texel): + return int(np.floor(texel[3])) + + +def slot_of_live(rig): + """The one slot whose PERSON id != 0.""" + live = [k for k in range(8) if rig.person(k)[0] != 0] + assert len(live) == 1, live + return live[0] + + +# ── Task 1: Body memory (Buffer B) ──────────────────────────────────────────── + +def b_at(rig, col, row): + """B texel under Body pixel (col, row), row 0 = top.""" + w, h = rig.size + return rig.buf("buffer_b")[int((1 - (row + 0.5) / BODY[1]) * h), int((col + 0.5) / BODY[0] * w)] + + +def test_presence_ignores_edge_flicker(ctx): + rig = Rig(ctx) + rig.run(flickering(30)) + for _ in range(10): + rig.run(flickering(1, seed=_ + 5)) + assert b_at(rig, 32, 20)[0] > 0.95 # torso centre + assert b_at(rig, 22, 20)[0] < 0.05 # 3 px outside the flickering border + rig.release() + + +def test_run_extents_span_leg_to_shoulder(ctx): + rig = Rig(ctx) + rig.run([person()] * 10) + g, b = b_at(rig, 29, 40)[1:3] # in a leg + assert abs(g - (1 - 44 / 48)) < 1 / 48 + assert abs(b - (1 - 12 / 48)) < 2 / 48 # shoulder top: row 6 + 6 + rig.release() + + +def test_raised_arm_runs_start_at_its_lowest_point(ctx): + rig = Rig(ctx) + rig.run([person(arm="up")] * 10) + g = b_at(rig, 36, 3)[1] # in the raised arm, above the head + assert abs(g - (1 - 12 / 48)) < 2 / 48 # its lowest row (the shoulder), not the feet + assert g > 0.5 + rig.release() + + +def test_owner_is_record_slot(ctx): + rig = Rig(ctx) + rig.run([person()] * 10) + assert owner(b_at(rig, 30, 20)) == 1 and owner(b_at(rig, 2, 2)) == 0 + rig.release() + + +def test_walker_leaves_no_hold_trail(ctx): + rig = Rig(ctx) + rig.run(walking(20)) + b = rig.buf("buffer_b") + gone = b[..., 0] < 0.05 + assert gone.any() and (b[..., 3][gone] == 0).all() + rig.release() + + +def test_lost_person_holds_then_fades(ctx): + rig = Rig(ctx) + rig.run([person()] * 20) + rig.run([EMPTY] * 15) # 1.5 s: lost after 8 misses + h1 = b_at(rig, 32, 20) + hold1 = (h1[3] % 1.0) / 0.9 + g1, b1 = h1[1], h1[2] + assert 0 < hold1 < 1 and owner(h1) == 1 + rig.run([EMPTY] * 25) # LEAVE_S more + h2 = b_at(rig, 32, 20) + assert h2[3] == 0 + assert g1 > 0 and b1 > g1 # the extents were kept while hold > 0 + rig.release() From 659ada0309d3a58deffc7dba38c2ffb33caa961b Mon Sep 17 00:00:00 2001 From: ii-impy <314283863+ii-impy@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:21:41 -0500 Subject: [PATCH 04/22] Watering can: render-rate-independent hold fade Co-Authored-By: Claude Opus 5.5 --- .../shaders/watering_can/buffer_b.glsl | 10 +++++-- .../shaders/watering_can/common.glsl | 1 + .../FundingCAPTCHA/test_watering_can.py | 30 +++++++++++++++++-- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_b.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_b.glsl index c20f7cd..83cd821 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_b.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_b.glsl @@ -60,7 +60,7 @@ void mainImage(out vec4 o, in vec2 fc) { float ease = 1.0 - exp(-iTimeDelta / RUN_EASE_S); g = (!had || abs(lo - prev.g) > RUN_SNAP) ? lo : mix(prev.g, lo, ease); b = (!had || abs(hi - prev.b) > RUN_SNAP) ? hi : mix(prev.b, hi, ease); - a = float(own) + 0.9; + a = float(own) + floor(0.9 / HOLD_Q) * HOLD_Q; } else { int po = bOwner(prev); float ph = bHold(prev); @@ -68,8 +68,12 @@ void mainImage(out vec4 o, in vec2 fc) { float st = recIdn(iChannel2, po - 1).y; // A missing body coasts first: keep its plants until the track is lost, then rewind them away. if (!isState(st, S_TENTATIVE) && !isState(st, S_CONFIRMED)) { - float h = ph - iTimeDelta / LEAVE_S; - if (h > 0.0) { g = prev.g; b = prev.b; a = float(po) + 0.9 * h; } + // The step is below a half float's resolution at high render rates, so round it + // stochastically to HOLD_Q: the expected fade is exact at any rate. + float f = fract(prev.a); + float d = 0.9 * iTimeDelta / LEAVE_S; + f -= floor(d / HOLD_Q + hash12(fc + vec2(113.0, 57.0) * float(iFrame % 997))) * HOLD_Q; + if (f > 0.5 * HOLD_Q) { g = prev.g; b = prev.b; a = float(po) + f; } } } } diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl index 639213a..2a3215b 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl @@ -22,6 +22,7 @@ const float RUN_STEP_H = 1.0/64.0; // march step, screen heights: RUN_STEP const float RUN_EASE_S = 0.15; // eases run extents; snaps when they jump more than RUN_SNAP const float RUN_SNAP = 0.08; // screen heights const float LEAVE_S = 2.0; // a lost person's plants rewind away over this +const float HOLD_Q = 1.0/128.0; // 0.9 * hold is stored in multiples of this, so it is exact in a half float for any owner // Garden const float GROW_S = 20.0, REWIND_S = 20.0; // empty → full bloom, full bloom → empty const float BASE_BAND = 0.04; // screen heights above the box bottom that count as feet diff --git a/ShowControl/FundingCAPTCHA/test_watering_can.py b/ShowControl/FundingCAPTCHA/test_watering_can.py index af5d39e..75fbd36 100644 --- a/ShowControl/FundingCAPTCHA/test_watering_can.py +++ b/ShowControl/FundingCAPTCHA/test_watering_can.py @@ -20,6 +20,7 @@ BODY = (64, 48) # camera ROI (w, h) SLABS = [{"near_mm": 1000, "far_mm": 4000}] DT = 1 / 30 +LEAVE_S = 2.0 # shaders/watering_can/common.glsl BLOBS = BlobConfig(min_blob_pixels=20) # the shipped defaults, but for the small test Body @@ -43,8 +44,8 @@ def ctx(): class Rig: """Renderer + the real BodyBlobs on a fake clock: camera at 10 Hz, render at `dt`.""" - def __init__(self, ctx, size=SIZE, dt=DT): - self.size, self.dt = size, dt + def __init__(self, ctx, size=SIZE, dt=DT, slot=0): + self.size, self.dt, self.slot = size, dt, slot # slot: move the tracker's records there (owner = slot + 1) self.r = Renderer(ctx, size, body_mode="camera") self.r.load(load_setup(SETUP)) self.blobs = BodyBlobs(BLOBS) @@ -57,7 +58,7 @@ def run(self, masks, frames_per_camera=3): 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.upload_blobs(np.roll(self.blobs.records(now=self.t), self.slot, axis=1)) self.r.render(self.dt, (0, 0, 0, 0)) self.t += self.dt @@ -224,3 +225,26 @@ def test_lost_person_holds_then_fades(ctx): assert h2[3] == 0 assert g1 > 0 and b1 > g1 # the extents were kept while hold > 0 rig.release() + + +@pytest.mark.parametrize("slot", [0, 7]) # owner 8 has the coarsest half-float spacing +@pytest.mark.parametrize("dt, per_cam", [(1 / 30, 3), (1 / 240, 24)]) +def test_hold_fade_independent_of_render_rate(ctx, dt, per_cam, slot): + """A lost person's hold falls by 1/LEAVE_S per second, +/- 15%, at any render rate. + Measured as the slope of the mean hold over the torso between 0.5 s and 1.5 s into the fade.""" + rig = Rig(ctx, dt=dt, slot=slot) + rig.run([person()] * 20, frames_per_camera=per_cam) + + def hold(): # mean over a torso patch, so per-pixel noise averages out + a = np.array([b_at(rig, c, r)[3] for c in range(30, 35) for r in range(14, 22)]) + return float(np.mean((a % 1.0) / 0.9)) + + h = [] + for _ in range(16): + rig.run([EMPTY], frames_per_camera=per_cam) + h.append(hold()) + assert h[1] < 1.0 # fading has started + # camera frames are 0.1 s apart: frames 4 -> 14 span 1.0 s + fall = h[4] - h[14] + assert abs(fall - 1.0 / LEAVE_S) < 0.15 * (1.0 / LEAVE_S), fall + rig.release() From cadb21a441fecace59c4327313d0063b6932ea4e Mon Sep 17 00:00:00 2001 From: ii-impy <314283863+ii-impy@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:32:40 -0500 Subject: [PATCH 05/22] Watering can: growth, walking, inheritance and sun level Co-Authored-By: Claude Opus 5.5 --- .../shaders/watering_can/buffer_c.glsl | 127 ++++++++++- .../shaders/watering_can/common.glsl | 3 +- .../FundingCAPTCHA/test_watering_can.py | 213 ++++++++++++++++++ 3 files changed, 341 insertions(+), 2 deletions(-) diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl index 04451db..8bfb27f 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl @@ -1,4 +1,129 @@ -// Buffer C — Garden state (stub; Task 2). iChannel0 = Buffer B, iChannel1 = Buffer C, iChannel2 = blobs +// Buffer C — Garden state. iChannel0 = Buffer B, iChannel1 = Buffer C (itself), iChannel2 = blobs +// Layout: see common.glsl. Each texel reads the previous frame of every other texel; texels that +// need the same derived value (PERSON and BASE both need this frame's base) compute it from the same inputs. + +#define BB iChannel0 +#define C iChannel1 +#define BLOBS iChannel2 + +// The starting value of every texel: "seconds since" counters start long ago. +vec4 initial(ivec2 c) { + if (c.y == 1) return vec4(0.0, 0.0, T_CLAMP, S_EMPTY); // BASE + if (c.y == 2) return vec4(0.0, 0.0, 0.0, T_CLAMP); // GHOST + return vec4(0.0); +} + +// The record in slot k carries a different person than PERSON k remembers (or none). +bool fresh(int k) { + vec4 idn = recIdn(BLOBS, k); + return isState(idn.y, S_EMPTY) || abs(idn.x - cPerson(C, k).x) > 0.5; +} +bool live(float st) { return isState(st, S_TENTATIVE) || isState(st, S_CONFIRMED) || isState(st, S_COASTING); } +// The box follows a real detection (a coasting or lost box only drifts on its last velocity). +bool seen(float st) { return isState(st, S_TENTATIVE) || isState(st, S_CONFIRMED); } + +// The feet: presence-weighted mean x of B across the box, just above its bottom; y = the box bottom. +vec2 baseTarget(vec4 box) { + float y = box.y + 0.5 * BASE_BAND; // screen heights = UV in y + float w = 0.0, wx = 0.0; + for (int i = 0; i < BASE_TAPS; i++) { + float x = mix(box.x, box.z, (float(i) + 0.5) / float(BASE_TAPS)); + float p = texture(BB, vec2(x, y)).r; + w += p; wx += p * x; + } + return vec2(w > 1e-3 ? wx / w : 0.5 * (box.x + box.z), box.y); +} + +// Gap between two boxes, in screen heights (0 when they overlap). +float boxGap(vec4 a, vec4 b) { + vec2 d = max(vec2(0.0), max(a.xy - b.zw, b.xy - a.zw)); + return length(toScreen(d)); +} + +// Growth a new track at `base` starts from: a recent ghost standing there, or a live +// neighbour its box overlaps or touches (a split, a merge, or a part of a planted person). +float inherit(int k, vec2 base) { + vec4 box = recBox(BLOBS, k); + float g = 0.0; + for (int j = 0; j < SLOTS; j++) { + vec4 gh = cGhost(C, j); + if (gh.w < INHERIT_S && length(toScreen(gh.xy - base)) < INHERIT_DIST) g = max(g, gh.z); + if (j == k || fresh(j) || !live(recIdn(BLOBS, j).y)) continue; + if (boxGap(box, recBox(BLOBS, j)) < INHERIT_DIST) g = max(g, cPerson(C, j).y); + } + return g; +} + +// ── BASE ────────────────────────────────────────────────────────────────────── +// This frame's BASE for slot k: eased base, seconds since the last step, record state (for GHOST). +vec4 baseNext(int k, float dt) { + vec4 idn = recIdn(BLOBS, k); + if (isState(idn.y, S_EMPTY)) return vec4(0.0, 0.0, T_CLAMP, S_EMPTY); + if (fresh(k)) return vec4(baseTarget(recBox(BLOBS, k)), T_CLAMP, idn.y); + vec4 b = cBase(C, k); + // A coasting or lost box drifts without a body under it: hold the base, so a dropped + // camera frame can't read as a step. + if (seen(idn.y)) b.xy = mix(b.xy, baseTarget(recBox(BLOBS, k)), 1.0 - exp(-dt / BASE_EASE_S)); + bool step = length(toScreen(b.xy - cPerson(C, k).zw)) > WALK_DIST; + b.z = step ? 0.0 : min(b.z + dt, T_CLAMP); + b.w = idn.y; + return b; +} + +// ── PERSON ──────────────────────────────────────────────────────────────────── +vec4 personNext(int k, float dt, vec2 fc) { + vec4 idn = recIdn(BLOBS, k); + if (isState(idn.y, S_EMPTY)) return vec4(0.0); + vec4 b = baseNext(k, dt); + if (fresh(k)) return vec4(idn.x, inherit(k, b.xy), b.xy); + vec4 p = cPerson(C, k); + if (b.z == 0.0) p.zw = b.xy; // a step: the new spot is here + if (isState(idn.y, S_CONFIRMED)) { // tentative and coasting hold their level + float rate = b.z < WALK_HOLD_S ? -1.0 / REWIND_S : 1.0 / GROW_S; + // The step is a few half-float ulps: round it stochastically to GROW_Q, so the + // expected growth is exact at any render rate. + float h = hash12(fc + vec2(113.0, 57.0) * float(iFrame % 997)); + p.y += sign(rate) * floor(abs(rate) * dt / GROW_Q + h) * GROW_Q; + p.y = clamp(p.y, 0.0, 1.0); + } + return p; +} + +// ── GHOST ───────────────────────────────────────────────────────────────────── +// Written once when slot k's person goes: their track is lost, or the slot holds someone else. +vec4 ghostNext(int k, float dt) { + vec4 p = cPerson(C, k); + float st = recIdn(BLOBS, k).y, was = cBase(C, k).w; // was = the record state last frame + bool fr = fresh(k); + bool gone = !isState(was, S_LOST) && (fr || isState(st, S_LOST)); + if (p.x > 0.5 && gone) return vec4(p.zw, p.y, 0.0); + vec4 g = cGhost(C, k); + g.w = min(g.w + dt, T_CLAMP); + return g; +} + +// ── SUN ─────────────────────────────────────────────────────────────────────── +vec4 sunNext(float dt) { + float raw = 0.0; + for (int k = 0; k < SLOTS; k++) { + if (live(recIdn(BLOBS, k).y) && !fresh(k)) raw += cPerson(C, k).y; + vec4 g = cGhost(C, k); + raw += g.z * max(0.0, 1.0 - g.w / LEAVE_S); + } + float lvl = cSun(C).x; + lvl = mix(lvl, clamp(raw / SUN_FULL, 0.0, 1.0), 1.0 - exp(-dt / SUN_EASE_S)); + return vec4(lvl, raw, 0.0, 0.0); +} + void mainImage(out vec4 o, in vec2 fc) { + ivec2 c = ivec2(fc); o = vec4(0.0); + if (c.y > 3 || c.x >= SLOTS || (c.y == 3 && c.x > 0)) return; + if (iFrame == 0) { o = initial(c); return; } + float dt = clamp(iTimeDelta, 0.0, T_CLAMP); + + if (c.y == 0) o = personNext(c.x, dt, fc); + else if (c.y == 1) o = baseNext(c.x, dt); + else if (c.y == 2) o = ghostNext(c.x, dt); + else o = sunNext(dt); } diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl index 2a3215b..6a56269 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl @@ -7,7 +7,7 @@ // .a owner + 0.9 * hold (owner = slot + 1, 0 none; hold 0–1, 1 while the body is present) // Garden state (Buffer C), texel (x, y), k = slot 0–7: // (k, 0) PERSON id (0 none), growth 0–1, planted spot x, y (UV) -// (k, 1) BASE base x, base y (UV, smoothed), seconds since last walking step, — +// (k, 1) BASE base x, base y (UV, smoothed), seconds since last walking step, record state last frame // (k, 2) GHOST spot x, y, growth at loss, seconds since loss (T_CLAMP = no ghost) // (0, 3) SUN sun level 0–1 (eased), raw total growth, —, — @@ -25,6 +25,7 @@ const float LEAVE_S = 2.0; // a lost person's plants rewind away o const float HOLD_Q = 1.0/128.0; // 0.9 * hold is stored in multiples of this, so it is exact in a half float for any owner // Garden const float GROW_S = 20.0, REWIND_S = 20.0; // empty → full bloom, full bloom → empty +const float GROW_Q = 1.0/2048.0; // growth is stored in multiples of this (a half float's step just below 1) const float BASE_BAND = 0.04; // screen heights above the box bottom that count as feet const int BASE_TAPS = 16; const float BASE_EASE_S = 0.3; diff --git a/ShowControl/FundingCAPTCHA/test_watering_can.py b/ShowControl/FundingCAPTCHA/test_watering_can.py index 75fbd36..2f19099 100644 --- a/ShowControl/FundingCAPTCHA/test_watering_can.py +++ b/ShowControl/FundingCAPTCHA/test_watering_can.py @@ -149,6 +149,11 @@ def stepping_toward(n, **kw): return [person(feet=min(36 + k, 47), **kw) for k in range(n)] +def both(a, b): + """Two people at once: the camera frames of two mask lists, overlaid.""" + return [x | y for x, y in zip(a, b)] + + def owner(texel): return int(np.floor(texel[3])) @@ -248,3 +253,211 @@ def hold(): # mean over a torso patch, so fall = h[4] - h[14] assert abs(fall - 1.0 / LEAVE_S) < 0.15 * (1.0 / LEAVE_S), fall rig.release() + + +# ── Task 2: Garden state (Buffer C) ─────────────────────────────────────────── + +GROW_S = 20.0 # shaders/watering_can/common.glsl +INHERIT_S, SUN_EASE_S = 3.0, 1.5 +COAST_S = 0.8 # BlobConfig max_miss_frames at 10 Hz: missing → lost +# Walks start at x=16 so 30 steps never reach walking()'s bounce at 48: a walker who turns +# back crosses their own last spot, and that stretch (~0.7 s) rightly isn't a step. +WALK_X = 16 + + +def live_slots(rig): + return [k for k in range(8) if rig.person(k)[0] != 0] + + +def slot_near(rig, x_px): + """The live slot whose base is nearest Body column x_px.""" + return min(live_slots(rig), key=lambda k: abs(rig.base(k)[0] - x_px / BODY[0])) + + +def test_planted_flickering_person_blooms_in_20s(ctx): # spec: ~20 s, never loses growth + rig = Rig(ctx) + levels = [] + for _ in range(22): + rig.run(flickering(10)) + levels.append(rig.person(0)[1]) + assert abs(levels[9] - 0.5) < 0.06 and levels[-1] > 0.98, levels + assert all(b >= a - 1e-3 for a, b in zip(levels, levels[1:])), levels + rig.release() + + +def test_arm_waving_does_not_rewind(ctx): + rig = Rig(ctx) + rig.run([person(arm=("down", "out", "up")[k // 5 % 3]) for k in range(100)]) + assert abs(rig.person(0)[1] - 0.5) < 0.06, rig.person(0) + rig.release() + + +def test_weight_shift_does_not_rewind(ctx): + rig = Rig(ctx) + rig.run([person(lift=(None, "L", None, "R")[k // 3 % 4]) for k in range(100)]) + assert abs(rig.person(0)[1] - 0.5) < 0.06, rig.person(0) + rig.release() + + +def test_walking_sideways_rewinds(ctx): + rig = Rig(ctx) + rig.run([person(x=WALK_X)] * 100) + g0 = rig.person(0)[1] + rig.run(walking(30, x=WALK_X)) + assert abs((g0 - rig.person(0)[1]) - 3.0 / 20.0) < 0.05, (g0, rig.person(0)) + rig.release() + + +def test_stepping_toward_camera_rewinds(ctx): + # Toward the camera for 1.1 s, then back away for 1.1 s. Detecting a step takes ~2 px of + # movement plus easing (~0.4 s, still growing meanwhile), so 1.1 s alone nets only ~0.01. + rig = Rig(ctx) + rig.run([person(feet=36)] * 100) + g0 = rig.person(0)[1] + assert g0 > 0.4 + rig.run(stepping_toward(11) + [person(feet=46 - k) for k in range(11)]) + assert rig.person(0)[1] < g0 - 0.03, (g0, rig.person(0)) + rig.release() + + +def test_growth_resumes_after_walking(ctx): + rig = Rig(ctx) + rig.run([person(x=WALK_X)] * 100) + walk = walking(30, x=WALK_X) + rig.run(walk) + g1 = rig.person(0)[1] + assert 0.25 < g1 < 0.45, g1 # rewound, not reset + levels = [] + for _ in range(3): + rig.run([walk[-1]] * 10) + levels.append(rig.person(0)[1]) + # Walking lasts WALK_HOLD_S (plus easing) past the last step, so the first ~0.6 s still + # rewinds a little; then it grows at the full rate from where the rewind got to, not from 0. + assert all(g >= g1 - 0.05 for g in levels), (g1, levels) + assert abs((levels[-1] - levels[0]) - 2.0 / GROW_S) < 0.02, (g1, levels) + rig.release() + + +def test_neighbour_walking_does_not_rewind_still_person(ctx): + rig = Rig(ctx) + rig.run(both([person(x=46)] * 100, walking(100, x=4, bounce=(4, 20)))) + assert len(live_slots(rig)) == 2 + still, walker = slot_near(rig, 52), slot_near(rig, 4) + assert still != walker + assert abs(rig.person(still)[1] - 0.5) < 0.06, rig.person(still) + assert rig.person(walker)[1] < 0.1, rig.person(walker) + rig.release() + + +def test_brief_loss_keeps_growth(ctx): + rig = Rig(ctx) + rig.run([person()] * 100) + rig.run([EMPTY] * 20) # lost, then emptied: the track is gone + assert rig.person(0)[0] == 0 + rig.run([person()] * 5) + assert rig.person(slot_of_live(rig))[1] > 0.45 # whichever slot and id it came back in + rig.release() + + +def test_merge_then_split_both_inherit(ctx): + rig = Rig(ctx) + rig.run(both([person(x=4)] * 100, [person(x=40)] * 100)) + assert len(live_slots(rig)) == 2 + # B slides left into A until their masks overlap (one blob), and they stand merged for 3 s + rig.run(both([person(x=4)] * 26, [person(x=max(40 - k, 14)) for k in range(26)])) + rig.run(both([person(x=4)] * 30, [person(x=14)] * 30)) + merged = max(rig.person(k)[1] for k in live_slots(rig)) + assert merged > 0.4 + # then B jumps back out: two blobs again + rig.run(both([person(x=4)] * 8, [person(x=22)] * 8)) + live = live_slots(rig) + assert len(live) == 2, live + for k in live: + assert rig.person(k)[1] >= merged - 0.05, (merged, [rig.person(j) for j in live]) + rig.release() + + +def detached_arm(x=26, feet=44): + """person(arm="out") with a 1-px gap between the outstretched arm and the torso: two blobs.""" + m = person(x=x, feet=feet, arm="out") + m[:, x + 10] = False + return m + + +def test_split_off_part_inherits(ctx): # Review Focus 1 + from body_blobs import find_blobs + arm = detached_arm() + sizes = sorted(d.pixels for d in find_blobs(arm, np.zeros(arm.shape), BLOBS.min_blob_pixels)) + assert len(sizes) == 2 and sizes[0] >= BLOBS.min_blob_pixels, sizes # really a second blob + rig = Rig(ctx) + rig.run([person(arm="out")] * 100) + g = rig.person(0)[1] + assert g > 0.4 + rig.run([arm] * 20) + live = live_slots(rig) + assert len(live) == 2, live # a second live track exists + new = [k for k in live if k != 0][0] + assert rig.person(new)[1] >= rig.person(0)[1] - 0.05, (rig.person(0), rig.person(new)) + rig.release() + + +def test_feet_off_frame(ctx): # Review Focus 2 + rig = Rig(ctx) + for _ in range(10): + rig.run(flickering(10, x=WALK_X, feet=48)) + g0 = rig.person(0)[1] + assert abs(g0 - 0.5) < 0.06, g0 + rig.run(walking(30, x=WALK_X, feet=48)) + assert abs((g0 - rig.person(0)[1]) - 3.0 / 20.0) < 0.05, (g0, rig.person(0)) + rig.release() + + +def test_new_person_far_from_ghosts_starts_bare(ctx): + rig = Rig(ctx) + rig.run([person(x=4)] * 100) + assert rig.person(0)[1] > 0.4 + rig.run([EMPTY] * 10) # A is lost; its ghost is fresh + gh = rig.ghost(0) + assert gh[2] > 0.4 and gh[3] < INHERIT_S, gh + rig.run([person(x=46)] * 10) + b = slot_near(rig, 52) + assert rig.person(b)[0] != 0 and rig.ghost(0)[3] < INHERIT_S + assert rig.person(b)[1] < 0.05, rig.person(b) + rig.release() + + +def test_sun_tracks_growth(ctx): + rig = Rig(ctx) + rig.run([EMPTY] * 30) + assert rig.sun()[0] < 0.02 + rig.run([person()] * 220) + assert rig.sun()[0] > 0.95, rig.sun() + rig.run([EMPTY] * int(round((COAST_S + LEAVE_S + 3 * SUN_EASE_S) * 10))) + assert rig.sun()[0] < 0.05, rig.sun() + rig.release() + + +@pytest.mark.parametrize("dt, per_cam", [(1 / 60, 6), (1 / 240, 24)]) # a half float stalls small steps +def test_growth_independent_of_render_rate(ctx, dt, per_cam): + a = Rig(ctx, dt=1 / 30) + a.run([person()] * 150, frames_per_camera=3) # 15 s: through the coarser steps above 0.5 + b = Rig(ctx, dt=dt) + b.run([person()] * 150, frames_per_camera=per_cam) + assert abs(a.person(0)[1] - 0.73) < 0.06, a.person(0) + assert abs(a.person(0)[1] - b.person(0)[1]) < 0.02, (a.person(0), b.person(0)) + a.release(); b.release() + + +def test_state_stays_bounded_when_idle(ctx): # Review Focus 4 + rig = Rig(ctx) + rig.run([person()] * 10) + rig.r.render(200.0, (0, 0, 0, 0)) # one huge dt + rig.run([EMPTY] * 3) + rig.r.render(200.0, (0, 0, 0, 0)) + rig.run([EMPTY] * 3) + for name in ("buffer_b", "buffer_c"): + v = rig.buf(name) + assert np.isfinite(v).all(), name + assert np.abs(rig.buf("buffer_c")).max() <= 99.0 + assert rig.ghost(0)[3] > INHERIT_S # the counters ran: the ghost is long gone + rig.release() From 2d9122ee81d88e7cedbae480f1528e4329fd1583 Mon Sep 17 00:00:00 2001 From: ii-impy <314283863+ii-impy@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:38:10 -0500 Subject: [PATCH 06/22] Watering can: keep strong ghosts; inherit from neighbours only on splits Co-Authored-By: Claude Opus 5.5 --- .../shaders/watering_can/buffer_c.glsl | 13 +++++-- .../shaders/watering_can/common.glsl | 2 + .../FundingCAPTCHA/test_watering_can.py | 37 +++++++++++++++++++ 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl index 8bfb27f..9567ebc 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl @@ -41,7 +41,8 @@ float boxGap(vec4 a, vec4 b) { } // Growth a new track at `base` starts from: a recent ghost standing there, or a live -// neighbour its box overlaps or touches (a split, a merge, or a part of a planted person). +// neighbour its box overlaps or all but touches (a split, a merge, or a part of a planted +// person). A newcomer further away starts bare, so nobody pops into bloom on arrival. float inherit(int k, vec2 base) { vec4 box = recBox(BLOBS, k); float g = 0.0; @@ -49,7 +50,7 @@ float inherit(int k, vec2 base) { vec4 gh = cGhost(C, j); if (gh.w < INHERIT_S && length(toScreen(gh.xy - base)) < INHERIT_DIST) g = max(g, gh.z); if (j == k || fresh(j) || !live(recIdn(BLOBS, j).y)) continue; - if (boxGap(box, recBox(BLOBS, j)) < INHERIT_DIST) g = max(g, cPerson(C, j).y); + if (boxGap(box, recBox(BLOBS, j)) <= SPLIT_GAP) g = max(g, cPerson(C, j).y); } return g; } @@ -96,8 +97,12 @@ vec4 ghostNext(int k, float dt) { float st = recIdn(BLOBS, k).y, was = cBase(C, k).w; // was = the record state last frame bool fr = fresh(k); bool gone = !isState(was, S_LOST) && (fr || isState(st, S_LOST)); - if (p.x > 0.5 && gone) return vec4(p.zw, p.y, 0.0); - vec4 g = cGhost(C, k); + vec4 g = cGhost(C, k); + // The slot is reused as soon as a lost record frees it: a short-lived fragment leaving + // must not wipe a ghost someone may still come back to. Replace a stale ghost, or one + // worth less than the leaver (strictly, so a bare fragment never wins). + bool stronger = g.w >= INHERIT_S || p.y > g.z * max(0.0, 1.0 - g.w / LEAVE_S); + if (p.x > 0.5 && gone && stronger) return vec4(p.zw, p.y, 0.0); g.w = min(g.w + dt, T_CLAMP); return g; } diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl index 6a56269..fba3617 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl @@ -32,6 +32,8 @@ const float BASE_EASE_S = 0.3; const float WALK_DIST = 0.04; // screen heights the base must move from the spot to count as a step const float WALK_HOLD_S = 0.4; // walking lasts this long after the last step const float INHERIT_S = 3.0, INHERIT_DIST = 0.15; // ghost lifetime; how near a new track must appear (screen heights) +const float SPLIT_GAP = 0.03; // a new track this close to a live one (box gap, screen heights) is a split of it and + // inherits its growth: the tracker merges touching people, so a gap this small means a split const float SUN_FULL = 1.0, SUN_EASE_S = 1.5; // total growth that fully lights the sun // Plants const float VINE_SPACING = 4.5/360.0; // ~30 vines across an adult, as the mockup's Dense panel diff --git a/ShowControl/FundingCAPTCHA/test_watering_can.py b/ShowControl/FundingCAPTCHA/test_watering_can.py index 2f19099..aaabdf2 100644 --- a/ShowControl/FundingCAPTCHA/test_watering_can.py +++ b/ShowControl/FundingCAPTCHA/test_watering_can.py @@ -426,6 +426,43 @@ def test_new_person_far_from_ghosts_starts_bare(ctx): rig.release() +def test_fragment_in_freed_slot_keeps_ghost(ctx): + # A is lost and their slot frees; a short fragment takes that slot and expires; A comes + # back at their old spot within INHERIT_S and must still inherit their own ghost. + rig = Rig(ctx) + rig.run([person(x=4)] * 100) + g = rig.person(0)[1] + assert g > 0.4 + rig.run([EMPTY] * 19) # coasted, lost, and the slot freed + assert rig.person(0)[0] == 0 and rig.ghost(0)[2] > 0.4 + rig.run([person(x=46)]) # a fragment takes the freed slot 0 ... + assert rig.person(0)[0] != 0 and rig.person(0)[2] > 0.5 + for _ in range(12): # ... and expires + rig.run([EMPTY]) + if rig.person(0)[0] == 0: + break + assert rig.person(0)[0] == 0 + assert rig.ghost(0)[3] < INHERIT_S + rig.run([person(x=4)] * 5) + k = slot_of_live(rig) + assert abs(rig.person(k)[1] - g) < 0.05, (g, rig.person(k)) + rig.release() + + +def test_newcomer_beside_bloomed_person_starts_bare(ctx): + rig = Rig(ctx) + rig.run([person(x=4)] * 200) # A in full bloom + assert rig.person(0)[1] > 0.9 + rig.run(both([person(x=4)] * 10, [person(x=20)] * 10)) # B appears with a 4 px gap + live = live_slots(rig) + assert len(live) == 2, live # a live, separate track + a, b = slot_near(rig, 10), slot_near(rig, 26) + assert a != b + assert rig.person(a)[1] > 0.9 + assert rig.person(b)[1] < 0.05, rig.person(b) + rig.release() + + def test_sun_tracks_growth(ctx): rig = Rig(ctx) rig.run([EMPTY] * 30) From 3e6b548bbbf8400df3af688351688527ca8576e0 Mon Sep 17 00:00:00 2001 From: ii-impy <314283863+ii-impy@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:39:17 -0500 Subject: [PATCH 07/22] Watering can: ghosts yield only to stronger or after INHERIT_S Co-Authored-By: Claude Opus 5.5 --- .../shaders/watering_can/buffer_c.glsl | 6 ++--- .../FundingCAPTCHA/test_watering_can.py | 26 ++++++++++++------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl index 9567ebc..9c7a952 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl @@ -99,9 +99,9 @@ vec4 ghostNext(int k, float dt) { bool gone = !isState(was, S_LOST) && (fr || isState(st, S_LOST)); vec4 g = cGhost(C, k); // The slot is reused as soon as a lost record frees it: a short-lived fragment leaving - // must not wipe a ghost someone may still come back to. Replace a stale ghost, or one - // worth less than the leaver (strictly, so a bare fragment never wins). - bool stronger = g.w >= INHERIT_S || p.y > g.z * max(0.0, 1.0 - g.w / LEAVE_S); + // must not wipe a ghost someone may still come back to. Replace a ghost only once it can + // no longer be inherited, or with one at least as grown. + bool stronger = g.w >= INHERIT_S || p.y >= g.z; if (p.x > 0.5 && gone && stronger) return vec4(p.zw, p.y, 0.0); g.w = min(g.w + dt, T_CLAMP); return g; diff --git a/ShowControl/FundingCAPTCHA/test_watering_can.py b/ShowControl/FundingCAPTCHA/test_watering_can.py index aaabdf2..42d19b9 100644 --- a/ShowControl/FundingCAPTCHA/test_watering_can.py +++ b/ShowControl/FundingCAPTCHA/test_watering_can.py @@ -426,25 +426,33 @@ def test_new_person_far_from_ghosts_starts_bare(ctx): rig.release() -def test_fragment_in_freed_slot_keeps_ghost(ctx): - # A is lost and their slot frees; a short fragment takes that slot and expires; A comes +@pytest.mark.parametrize("frag_frames", [1, 6]) +def test_fragment_in_freed_slot_keeps_ghost(ctx, frag_frames): + # A is lost and their slot frees; a short fragment takes that slot and leaves; A comes # back at their old spot within INHERIT_S and must still inherit their own ghost. + # 1 frame: a tentative fragment that expires bare. 6 frames: it confirms, grows a little, + # and is lost after the ghost has faded from the sun (since > LEAVE_S) but is still inheritable. rig = Rig(ctx) rig.run([person(x=4)] * 100) g = rig.person(0)[1] assert g > 0.4 rig.run([EMPTY] * 19) # coasted, lost, and the slot freed assert rig.person(0)[0] == 0 and rig.ghost(0)[2] > 0.4 - rig.run([person(x=46)]) # a fragment takes the freed slot 0 ... + rig.run([person(x=46)] * frag_frames) # a fragment takes the freed slot 0 ... assert rig.person(0)[0] != 0 and rig.person(0)[2] > 0.5 - for _ in range(12): # ... and expires + frag_g = rig.person(0)[1] + for _ in range(12): # ... and leaves: lost or expired rig.run([EMPTY]) - if rig.person(0)[0] == 0: + if rig.person(0)[0] == 0 or rig.base(0)[3] > 3.5: break - assert rig.person(0)[0] == 0 - assert rig.ghost(0)[3] < INHERIT_S - rig.run([person(x=4)] * 5) - k = slot_of_live(rig) + assert rig.person(0)[0] == 0 or rig.base(0)[3] > 3.5 + since = rig.ghost(0)[3] + assert since < INHERIT_S + if frag_frames > 1: # the case R5 measured wrongly + assert 0 < frag_g < g and since > LEAVE_S, (frag_g, since) + rig.run([person(x=4)] * 3) + k = slot_near(rig, 10) + assert rig.person(k)[2] < 0.3 # A, back at their spot assert abs(rig.person(k)[1] - g) < 0.05, (g, rig.person(k)) rig.release() From a08bac71d158bcb6f43c2c3024469179829bbb90 Mon Sep 17 00:00:00 2001 From: ii-impy <314283863+ii-impy@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:06:26 -0500 Subject: [PATCH 08/22] Watering can: vines, leaves and flowers Co-Authored-By: Claude Opus 5.5 --- .../shaders/watering_can/buffer_b.glsl | 35 ++- .../shaders/watering_can/common.glsl | 11 +- .../shaders/watering_can/image.glsl | 208 +++++++++++++++++- .../FundingCAPTCHA/test_watering_can.py | 177 +++++++++++++++ 4 files changed, 419 insertions(+), 12 deletions(-) diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_b.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_b.glsl index 83cd821..155f5df 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_b.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_b.glsl @@ -33,15 +33,33 @@ int ownerAt(vec2 uv) { return best; } -// How far the run extends from uv in direction dir (±1, in UV y) while previous presence stays on body. +// Was this texel body last frame? From BODY_ON, and once body, until below BODY_OFF: hysteresis, +// so a flickering edge holds still. (A held, fading texel has less than full hold: not body.) +bool wasBody(vec4 t) { + bool run = t.g > 0.0 || t.b > 0.0; + return t.r >= BODY_ON || (run && t.r >= BODY_OFF && fract(t.a) > floor(0.9 / HOLD_Q) * HOLD_Q - 0.5 * HOLD_Q); +} +bool bodyAt(float x, float y) { + return wasBody(texelFetch(iChannel1, ivec2(min(vec2(x, y) * iResolution.xy, iResolution.xy - 1.0)), 0)); +} + +// How far the run extends from uv in direction dir (±1, in UV y) while last frame's body continues. +// Every texel of a run must agree on its ends, or the plants tear into rows: the march steps on a +// grid fixed to the screen, so the texels of a run all probe the same points along a ragged edge, +// then bisects the last step to find the edge texel itself. float march(vec2 uv, float dir) { - float y = uv.y; + float y = uv.y, off = clamp(uv.y + dir * float(RUN_STEPS + 1) * RUN_STEP_H, 0.0, 1.0); + float k0 = dir > 0.0 ? floor(uv.y / RUN_STEP_H) : ceil(uv.y / RUN_STEP_H); for (int i = 1; i <= RUN_STEPS; i++) { - float ny = uv.y + dir * float(i) * RUN_STEP_H; - if (ny < 0.0 || ny > 1.0) break; - if (texture(iChannel1, vec2(uv.x, ny)).r < BODY_ON) break; + float ny = (k0 + dir * float(i)) * RUN_STEP_H; + if (ny < 0.0 || ny > 1.0) { off = clamp(ny, 0.0, 1.0); break; } + if (!bodyAt(uv.x, ny)) { off = ny; break; } y = ny; } + for (int i = 0; i < RUN_REFINE; i++) { + float m = 0.5 * (y + off); + if (bodyAt(uv.x, m)) y = m; else off = m; + } return y; } @@ -53,14 +71,13 @@ void mainImage(out vec4 o, in vec2 fc) { float pres = iFrame == 0 ? target : mix(prev.r, target, 1.0 - exp(-iTimeDelta / PRESENCE_EASE_S)); float g = 0.0, b = 0.0, a = 0.0; - if (pres >= BODY_ON) { - int own = ownerAt(uv); + bool had = prev.g > 0.0 || prev.b > 0.0; + if (pres >= BODY_ON || (wasBody(prev) && pres >= BODY_OFF)) { float lo = march(uv, -1.0), hi = march(uv, 1.0); - bool had = prev.g > 0.0 || prev.b > 0.0; float ease = 1.0 - exp(-iTimeDelta / RUN_EASE_S); g = (!had || abs(lo - prev.g) > RUN_SNAP) ? lo : mix(prev.g, lo, ease); b = (!had || abs(hi - prev.b) > RUN_SNAP) ? hi : mix(prev.b, hi, ease); - a = float(own) + floor(0.9 / HOLD_Q) * HOLD_Q; + a = float(ownerAt(uv)) + floor(0.9 / HOLD_Q) * HOLD_Q; } else { int po = bOwner(prev); float ph = bHold(prev); diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl index fba3617..d3776d6 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl @@ -17,8 +17,10 @@ const float T_CLAMP = 99.0; // every "seconds since" counter clamps const float BODY_BLUR_TEXELS = 1.5, HOLE_FILL_TEXELS = 2.5; // as endless_sunset const float PRESENCE_EASE_S = 0.12; // eases presence so 1-px edge flicker averages out const float BODY_ON = 0.5; // presence above this is body +const float BODY_OFF = 0.3; // ... and a texel stays body until below this, so a flickering edge holds still const int RUN_STEPS = 64; // max march taps each way const float RUN_STEP_H = 1.0/64.0; // march step, screen heights: RUN_STEPS of them cover a full screen height +const int RUN_REFINE = 6; // bisections of the last march step, down to the edge texel (to 1080p), so a run's texels agree const float RUN_EASE_S = 0.15; // eases run extents; snaps when they jump more than RUN_SNAP const float RUN_SNAP = 0.08; // screen heights const float LEAVE_S = 2.0; // a lost person's plants rewind away over this @@ -37,13 +39,20 @@ const float SPLIT_GAP = 0.03; // a new track this close to a live one const float SUN_FULL = 1.0, SUN_EASE_S = 1.5; // total growth that fully lights the sun // Plants const float VINE_SPACING = 4.5/360.0; // ~30 vines across an adult, as the mockup's Dense panel -const int VINE_NEIGHBOURS = 2; // vines evaluated each side of a pixel +const int VINE_NEIGHBOURS = 4; // vines evaluated each side of a pixel. Was 2: a leaf or flower reaches + // ~4.5 vines sideways (wobble + sway + its size), and at 2 they were cut off + // in visible straight lines. Lower it first if the Pi falls short of frame rate. const float VINE_WOBBLE = 5.0/360.0, SWAY_AMP = 2.0/360.0, SWAY_HZ = 0.25; const float STEM_W = 2.2/360.0, INK_W = 1.2/360.0; const float LEAF_STEP = 18.0/360.0, LEAF_LEN = 9.0/360.0, LEAF_WID = 3.8/360.0; const float FLOWER_FROM = 0.6, FLOWER_FULL = 0.9; // growth at which buds start, and are fully open const float FLOWER_R = 8.0/360.0, BUD_R = 2.5/360.0; const float SAT_MIN = 0.1, SAT_MAX = 0.95; +const float WOBBLE_FREQ = 21.6; // radians per screen height up a vine (the mockup's 0.06 / px) +const float LEAF_DROOP = 0.8, LEAF_DROOP_VAR = 0.3; // leaf angle below horizontal, and its random spread (radians) +const float SPROUT_H = 18.0/360.0; // a stem fades in, and a leaf or bud grows in, over this much vine below it +const float PETAL_R = 0.8, HEART_R = 0.45; // petal and yellow-centre radius, as fractions of the flower radius +const float PEN_MIN = 1.5; // thinnest line, render pixels: thinner ones are drawn this wide but fainter // Sun const vec2 SUN_POS = vec2(-0.16, 0.84); // x from the right edge (screen heights), y as a fraction of height const float SUN_R = 0.09, RAY_LEN = 0.07; diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl index f6478bc..231847f 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl @@ -1,4 +1,208 @@ -// Image — paper (stub; Tasks 3–4). iChannel0 = Buffer B, iChannel1 = Buffer C, iChannel2 = blobs +// Image — paper and plants (the sun is Task 4). iChannel0 = Buffer B, iChannel1 = Buffer C, iChannel2 = blobs +// p is in screen heights, origin bottom left, y up; screen-height y and UV y are the same number. +// +// Vine i stands at screen x i * VINE_SPACING. Where it crosses a run of body (B), it grows from +// the run's bottom g toward its top b: stem, leaves on alternating sides, and a bud at the tip +// that opens into a flower late. The drawing derives from B and the owner's growth every frame, +// so the plants ride along on the body. +// +// Each pixel looks at VINE_NEIGHBOURS vines each side and taps B three times per vine, on the +// vine's own x: at the pixel's height, and a flower's reach above and below, so leaves and +// flowers hanging past the edge of the body still find their run. The taps don't follow the +// wobble: a vine's runs must be the same from one pixel row to the next, or its leaves and +// flowers would be cut into strips (B's runs agree along a column for the same reason). One more +// tap, at the pixel, keeps stems inside the body, and a leaf taps B at its node, only on the +// pixels it covers, so leaves grow only from nodes on the body. + +const float TAU = 6.2831853; +const int NV = 2 * VINE_NEIGHBOURS + 1; +const int TAPS = 3; +const float REACH = FLOWER_R * (1.0 + PETAL_R); // a flower's outer radius (FLOWER_R is its petals' ring, as the mockup's r) +const float SAME_RUN = 0.5 * RUN_STEP_H; // two taps whose extents agree this closely found the same run + +float AA; // one render pixel, in screen heights + +float fillA(float sd) { return 1.0 - smoothstep(-0.6 * AA, 0.6 * AA, sd); } +float lineA(float sd, float w) { // a line of width w along sd = 0 + float wf = max(w, PEN_MIN * AA); // never thinner than the pen: fainter instead + return (w / wf) * (1.0 - smoothstep(0.5 * wf - 0.6 * AA, 0.5 * wf + 0.6 * AA, abs(sd))); +} + +// Premultiplied "over": lay colour c at coverage a on top of what's drawn so far. +void over(inout vec4 acc, vec3 c, float a) { acc = vec4(c * a, a) + acc * (1.0 - a); } +// A flat fill with an ink outline; k fades a shape in as it grows from nothing. +void inked(inout vec4 acc, vec3 c, float sd, float k) { + over(acc, c, fillA(sd) * k); + over(acc, INK, lineA(sd, INK_W) * k); +} + +vec3 hsl(float h, float s, float l) { // h in degrees + vec3 k = mod(vec3(0.0, 8.0, 4.0) + h / 30.0, 12.0); + return l - s * min(l, 1.0 - l) * clamp(min(k - 3.0, 9.0 - k), -1.0, 1.0); +} +vec3 desaturate(vec3 c, float sat) { return mix(vec3(dot(c, vec3(0.299, 0.587, 0.114))), c, sat / SAT_MAX); } +vec3 stemColour(float sat) { return hsl(105.0, sat, 0.28); } +vec3 leafColour(float sat) { return hsl(110.0, sat, 0.44); } +vec3 flowerColour(int i) { + float h = hash12(vec2(float(i), 9.7)); + return h < 0.25 ? PINK : h < 0.5 ? ORANGE : h < 0.75 ? VIOLET : RUST; +} + +// iq's vesica: a pointed leaf along y, half-length sqrt(r² − d²), half-width r − d. +float sdVesica(vec2 p, float r, float d) { + p = abs(p); + float b = sqrt(r * r - d * d); + return ((p.y - b) * d > p.x * b) ? length(p - vec2(0.0, b)) : length(p - vec2(-d, 0.0)) - r; +} + +// ── Body memory and growth ──────────────────────────────────────────────────── + +vec4 tapB(float x, float y) { // B under screen point (x, y); 0 off screen + float aspect = iResolution.x / iResolution.y; + if (y < 0.0 || y >= 1.0 || x < 0.0 || x >= aspect) return vec4(0.0); + return texelFetch(iChannel0, ivec2(vec2(x / aspect, y) * iChannelResolution[0].xy), 0); +} +// Is t on a body (or its held shape)? B's membership has hysteresis, so a flickering edge holds +// still; a softer test on raw presence here would bring the flicker back. +bool onBody(vec4 t) { return bOwner(t) > 0 && bHold(t) > 0.0; } + +// Growth for owner slot k (R7): the live person's, or while their plants rewind away, their ghost's. +float growthOf(int k) { + vec4 pr = cPerson(iChannel1, k); + if (abs(pr.x) > 0.5) return pr.y; + vec4 gh = cGhost(iChannel1, k); + return gh.w < LEAVE_S ? gh.z : 0.0; +} + +struct Run { float g, b, tip, f, sat; }; // bottom, top, tip (y), reach fraction G·hold, saturation + +// The run under screen point (x, y), if anything has grown on it. +bool tapRun(float x, float y, out Run r) { + r = Run(0.0, 0.0, 0.0, 0.0, 0.0); + vec4 t = tapB(x, y); + int own = bOwner(t); + if (own <= 0 || own > SLOTS || t.b < t.g) return false; + float h = t.r >= BODY_ON ? 1.0 : bHold(t); // present body is fully held (its stored hold is ≈ 0.998) + if (h <= 0.0) return false; + float G = growthOf(own - 1); + // The three taps are REACH apart, so they only find a shorter run from some rows: nothing + // grows on one (a ragged edge, a fragment), and a run growing past that length grows in. + float len = smoothstep(REACH, 1.5 * REACH, t.b - t.g); + r = Run(t.g, t.b, 0.0, G * h * len, mix(SAT_MIN, SAT_MAX, G)); + r.tip = mix(r.g, r.b, r.f); + return r.tip > r.g; +} + +// ── Vines ───────────────────────────────────────────────────────────────────── + +float vineHash(int i) { return hash12(vec2(float(i), 1.7)); } +float wobbleX(int i, float y) { + return float(i) * VINE_SPACING + VINE_WOBBLE * sin(y * WOBBLE_FREQ + TAU * vineHash(i)); +} +float swayX(int i, float y, Run r) { // grows up the run + float up = clamp((y - r.g) / max(r.b - r.g, 1e-3), 0.0, 1.0); + return SWAY_AMP * up * sin(TAU * SWAY_HZ * iTime - 3.0 * float(i) * VINE_SPACING + vineHash(i)); +} +float vineX(int i, float y, Run r) { return wobbleX(i, y) + swayX(i, y, r); } + +float sprout(float len) { return smoothstep(0.0, SPROUT_H, len); } + +void stem(inout vec4 acc, vec2 p, int i, Run r) { + float y = clamp(p.y, r.g, r.tip); + float s = VINE_WOBBLE * WOBBLE_FREQ * cos(y * WOBBLE_FREQ + TAU * vineHash(i)); // slope of the wobble + float dx = (p.x - vineX(i, y, r)) / sqrt(1.0 + s * s); + over(acc, stemColour(r.sat), lineA(length(vec2(dx, p.y - y)), STEM_W) * sprout(r.tip - r.g)); +} + +void leaves(inout vec4 acc, vec2 p, int i, Run r) { + float off = 0.5 + 0.5 * hash12(vec2(float(i), 3.1)); // the first leaf, in steps above g + float flip = step(0.5, hash12(vec2(float(i), 5.3))); + float down = 2.0 * LEAF_LEN + LEAF_WID; // a leaf hangs at most this far below its node + int j0 = max(int(ceil((p.y - LEAF_WID - r.g) / LEAF_STEP - off)), 0); + float rr = 0.5 * (LEAF_LEN * LEAF_LEN / LEAF_WID + LEAF_WID); // vesica of half-length LEAF_LEN, half-width LEAF_WID + float dd = rr - LEAF_WID; + for (int n = 2; n >= 0; n--) { // top leaf first, so lower leaves overlap it + int j = j0 + n; + float yj = r.g + (float(j) + off) * LEAF_STEP; + if (yj > p.y + down || yj >= r.tip) continue; + float k = sprout(r.tip - yj); // grows in as the tip passes + float side = mod(float(j) + flip, 2.0) < 0.5 ? 1.0 : -1.0; + float a = LEAF_DROOP + LEAF_DROOP_VAR * (2.0 * hash12(vec2(float(i), float(j) + 0.5)) - 1.0); + vec2 dir = vec2(side * cos(a), -sin(a)); + vec2 q = p - (vec2(vineX(i, yj, r), yj) + dir * LEAF_LEN * k); + vec2 lq = vec2(dot(q, vec2(-dir.y, dir.x)), dot(q, dir)); // across, along + float sd = sdVesica(lq / max(k, 1e-3), rr, dd) * k; + if (sd > INK_W + 2.0 * AA) continue; + if (!onBody(tapB(wobbleX(i, yj), yj))) continue; // leaves grow from nodes on the body + float o = min(1.0, 3.0 * k); // solid long before it's full size: flat, not a wash + inked(acc, leafColour(r.sat), sd, o); + float rib = length(vec2(lq.x, max(abs(lq.y) - 0.6 * LEAF_LEN, 0.0))); + over(acc, INK, lineA(rib, 0.6 * INK_W) * fillA(sd) * o); // midrib + } +} + +void bloom(inout vec4 acc, vec2 p, int i, Run r) { + float open = clamp((r.f - FLOWER_FROM) / (FLOWER_FULL - FLOWER_FROM), 0.0, 1.0); + // The bud sits on the stem's tip. Opening, the flower drifts to the vine's own x (on the body), + // so the wobble can't carry it further past the edge than its own size. + vec2 tip = vec2(vineX(i, r.tip, r), r.tip); + vec2 c = vec2(mix(tip.x, float(i) * VINE_SPACING + swayX(i, r.tip, r), open), r.tip); + if (length(p - c) > REACH + INK_W + 2.0 * AA && length(p - tip) > BUD_R + INK_W + 2.0 * AA) return; + float k = sprout(r.tip - r.g); + inked(acc, leafColour(r.sat), length(p - mix(tip, c, open)) - BUD_R * k, k); // the bud, later the calyx + if (open <= 0.0) return; + vec2 q = p - c; + float fr = mix(BUD_R, FLOWER_R, open); + float e = smoothstep(0.0, 0.25, open); // petals swell out of the bud + vec3 pc = desaturate(flowerColour(i), r.sat); + float a0 = TAU * hash12(vec2(float(i), 11.3)); + for (int n = 0; n < 5; n++) { + float a = a0 + TAU * float(n) / 5.0; + inked(acc, pc, length(q - fr * vec2(cos(a), sin(a))) - PETAL_R * fr * e, 1.0); + } + inked(acc, desaturate(YELLOW, r.sat), length(q) - HEART_R * fr * e, 1.0); +} + +// The plants over transparent, premultiplied. Stems and leaves vine by vine, then buds and flowers on top. +vec4 plants(vec2 fc) { + vec2 p = fc / iResolution.y; + int i0 = int(floor(p.x / VINE_SPACING + 0.5)); + Run runs[NV * TAPS]; + bool has[NV * TAPS]; + bool found = false; + for (int v = 0; v < NV; v++) { + float x = float(i0 + v - VINE_NEIGHBOURS) * VINE_SPACING; + for (int t = 0; t < TAPS; t++) { + int s = v * TAPS + t; + has[s] = tapRun(x, p.y + (t == 0 ? 0.0 : t == 1 ? REACH : -REACH), runs[s]); + for (int u = 0; u < t; u++) { // taps on the same run draw it once + int o = v * TAPS + u; + if (has[s] && has[o] && abs(runs[s].g - runs[o].g) < SAME_RUN && abs(runs[s].b - runs[o].b) < SAME_RUN) + has[s] = false; + } + found = found || has[s]; + } + } + vec4 acc = vec4(0.0); + if (!found) return acc; // nothing grows near: paper + bool inside = onBody(tapB(p.x, p.y)); // stems stay inside the body + for (int v = 0; v < NV; v++) { + int i = i0 + v - VINE_NEIGHBOURS; + for (int t = 0; t < TAPS; t++) { + if (!has[v * TAPS + t]) continue; + if (inside) stem(acc, p, i, runs[v * TAPS + t]); + leaves(acc, p, i, runs[v * TAPS + t]); + } + } + for (int v = 0; v < NV; v++) + for (int t = 0; t < TAPS; t++) + if (has[v * TAPS + t]) bloom(acc, p, i0 + v - VINE_NEIGHBOURS, runs[v * TAPS + t]); + return acc; +} + void mainImage(out vec4 o, in vec2 fc) { - o = vec4(PAPER, 1.0); + AA = 1.0 / iResolution.y; + vec3 col = PAPER; // Task 4: the sun goes here, under the plants + vec4 pl = plants(fc); + o = vec4(pl.rgb + col * (1.0 - pl.a), 1.0); } diff --git a/ShowControl/FundingCAPTCHA/test_watering_can.py b/ShowControl/FundingCAPTCHA/test_watering_can.py index 42d19b9..3d376b2 100644 --- a/ShowControl/FundingCAPTCHA/test_watering_can.py +++ b/ShowControl/FundingCAPTCHA/test_watering_can.py @@ -506,3 +506,180 @@ def test_state_stays_bounded_when_idle(ctx): # Review Focus assert np.abs(rig.buf("buffer_c")).max() <= 99.0 assert rig.ghost(0)[3] > INHERIT_S # the counters ran: the ghost is long gone rig.release() + + +# ── Task 3: plants (Image) ──────────────────────────────────────────────────── + +PAPER = np.array([246, 241, 228]) +FLOWER_R, LEAF_LEN = 8.0 / 360.0, 9.0 / 360.0 # shaders/watering_can/common.glsl +SUN_POS, SUN_R, RAY_LEN = (-0.16, 0.84), 0.09, 0.07 +FLOWER_RGB = np.array([[232, 149, 127], [232, 134, 47], [107, 91, 149], [188, 76, 38]]) # pink, orange, violet, rust +IMG = (256, 144) # image tests: big enough that petals have pure-colour pixels + + +def screen_xy(size): + """Pixel centres in screen heights, (h, w) each, row 0 = bottom.""" + w, h = size + ys, xs = np.mgrid[0:h, 0:w] + return (xs + 0.5) / h, (ys + 0.5) / h + + +def sun_region(size): + """Pixels the sun (Task 4) may draw on: within SUN_R + RAY_LEN of its centre.""" + x, y = screen_xy(size) + cx = size[0] / size[1] + SUN_POS[0] + return np.hypot(x - cx, y - SUN_POS[1]) <= SUN_R + RAY_LEN + + +def drawn(img, region=None): + """Pixels differing from paper by > 12 in any channel, within region (default: all but the sun's corner).""" + d = (np.abs(img[..., :3].astype(int) - PAPER) > 12).any(axis=-1) + if region is None: + region = ~sun_region((img.shape[1], img.shape[0])) + return d & region + + +def on_screen(mask, size): + """A Body mask (row 0 = top) as screen pixels (row 0 = bottom): the camera view is stretched to the screen.""" + w, h = size + ys, xs = np.mgrid[0:h, 0:w] + col = ((xs + 0.5) / w * BODY[0]).astype(int) + row = ((1 - (ys + 0.5) / h) * BODY[1]).astype(int) + return mask[row, col] + + +def rows(r0, r1): + """Body rows r0..r1 (row 0 = top) as a Body mask.""" + return rect(0, r0, BODY[0], r1) + + +def saturation(px): + """HSV saturation of uint8 RGB pixels (n, 3).""" + px = px.astype(float) + mx, mn = px.max(axis=-1), px.min(axis=-1) + return np.where(mx > 0, (mx - mn) / np.maximum(mx, 1), 0.0) + + +def flower_pixels(img): + """Pixels within 30 per channel of a flower colour.""" + rgb = img[..., :3].astype(int) + return (np.abs(rgb[..., None, :] - FLOWER_RGB).max(axis=-1) <= 30).any(axis=-1) + + +def grown_rig(ctx, frames, size=IMG, **kw): + rig = Rig(ctx, size=size) + rig.run([person(**kw)] * frames) + return rig + + +def test_run_ends_agree_along_a_column(ctx): # else the plants tear into rows + size = (640, 360) # the march's step is 5.6 px here + rig = Rig(ctx, size=size) + rig.run(flickering(20)) + b = rig.buf("buffer_b") + for col in (29.5, 32.5, 34.5): # inside the legs and torso (an edge is ragged under flicker) + c = b[:, int(col / BODY[0] * size[0])] + run = c[(c[:, 0] > 0.9) & (c[:, 2] > 0)] + assert len(run) > 100 + assert (np.ptp(run[:, 1:3], axis=0) < 1.0 / size[1]).all(), (col, np.ptp(run[:, 1:3], axis=0) * size[1]) + rig.release() + + +def test_sun_region_is_top_right(): + r, (w, h) = sun_region(IMG), IMG + assert r[int(0.84 * h), int(w - 0.16 * h)] # its centre + assert not r[: h // 2].any() and not r[:, : w // 2].any() # only in the top right quarter + + +def test_nothing_drawn_before_growth(ctx): # anti-goal: never draw the silhouette + rig = grown_rig(ctx, 5) + assert 0 < rig.person(0)[1] < 0.03, rig.person(0) # growing, barely + assert drawn(rig.image()).mean() < 0.002 + rig.release() + + +def test_bloom_fills_the_silhouette(ctx): + rig = grown_rig(ctx, 220) + assert rig.person(0)[1] > 0.99 + d, fig = drawn(rig.image()), on_screen(person(), IMG) + assert d[fig].mean() >= 0.7, d[fig].mean() + # Overhang is leaves and flowers only: within FLOWER_R + LEAF_LEN (and a pixel of antialiasing) of the figure. + x, y = screen_xy(IMG) + fx, fy = x[fig], y[fig] + out = d & ~fig + far = [np.hypot(fx - x[r, c], fy - y[r, c]).min() for r, c in np.argwhere(out)] + assert out.sum() > 0 and max(far) <= FLOWER_R + LEAF_LEN + 1.5 / IMG[1], max(far) + rig.release() + + +def test_growth_climbs_from_the_feet(ctx): + rig = grown_rig(ctx, 60) + assert abs(rig.person(0)[1] - 0.3) < 0.05, rig.person(0) + d, fig = drawn(rig.image()), person() + top, bottom = on_screen(fig & rows(6, 19), IMG), on_screen(fig & rows(31, 44), IMG) # thirds of rows 6..44 + assert d[bottom].mean() > 0.5, d[bottom].mean() + assert d[top].mean() < 0.1, d[top].mean() + rig.release() + + +def test_colour_ripens(ctx): + sats = [] + for frames, g in ((60, 0.3), (220, 1.0)): + rig = grown_rig(ctx, frames) + assert abs(rig.person(0)[1] - g) < 0.05, rig.person(0) + img = rig.image() + sats.append(saturation(img[..., :3][drawn(img)]).mean()) + rig.release() + assert sats[0] < sats[1] - 0.25, sats + + +def test_flowers_only_late(ctx): + found = [] + for frames, g in ((85, 0.4), (220, 1.0)): + rig = grown_rig(ctx, frames) + assert abs(rig.person(0)[1] - g) < 0.05, rig.person(0) + img = rig.image() + found.append((flower_pixels(img) & drawn(img)).sum()) + rig.release() + assert found[0] == 0 and found[1] > 100, found + + +def test_vines_follow_the_arm(ctx): + rig = grown_rig(ctx, 220, arm="up") + # The raised arm above the head (arm-only columns, clear of the shoulders' flowers). + arm = on_screen(rect(36, 0, 38, 8), IMG) + assert drawn(rig.image())[arm].mean() > 0.5, drawn(rig.image())[arm].mean() + rig.run([person(arm="down")] * 3) + assert drawn(rig.image())[arm].mean() < 0.1, drawn(rig.image())[arm].mean() + rig.release() + + +def test_bloom_is_stable_under_flicker(ctx): # Review Focus 3 + rig = Rig(ctx, size=IMG) + rig.run(flickering(220)) + fig = on_screen(person(), IMG) + prev, deltas = rig.image()[..., :3].astype(int), [] + for k in range(10): + rig.run(flickering(1, seed=100 + k)) + img = rig.image()[..., :3].astype(int) + deltas.append(np.abs(img - prev)[fig].mean()) + prev = img + assert max(deltas) < 6, deltas + rig.release() + + +def test_lost_person_plants_rewind_without_popping(ctx): + rig = grown_rig(ctx, 220) + n0, counts = drawn(rig.image()).sum(), [] + for _ in range(40): + rig.run([EMPTY]) + counts.append(drawn(rig.image()).sum()) + end = int(round((1.5 + LEAVE_S + 0.5) * 10)) + assert counts[end - 1] == 0, counts + # No popping: no camera frame loses more than 30% of the previous count. A steady fade over + # LEAVE_S loses ~5% of the bloom a frame, so near its end that is more than 30% of what's + # left: drops of up to 10% of the full bloom (twice the steady rate) are allowed too. + prev = [n0] + counts[:-1] + assert all(p - c <= max(0.3 * p, 0.1 * n0) for p, c in zip(prev, counts)), (n0, counts) + assert counts[4] > 0.5 * n0, (n0, counts) # it rewinds: still mostly there half a second in + rig.release() From 144d9d9833fb11b242d2341298c44b48a6b4ba6d Mon Sep 17 00:00:00 2001 From: ii-impy <314283863+ii-impy@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:12:39 -0500 Subject: [PATCH 09/22] Watering can: thin runs grow too Co-Authored-By: Claude Opus 5.5 --- .../shaders/watering_can/image.glsl | 26 ++++++++++++------- .../FundingCAPTCHA/test_watering_can.py | 21 ++++++++++++--- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl index 231847f..55cb09b 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl @@ -6,9 +6,10 @@ // that opens into a flower late. The drawing derives from B and the owner's growth every frame, // so the plants ride along on the body. // -// Each pixel looks at VINE_NEIGHBOURS vines each side and taps B three times per vine, on the -// vine's own x: at the pixel's height, and a flower's reach above and below, so leaves and -// flowers hanging past the edge of the body still find their run. The taps don't follow the +// Each pixel looks at VINE_NEIGHBOURS vines each side and taps B on the vine's own x: at the +// pixel's height, and a flower's reach above and below, so leaves and flowers hanging past the +// edge of the body still find their run; halfway between too, so a run thinner than a flower +// (an outstretched arm, a hand) is found from every row its plants cover. The taps don't follow the // wobble: a vine's runs must be the same from one pixel row to the next, or its leaves and // flowers would be cut into strips (B's runs agree along a column for the same reason). One more // tap, at the pixel, keeps stems inside the body, and a leaf taps B at its node, only on the @@ -16,7 +17,7 @@ const float TAU = 6.2831853; const int NV = 2 * VINE_NEIGHBOURS + 1; -const int TAPS = 3; +const int TAPS = 5; // offsets 0, ±REACH, ±REACH/2 (see plants()) const float REACH = FLOWER_R * (1.0 + PETAL_R); // a flower's outer radius (FLOWER_R is its petals' ring, as the mockup's r) const float SAME_RUN = 0.5 * RUN_STEP_H; // two taps whose extents agree this closely found the same run @@ -85,10 +86,7 @@ bool tapRun(float x, float y, out Run r) { float h = t.r >= BODY_ON ? 1.0 : bHold(t); // present body is fully held (its stored hold is ≈ 0.998) if (h <= 0.0) return false; float G = growthOf(own - 1); - // The three taps are REACH apart, so they only find a shorter run from some rows: nothing - // grows on one (a ragged edge, a fragment), and a run growing past that length grows in. - float len = smoothstep(REACH, 1.5 * REACH, t.b - t.g); - r = Run(t.g, t.b, 0.0, G * h * len, mix(SAT_MIN, SAT_MAX, G)); + r = Run(t.g, t.b, 0.0, G * h, mix(SAT_MIN, SAT_MAX, G)); r.tip = mix(r.g, r.b, r.f); return r.tip > r.g; } @@ -143,6 +141,9 @@ void leaves(inout vec4 acc, vec2 p, int i, Run r) { void bloom(inout vec4 acc, vec2 p, int i, Run r) { float open = clamp((r.f - FLOWER_FROM) / (FLOWER_FULL - FLOWER_FROM), 0.0, 1.0); + // The taps are REACH / 2 apart, so a run shorter than that isn't found from every row an open + // flower covers: on one (a ragged edge, a fragment) the flower stays a bud. Only the bloom. + open *= smoothstep(0.5 * REACH, 0.75 * REACH, r.b - r.g); // The bud sits on the stem's tip. Opening, the flower drifts to the vine's own x (on the body), // so the wobble can't carry it further past the edge than its own size. vec2 tip = vec2(vineX(i, r.tip, r), r.tip); @@ -174,7 +175,14 @@ vec4 plants(vec2 fc) { float x = float(i0 + v - VINE_NEIGHBOURS) * VINE_SPACING; for (int t = 0; t < TAPS; t++) { int s = v * TAPS + t; - has[s] = tapRun(x, p.y + (t == 0 ? 0.0 : t == 1 ? REACH : -REACH), runs[s]); + has[s] = false; + // The halfway taps only matter when the outer two aren't on one run: if they are, so is + // everything between (a run is unbroken), and the halfway taps would find it again. + if (t >= 3 && has[v * TAPS + 1] && has[v * TAPS + 2] + && abs(runs[v * TAPS + 1].g - runs[v * TAPS + 2].g) < SAME_RUN + && abs(runs[v * TAPS + 1].b - runs[v * TAPS + 2].b) < SAME_RUN) continue; + float dy = t == 0 ? 0.0 : t == 1 ? REACH : t == 2 ? -REACH : t == 3 ? 0.5 * REACH : -0.5 * REACH; + has[s] = tapRun(x, p.y + dy, runs[s]); for (int u = 0; u < t; u++) { // taps on the same run draw it once int o = v * TAPS + u; if (has[s] && has[o] && abs(runs[s].g - runs[o].g) < SAME_RUN && abs(runs[s].b - runs[o].b) < SAME_RUN) diff --git a/ShowControl/FundingCAPTCHA/test_watering_can.py b/ShowControl/FundingCAPTCHA/test_watering_can.py index 3d376b2..eb0467d 100644 --- a/ShowControl/FundingCAPTCHA/test_watering_can.py +++ b/ShowControl/FundingCAPTCHA/test_watering_can.py @@ -54,7 +54,7 @@ def __init__(self, ctx, size=SIZE, dt=DT, slot=0): def run(self, masks, frames_per_camera=3): for m in masks: # one mask per camera frame, row 0 = top fg = np.where(m, 2000, 0).astype(np.uint16) - body = pack_body(fg, {"x": 0, "y": 0, "w": BODY[0], "h": BODY[1]}, SLABS) + body = pack_body(fg, {"x": 0, "y": 0, "w": m.shape[1], "h": m.shape[0]}, SLABS) # usually BODY self.r.upload_body(body) self.blobs.update(body[..., 0] > 0, body[..., 1] / 255.0, now=self.t) for _ in range(frames_per_camera): @@ -543,8 +543,8 @@ def on_screen(mask, size): """A Body mask (row 0 = top) as screen pixels (row 0 = bottom): the camera view is stretched to the screen.""" w, h = size ys, xs = np.mgrid[0:h, 0:w] - col = ((xs + 0.5) / w * BODY[0]).astype(int) - row = ((1 - (ys + 0.5) / h) * BODY[1]).astype(int) + col = ((xs + 0.5) / w * mask.shape[1]).astype(int) + row = ((1 - (ys + 0.5) / h) * mask.shape[0]).astype(int) return mask[row, col] @@ -654,6 +654,21 @@ def test_vines_follow_the_arm(ctx): rig.release() +@pytest.mark.parametrize("thick", [6, 4]) # Body rows of a 96-row Body: 0.0625 and 0.042 screen heights +def test_horizontal_arm_blooms(ctx, thick): # a thin run grows to full bloom too: no bare stencil hole + # A finer Body than BODY, so the arm can be thinner than a flower and still be body after B's blur. + m = np.kron(person(arm="out"), np.ones((2, 2), dtype=bool)) # 128 x 96; the arm is rows 26-31 + m[26 + thick:32, 72:] = False # thin it from below + rig = Rig(ctx, size=IMG) + rig.run([m] * 220) + assert rig.person(0)[1] > 0.99 + arm = rect(0, 0, 0, 0).repeat(2, 0).repeat(2, 1) + arm[26:26 + thick, 76:100] = True # the outstretched arm, clear of the torso + d = drawn(rig.image())[on_screen(arm, IMG)].mean() + assert d >= 0.5, d + rig.release() + + def test_bloom_is_stable_under_flicker(ctx): # Review Focus 3 rig = Rig(ctx, size=IMG) rig.run(flickering(220)) From 18f959d33e311c9d185593aed636b28e06dc030c Mon Sep 17 00:00:00 2001 From: ii-impy <314283863+ii-impy@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:16:22 -0500 Subject: [PATCH 10/22] Watering can: coarse near-body grid so empty paper is cheap Co-Authored-By: Claude Opus 5.5 --- .../shaders/watering_can/buffer_c.glsl | 31 +++++++++++++++++-- .../shaders/watering_can/common.glsl | 17 ++++++++++ .../shaders/watering_can/image.glsl | 4 +-- .../FundingCAPTCHA/test_watering_can.py | 22 +++++++++++++ 4 files changed, 69 insertions(+), 5 deletions(-) diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl index 9c7a952..b05af54 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl @@ -1,6 +1,7 @@ // Buffer C — Garden state. iChannel0 = Buffer B, iChannel1 = Buffer C (itself), iChannel2 = blobs -// Layout: see common.glsl. Each texel reads the previous frame of every other texel; texels that -// need the same derived value (PERSON and BASE both need this frame's base) compute it from the same inputs. +// Layout: see common.glsl (rows 0–3 garden state, rows 4+ the near-body grid). Each texel reads the +// previous frame of every other texel; texels that need the same derived value (PERSON and BASE +// both need this frame's base) compute it from the same inputs. #define BB iChannel0 #define C iChannel1 @@ -107,6 +108,26 @@ vec4 ghostNext(int k, float dt) { return g; } +// ── NEAR ────────────────────────────────────────────────────────────────────── +// Is there body (or a held shape: anything a vine could grow on) within NEAR_PAD of cell (i, j)? +// Reads this frame's B, so the grid never lags the plants. +float nearNext(ivec2 cell) { + float aspect = iResolution.x / iResolution.y; + vec2 lo = vec2(cell) * NEAR_CELL - NEAR_PAD; + vec2 bRes = iChannelResolution[0].xy; + for (int y = 0; y < NEAR_TAPS; y++) { + float sy = lo.y + float(y) * NEAR_STEP; + if (sy < 0.0 || sy >= 1.0) continue; + for (int x = 0; x < NEAR_TAPS; x++) { + float sx = lo.x + float(x) * NEAR_STEP; + if (sx < 0.0 || sx >= aspect) continue; + vec4 t = texelFetch(BB, ivec2(vec2(sx / aspect, sy) * bRes), 0); + if (bOwner(t) > 0 && bHold(t) > 0.0) return 1.0; + } + } + return 0.0; +} + // ── SUN ─────────────────────────────────────────────────────────────────────── vec4 sunNext(float dt) { float raw = 0.0; @@ -123,7 +144,11 @@ vec4 sunNext(float dt) { void mainImage(out vec4 o, in vec2 fc) { ivec2 c = ivec2(fc); o = vec4(0.0); - if (c.y > 3 || c.x >= SLOTS || (c.y == 3 && c.x > 0)) return; + if (c.y >= 4) { // the near-body grid + if (c.y < 4 + NEAR_ROWS && float(c.x) * NEAR_CELL < iResolution.x / iResolution.y) o.x = nearNext(c - ivec2(0, 4)); + return; + } + if (c.x >= SLOTS || (c.y == 3 && c.x > 0)) return; if (iFrame == 0) { o = initial(c); return; } float dt = clamp(iTimeDelta, 0.0, T_CLAMP); diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl index d3776d6..96643a5 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl @@ -10,6 +10,8 @@ // (k, 1) BASE base x, base y (UV, smoothed), seconds since last walking step, record state last frame // (k, 2) GHOST spot x, y, growth at loss, seconds since loss (T_CLAMP = no ghost) // (0, 3) SUN sun level 0–1 (eased), raw total growth, —, — +// (i, 4 + j) NEAR 1 if body (or a held shape) lies within NEAR_PAD of grid cell (i, j), else 0; +// cell (i, j) is screen [i, i + 1] × [j, j + 1] · NEAR_CELL (screen heights). See cNear(). const int SLOTS = 8; const float T_CLAMP = 99.0; // every "seconds since" counter clamps here @@ -53,6 +55,18 @@ const float LEAF_DROOP = 0.8, LEAF_DROOP_VAR = 0.3; // leaf angle below horizo const float SPROUT_H = 18.0/360.0; // a stem fades in, and a leaf or bud grows in, over this much vine below it const float PETAL_R = 0.8, HEART_R = 0.45; // petal and yellow-centre radius, as fractions of the flower radius const float PEN_MIN = 1.5; // thinnest line, render pixels: thinner ones are drawn this wide but fainter +const float REACH = FLOWER_R * (1.0 + PETAL_R); // a flower's outer radius (FLOWER_R is its petals' ring, as the mockup's r); + // the Image taps B at ±REACH and ±REACH / 2 above and below each pixel +const float SHORT_BLOOM_FROM = 0.5, SHORT_BLOOM_FULL = 0.75; // a run shorter than these × REACH keeps a bud, not a flower + // (the taps are REACH / 2 apart: they don't find it from every row a flower covers) +// Near-body grid (Buffer C rows 4+): the Image skips cells with no body near, so empty paper costs one fetch. +const float NEAR_CELL = 1.0/32.0; // cell size; 4 + 1/NEAR_CELL rows of C must fit the render height (36 ≤ 72 at test size) +const float NEAR_STEP = 0.01; // B is sampled this far apart across a padded cell: finer than the thinnest body +// Furthest a pixel's B taps reach from it: sideways to its outermost vine, up and down to ±REACH. A pixel whose +// taps find no grown run draws nothing, so a cell whose padded box holds no body is paper. Plus a step of slack. +const float NEAR_PAD = max((float(VINE_NEIGHBOURS) + 0.5) * VINE_SPACING, REACH) + NEAR_STEP; +const int NEAR_ROWS = int(ceil(1.0 / NEAR_CELL)); +const int NEAR_TAPS = int(ceil((NEAR_CELL + 2.0 * NEAR_PAD) / NEAR_STEP)) + 1; // B samples per side of a padded cell // Sun const vec2 SUN_POS = vec2(-0.16, 0.84); // x from the right edge (screen heights), y as a fraction of height const float SUN_R = 0.09, RAY_LEN = 0.07; @@ -79,6 +93,9 @@ vec4 cPerson(sampler2D c, int k) { return texelFetch(c, ivec2(k, 0), 0); } vec4 cBase (sampler2D c, int k) { return texelFetch(c, ivec2(k, 1), 0); } vec4 cGhost (sampler2D c, int k) { return texelFetch(c, ivec2(k, 2), 0); } vec4 cSun (sampler2D c) { return texelFetch(c, ivec2(0, 3), 0); } +bool cNear (sampler2D c, vec2 screenPos) { // screenPos in screen heights; is any body near? + return texelFetch(c, ivec2(floor(screenPos / NEAR_CELL)) + ivec2(0, 4), 0).x > 0.5; +} // ── Body memory accessors (Buffer B .a = owner + 0.9 * hold) ────────────────── int bOwner(vec4 b) { return int(floor(b.a)); } diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl index 55cb09b..5767347 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl @@ -18,7 +18,6 @@ const float TAU = 6.2831853; const int NV = 2 * VINE_NEIGHBOURS + 1; const int TAPS = 5; // offsets 0, ±REACH, ±REACH/2 (see plants()) -const float REACH = FLOWER_R * (1.0 + PETAL_R); // a flower's outer radius (FLOWER_R is its petals' ring, as the mockup's r) const float SAME_RUN = 0.5 * RUN_STEP_H; // two taps whose extents agree this closely found the same run float AA; // one render pixel, in screen heights @@ -143,7 +142,7 @@ void bloom(inout vec4 acc, vec2 p, int i, Run r) { float open = clamp((r.f - FLOWER_FROM) / (FLOWER_FULL - FLOWER_FROM), 0.0, 1.0); // The taps are REACH / 2 apart, so a run shorter than that isn't found from every row an open // flower covers: on one (a ragged edge, a fragment) the flower stays a bud. Only the bloom. - open *= smoothstep(0.5 * REACH, 0.75 * REACH, r.b - r.g); + open *= smoothstep(SHORT_BLOOM_FROM * REACH, SHORT_BLOOM_FULL * REACH, r.b - r.g); // The bud sits on the stem's tip. Opening, the flower drifts to the vine's own x (on the body), // so the wobble can't carry it further past the edge than its own size. vec2 tip = vec2(vineX(i, r.tip, r), r.tip); @@ -167,6 +166,7 @@ void bloom(inout vec4 acc, vec2 p, int i, Run r) { // The plants over transparent, premultiplied. Stems and leaves vine by vine, then buds and flowers on top. vec4 plants(vec2 fc) { vec2 p = fc / iResolution.y; + if (!cNear(iChannel1, p)) return vec4(0.0); // no body near (Buffer C's grid): paper, one fetch int i0 = int(floor(p.x / VINE_SPACING + 0.5)); Run runs[NV * TAPS]; bool has[NV * TAPS]; diff --git a/ShowControl/FundingCAPTCHA/test_watering_can.py b/ShowControl/FundingCAPTCHA/test_watering_can.py index eb0467d..c1e777c 100644 --- a/ShowControl/FundingCAPTCHA/test_watering_can.py +++ b/ShowControl/FundingCAPTCHA/test_watering_can.py @@ -585,6 +585,28 @@ def test_run_ends_agree_along_a_column(ctx): # else the plants tear int rig.release() +NEAR_CELL = 1 / 32 # shaders/watering_can/common.glsl +NEAR_PAD = max(4.5 * 4.5 / 360, 1.8 * FLOWER_R) + 0.01 # (VINE_NEIGHBOURS + 0.5) * VINE_SPACING vs REACH, + NEAR_STEP + + +def test_near_grid_marks_body_neighbourhood(ctx): + rig = Rig(ctx) # SIZE: 4 + 32 grid rows fit in 72 + rig.run([person()] * 10) + c = rig.buf("buffer_c") + aspect = SIZE[0] / SIZE[1] + + def near(x_sh, y_sh): # the cell holding screen point (x, y), screen heights + return c[4 + int(y_sh / NEAR_CELL), int(x_sh / NEAR_CELL), 0] + + y = 1 - 18 / BODY[1] # torso height + edge = 38 / BODY[0] * aspect # the figure's right edge (arm hanging at x 35..38) + assert near(32 / BODY[0] * aspect, y) == 1 # under the torso + assert near(edge + 0.5 * NEAR_PAD, y) == 1 # just outside, within NEAR_PAD + assert near(edge + NEAR_PAD + 1.5 * NEAR_CELL, y) == 0 # beyond NEAR_PAD + assert near(0.05, 0.05) == 0 and near(aspect - 0.05, 0.5) == 0 + rig.release() + + def test_sun_region_is_top_right(): r, (w, h) = sun_region(IMG), IMG assert r[int(0.84 * h), int(w - 0.16 * h)] # its centre From abd06f2587134f8b77084d28816144e0ff5a7cad Mon Sep 17 00:00:00 2001 From: ii-impy <314283863+ii-impy@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:19:34 -0500 Subject: [PATCH 11/22] Watering can: crayon sun Co-Authored-By: Claude Opus 5.5 --- .../shaders/watering_can/common.glsl | 7 ++++ .../shaders/watering_can/image.glsl | 37 ++++++++++++++++++- .../FundingCAPTCHA/test_watering_can.py | 32 ++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl index 96643a5..d4c2115 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl @@ -70,6 +70,13 @@ const int NEAR_TAPS = int(ceil((NEAR_CELL + 2.0 * NEAR_PAD) / NEAR_STEP)) + 1; // Sun const vec2 SUN_POS = vec2(-0.16, 0.84); // x from the right edge (screen heights), y as a fraction of height const float SUN_R = 0.09, RAY_LEN = 0.07; +const int SUN_RAYS = 12; // crayon rays at full level +const float SUN_MUTE = 0.8; // paper mixed into the yellow at level 0 (0 = full yellow) +const float SUN_EDGE_WOBBLE = 0.07; // the disc's edge is broken by noise: ± this fraction of SUN_R +const float SUN_GRAIN = 45.0; // crayon grain scale, cycles per screen height +const float SUN_COVER = 0.4; // grain threshold: higher = more paper shows through the fill +const float SUN_RAY_W = 0.011; // ray half-width (screen heights), tapering to a point +const float SUN_RAY_FROM = 1.2; // rays start this many SUN_R from the centre // ── Palette ─────────────────────────────────────────────────────────────────── const vec3 PAPER = vec3(0.965, 0.945, 0.894); // #f6f1e4 diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl index 5767347..6da1c1b 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl @@ -1,4 +1,4 @@ -// Image — paper and plants (the sun is Task 4). iChannel0 = Buffer B, iChannel1 = Buffer C, iChannel2 = blobs +// Image — paper, the crayon sun and plants. iChannel0 = Buffer B, iChannel1 = Buffer C, iChannel2 = blobs // p is in screen heights, origin bottom left, y up; screen-height y and UV y are the same number. // // Vine i stands at screen x i * VINE_SPACING. Where it crosses a run of body (B), it grows from @@ -208,9 +208,42 @@ vec4 plants(vec2 fc) { return acc; } +// The crayon sun: a waxy, grain-filled disc with a noise-broken edge, and rays in the same hand. +// Level 0 is a muted, paper-mixed yellow with no rays; level 1 is full yellow with its rays out. +vec3 sunColour(vec2 fc) { + vec2 d = fc / iResolution.y - vec2(iResolution.x / iResolution.y + SUN_POS.x, SUN_POS.y); + float r = length(d); + if (r > SUN_R * (1.0 + SUN_EDGE_WOBBLE) + RAY_LEN) return PAPER; + float lv = clamp(cSun(iChannel1).x, 0.0, 1.0); + float t = iTime * 0.3; // a slow wobble; nothing is stored + float grain = 0.6 * noise(d * SUN_GRAIN + t) + 0.4 * noise(vec2(d.x + d.y, d.x - d.y) * vec2(SUN_GRAIN * 0.2, SUN_GRAIN * 1.3)); + float cov = 0.0; + float edge = SUN_R * (1.0 + SUN_EDGE_WOBBLE * (2.0 * noise(d * 25.0 + t) - 1.0)); + if (r < edge) { + float rim = smoothstep(0.75, 1.0, r / edge); // the stroke round the edge is pressed harder + cov = smoothstep(SUN_COVER - 0.03, SUN_COVER + 0.03, grain + 0.25 * rim); + } + float len = RAY_LEN * lv; + float r0 = SUN_R * SUN_RAY_FROM; + if (len > 0.003 && r > r0 && r < SUN_R + len) { + float a = atan(d.y, d.x) / TAU * float(SUN_RAYS) + 0.02 * t; + float id = floor(a + 0.5); + float rl = (SUN_R + len - r0) * (0.8 + 0.2 * hash12(vec2(id, 3.1))); + float u = (r - r0) / rl; // 0 at the root, 1 at the tip + float perp = (a - id + 0.1 * (hash12(vec2(id, 7.7)) - 0.5)) * TAU * r / float(SUN_RAYS); + float w = SUN_RAY_W * (1.0 - 0.6 * u) * (0.85 + 0.3 * noise(vec2(u * 6.0, id))); + if (u < 1.0 && abs(perp) < w) { + float rg = smoothstep(SUN_COVER - 0.2, SUN_COVER - 0.1, 0.7 * noise(d * SUN_GRAIN + 9.0) + 0.25 * (1.0 - u) + 0.3); + cov = max(cov, rg); + } + } + vec3 c = mix(mix(YELLOW, PAPER, SUN_MUTE), YELLOW, lv); + return mix(PAPER, c, cov); +} + void mainImage(out vec4 o, in vec2 fc) { AA = 1.0 / iResolution.y; - vec3 col = PAPER; // Task 4: the sun goes here, under the plants + vec3 col = sunColour(fc); // the sun, under the plants vec4 pl = plants(fc); o = vec4(pl.rgb + col * (1.0 - pl.a), 1.0); } diff --git a/ShowControl/FundingCAPTCHA/test_watering_can.py b/ShowControl/FundingCAPTCHA/test_watering_can.py index c1e777c..496fd43 100644 --- a/ShowControl/FundingCAPTCHA/test_watering_can.py +++ b/ShowControl/FundingCAPTCHA/test_watering_can.py @@ -720,3 +720,35 @@ def test_lost_person_plants_rewind_without_popping(ctx): assert all(p - c <= max(0.3 * p, 0.1 * n0) for p, c in zip(prev, counts)), (n0, counts) assert counts[4] > 0.5 * n0, (n0, counts) # it rewinds: still mostly there half a second in rig.release() + + +# ── Task 4: the crayon sun (Image) ──────────────────────────────────────────── + +def sun_patch(img): + """(disc, ring) masks: pixels inside the sun's disc, and in the ring where its rays reach.""" + size = (img.shape[1], img.shape[0]) + x, y = screen_xy(size) + r = np.hypot(x - (size[0] / size[1] + SUN_POS[0]), y - SUN_POS[1]) + return r <= 0.85 * SUN_R, (r >= 1.2 * SUN_R) & (r <= SUN_R + RAY_LEN) + + +def test_sun_is_muted_when_empty(ctx): + rig = Rig(ctx, size=IMG) + rig.run([EMPTY] * 30) + img = rig.image() + disc, ring = sun_patch(img) + d = drawn(img, disc) + assert d.sum() > 0.3 * disc.sum(), (d.sum(), disc.sum()) # the sun is drawn, not paper + assert saturation(img[..., :3][d]).mean() < 0.35 + assert drawn(img, ring).sum() == 0 # no rays + rig.release() + + +def test_sun_brightens_and_grows_rays_with_bloom(ctx): + rig = grown_rig(ctx, 220) + img = rig.image() + disc, ring = sun_patch(img) + d = drawn(img, disc) + assert saturation(img[..., :3][d]).mean() > 0.7 + assert drawn(img, ring).sum() > 0.15 * ring.sum(), (drawn(img, ring).sum(), ring.sum()) + rig.release() From fd53d9919fda9bef034cdab5e73f743d9fb0e64a Mon Sep 17 00:00:00 2001 From: ii-impy <314283863+ii-impy@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:27:14 -0500 Subject: [PATCH 12/22] Watering can: tuning pass and docs - Constants changed: none retuned (stills at 640x360 matched the Dense look). Added named sun constants to common.glsl in place of inline literals (SUN_WOBBLE_RATE, SUN_DRIFT, SUN_EDGE_SCALE, SUN_RIM_*, SUN_FILL_SOFT, SUN_GRAIN_A, SUN_HATCH, SUN_RAY_*), values unchanged. - Fix: the sun ray at atan's branch cut had two hash ids (-6 and 6) and popped as drift carried it across; the id is now wrapped with mod(id, SUN_RAYS). Test added (RED on the old image.glsl, GREEN now). - fps with --mock-camera --windowed: 30.1-30.2 (frame cap) over 2 min on a desktop GPU. Not measured on the Pi 5. - NOT verified with the real camera: the walking threshold, flicker immunity and re-ID inheritance. On site with --camera: a visitor stands 25 s and reaches full bloom with no rewind; waving arms does not rewind; one sideways step visibly rewinds. - Mouse (--test-input) check not done by eye (no human at the screen). Shadertoy pasting not checked (no browser). - Docs: watering_can added to "Setups in rotation". Co-Authored-By: Claude Opus 5.5 --- .../shaders/watering_can/common.glsl | 15 ++++++++++ .../shaders/watering_can/image.glsl | 24 +++++++-------- .../FundingCAPTCHA/test_watering_can.py | 30 +++++++++++++++++++ docs/FundingCAPTCHA.md | 2 +- 4 files changed, 58 insertions(+), 13 deletions(-) diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl index d4c2115..8b597d3 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl @@ -77,6 +77,21 @@ const float SUN_GRAIN = 45.0; // crayon grain scale, cycles per scree const float SUN_COVER = 0.4; // grain threshold: higher = more paper shows through the fill const float SUN_RAY_W = 0.011; // ray half-width (screen heights), tapering to a point const float SUN_RAY_FROM = 1.2; // rays start this many SUN_R from the centre +const float SUN_WOBBLE_RATE = 0.3; // the wobble's clock: noise offsets move this fast per second +const float SUN_DRIFT = 0.02; // rays turn this many ray-widths per wobble-second (slow drift) +const float SUN_EDGE_SCALE = 25.0; // scale of the noise that breaks the disc's edge, cycles per screen height +const float SUN_RIM_FROM = 0.75, SUN_RIM_WEIGHT = 0.25; // the pressed stroke starts at this fraction of the edge; extra grain it adds +const float SUN_FILL_SOFT = 0.03; // softness of the fill's grain threshold +const float SUN_GRAIN_A = 0.6; // weight of the round grain; the stretched (hatching) grain gets the rest +const vec2 SUN_HATCH = vec2(0.2, 1.3); // the hatching grain's scale along and across the stroke, × SUN_GRAIN +const float SUN_RAY_MIN = 0.003; // rays shorter than this (screen heights) aren't drawn +const float SUN_RAY_LEN_JITTER = 0.2; // each ray's length varies by up to this fraction below full +const float SUN_RAY_SKEW = 0.1; // each ray's angle varies by ± half this many ray spacings +const float SUN_RAY_TAPER = 0.6; // a ray's width shrinks by this fraction toward its tip +const float SUN_RAY_W_JITTER = 0.3; // width varies by ± half this fraction, along the ray (noise rate SUN_RAY_W_RATE) +const float SUN_RAY_W_RATE = 6.0; +const float SUN_RAY_GRAIN = 0.7, SUN_RAY_FADE = 0.25, SUN_RAY_BIAS = 0.3; // ray grain weight; extra fill near the root; base fill +const float SUN_RAY_SOFT = 0.1; // softness of the ray grain threshold // ── Palette ─────────────────────────────────────────────────────────────────── const vec3 PAPER = vec3(0.965, 0.945, 0.894); // #f6f1e4 diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl index 6da1c1b..956816e 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl @@ -215,25 +215,25 @@ vec3 sunColour(vec2 fc) { float r = length(d); if (r > SUN_R * (1.0 + SUN_EDGE_WOBBLE) + RAY_LEN) return PAPER; float lv = clamp(cSun(iChannel1).x, 0.0, 1.0); - float t = iTime * 0.3; // a slow wobble; nothing is stored - float grain = 0.6 * noise(d * SUN_GRAIN + t) + 0.4 * noise(vec2(d.x + d.y, d.x - d.y) * vec2(SUN_GRAIN * 0.2, SUN_GRAIN * 1.3)); + float t = iTime * SUN_WOBBLE_RATE; // a slow wobble; nothing is stored + float grain = SUN_GRAIN_A * noise(d * SUN_GRAIN + t) + (1.0 - SUN_GRAIN_A) * noise(vec2(d.x + d.y, d.x - d.y) * SUN_GRAIN * SUN_HATCH); float cov = 0.0; - float edge = SUN_R * (1.0 + SUN_EDGE_WOBBLE * (2.0 * noise(d * 25.0 + t) - 1.0)); + float edge = SUN_R * (1.0 + SUN_EDGE_WOBBLE * (2.0 * noise(d * SUN_EDGE_SCALE + t) - 1.0)); if (r < edge) { - float rim = smoothstep(0.75, 1.0, r / edge); // the stroke round the edge is pressed harder - cov = smoothstep(SUN_COVER - 0.03, SUN_COVER + 0.03, grain + 0.25 * rim); + float rim = smoothstep(SUN_RIM_FROM, 1.0, r / edge); // the stroke round the edge is pressed harder + cov = smoothstep(SUN_COVER - SUN_FILL_SOFT, SUN_COVER + SUN_FILL_SOFT, grain + SUN_RIM_WEIGHT * rim); } float len = RAY_LEN * lv; float r0 = SUN_R * SUN_RAY_FROM; - if (len > 0.003 && r > r0 && r < SUN_R + len) { - float a = atan(d.y, d.x) / TAU * float(SUN_RAYS) + 0.02 * t; - float id = floor(a + 0.5); - float rl = (SUN_R + len - r0) * (0.8 + 0.2 * hash12(vec2(id, 3.1))); + if (len > SUN_RAY_MIN && r > r0 && r < SUN_R + len) { + float a = atan(d.y, d.x) / TAU * float(SUN_RAYS) + SUN_DRIFT * t; + float id = mod(floor(a + 0.5), float(SUN_RAYS)); // atan's branch cut splits one ray in two: wrap its id + float rl = (SUN_R + len - r0) * (1.0 - SUN_RAY_LEN_JITTER + SUN_RAY_LEN_JITTER * hash12(vec2(id, 3.1))); float u = (r - r0) / rl; // 0 at the root, 1 at the tip - float perp = (a - id + 0.1 * (hash12(vec2(id, 7.7)) - 0.5)) * TAU * r / float(SUN_RAYS); - float w = SUN_RAY_W * (1.0 - 0.6 * u) * (0.85 + 0.3 * noise(vec2(u * 6.0, id))); + float perp = (a - floor(a + 0.5) + SUN_RAY_SKEW * (hash12(vec2(id, 7.7)) - 0.5)) * TAU * r / float(SUN_RAYS); + float w = SUN_RAY_W * (1.0 - SUN_RAY_TAPER * u) * (1.0 - 0.5 * SUN_RAY_W_JITTER + SUN_RAY_W_JITTER * noise(vec2(u * SUN_RAY_W_RATE, id))); if (u < 1.0 && abs(perp) < w) { - float rg = smoothstep(SUN_COVER - 0.2, SUN_COVER - 0.1, 0.7 * noise(d * SUN_GRAIN + 9.0) + 0.25 * (1.0 - u) + 0.3); + float rg = smoothstep(SUN_COVER - 2.0 * SUN_RAY_SOFT, SUN_COVER - SUN_RAY_SOFT, SUN_RAY_GRAIN * noise(d * SUN_GRAIN + 9.0) + SUN_RAY_FADE * (1.0 - u) + SUN_RAY_BIAS); cov = max(cov, rg); } } diff --git a/ShowControl/FundingCAPTCHA/test_watering_can.py b/ShowControl/FundingCAPTCHA/test_watering_can.py index 496fd43..e290854 100644 --- a/ShowControl/FundingCAPTCHA/test_watering_can.py +++ b/ShowControl/FundingCAPTCHA/test_watering_can.py @@ -752,3 +752,33 @@ def test_sun_brightens_and_grows_rays_with_bloom(ctx): assert saturation(img[..., :3][d]).mean() > 0.7 assert drawn(img, ring).sum() > 0.15 * ring.sum(), (drawn(img, ring).sum(), ring.sum()) rig.release() + + +def seam_extents(img): + """Farthest drawn radius from the sun's centre in the two pixel rows of the left-pointing ray that are just above + and just below atan's branch cut (the ray's axis when it hasn't drifted). 0 where the ray isn't drawn in a row.""" + size = (img.shape[1], img.shape[0]) + x, y = screen_xy(size) + dx, dy = x - (size[0] / size[1] + SUN_POS[0]), y - SUN_POS[1] + r = np.hypot(dx, dy) + ray = (dx < 0) & (r > 1.3 * SUN_R) & (r < SUN_R + RAY_LEN + 0.02) + d = (np.abs(img[..., :3].astype(int) - PAPER) > 12).any(axis=-1) + row = 1.0 / size[1] + far = lambda m: r[m].max(initial=0.0) + return far(ray & d & (dy > 0) & (dy < row)), far(ray & d & (dy <= 0) & (dy > -row)) + + +def test_sun_ray_has_no_seam_at_the_atan_cut(ctx): + # The ray pointing left lies on atan's branch cut, and the slow drift (one ray width in ~170 s) carries it across. + # It must stay one ray, not two halves with their own length. Drift is a function of iTime: sample it directly. + rig = grown_rig(ctx, 220, size=(1024, 576)) + straddled = 0 + for k in range(16): # iTime 0–3.75 s: the ray is within a pixel or two of the cut + rig.r.time = 0.25 * k + rig.r.render(1e-4, (0, 0, 0, 0)) + up, down = seam_extents(rig.image()) + if up and down: + straddled += 1 + assert abs(up - down) < 0.0025, (up, down, rig.r.time) # 0.0025 = 1.4 px of this render + assert straddled >= 4, straddled # the ray did sit on the cut + rig.release() diff --git a/docs/FundingCAPTCHA.md b/docs/FundingCAPTCHA.md index b5aceb9..40ad58b 100644 --- a/docs/FundingCAPTCHA.md +++ b/docs/FundingCAPTCHA.md @@ -58,7 +58,7 @@ Keys at runtime: Alternative show mode: instead of the Game Arc, cycles Shadertoy **Setups** from `shaders/` with the depth silhouette as Buffer A (ADR-0021). Same camera, calibration, and settings as `app.py`. Prototyping guide: [`shaders/README.md`](../ShowControl/FundingCAPTCHA/shaders/README.md). -Setups in rotation: `ripples` (example), `endless_sunset` ([design](superpowers/specs/2026-10-30-endless-sunset-design.md); tunables at the top of its `common.glsl`), `hunter_bird` ([design](superpowers/specs/2026-11-04-hunter-bird-design.md); a bird's-eye debug view of the blob tracker — tunables, including the motion noise floor, at the top of its `common.glsl`). +Setups in rotation: `ripples` (example), `endless_sunset` ([design](superpowers/specs/2026-10-30-endless-sunset-design.md); tunables at the top of its `common.glsl`), `hunter_bird` ([design](superpowers/specs/2026-11-04-hunter-bird-design.md); a bird's-eye debug view of the blob tracker — tunables, including the motion noise floor, at the top of its `common.glsl`), `watering_can` ([design](superpowers/specs/2026-11-06-watering-can-design.md); a visitor standing still grows vines, leaves and flowers over their silhouette in ~20 s, and stepping sideways rewinds them — tunables, including `WALK_DIST`, `GROW_S` and `REWIND_S`, at the top of its `common.glsl`). Setups can also read the tracker's **blob records** (boxes, stable IDs, lifecycle) through the `blobs` channel source (ADR-0022). `blob_debug` is the reference reader, outside the rotation: `python3 shader_app.py --mock-camera --windowed --setup blob_debug`. From 7b5d00ac0430eed78b9ae1354ecc6c9f517a11ba Mon Sep 17 00:00:00 2001 From: ii-impy <314283863+ii-impy@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:42:59 -0500 Subject: [PATCH 13/22] Watering can: fade holds step together per person; test fixes from final review - Buffer B: a lost person's hold is rounded with one frame-wide golden-ratio offset instead of a per-texel hash, and every texel of theirs lets go the frame the Body leaves (not as eased presence crosses BODY_OFF), so a run's texels agree on the hold all the way through the fade and vines don't tear. - Buffer C: the sun's easing is stochastically rounded to GROW_Q so it reaches full instead of stalling at ~0.96-0.98 in a half float. - Tests: constants mirrored from common.glsl are parsed from it; new test_fading_hold_agrees_along_a_run; walker test checks body texels hold ~1; merge test uses unequal levels; sun test requires > 0.99 at 30 and 60 fps. Co-Authored-By: Claude Opus 5.5 --- .../shaders/watering_can/buffer_b.glsl | 19 ++-- .../shaders/watering_can/buffer_c.glsl | 10 +- .../FundingCAPTCHA/test_watering_can.py | 93 +++++++++++++++---- 3 files changed, 96 insertions(+), 26 deletions(-) diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_b.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_b.glsl index 155f5df..1a30c53 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_b.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_b.glsl @@ -37,7 +37,7 @@ int ownerAt(vec2 uv) { // so a flickering edge holds still. (A held, fading texel has less than full hold: not body.) bool wasBody(vec4 t) { bool run = t.g > 0.0 || t.b > 0.0; - return t.r >= BODY_ON || (run && t.r >= BODY_OFF && fract(t.a) > floor(0.9 / HOLD_Q) * HOLD_Q - 0.5 * HOLD_Q); + return fract(t.a) > floor(0.9 / HOLD_Q) * HOLD_Q - 0.5 * HOLD_Q && (t.r >= BODY_ON || (run && t.r >= BODY_OFF)); } bool bodyAt(float x, float y) { return wasBody(texelFetch(iChannel1, ivec2(min(vec2(x, y) * iResolution.xy, iResolution.xy - 1.0)), 0)); @@ -72,24 +72,29 @@ void mainImage(out vec4 o, in vec2 fc) { float g = 0.0, b = 0.0, a = 0.0; bool had = prev.g > 0.0 || prev.b > 0.0; - if (pres >= BODY_ON || (wasBody(prev) && pres >= BODY_OFF)) { + // The body has gone from under a person whose track is missing: every texel of theirs lets go this + // frame, not as its eased presence crosses BODY_OFF (sooner at the edges), so their hold fades in step. + int po = bOwner(prev); + float pst = po > 0 ? recIdn(iChannel2, po - 1).y : S_EMPTY; + bool leaving = po > 0 && target < BODY_OFF && !isState(pst, S_TENTATIVE) && !isState(pst, S_CONFIRMED); + if (!leaving && (pres >= BODY_ON || (wasBody(prev) && pres >= BODY_OFF))) { float lo = march(uv, -1.0), hi = march(uv, 1.0); float ease = 1.0 - exp(-iTimeDelta / RUN_EASE_S); g = (!had || abs(lo - prev.g) > RUN_SNAP) ? lo : mix(prev.g, lo, ease); b = (!had || abs(hi - prev.b) > RUN_SNAP) ? hi : mix(prev.b, hi, ease); a = float(ownerAt(uv)) + floor(0.9 / HOLD_Q) * HOLD_Q; } else { - int po = bOwner(prev); float ph = bHold(prev); if (po > 0 && ph > 0.0) { - float st = recIdn(iChannel2, po - 1).y; // A missing body coasts first: keep its plants until the track is lost, then rewind them away. - if (!isState(st, S_TENTATIVE) && !isState(st, S_CONFIRMED)) { + if (!isState(pst, S_TENTATIVE) && !isState(pst, S_CONFIRMED)) { // The step is below a half float's resolution at high render rates, so round it - // stochastically to HOLD_Q: the expected fade is exact at any rate. + // stochastically to HOLD_Q: the expected fade is exact at any rate. The offset depends on + // the frame only, so every texel of a person steps together and their vines don't tear + // into rows; a golden-ratio sequence, not a hash, so the count of steps stays near exact. float f = fract(prev.a); float d = 0.9 * iTimeDelta / LEAVE_S; - f -= floor(d / HOLD_Q + hash12(fc + vec2(113.0, 57.0) * float(iFrame % 997))) * HOLD_Q; + f -= floor(d / HOLD_Q + fract(float(iFrame % 4096) * 0.618034)) * HOLD_Q; if (f > 0.5 * HOLD_Q) { g = prev.g; b = prev.b; a = float(po) + f; } } } diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl index b05af54..3fe2dde 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl @@ -129,7 +129,7 @@ float nearNext(ivec2 cell) { } // ── SUN ─────────────────────────────────────────────────────────────────────── -vec4 sunNext(float dt) { +vec4 sunNext(float dt, vec2 fc) { float raw = 0.0; for (int k = 0; k < SLOTS; k++) { if (live(recIdn(BLOBS, k).y) && !fresh(k)) raw += cPerson(C, k).y; @@ -137,7 +137,11 @@ vec4 sunNext(float dt) { raw += g.z * max(0.0, 1.0 - g.w / LEAVE_S); } float lvl = cSun(C).x; - lvl = mix(lvl, clamp(raw / SUN_FULL, 0.0, 1.0), 1.0 - exp(-dt / SUN_EASE_S)); + float step = (clamp(raw / SUN_FULL, 0.0, 1.0) - lvl) * (1.0 - exp(-dt / SUN_EASE_S)); + // Near the target the step is below a half float's resolution and the easing would stall short of + // full: round it stochastically to GROW_Q, as growth is, so the expected easing is exact. + float h = hash12(fc + vec2(113.0, 57.0) * float(iFrame % 997)); + lvl = clamp(lvl + sign(step) * floor(abs(step) / GROW_Q + h) * GROW_Q, 0.0, 1.0); return vec4(lvl, raw, 0.0, 0.0); } @@ -155,5 +159,5 @@ void mainImage(out vec4 o, in vec2 fc) { if (c.y == 0) o = personNext(c.x, dt, fc); else if (c.y == 1) o = baseNext(c.x, dt); else if (c.y == 2) o = ghostNext(c.x, dt); - else o = sunNext(dt); + else o = sunNext(dt, fc); } diff --git a/ShowControl/FundingCAPTCHA/test_watering_can.py b/ShowControl/FundingCAPTCHA/test_watering_can.py index e290854..800bf04 100644 --- a/ShowControl/FundingCAPTCHA/test_watering_can.py +++ b/ShowControl/FundingCAPTCHA/test_watering_can.py @@ -6,7 +6,9 @@ """ from __future__ import annotations +import math import os +import re from pathlib import Path import numpy as np @@ -16,11 +18,41 @@ from shadertoy import Renderer, load_setup, pack_body SETUP = Path(__file__).parent / "shaders" / "watering_can" + + +def glsl_consts(path=SETUP / "common.glsl") -> dict: + """Every `const float|int|vec2|vec3 NAME = , ...;` in common.glsl, evaluated: simple arithmetic, + earlier constants, max/min/ceil, float(...)/int(...); vec2/vec3 become tuples.""" + src = re.sub(r"//[^\n]*", "", path.read_text()) + env = {"max": max, "min": min, "ceil": math.ceil, "float": float, "int": int, + "vec2": lambda *a: tuple(float(x) for x in a), "vec3": lambda *a: tuple(float(x) for x in a)} + out = {} + for m in re.finditer(r"\bconst\s+(?:float|int|vec2|vec3)\s+([^;]*);", src): + depth, start, decls = 0, 0, [] + body = m.group(1) + for i, ch in enumerate(body + ","): # split on top-level commas only + depth += (ch == "(") - (ch == ")") + if ch == "," and depth == 0: + decls.append(body[start:i]); start = i + 1 + for d in decls: + name, expr = (x.strip() for x in d.split("=", 1)) + out[name] = env[name] = eval(expr, {"__builtins__": {}}, env) + return out + + +class _Consts(dict): + def __getitem__(self, name): + if name not in self: + raise KeyError(f"{name} is not a constant in common.glsl") + return dict.__getitem__(self, name) + + +K = _Consts(glsl_consts()) SIZE = (128, 72) # render BODY = (64, 48) # camera ROI (w, h) SLABS = [{"near_mm": 1000, "far_mm": 4000}] DT = 1 / 30 -LEAVE_S = 2.0 # shaders/watering_can/common.glsl +LEAVE_S = K["LEAVE_S"] BLOBS = BlobConfig(min_blob_pixels=20) # the shipped defaults, but for the small test Body @@ -214,6 +246,10 @@ def test_walker_leaves_no_hold_trail(ctx): b = rig.buf("buffer_b") gone = b[..., 0] < 0.05 assert gone.any() and (b[..., 3][gone] == 0).all() + body = b[..., 0] > 0.95 # under the walker now: owned, and held at ~1 (present + assert body.any() # texels decode to floor(0.9 / HOLD_Q) * HOLD_Q / 0.9) + assert (b[..., 3][body] >= 1).all() + assert (np.abs((b[..., 3][body] % 1.0) / 0.9 - 1) < 0.01).all(), np.unique(b[..., 3][body]) rig.release() @@ -255,10 +291,31 @@ def hold(): # mean over a torso patch, so rig.release() +@pytest.mark.parametrize("dt, per_cam", [(1 / 30, 3), (1 / 60, 6)]) +def test_fading_hold_agrees_along_a_run(ctx, dt, per_cam): + """Every texel of a lost person's run fades in step, so the Image rebuilds each vine tip from one value: + the hold's peak-to-peak down a torso column stays within 2 * HOLD_Q at every point of the fade.""" + size = (640, 360) + rig = Rig(ctx, size=size, dt=dt) + rig.run([person()] * 20, frames_per_camera=per_cam) + seen = [] + for k in range(28): # coasting (0.8 s), then the whole LEAVE_S fade + rig.run([EMPTY], frames_per_camera=per_cam) + col = rig.buf("buffer_b")[:, int(32.5 / BODY[0] * size[0])] + held = col[col[:, 3] > 0] + if len(held) < 50: + continue + f = held[:, 3] % 1.0 + if f.max() < 0.89: # mid-fade + seen.append(round((k + 1) / 10, 1)) + assert np.ptp(f) <= 2 * K["HOLD_Q"] + 1e-6, ((k + 1) / 10, np.ptp(f) / K["HOLD_Q"]) + assert len(seen) >= 10, seen + rig.release() + + # ── Task 2: Garden state (Buffer C) ─────────────────────────────────────────── -GROW_S = 20.0 # shaders/watering_can/common.glsl -INHERIT_S, SUN_EASE_S = 3.0, 1.5 +GROW_S, INHERIT_S, SUN_EASE_S = K["GROW_S"], K["INHERIT_S"], K["SUN_EASE_S"] COAST_S = 0.8 # BlobConfig max_miss_frames at 10 Hz: missing → lost # Walks start at x=16 so 30 steps never reach walking()'s bounce at 48: a walker who turns # back crosses their own last spot, and that stretch (~0.7 s) rightly isn't a step. @@ -360,20 +417,24 @@ def test_brief_loss_keeps_growth(ctx): def test_merge_then_split_both_inherit(ctx): + # A has grown far more than B, so B starting from the merged level is told apart from B keeping its own. rig = Rig(ctx) - rig.run(both([person(x=4)] * 100, [person(x=40)] * 100)) + rig.run([person(x=4)] * 150) # A alone, 15 s + rig.run(both([person(x=4)] * 30, [person(x=40)] * 30)) # B arrives, 3 s assert len(live_slots(rig)) == 2 + ga, gb = rig.person(slot_near(rig, 10))[1], rig.person(slot_near(rig, 46))[1] + assert ga > 0.8 and gb < 0.2, (ga, gb) # B slides left into A until their masks overlap (one blob), and they stand merged for 3 s rig.run(both([person(x=4)] * 26, [person(x=max(40 - k, 14)) for k in range(26)])) rig.run(both([person(x=4)] * 30, [person(x=14)] * 30)) merged = max(rig.person(k)[1] for k in live_slots(rig)) - assert merged > 0.4 + assert merged > ga # then B jumps back out: two blobs again rig.run(both([person(x=4)] * 8, [person(x=22)] * 8)) live = live_slots(rig) assert len(live) == 2, live for k in live: - assert rig.person(k)[1] >= merged - 0.05, (merged, [rig.person(j) for j in live]) + assert rig.person(k)[1] >= merged - 0.05, (merged, gb, [rig.person(j) for j in live]) rig.release() @@ -471,13 +532,14 @@ def test_newcomer_beside_bloomed_person_starts_bare(ctx): rig.release() -def test_sun_tracks_growth(ctx): - rig = Rig(ctx) - rig.run([EMPTY] * 30) +@pytest.mark.parametrize("dt, per_cam", [(1 / 30, 3), (1 / 60, 6)]) # a half float stalls the easing short of full +def test_sun_tracks_growth(ctx, dt, per_cam): + rig = Rig(ctx, dt=dt) + rig.run([EMPTY] * 30, frames_per_camera=per_cam) assert rig.sun()[0] < 0.02 - rig.run([person()] * 220) - assert rig.sun()[0] > 0.95, rig.sun() - rig.run([EMPTY] * int(round((COAST_S + LEAVE_S + 3 * SUN_EASE_S) * 10))) + rig.run([person()] * 300, frames_per_camera=per_cam) # full bloom from ~21 s, then 6 SUN_EASE_S to catch up + assert rig.person(0)[1] == 1.0 and rig.sun()[0] > 0.99, rig.sun() + rig.run([EMPTY] * int(round((COAST_S + LEAVE_S + 3 * SUN_EASE_S) * 10)), frames_per_camera=per_cam) assert rig.sun()[0] < 0.05, rig.sun() rig.release() @@ -511,8 +573,8 @@ def test_state_stays_bounded_when_idle(ctx): # Review Focus # ── Task 3: plants (Image) ──────────────────────────────────────────────────── PAPER = np.array([246, 241, 228]) -FLOWER_R, LEAF_LEN = 8.0 / 360.0, 9.0 / 360.0 # shaders/watering_can/common.glsl -SUN_POS, SUN_R, RAY_LEN = (-0.16, 0.84), 0.09, 0.07 +FLOWER_R, LEAF_LEN = K["FLOWER_R"], K["LEAF_LEN"] +SUN_POS, SUN_R, RAY_LEN = K["SUN_POS"], K["SUN_R"], K["RAY_LEN"] FLOWER_RGB = np.array([[232, 149, 127], [232, 134, 47], [107, 91, 149], [188, 76, 38]]) # pink, orange, violet, rust IMG = (256, 144) # image tests: big enough that petals have pure-colour pixels @@ -585,8 +647,7 @@ def test_run_ends_agree_along_a_column(ctx): # else the plants tear int rig.release() -NEAR_CELL = 1 / 32 # shaders/watering_can/common.glsl -NEAR_PAD = max(4.5 * 4.5 / 360, 1.8 * FLOWER_R) + 0.01 # (VINE_NEIGHBOURS + 0.5) * VINE_SPACING vs REACH, + NEAR_STEP +NEAR_CELL, NEAR_PAD = K["NEAR_CELL"], K["NEAR_PAD"] def test_near_grid_marks_body_neighbourhood(ctx): From f67bc2e6dcc1abed8b116ecd6f8e364861bdc566 Mon Sep 17 00:00:00 2001 From: ii-impy <314283863+ii-impy@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:56:40 -0500 Subject: [PATCH 14/22] Watering can: local feet base so touching people keep growing When the tracker merged a neighbour into a planted person's blob, the base (the presence-weighted mean of the feet band across the whole box, y = the box bottom) jumped by about half the gap and counted as a step, and jumped back on the split: two still people in contact lost most of their growth. - The base is measured locally: the feet band within BASE_WIN of the last base (mean-shifted BASE_ITERS times so it keeps up with a walk), with y the box bottom while the window finds feet there (BASE_FEET), else the person's own lowest body row (B's run bottom BASE_UP above the last base). - A neighbour within SPLIT_GAP changing tracker state this frame (a merge or a split) re-plants the person for REPLANT_S instead of counting a step: the base snaps to the feet and the spot follows. BASE.z is negative while re-planting. - A coasting track merged into a confirmed neighbour's box shares that neighbour's walking or not, so merged people grow (or rewind) together. - Tests: touching still people whose contact toggles every 0.3 s, every 1 s and on 50% of frames each grow at 1/GROW_S; a passer-by costs a planted person nothing once clear. Co-Authored-By: Claude Opus 5.5 --- .../shaders/watering_can/buffer_c.glsl | 94 +++++++++++++++++-- .../shaders/watering_can/common.glsl | 15 ++- .../FundingCAPTCHA/test_watering_can.py | 51 ++++++++++ 3 files changed, 149 insertions(+), 11 deletions(-) diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl index 3fe2dde..9180c80 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl @@ -23,7 +23,7 @@ bool live(float st) { return isState(st, S_TENTATIVE) || isState(st, S_CONFIRMED // The box follows a real detection (a coasting or lost box only drifts on its last velocity). bool seen(float st) { return isState(st, S_TENTATIVE) || isState(st, S_CONFIRMED); } -// The feet: presence-weighted mean x of B across the box, just above its bottom; y = the box bottom. +// A new track's feet: presence-weighted mean x of B across the box, just above its bottom; y = the box bottom. vec2 baseTarget(vec4 box) { float y = box.y + 0.5 * BASE_BAND; // screen heights = UV in y float w = 0.0, wx = 0.0; @@ -41,6 +41,64 @@ float boxGap(vec4 a, vec4 b) { return length(toScreen(d)); } +// Presence-weighted mean x of B over [x0, x1], BASE_BAND / 2 above row y (.y: the total weight, in taps). +vec2 feetBand(float x0, float x1, float y) { + float w = 0.0, wx = 0.0; + for (int i = 0; i < BASE_TAPS; i++) { + float x = mix(x0, x1, (float(i) + 0.5) / float(BASE_TAPS)); + float p = texture(BB, vec2(x, y + 0.5 * BASE_BAND)).r; + w += p; wx += p * x; + } + return vec2(w > 1e-3 ? wx / w : 0.5 * (x0 + x1), w); +} + +// A tracked person's feet, measured near their last base so that only their own feet count: in a +// window BASE_WIN either side of it (and inside the box), the presence-weighted mean x of the feet +// band. The window then re-centres on that x and measures again (mean shift), so it keeps up with a +// walk but stays on the feet it started on: when the tracker merges a neighbour in, the box swells but +// this doesn't move. y is the box bottom while the window finds feet there; if it doesn't, the bottom +// is a merged neighbour's (nearer the camera), and y is this person's lowest body row in the window: +// the bottom of B's runs through their legs BASE_UP above the last base. The whole box if no body is +// near (they have moved off it faster than the window follows: that's a step). +vec2 baseNear(vec4 box, vec2 last) { + float hw = BASE_WIN * iResolution.y / iResolution.x; // screen heights → UV x + vec2 bRes = iChannelResolution[0].xy; + float up = min(last.y + BASE_UP, 1.0); + vec2 base = last; + for (int n = 0; n < BASE_ITERS; n++) { + float x0 = max(box.x, base.x - hw), x1 = min(box.z, base.x + hw); + if (x1 <= x0) return n == 0 ? baseTarget(box) : base; + vec2 f = feetBand(x0, x1, box.y); + if (f.y >= BASE_FEET) { base = vec2(f.x, box.y); continue; } + float lo = 2.0; + for (int i = 0; i < BASE_TAPS; i++) { + float x = mix(x0, x1, (float(i) + 0.5) / float(BASE_TAPS)); + vec4 t = texelFetch(BB, ivec2(min(vec2(x, up) * bRes, bRes - 1.0)), 0); + if (t.r >= BODY_ON && t.b > 0.0) lo = min(lo, t.g); + } + if (lo > 1.0) return n == 0 ? baseTarget(box) : base; + base = vec2(feetBand(x0, x1, lo).x, lo); + } + return base; +} + +// A neighbour within SPLIT_GAP changed tracker state this frame (it appeared, coasted, came back or +// was lost): the tracker is merging them into slot k's person or splitting them off, and slot k's box +// swells or shrinks with it. That isn't a step: slot k's person re-plants for REPLANT_S. +bool contactChange(int k) { + vec4 box = recBox(BLOBS, k); + for (int j = 0; j < SLOTS; j++) { + vec4 idn = recIdn(BLOBS, j); + if (j == k || isState(idn.y, S_EMPTY)) continue; + bool changed = fresh(j) || !isState(cBase(C, j).w, idn.y); + if (changed && boxGap(box, recBox(BLOBS, j)) <= SPLIT_GAP) return true; + } + return false; +} +// BASE .z, seconds since the last step, is negative while re-planting: -(seconds still to go). +bool replanting(int k) { return contactChange(k) || cBase(C, k).z < 0.0; } +bool walking(float since) { return since >= 0.0 && since < WALK_HOLD_S; } + // Growth a new track at `base` starts from: a recent ghost standing there, or a live // neighbour its box overlaps or all but touches (a split, a merge, or a part of a planted // person). A newcomer further away starts bare, so nobody pops into bloom on arrival. @@ -57,17 +115,23 @@ float inherit(int k, vec2 base) { } // ── BASE ────────────────────────────────────────────────────────────────────── -// This frame's BASE for slot k: eased base, seconds since the last step, record state (for GHOST). +// This frame's BASE for slot k: eased base, seconds since the last step (negative: re-planting), +// record state (for GHOST, and to see a neighbour's state change). vec4 baseNext(int k, float dt) { vec4 idn = recIdn(BLOBS, k); if (isState(idn.y, S_EMPTY)) return vec4(0.0, 0.0, T_CLAMP, S_EMPTY); if (fresh(k)) return vec4(baseTarget(recBox(BLOBS, k)), T_CLAMP, idn.y); vec4 b = cBase(C, k); + bool rp = replanting(k); + if (contactChange(k)) b.z = -REPLANT_S; // A coasting or lost box drifts without a body under it: hold the base, so a dropped - // camera frame can't read as a step. - if (seen(idn.y)) b.xy = mix(b.xy, baseTarget(recBox(BLOBS, k)), 1.0 - exp(-dt / BASE_EASE_S)); - bool step = length(toScreen(b.xy - cPerson(C, k).zw)) > WALK_DIST; - b.z = step ? 0.0 : min(b.z + dt, T_CLAMP); + // camera frame can't read as a step. Re-planting, the base snaps to the feet (and the spot with it). + if (seen(idn.y)) { + vec2 t = baseNear(recBox(BLOBS, k), b.xy); + b.xy = rp ? t : mix(b.xy, t, 1.0 - exp(-dt / BASE_EASE_S)); + } + if (rp) b.z = b.z + dt < 0.0 ? b.z + dt : WALK_HOLD_S; // planted again: not walking + else b.z = length(toScreen(b.xy - cPerson(C, k).zw)) > WALK_DIST ? 0.0 : min(b.z + dt, T_CLAMP); b.w = idn.y; return b; } @@ -79,9 +143,21 @@ vec4 personNext(int k, float dt, vec2 fc) { vec4 b = baseNext(k, dt); if (fresh(k)) return vec4(idn.x, inherit(k, b.xy), b.xy); vec4 p = cPerson(C, k); - if (b.z == 0.0) p.zw = b.xy; // a step: the new spot is here - if (isState(idn.y, S_CONFIRMED)) { // tentative and coasting hold their level - float rate = b.z < WALK_HOLD_S ? -1.0 / REWIND_S : 1.0 / GROW_S; + if (b.z == 0.0 || replanting(k)) p.zw = b.xy; // a step or a re-plant: the new spot is here + // Tentative and coasting hold their level, but a coasting track the tracker has merged into a + // neighbour's blob shares that neighbour's walking or not, so merged people grow (or rewind) together. + float since = b.z; // seconds since the last step + bool moves = isState(idn.y, S_CONFIRMED); + if (isState(idn.y, S_COASTING)) { + vec4 box = recBox(BLOBS, k); + since = T_CLAMP; + for (int j = 0; j < SLOTS; j++) { + if (j == k || fresh(j) || !isState(recIdn(BLOBS, j).y, S_CONFIRMED)) continue; + if (boxGap(box, recBox(BLOBS, j)) <= SPLIT_GAP) { moves = true; since = min(since, cBase(C, j).z); } + } + } + if (moves) { + float rate = walking(since) ? -1.0 / REWIND_S : 1.0 / GROW_S; // The step is a few half-float ulps: round it stochastically to GROW_Q, so the // expected growth is exact at any render rate. float h = hash12(fc + vec2(113.0, 57.0) * float(iFrame % 997)); diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl index 8b597d3..171a584 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl @@ -7,7 +7,8 @@ // .a owner + 0.9 * hold (owner = slot + 1, 0 none; hold 0–1, 1 while the body is present) // Garden state (Buffer C), texel (x, y), k = slot 0–7: // (k, 0) PERSON id (0 none), growth 0–1, planted spot x, y (UV) -// (k, 1) BASE base x, base y (UV, smoothed), seconds since last walking step, record state last frame +// (k, 1) BASE base x, base y (UV, smoothed), seconds since last walking step (negative: re-planting, +// -seconds to go), record state last frame // (k, 2) GHOST spot x, y, growth at loss, seconds since loss (T_CLAMP = no ghost) // (0, 3) SUN sun level 0–1 (eased), raw total growth, —, — // (i, 4 + j) NEAR 1 if body (or a held shape) lies within NEAR_PAD of grid cell (i, j), else 0; @@ -30,9 +31,19 @@ const float HOLD_Q = 1.0/128.0; // 0.9 * hold is stored in multiples o // Garden const float GROW_S = 20.0, REWIND_S = 20.0; // empty → full bloom, full bloom → empty const float GROW_Q = 1.0/2048.0; // growth is stored in multiples of this (a half float's step just below 1) -const float BASE_BAND = 0.04; // screen heights above the box bottom that count as feet +const float BASE_BAND = 0.04; // screen heights above the lowest body row that count as feet const int BASE_TAPS = 16; const float BASE_EASE_S = 0.3; +const float BASE_WIN = 0.14; // feet count only this far either side of the last base (screen heights; about + // a stance), so a neighbour the tracker merges into the box can't pull it +const float BASE_FEET = 1.0; // the box bottom is this person's feet if their window finds this much presence + // there (in taps' worth); else it is a merged neighbour's (nearer the camera) +const int BASE_ITERS = 3; // the window re-centres on the feet it finds this many times a frame, so it + // keeps up with a walk (each pass reaches BASE_WIN further) +const float BASE_UP = 0.1; // the window finds the lowest body row from the legs this far above the last + // base (screen heights): more than the eased base lags a step up the image +const float REPLANT_S = 0.5; // for this long after a neighbour within SPLIT_GAP changes tracker state (a + // merge or a split), a person re-plants where they stand instead of stepping const float WALK_DIST = 0.04; // screen heights the base must move from the spot to count as a step const float WALK_HOLD_S = 0.4; // walking lasts this long after the last step const float INHERIT_S = 3.0, INHERIT_DIST = 0.15; // ghost lifetime; how near a new track must appear (screen heights) diff --git a/ShowControl/FundingCAPTCHA/test_watering_can.py b/ShowControl/FundingCAPTCHA/test_watering_can.py index 800bf04..e2e282f 100644 --- a/ShowControl/FundingCAPTCHA/test_watering_can.py +++ b/ShowControl/FundingCAPTCHA/test_watering_can.py @@ -438,6 +438,57 @@ def test_merge_then_split_both_inherit(ctx): rig.release() +def confirmed_near(rig, x_px): + """The level of the confirmed track whose base is nearest Body column x_px (a coasting or lost record of + the same person, left over from a merge or split, doesn't count).""" + ks = [k for k in live_slots(rig) if rig.base(k)[3] == 2] + assert ks, [rig.base(k) for k in live_slots(rig)] + return float(rig.person(min(ks, key=lambda k: abs(rig.base(k)[0] - x_px / BODY[0])))[1]) + + +@pytest.mark.parametrize("contact", ["every 0.3 s", "every 1 s", "50% of frames"]) +def test_touching_still_people_keep_growing(ctx, contact): + # Two people planted side by side with a 1-px gap. The tracker merges them into one blob whenever the + # gap is bridged; neither moves, so neither may lose growth to the box swelling and shrinking. + rng = np.random.default_rng(1) + a, b = person(x=10), person(x=23) + rig = Rig(ctx) + rig.run([a | b] * 60) + before = confirmed_near(rig, 16), confirmed_near(rig, 29) + frames = [] + for k in range(100): + m = a | b + if {"every 0.3 s": k // 3 % 2 == 0, "every 1 s": k // 10 % 2 == 0, "50% of frames": rng.random() < 0.5}[contact]: + m[:, 22] = True # bridged: one blob + frames.append(m) + rig.run(frames + [a | b] * 8) + after = confirmed_near(rig, 16), confirmed_near(rig, 29) + want = 10.8 / GROW_S # still for 10.8 s + for g0, g1 in zip(before, after): + assert abs((g1 - g0) - want) < 0.15 * want, (contact, before, after) + rig.release() + + +@pytest.mark.parametrize("feet", [44, 47]) # the passer-by level with A, or nearer the camera +def test_passer_by_costs_nothing_once_past(ctx, feet): + # Someone walks across in front of planted A (merging with them on the way). Merged, they share a level; + # once the passer-by is clear, A loses nothing more and grows at the full rate straight away. + rig = Rig(ctx) + a = person(x=26) # spans Body columns 26..38 + rig.run([a] * 100) + xs = list(range(-12, 64, 2)) # 2 px per camera frame: a brisk walk + clear = xs.index(40) # the passer-by's left edge 2 px past A's right edge + rig.run([a | person(x=x, feet=feet) for x in xs[:clear]]) + levels = [] + for m in [a | person(x=x, feet=feet) for x in xs[clear:]] + [a] * 20: + rig.run([m]) + levels.append(confirmed_near(rig, 32)) + assert all(y >= x - 1e-3 for x, y in zip(levels, levels[1:])), str(np.round(levels, 3)) + want = (len(levels) - 1) / 10 / GROW_S + assert levels[-1] - levels[0] > 0.85 * want, (want, levels) + rig.release() + + def detached_arm(x=26, feet=44): """person(arm="out") with a 1-px gap between the outstretched arm and the torso: two blobs.""" m = person(x=x, feet=feet, arm="out") From 7660a3ff3e3e69114987c1b9f1f4fa311e8b0dca Mon Sep 17 00:00:00 2001 From: ii-impy <314283863+ii-impy@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:26:27 -0500 Subject: [PATCH 15/22] Watering can: branches, petal sprigs and daisies after the concept art Tapered, ink-outlined trunks that rise in pairs and merge as one shape, forking twigs ending in sprigs of pointed petals, and daisies on half the branch tips. Keeps the colour palette and ripening. Co-Authored-By: Claude Opus 5.5 --- .../shaders/watering_can/common.glsl | 37 ++- .../shaders/watering_can/image.glsl | 231 ++++++++++++++---- .../FundingCAPTCHA/test_watering_can.py | 13 +- 3 files changed, 212 insertions(+), 69 deletions(-) diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl index 171a584..bc74d12 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl @@ -51,20 +51,37 @@ const float SPLIT_GAP = 0.03; // a new track this close to a live one // inherits its growth: the tracker merges touching people, so a gap this small means a split const float SUN_FULL = 1.0, SUN_EASE_S = 1.5; // total growth that fully lights the sun // Plants -const float VINE_SPACING = 4.5/360.0; // ~30 vines across an adult, as the mockup's Dense panel -const int VINE_NEIGHBOURS = 4; // vines evaluated each side of a pixel. Was 2: a leaf or flower reaches - // ~4.5 vines sideways (wobble + sway + its size), and at 2 they were cut off +const float VINE_SPACING = 7.0/360.0; // ~19 branches across an adult; their twigs fill between +const int VINE_NEIGHBOURS = 4; // vines evaluated each side of a pixel. Was 2: a twig or flower reaches + // ~4 vines sideways (wobble + sway + its size), and at 2 they were cut off // in visible straight lines. Lower it first if the Pi falls short of frame rate. -const float VINE_WOBBLE = 5.0/360.0, SWAY_AMP = 2.0/360.0, SWAY_HZ = 0.25; -const float STEM_W = 2.2/360.0, INK_W = 1.2/360.0; -const float LEAF_STEP = 18.0/360.0, LEAF_LEN = 9.0/360.0, LEAF_WID = 3.8/360.0; +const float VINE_WOBBLE = 2.5/360.0, SWAY_AMP = 2.0/360.0, SWAY_HZ = 0.25; +const float WOBBLE_FREQ = 21.6; // radians per screen height up a branch (the mockup's 0.06 / px) +const float KINK_AMP = 0.5, KINK_FREQ = 2.7; // a second, faster wobble (× VINE_WOBBLE, × WOBBLE_FREQ): gnarled, not wavy +const float INK_W = 0.8/360.0; +// Branches are drawn as the concept art's: a filled, ink-outlined trunk that tapers to its tip, with +// twigs forking off it (blended into it, so the outline runs round the fork) that end in sprigs of petals. +const float STEM_W = 4.0/360.0, STEM_TIP_W = 1.2/360.0; // trunk width at its base and at its tip +const float STEM_TAPER_H = 60.0/360.0; // the trunk tapers over this much branch below its tip +const float BARK_HATCH = 0.2, BARK_FREQ = 90.0; // a broken ink stroke down the trunk's shaded side: offset (× width), dash rate +const float TWIG_STEP = 16.0/360.0; // twigs fork off every this much trunk, on alternating sides +const float TWIG_LEN = 11.0/360.0, TWIG_W = 1.8/360.0; // twig length, and width where it leaves the trunk +const float TWIG_RISE = 0.6, TWIG_RISE_VAR = 0.35; // twig angle above horizontal, and its random spread (radians) +const float TWIG_FORK = 0.45; // the fraction of twigs that fork once more, partway along... +const float FORK_AT = 0.55, FORK_LEN = 0.5, FORK_ANG = 0.75; // ... at this fraction of the twig, this × as long, turned up this far +const float WOOD_FILLET = 1.5/360.0; // the trunk and its twigs blend over this, so a fork reads as one piece of wood +const float PAIR_H = 50.0/360.0; // branches rise in pairs from one root, parting over about this much branch +const float SPRIG_LEN = 7.5/360.0, SPRIG_WID = 1.5/360.0; // a sprig's petals: length, half-width +const float SPRIG_FAN = 0.85, SPRIG_LIFT = 0.6; // petals fan ± this (radians) round the twig, turned up by this much const float FLOWER_FROM = 0.6, FLOWER_FULL = 0.9; // growth at which buds start, and are fully open const float FLOWER_R = 8.0/360.0, BUD_R = 2.5/360.0; const float SAT_MIN = 0.1, SAT_MAX = 0.95; -const float WOBBLE_FREQ = 21.6; // radians per screen height up a vine (the mockup's 0.06 / px) -const float LEAF_DROOP = 0.8, LEAF_DROOP_VAR = 0.3; // leaf angle below horizontal, and its random spread (radians) -const float SPROUT_H = 18.0/360.0; // a stem fades in, and a leaf or bud grows in, over this much vine below it -const float PETAL_R = 0.8, HEART_R = 0.45; // petal and yellow-centre radius, as fractions of the flower radius +const float SPROUT_H = 18.0/360.0; // a trunk fades in, and a twig or bud grows in, over this much branch below it +// The flower is a daisy: DAISY_N long petals round an oval centre, hatched in ink near their roots. +const float PETAL_R = 0.8, HEART_R = 0.7; // petals reach FLOWER_R × (1 + PETAL_R); centre radius, × FLOWER_R +const int DAISY_N = 12; +const float DAISY_FRAC = 0.5; // the fraction of branch tips that open into a daisy; the rest end in a sprig +const float DAISY_PETAL_W = 0.3; // a petal's half-width at its tip, × the flower radius const float PEN_MIN = 1.5; // thinnest line, render pixels: thinner ones are drawn this wide but fainter const float REACH = FLOWER_R * (1.0 + PETAL_R); // a flower's outer radius (FLOWER_R is its petals' ring, as the mockup's r); // the Image taps B at ±REACH and ±REACH / 2 above and below each pixel diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl index 956816e..2a7e0ca 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl @@ -2,18 +2,19 @@ // p is in screen heights, origin bottom left, y up; screen-height y and UV y are the same number. // // Vine i stands at screen x i * VINE_SPACING. Where it crosses a run of body (B), it grows from -// the run's bottom g toward its top b: stem, leaves on alternating sides, and a bud at the tip -// that opens into a flower late. The drawing derives from B and the owner's growth every frame, -// so the plants ride along on the body. +// the run's bottom g toward its top b, drawn as the concept art's branches: a trunk tapering to its +// tip, twigs forking up off it on alternating sides with sprigs of petals at their ends, and at the +// tip a sprig, or on some branches a bud that opens into a daisy late. Branches rise in pairs from +// one root, and all the wood near a pixel is one inked shape, so they merge and fork as the art's. +// The drawing derives from B and the owner's growth every frame, so the plants ride along on the body. // // Each pixel looks at VINE_NEIGHBOURS vines each side and taps B on the vine's own x: at the -// pixel's height, and a flower's reach above and below, so leaves and flowers hanging past the +// pixel's height, and a flower's reach above and below, so twigs and flowers reaching past the // edge of the body still find their run; halfway between too, so a run thinner than a flower // (an outstretched arm, a hand) is found from every row its plants cover. The taps don't follow the -// wobble: a vine's runs must be the same from one pixel row to the next, or its leaves and -// flowers would be cut into strips (B's runs agree along a column for the same reason). One more -// tap, at the pixel, keeps stems inside the body, and a leaf taps B at its node, only on the -// pixels it covers, so leaves grow only from nodes on the body. +// wobble: a vine's runs must be the same from one pixel row to the next, or its twigs and +// flowers would be cut into strips (B's runs agree along a column for the same reason). A twig +// taps B at its node, only on the pixels it covers, so twigs grow only from nodes on the body. const float TAU = 6.2831853; const int NV = 2 * VINE_NEIGHBOURS + 1; @@ -41,8 +42,8 @@ vec3 hsl(float h, float s, float l) { // h in degrees return l - s * min(l, 1.0 - l) * clamp(min(k - 3.0, 9.0 - k), -1.0, 1.0); } vec3 desaturate(vec3 c, float sat) { return mix(vec3(dot(c, vec3(0.299, 0.587, 0.114))), c, sat / SAT_MAX); } -vec3 stemColour(float sat) { return hsl(105.0, sat, 0.28); } -vec3 leafColour(float sat) { return hsl(110.0, sat, 0.44); } +vec3 stemColour(float sat) { return hsl(95.0, sat, 0.34); } +vec3 leafColour(float sat) { return hsl(100.0, sat, 0.58); } vec3 flowerColour(int i) { float h = hash12(vec2(float(i), 9.7)); return h < 0.25 ? PINK : h < 0.5 ? ORANGE : h < 0.75 ? VIOLET : RUST; @@ -90,54 +91,137 @@ bool tapRun(float x, float y, out Run r) { return r.tip > r.g; } -// ── Vines ───────────────────────────────────────────────────────────────────── +// ── Branches ────────────────────────────────────────────────────────────────── float vineHash(int i) { return hash12(vec2(float(i), 1.7)); } -float wobbleX(int i, float y) { - return float(i) * VINE_SPACING + VINE_WOBBLE * sin(y * WOBBLE_FREQ + TAU * vineHash(i)); +float wobbleX(int i, float y) { // a slow wave and a faster kink: gnarled wood + float ph = TAU * vineHash(i); + return float(i) * VINE_SPACING + + VINE_WOBBLE * (sin(y * WOBBLE_FREQ + ph) + KINK_AMP * sin(y * WOBBLE_FREQ * KINK_FREQ + 2.0 * ph)); +} +float wobbleSlope(int i, float y) { + float ph = TAU * vineHash(i); + return VINE_WOBBLE * WOBBLE_FREQ * (cos(y * WOBBLE_FREQ + ph) + KINK_AMP * KINK_FREQ * cos(y * WOBBLE_FREQ * KINK_FREQ + 2.0 * ph)); } float swayX(int i, float y, Run r) { // grows up the run float up = clamp((y - r.g) / max(r.b - r.g, 1e-3), 0.0, 1.0); return SWAY_AMP * up * sin(TAU * SWAY_HZ * iTime - 3.0 * float(i) * VINE_SPACING + vineHash(i)); } -float vineX(int i, float y, Run r) { return wobbleX(i, y) + swayX(i, y, r); } +// Branches come up in pairs from one root: at the run's bottom both lean to their pair's middle, +// and part over PAIR_H, so the wood forks in a Y as the art's. +float pairX(int i, float y, Run r) { + float pr = floor(float(i) / 2.0); + float h = PAIR_H * (0.6 + 0.8 * hash12(vec2(pr, 4.4))); + return (2.0 * pr + 0.5 - float(i)) * VINE_SPACING * (1.0 - smoothstep(0.0, h, y - r.g)); +} +float vineX(int i, float y, Run r) { return wobbleX(i, y) + pairX(i, y, r) + swayX(i, y, r); } float sprout(float len) { return smoothstep(0.0, SPROUT_H, len); } -void stem(inout vec4 acc, vec2 p, int i, Run r) { - float y = clamp(p.y, r.g, r.tip); - float s = VINE_WOBBLE * WOBBLE_FREQ * cos(y * WOBBLE_FREQ + TAU * vineHash(i)); // slope of the wobble - float dx = (p.x - vineX(i, y, r)) / sqrt(1.0 + s * s); - over(acc, stemColour(r.sat), lineA(length(vec2(dx, p.y - y)), STEM_W) * sprout(r.tip - r.g)); +float smin(float a, float b, float k) { // polynomial smooth min: the fillet where a twig leaves the trunk + float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0); + return mix(b, a, h) - k * h * (1.0 - h); } +// A segment a → b, radius ra at a tapering to rb at b. +float sdTaper(vec2 p, vec2 a, vec2 b, float ra, float rb) { + vec2 pa = p - a, ba = b - a; + float h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-10), 0.0, 1.0); + return length(pa - ba * h) - mix(ra, rb, h); +} +vec2 rot(vec2 v, float a) { float c = cos(a), s = sin(a); return vec2(c * v.x - s * v.y, s * v.x + c * v.y); } -void leaves(inout vec4 acc, vec2 p, int i, Run r) { - float off = 0.5 + 0.5 * hash12(vec2(float(i), 3.1)); // the first leaf, in steps above g - float flip = step(0.5, hash12(vec2(float(i), 5.3))); - float down = 2.0 * LEAF_LEN + LEAF_WID; // a leaf hangs at most this far below its node - int j0 = max(int(ceil((p.y - LEAF_WID - r.g) / LEAF_STEP - off)), 0); - float rr = 0.5 * (LEAF_LEN * LEAF_LEN / LEAF_WID + LEAF_WID); // vesica of half-length LEAF_LEN, half-width LEAF_WID - float dd = rr - LEAF_WID; - for (int n = 2; n >= 0; n--) { // top leaf first, so lower leaves overlap it - int j = j0 + n; - float yj = r.g + (float(j) + off) * LEAF_STEP; - if (yj > p.y + down || yj >= r.tip) continue; - float k = sprout(r.tip - yj); // grows in as the tip passes - float side = mod(float(j) + flip, 2.0) < 0.5 ? 1.0 : -1.0; - float a = LEAF_DROOP + LEAF_DROOP_VAR * (2.0 * hash12(vec2(float(i), float(j) + 0.5)) - 1.0); - vec2 dir = vec2(side * cos(a), -sin(a)); - vec2 q = p - (vec2(vineX(i, yj, r), yj) + dir * LEAF_LEN * k); - vec2 lq = vec2(dot(q, vec2(-dir.y, dir.x)), dot(q, dir)); // across, along - float sd = sdVesica(lq / max(k, 1e-3), rr, dd) * k; +// Twig j of vine i: where it leaves the trunk (o), its tip (e), and its fork's tip (f; f == m when +// it doesn't fork, m being where the fork leaves the twig). k is how far it has grown in, 0–1. +struct Twig { vec2 o, e, m, f, dir; float k; bool fork; }; + +Twig twigOf(int i, int j, Run r, float off, float flip) { + Twig t; + float yj = r.g + (float(j) + off) * TWIG_STEP; + t.k = sprout(r.tip - yj); // grows in as the tip passes + float side = mod(float(j) + flip, 2.0) < 0.5 ? 1.0 : -1.0; + float a = TWIG_RISE + TWIG_RISE_VAR * (2.0 * hash12(vec2(float(i), float(j) + 0.5)) - 1.0); + t.dir = vec2(side * cos(a), sin(a)); + t.o = vec2(vineX(i, yj, r), yj); + t.e = t.o + t.dir * TWIG_LEN * t.k; + t.m = mix(t.o, t.e, FORK_AT); + t.fork = hash12(vec2(float(i), float(j) + 7.3)) < TWIG_FORK; + t.f = t.fork ? t.m + rot(t.dir, side * FORK_ANG) * TWIG_LEN * FORK_LEN * t.k : t.m; + return t; +} + +// A sprig: SPRIG petals fanned round direction d from point s, at scale k, with an ink vein each. +void sprig(inout vec4 acc, vec2 p, vec2 s, vec2 d, float k, float seed, vec3 col) { + if (length(p - s) > SPRIG_LEN * k + INK_W + 2.0 * AA) return; + d = normalize(d + vec2(0.0, SPRIG_LIFT)); // sprigs turn to the light + float half_len = 0.5 * SPRIG_LEN * k, wid = SPRIG_WID * k; + float rr = 0.5 * (half_len * half_len / max(wid, 1e-6) + wid); // vesica of half-length half_len, half-width wid + float dd = rr - wid; + int n = 3 + int(2.0 * hash12(vec2(seed, 2.9))); // 3 or 4 petals + float o = min(1.0, 3.0 * k); // solid long before it's full size: flat, not a wash + for (int m = 0; m < 4; m++) { + if (m >= n) break; + float a = SPRIG_FAN * (2.0 * float(m) / float(n - 1) - 1.0) + 0.25 * (hash12(vec2(seed, float(m))) - 0.5); + vec2 u = rot(d, a); + vec2 q = p - (s + u * half_len); + vec2 lq = vec2(dot(q, vec2(-u.y, u.x)), dot(q, u)); // across, along + float sd = sdVesica(lq, rr, dd); if (sd > INK_W + 2.0 * AA) continue; - if (!onBody(tapB(wobbleX(i, yj), yj))) continue; // leaves grow from nodes on the body - float o = min(1.0, 3.0 * k); // solid long before it's full size: flat, not a wash - inked(acc, leafColour(r.sat), sd, o); - float rib = length(vec2(lq.x, max(abs(lq.y) - 0.6 * LEAF_LEN, 0.0))); - over(acc, INK, lineA(rib, 0.6 * INK_W) * fillA(sd) * o); // midrib + inked(acc, col, sd, o); + float vein = length(vec2(lq.x, max(abs(lq.y + 0.15 * half_len) - 0.45 * half_len, 0.0))); + over(acc, INK, lineA(vein, 0.6 * INK_W) * fillA(sd) * o); + } +} + +// Vine i's wood near p: its trunk and the twigs near p, blended into one +// shape, as a signed distance. Out: bark, the ink of the trunk's bark stroke at p; sat, the wood's +// saturation; nodes, a bit per twig j0 + n (n = 0–2) near p, for sprigs(); j0. +float wood(vec2 p, int i, Run r, out float bark, out int nodes, out int j0) { + float o = sprout(r.tip - r.g); + float y = clamp(p.y, r.g, r.tip); + float dx = (p.x - vineX(i, y, r)) / sqrt(1.0 + pow(wobbleSlope(i, y), 2.0)); + float w = mix(STEM_TIP_W, STEM_W, clamp((r.tip - y) / STEM_TAPER_H, 0.0, 1.0)); + // A sprouting trunk is thinned past nothing, so its outline doesn't draw a line where it hasn't grown. + float trunk = length(vec2(dx, p.y - y)) - 0.5 * w + (1.0 - o) * 2.0 * INK_W; + float dash = smoothstep(0.45, 0.55, noise(vec2(y * BARK_FREQ, float(i) * 7.1))); + bark = lineA(dx - BARK_HATCH * w, 0.5 * INK_W) * fillA(trunk) * dash; + float sd = trunk; + + float off = 0.5 + 0.5 * hash12(vec2(float(i), 3.1)); // the first twig, in steps above g + float flip = step(0.5, hash12(vec2(float(i), 5.3))); + float up = (1.0 + FORK_LEN) * TWIG_LEN + SPRIG_LEN; // a twig reaches at most this far above its node + float down = SPRIG_LEN; // ... and this far below + j0 = max(int(ceil((p.y - up - r.g) / TWIG_STEP - off)), 0); + nodes = 0; + for (int n = 0; n < 3; n++) { + int j = j0 + n; + float yj = r.g + (float(j) + off) * TWIG_STEP; + if (yj > p.y + down || yj >= r.tip || abs(p.x - vineX(i, yj, r)) > up) continue; + if (!onBody(tapB(wobbleX(i, yj), yj))) continue; // twigs grow from nodes on the body + nodes |= 1 << n; + Twig t = twigOf(i, j, r, off, flip); + float tw = 0.5 * TWIG_W * t.k, tip = 0.5 * STEM_TIP_W * t.k, thin = (1.0 - min(1.0, 3.0 * t.k)) * 2.0 * INK_W; + sd = smin(sd, sdTaper(p, t.o, t.e, tw, tip) + thin, WOOD_FILLET); + if (t.fork) sd = smin(sd, sdTaper(p, t.m, t.f, mix(tw, tip, FORK_AT), tip) + thin, WOOD_FILLET); + } + return sd; +} + +// The sprigs on vine i's twigs near p (as wood() found them), top twig first, so lower ones overlap it. +void sprigs(inout vec4 acc, vec2 p, int i, Run r, int nodes, int j0) { + float off = 0.5 + 0.5 * hash12(vec2(float(i), 3.1)); + float flip = step(0.5, hash12(vec2(float(i), 5.3))); + vec3 col = leafColour(r.sat); + for (int n = 2; n >= 0; n--) { + if ((nodes & (1 << n)) == 0) continue; + int j = j0 + n; + Twig t = twigOf(i, j, r, off, flip); + float seed = float(i) * 31.0 + float(j); + sprig(acc, p, t.e, t.dir, t.k, seed, col); + if (t.fork) sprig(acc, p, t.f, normalize(t.f - t.m + 1e-6), 0.8 * t.k, seed + 0.5, col); } } +// The trunk's tip: a sprig, or on DAISY_FRAC of branches a bud, which late in growth opens into a daisy. void bloom(inout vec4 acc, vec2 p, int i, Run r) { float open = clamp((r.f - FLOWER_FROM) / (FLOWER_FULL - FLOWER_FROM), 0.0, 1.0); // The taps are REACH / 2 apart, so a run shorter than that isn't found from every row an open @@ -149,6 +233,10 @@ void bloom(inout vec4 acc, vec2 p, int i, Run r) { vec2 c = vec2(mix(tip.x, float(i) * VINE_SPACING + swayX(i, r.tip, r), open), r.tip); if (length(p - c) > REACH + INK_W + 2.0 * AA && length(p - tip) > BUD_R + INK_W + 2.0 * AA) return; float k = sprout(r.tip - r.g); + if (hash12(vec2(float(i), 13.9)) >= DAISY_FRAC) { // no daisy: the branch ends in a sprig + sprig(acc, p, tip, vec2(0.0, 1.0), k, float(i) * 31.0 - 7.0, leafColour(r.sat)); + return; + } inked(acc, leafColour(r.sat), length(p - mix(tip, c, open)) - BUD_R * k, k); // the bud, later the calyx if (open <= 0.0) return; vec2 q = p - c; @@ -156,14 +244,35 @@ void bloom(inout vec4 acc, vec2 p, int i, Run r) { float e = smoothstep(0.0, 0.25, open); // petals swell out of the bud vec3 pc = desaturate(flowerColour(i), r.sat); float a0 = TAU * hash12(vec2(float(i), 11.3)); - for (int n = 0; n < 5; n++) { - float a = a0 + TAU * float(n) / 5.0; - inked(acc, pc, length(q - fr * vec2(cos(a), sin(a))) - PETAL_R * fr * e, 1.0); - } - inked(acc, desaturate(YELLOW, r.sat), length(q) - HEART_R * fr * e, 1.0); + float da = TAU / float(DAISY_N); + float r0 = 0.55 * fr, r1 = mix(r0, fr * (1.0 + PETAL_R), e), pw = DAISY_PETAL_W * fr * e; + // Only the three petals nearest p's angle can cover it (the centre hides where more overlap). + // Even petals lie under odd ones, the same at every pixel, so the overlaps agree. + int nn = int(floor((atan(q.y, q.x) - a0) / da + 0.5)); + for (int pass = 0; pass < 2; pass++) + for (int m = nn - 1; m <= nn + 1; m++) { + float id = mod(float(m), float(DAISY_N)); + if (int(mod(id, 2.0)) != pass) continue; + float a = a0 + da * float(m) + 0.15 * (hash12(vec2(float(i), id)) - 0.5); + vec2 u = vec2(cos(a), sin(a)); + vec2 lq = vec2(dot(q, vec2(-u.y, u.x)), dot(q, u)); // across, along + float sd = sdTaper(lq, vec2(0.0, r0), vec2(0.0, r1 - pw), 0.7 * pw, pw); + if (sd > INK_W + 2.0 * AA) continue; + inked(acc, pc, sd, 1.0); + float root = 1.0 - smoothstep(r0, mix(r0, r1, 0.55), lq.y); // hatched in ink near the centre + over(acc, INK, lineA(abs(lq.x) - 0.4 * pw, 0.5 * INK_W) * fillA(sd) * root * e); + } + // The centre: an oval, hatched on its shaded side. + vec2 rad = HEART_R * fr * e * vec2(0.8, 1.05); + float hs = length(q / rad) - 1.0; + float sd = hs * min(rad.x, rad.y); + inked(acc, desaturate(YELLOW, r.sat), sd, 1.0); + float step_ = 0.22 * rad.y; + float stripe = abs(fract(q.y / step_) - 0.5) * step_; + over(acc, INK, lineA(stripe, 0.5 * INK_W) * fillA(sd + 0.15 * rad.x) * smoothstep(0.0, -0.4 * rad.x, q.x) * e); } -// The plants over transparent, premultiplied. Stems and leaves vine by vine, then buds and flowers on top. +// The plants over transparent, premultiplied. Branches vine by vine, then buds and flowers on top. vec4 plants(vec2 fc) { vec2 p = fc / iResolution.y; if (!cNear(iChannel1, p)) return vec4(0.0); // no body near (Buffer C's grid): paper, one fetch @@ -193,15 +302,29 @@ vec4 plants(vec2 fc) { } vec4 acc = vec4(0.0); if (!found) return acc; // nothing grows near: paper - bool inside = onBody(tapB(p.x, p.y)); // stems stay inside the body - for (int v = 0; v < NV; v++) { - int i = i0 + v - VINE_NEIGHBOURS; + // All the wood near p is one shape, inked once: branches that meet merge, as the art's forks. + float wd = 1e3, bark = 0.0, sat = SAT_MIN; + int nodes[NV * TAPS], j0s[NV * TAPS]; + for (int v = 0; v < NV; v++) for (int t = 0; t < TAPS; t++) { - if (!has[v * TAPS + t]) continue; - if (inside) stem(acc, p, i, runs[v * TAPS + t]); - leaves(acc, p, i, runs[v * TAPS + t]); + int s = v * TAPS + t; + nodes[s] = 0; + if (!has[s]) continue; + float bk; + float d = wood(p, i0 + v - VINE_NEIGHBOURS, runs[s], bk, nodes[s], j0s[s]); + if (d < wd) sat = runs[s].sat; + wd = smin(wd, d, WOOD_FILLET); + bark = max(bark, bk); } + if (wd < INK_W + 2.0 * AA) { + inked(acc, stemColour(sat), wd, 1.0); + over(acc, INK, bark * fillA(wd)); } + for (int v = 0; v < NV; v++) + for (int t = 0; t < TAPS; t++) { + int s = v * TAPS + t; + if (nodes[s] != 0) sprigs(acc, p, i0 + v - VINE_NEIGHBOURS, runs[s], nodes[s], j0s[s]); + } for (int v = 0; v < NV; v++) for (int t = 0; t < TAPS; t++) if (has[v * TAPS + t]) bloom(acc, p, i0 + v - VINE_NEIGHBOURS, runs[v * TAPS + t]); diff --git a/ShowControl/FundingCAPTCHA/test_watering_can.py b/ShowControl/FundingCAPTCHA/test_watering_can.py index e2e282f..b94153e 100644 --- a/ShowControl/FundingCAPTCHA/test_watering_can.py +++ b/ShowControl/FundingCAPTCHA/test_watering_can.py @@ -624,7 +624,8 @@ def test_state_stays_bounded_when_idle(ctx): # Review Focus # ── Task 3: plants (Image) ──────────────────────────────────────────────────── PAPER = np.array([246, 241, 228]) -FLOWER_R, LEAF_LEN = K["FLOWER_R"], K["LEAF_LEN"] +FLOWER_R, REACH = K["FLOWER_R"], K["REACH"] +TWIG_REACH = (1 + K["FORK_LEN"]) * K["TWIG_LEN"] + K["SPRIG_LEN"] # a forked twig and its sprig, from its node SUN_POS, SUN_R, RAY_LEN = K["SUN_POS"], K["SUN_R"], K["RAY_LEN"] FLOWER_RGB = np.array([[232, 149, 127], [232, 134, 47], [107, 91, 149], [188, 76, 38]]) # pink, orange, violet, rust IMG = (256, 144) # image tests: big enough that petals have pure-colour pixels @@ -737,12 +738,13 @@ def test_bloom_fills_the_silhouette(ctx): assert rig.person(0)[1] > 0.99 d, fig = drawn(rig.image()), on_screen(person(), IMG) assert d[fig].mean() >= 0.7, d[fig].mean() - # Overhang is leaves and flowers only: within FLOWER_R + LEAF_LEN (and a pixel of antialiasing) of the figure. + # Overhang is twigs, sprigs and flowers only: within a twig's or a daisy's reach (plus sway and a pixel + # of antialiasing) of the figure. x, y = screen_xy(IMG) fx, fy = x[fig], y[fig] out = d & ~fig far = [np.hypot(fx - x[r, c], fy - y[r, c]).min() for r, c in np.argwhere(out)] - assert out.sum() > 0 and max(far) <= FLOWER_R + LEAF_LEN + 1.5 / IMG[1], max(far) + assert out.sum() > 0 and max(far) <= max(TWIG_REACH, REACH) + K["SWAY_AMP"] + 1.5 / IMG[1], max(far) rig.release() @@ -775,7 +777,7 @@ def test_flowers_only_late(ctx): img = rig.image() found.append((flower_pixels(img) & drawn(img)).sum()) rig.release() - assert found[0] == 0 and found[1] > 100, found + assert found[0] == 0 and found[1] > 50, found # only DAISY_FRAC of tips bloom, each petal partly hatched def test_vines_follow_the_arm(ctx): @@ -783,7 +785,8 @@ def test_vines_follow_the_arm(ctx): # The raised arm above the head (arm-only columns, clear of the shoulders' flowers). arm = on_screen(rect(36, 0, 38, 8), IMG) assert drawn(rig.image())[arm].mean() > 0.5, drawn(rig.image())[arm].mean() - rig.run([person(arm="down")] * 3) + # 0.6 s: the rewinding tip clears in a few camera frames; twigs point up, so they outlast it by a frame or two + rig.run([person(arm="down")] * 6) assert drawn(rig.image())[arm].mean() < 0.1, drawn(rig.image())[arm].mean() rig.release() From 30c8fc5897251fef722a1428b35895513e08de12 Mon Sep 17 00:00:00 2001 From: ii-impy <314283863+ii-impy@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:33:56 -0500 Subject: [PATCH 16/22] Watering can: one body rule for a texel and its neighbours' marches, so moving plants don't tear Each texel decided it was body from this frame's presence, but the march found its run's ends over last frame's body, so wherever the body had just arrived or left the texels of one run disagreed about its ends and the plants tore into rows. Membership now comes from last frame's stored texel alone (a frame of latency), and the per-texel easing of run ends, which gave each texel its own history, is gone. Co-Authored-By: Claude Opus 5.5 --- .../shaders/watering_can/buffer_b.glsl | 26 +++++++------ .../shaders/watering_can/common.glsl | 2 - .../FundingCAPTCHA/test_watering_can.py | 38 +++++++++++++++++++ 3 files changed, 53 insertions(+), 13 deletions(-) diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_b.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_b.glsl index 1a30c53..e1427c3 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_b.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_b.glsl @@ -33,14 +33,19 @@ int ownerAt(vec2 uv) { return best; } -// Was this texel body last frame? From BODY_ON, and once body, until below BODY_OFF: hysteresis, -// so a flickering edge holds still. (A held, fading texel has less than full hold: not body.) -bool wasBody(vec4 t) { +// Is this texel body this frame? Decided from last frame's stored texel t alone, so the march below, +// probing its neighbours, judges each exactly as that texel judges itself: every texel of a run then +// finds the same ends, even while the body moves (a rule using this frame's presence can't be +// evaluated at a neighbour, and the two disagreed wherever the body had just arrived or left, tearing +// the plants into rows). The cost is a frame of latency. From BODY_ON, and once body, until below +// BODY_OFF: hysteresis, so a flickering edge holds still. (A held, fading texel isn't body.) +bool fullHold(vec4 t) { return fract(t.a) > floor(0.9 / HOLD_Q) * HOLD_Q - 0.5 * HOLD_Q; } +bool member(vec4 t) { bool run = t.g > 0.0 || t.b > 0.0; - return fract(t.a) > floor(0.9 / HOLD_Q) * HOLD_Q - 0.5 * HOLD_Q && (t.r >= BODY_ON || (run && t.r >= BODY_OFF)); + return t.r >= BODY_ON || (fullHold(t) && run && t.r >= BODY_OFF); } bool bodyAt(float x, float y) { - return wasBody(texelFetch(iChannel1, ivec2(min(vec2(x, y) * iResolution.xy, iResolution.xy - 1.0)), 0)); + return member(texelFetch(iChannel1, ivec2(min(vec2(x, y) * iResolution.xy, iResolution.xy - 1.0)), 0)); } // How far the run extends from uv in direction dir (±1, in UV y) while last frame's body continues. @@ -71,17 +76,16 @@ void mainImage(out vec4 o, in vec2 fc) { float pres = iFrame == 0 ? target : mix(prev.r, target, 1.0 - exp(-iTimeDelta / PRESENCE_EASE_S)); float g = 0.0, b = 0.0, a = 0.0; - bool had = prev.g > 0.0 || prev.b > 0.0; // The body has gone from under a person whose track is missing: every texel of theirs lets go this // frame, not as its eased presence crosses BODY_OFF (sooner at the edges), so their hold fades in step. int po = bOwner(prev); float pst = po > 0 ? recIdn(iChannel2, po - 1).y : S_EMPTY; bool leaving = po > 0 && target < BODY_OFF && !isState(pst, S_TENTATIVE) && !isState(pst, S_CONFIRMED); - if (!leaving && (pres >= BODY_ON || (wasBody(prev) && pres >= BODY_OFF))) { - float lo = march(uv, -1.0), hi = march(uv, 1.0); - float ease = 1.0 - exp(-iTimeDelta / RUN_EASE_S); - g = (!had || abs(lo - prev.g) > RUN_SNAP) ? lo : mix(prev.g, lo, ease); - b = (!had || abs(hi - prev.b) > RUN_SNAP) ? hi : mix(prev.b, hi, ease); + if (!leaving && iFrame > 0 && member(prev)) { + // Not eased: an eased end has a history per texel, and texels that joined the run at different + // times would disagree. The hysteresis above already holds a flickering edge still. + g = march(uv, -1.0); + b = march(uv, 1.0); a = float(ownerAt(uv)) + floor(0.9 / HOLD_Q) * HOLD_Q; } else { float ph = bHold(prev); diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl index bc74d12..80562a8 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl @@ -24,8 +24,6 @@ const float BODY_OFF = 0.3; // ... and a texel stays body until bel const int RUN_STEPS = 64; // max march taps each way const float RUN_STEP_H = 1.0/64.0; // march step, screen heights: RUN_STEPS of them cover a full screen height const int RUN_REFINE = 6; // bisections of the last march step, down to the edge texel (to 1080p), so a run's texels agree -const float RUN_EASE_S = 0.15; // eases run extents; snaps when they jump more than RUN_SNAP -const float RUN_SNAP = 0.08; // screen heights const float LEAVE_S = 2.0; // a lost person's plants rewind away over this const float HOLD_Q = 1.0/128.0; // 0.9 * hold is stored in multiples of this, so it is exact in a half float for any owner // Garden diff --git a/ShowControl/FundingCAPTCHA/test_watering_can.py b/ShowControl/FundingCAPTCHA/test_watering_can.py index b94153e..0794087 100644 --- a/ShowControl/FundingCAPTCHA/test_watering_can.py +++ b/ShowControl/FundingCAPTCHA/test_watering_can.py @@ -686,6 +686,44 @@ def grown_rig(ctx, frames, size=IMG, **kw): return rig +def column_runs(b): + """Each vertical run of body texels in B (h, w, 4) as its texels' (bottom, top) arrays.""" + member = (b[..., 1] > 0) | (b[..., 2] > 0) + for c in range(b.shape[1]): + col, r = member[:, c], 0 + while r < len(col): + if not col[r]: + r += 1 + continue + s = r + while r < len(col) and col[r]: + r += 1 + yield b[s:r, c, 1], b[s:r, c, 2] + + +def swinging_arm(angle_deg, dx=0): + """person() on a 4x finer Body (256 x 192), right arm a 12 px bar from the shoulder at angle_deg + (0 hanging, 90 out, 180 up), shifted dx px sideways.""" + m = np.roll(np.kron(person(), np.ones((4, 4), dtype=bool)), dx, axis=1) + yy, xx = np.mgrid[0:m.shape[0], 0:m.shape[1]] + a = np.radians(angle_deg) + px, py = xx - (36 * 4 + dx), yy - 14 * 4 # from the right shoulder, y down + along = px * np.sin(a) + py * np.cos(a) + return m | ((along > 0) & (along < 60) & (np.abs(-px * np.cos(a) + py * np.sin(a)) < 6)) + + +def test_run_ends_agree_while_moving(ctx): # a waving arm, a swaying body: the plants don't tear + rig = Rig(ctx, size=(320, 240)) + rig.run([swinging_arm(20)] * 30) + worst = [] + for k in range(16): # down to up over 1.6 s, drifting a Body px a frame + rig.run([swinging_arm(20 + 160 * (0.5 - 0.5 * np.cos(np.pi * k / 15)), dx=k % 4)]) + spreads = [max(np.ptp(g), np.ptp(t)) for g, t in column_runs(rig.buf("buffer_b"))] + worst.append(max(spreads)) + assert max(worst) < 2.0 / 240, worst # every texel of a run finds the same ends, to a texel or two + rig.release() + + def test_run_ends_agree_along_a_column(ctx): # else the plants tear into rows size = (640, 360) # the march's step is 5.6 px here rig = Rig(ctx, size=size) From 82ac0b2d66588b835718870e6f77864c8c50d4dd Mon Sep 17 00:00:00 2001 From: ii-impy <314283863+ii-impy@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:35:26 -0500 Subject: [PATCH 17/22] Watering can: three-quarter density; growth starts fast, then slows Branches and twigs spaced further apart (about 0.76 of the density). Growth reaches GROW_EARLY (0.3) in GROW_EARLY_S (2 s), then slows to full bloom at GROW_S as before. Tests take expected levels from the same constants. Co-Authored-By: Claude Opus 5.5 --- .../shaders/watering_can/buffer_c.glsl | 3 +- .../shaders/watering_can/common.glsl | 6 ++- .../FundingCAPTCHA/test_watering_can.py | 46 ++++++++++++------- 3 files changed, 35 insertions(+), 20 deletions(-) diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl index 9180c80..6e006ed 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl @@ -157,7 +157,8 @@ vec4 personNext(int k, float dt, vec2 fc) { } } if (moves) { - float rate = walking(since) ? -1.0 / REWIND_S : 1.0 / GROW_S; + float grow = p.y < GROW_EARLY ? GROW_EARLY / GROW_EARLY_S : (1.0 - GROW_EARLY) / (GROW_S - GROW_EARLY_S); + float rate = walking(since) ? -1.0 / REWIND_S : grow; // The step is a few half-float ulps: round it stochastically to GROW_Q, so the // expected growth is exact at any render rate. float h = hash12(fc + vec2(113.0, 57.0) * float(iFrame % 997)); diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl index 80562a8..e723cdd 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl @@ -28,6 +28,8 @@ const float LEAVE_S = 2.0; // a lost person's plants rewind away o const float HOLD_Q = 1.0/128.0; // 0.9 * hold is stored in multiples of this, so it is exact in a half float for any owner // Garden const float GROW_S = 20.0, REWIND_S = 20.0; // empty → full bloom, full bloom → empty +const float GROW_EARLY = 0.3, GROW_EARLY_S = 2.0; // growth starts fast, reaching GROW_EARLY in GROW_EARLY_S, + // then slows to reach full bloom at GROW_S const float GROW_Q = 1.0/2048.0; // growth is stored in multiples of this (a half float's step just below 1) const float BASE_BAND = 0.04; // screen heights above the lowest body row that count as feet const int BASE_TAPS = 16; @@ -49,7 +51,7 @@ const float SPLIT_GAP = 0.03; // a new track this close to a live one // inherits its growth: the tracker merges touching people, so a gap this small means a split const float SUN_FULL = 1.0, SUN_EASE_S = 1.5; // total growth that fully lights the sun // Plants -const float VINE_SPACING = 7.0/360.0; // ~19 branches across an adult; their twigs fill between +const float VINE_SPACING = 8.0/360.0; // ~17 branches across an adult; their twigs fill between const int VINE_NEIGHBOURS = 4; // vines evaluated each side of a pixel. Was 2: a twig or flower reaches // ~4 vines sideways (wobble + sway + its size), and at 2 they were cut off // in visible straight lines. Lower it first if the Pi falls short of frame rate. @@ -62,7 +64,7 @@ const float INK_W = 0.8/360.0; const float STEM_W = 4.0/360.0, STEM_TIP_W = 1.2/360.0; // trunk width at its base and at its tip const float STEM_TAPER_H = 60.0/360.0; // the trunk tapers over this much branch below its tip const float BARK_HATCH = 0.2, BARK_FREQ = 90.0; // a broken ink stroke down the trunk's shaded side: offset (× width), dash rate -const float TWIG_STEP = 16.0/360.0; // twigs fork off every this much trunk, on alternating sides +const float TWIG_STEP = 18.5/360.0; // twigs fork off every this much trunk, on alternating sides const float TWIG_LEN = 11.0/360.0, TWIG_W = 1.8/360.0; // twig length, and width where it leaves the trunk const float TWIG_RISE = 0.6, TWIG_RISE_VAR = 0.35; // twig angle above horizontal, and its random spread (radians) const float TWIG_FORK = 0.45; // the fraction of twigs that fork once more, partway along... diff --git a/ShowControl/FundingCAPTCHA/test_watering_can.py b/ShowControl/FundingCAPTCHA/test_watering_can.py index 0794087..e339f54 100644 --- a/ShowControl/FundingCAPTCHA/test_watering_can.py +++ b/ShowControl/FundingCAPTCHA/test_watering_can.py @@ -316,6 +316,18 @@ def test_fading_hold_agrees_along_a_run(ctx, dt, per_cam): # ── Task 2: Garden state (Buffer C) ─────────────────────────────────────────── GROW_S, INHERIT_S, SUN_EASE_S = K["GROW_S"], K["INHERIT_S"], K["SUN_EASE_S"] +EARLY, EARLY_S = K["GROW_EARLY"], K["GROW_EARLY_S"] +LATE_RATE = (1 - EARLY) / (GROW_S - EARLY_S) # growth per second once past GROW_EARLY + + +def grown(t): + """Growth after t seconds planted from bare: fast to GROW_EARLY, then slower to full bloom at GROW_S.""" + return min(1.0, EARLY / EARLY_S * t if t <= EARLY_S else EARLY + LATE_RATE * (t - EARLY_S)) + + +def seconds_to(g): + """Seconds planted from bare to reach growth g (the inverse of grown).""" + return g / EARLY * EARLY_S if g <= EARLY else EARLY_S + (g - EARLY) / LATE_RATE COAST_S = 0.8 # BlobConfig max_miss_frames at 10 Hz: missing → lost # Walks start at x=16 so 30 steps never reach walking()'s bounce at 48: a walker who turns # back crosses their own last spot, and that stretch (~0.7 s) rightly isn't a step. @@ -337,7 +349,7 @@ def test_planted_flickering_person_blooms_in_20s(ctx): # spec: ~20 s, n for _ in range(22): rig.run(flickering(10)) levels.append(rig.person(0)[1]) - assert abs(levels[9] - 0.5) < 0.06 and levels[-1] > 0.98, levels + assert abs(levels[9] - grown(10)) < 0.06 and levels[-1] > 0.98, levels assert all(b >= a - 1e-3 for a, b in zip(levels, levels[1:])), levels rig.release() @@ -345,14 +357,14 @@ def test_planted_flickering_person_blooms_in_20s(ctx): # spec: ~20 s, n def test_arm_waving_does_not_rewind(ctx): rig = Rig(ctx) rig.run([person(arm=("down", "out", "up")[k // 5 % 3]) for k in range(100)]) - assert abs(rig.person(0)[1] - 0.5) < 0.06, rig.person(0) + assert abs(rig.person(0)[1] - grown(10)) < 0.06, rig.person(0) rig.release() def test_weight_shift_does_not_rewind(ctx): rig = Rig(ctx) rig.run([person(lift=(None, "L", None, "R")[k // 3 % 4]) for k in range(100)]) - assert abs(rig.person(0)[1] - 0.5) < 0.06, rig.person(0) + assert abs(rig.person(0)[1] - grown(10)) < 0.06, rig.person(0) rig.release() @@ -383,7 +395,7 @@ def test_growth_resumes_after_walking(ctx): walk = walking(30, x=WALK_X) rig.run(walk) g1 = rig.person(0)[1] - assert 0.25 < g1 < 0.45, g1 # rewound, not reset + assert abs(g1 - (grown(10) - 3.0 / K["REWIND_S"])) < 0.1, g1 # rewound, not reset levels = [] for _ in range(3): rig.run([walk[-1]] * 10) @@ -391,7 +403,7 @@ def test_growth_resumes_after_walking(ctx): # Walking lasts WALK_HOLD_S (plus easing) past the last step, so the first ~0.6 s still # rewinds a little; then it grows at the full rate from where the rewind got to, not from 0. assert all(g >= g1 - 0.05 for g in levels), (g1, levels) - assert abs((levels[-1] - levels[0]) - 2.0 / GROW_S) < 0.02, (g1, levels) + assert abs((levels[-1] - levels[0]) - 2.0 * LATE_RATE) < 0.02, (g1, levels) rig.release() @@ -401,7 +413,7 @@ def test_neighbour_walking_does_not_rewind_still_person(ctx): assert len(live_slots(rig)) == 2 still, walker = slot_near(rig, 52), slot_near(rig, 4) assert still != walker - assert abs(rig.person(still)[1] - 0.5) < 0.06, rig.person(still) + assert abs(rig.person(still)[1] - grown(10)) < 0.06, rig.person(still) assert rig.person(walker)[1] < 0.1, rig.person(walker) rig.release() @@ -423,7 +435,7 @@ def test_merge_then_split_both_inherit(ctx): rig.run(both([person(x=4)] * 30, [person(x=40)] * 30)) # B arrives, 3 s assert len(live_slots(rig)) == 2 ga, gb = rig.person(slot_near(rig, 10))[1], rig.person(slot_near(rig, 46))[1] - assert ga > 0.8 and gb < 0.2, (ga, gb) + assert ga > 0.8 and gb < 0.45, (ga, gb) # B slides left into A until their masks overlap (one blob), and they stand merged for 3 s rig.run(both([person(x=4)] * 26, [person(x=max(40 - k, 14)) for k in range(26)])) rig.run(both([person(x=4)] * 30, [person(x=14)] * 30)) @@ -463,7 +475,7 @@ def test_touching_still_people_keep_growing(ctx, contact): frames.append(m) rig.run(frames + [a | b] * 8) after = confirmed_near(rig, 16), confirmed_near(rig, 29) - want = 10.8 / GROW_S # still for 10.8 s + want = 10.8 * LATE_RATE # still for 10.8 s, past the early spurt for g0, g1 in zip(before, after): assert abs((g1 - g0) - want) < 0.15 * want, (contact, before, after) rig.release() @@ -484,7 +496,7 @@ def test_passer_by_costs_nothing_once_past(ctx, feet): rig.run([m]) levels.append(confirmed_near(rig, 32)) assert all(y >= x - 1e-3 for x, y in zip(levels, levels[1:])), str(np.round(levels, 3)) - want = (len(levels) - 1) / 10 / GROW_S + want = (len(levels) - 1) / 10 * LATE_RATE assert levels[-1] - levels[0] > 0.85 * want, (want, levels) rig.release() @@ -518,7 +530,7 @@ def test_feet_off_frame(ctx): # Review Focus 2 for _ in range(10): rig.run(flickering(10, x=WALK_X, feet=48)) g0 = rig.person(0)[1] - assert abs(g0 - 0.5) < 0.06, g0 + assert abs(g0 - grown(10)) < 0.06, g0 rig.run(walking(30, x=WALK_X, feet=48)) assert abs((g0 - rig.person(0)[1]) - 3.0 / 20.0) < 0.05, (g0, rig.person(0)) rig.release() @@ -534,7 +546,7 @@ def test_new_person_far_from_ghosts_starts_bare(ctx): rig.run([person(x=46)] * 10) b = slot_near(rig, 52) assert rig.person(b)[0] != 0 and rig.ghost(0)[3] < INHERIT_S - assert rig.person(b)[1] < 0.05, rig.person(b) + assert rig.person(b)[1] < grown(1.0) + 0.02, rig.person(b) # its own second of growth, nothing inherited rig.release() @@ -579,7 +591,7 @@ def test_newcomer_beside_bloomed_person_starts_bare(ctx): a, b = slot_near(rig, 10), slot_near(rig, 26) assert a != b assert rig.person(a)[1] > 0.9 - assert rig.person(b)[1] < 0.05, rig.person(b) + assert rig.person(b)[1] < grown(1.0) + 0.02, rig.person(b) # its own second of growth, nothing inherited rig.release() @@ -601,7 +613,7 @@ def test_growth_independent_of_render_rate(ctx, dt, per_cam): a.run([person()] * 150, frames_per_camera=3) # 15 s: through the coarser steps above 0.5 b = Rig(ctx, dt=dt) b.run([person()] * 150, frames_per_camera=per_cam) - assert abs(a.person(0)[1] - 0.73) < 0.06, a.person(0) + assert abs(a.person(0)[1] - grown(15)) < 0.06, a.person(0) assert abs(a.person(0)[1] - b.person(0)[1]) < 0.02, (a.person(0), b.person(0)) a.release(); b.release() @@ -765,7 +777,7 @@ def test_sun_region_is_top_right(): def test_nothing_drawn_before_growth(ctx): # anti-goal: never draw the silhouette - rig = grown_rig(ctx, 5) + rig = grown_rig(ctx, 4) assert 0 < rig.person(0)[1] < 0.03, rig.person(0) # growing, barely assert drawn(rig.image()).mean() < 0.002 rig.release() @@ -787,7 +799,7 @@ def test_bloom_fills_the_silhouette(ctx): def test_growth_climbs_from_the_feet(ctx): - rig = grown_rig(ctx, 60) + rig = grown_rig(ctx, round(10 * seconds_to(0.3))) assert abs(rig.person(0)[1] - 0.3) < 0.05, rig.person(0) d, fig = drawn(rig.image()), person() top, bottom = on_screen(fig & rows(6, 19), IMG), on_screen(fig & rows(31, 44), IMG) # thirds of rows 6..44 @@ -798,7 +810,7 @@ def test_growth_climbs_from_the_feet(ctx): def test_colour_ripens(ctx): sats = [] - for frames, g in ((60, 0.3), (220, 1.0)): + for frames, g in ((round(10 * seconds_to(0.3)), 0.3), (220, 1.0)): rig = grown_rig(ctx, frames) assert abs(rig.person(0)[1] - g) < 0.05, rig.person(0) img = rig.image() @@ -809,7 +821,7 @@ def test_colour_ripens(ctx): def test_flowers_only_late(ctx): found = [] - for frames, g in ((85, 0.4), (220, 1.0)): + for frames, g in ((round(10 * seconds_to(0.4)), 0.4), (220, 1.0)): rig = grown_rig(ctx, frames) assert abs(rig.person(0)[1] - g) < 0.05, rig.person(0) img = rig.image() From 850f42b6036b2f60a5ac9ae14abfb8a214794e15 Mon Sep 17 00:00:00 2001 From: ii-impy <314283863+ii-impy@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:38:51 -0500 Subject: [PATCH 18/22] Watering can: walkers leave footprints of plants where they step Buffer C keeps a ring of FOOTPRINTS in row 3 (head in the SUN texel). A seen, walking person drops one at their feet once FOOT_GAP from every live footprint, only into an expired slot, so a live one never pops. Each grows a small clump of the same ink branches, sprigs and a daisy off the floor, fast, holds, then rewinds away. Footprints join the near grid and draw behind people's plants. The daisy is shared out of bloom(). Co-Authored-By: Claude Opus 5.5 --- .../shaders/watering_can/buffer_c.glsl | 54 +++++++- .../shaders/watering_can/common.glsl | 28 ++++- .../shaders/watering_can/image.glsl | 117 +++++++++++++----- .../FundingCAPTCHA/test_watering_can.py | 61 +++++++++ .../specs/2026-11-06-watering-can-design.md | 6 + 5 files changed, 231 insertions(+), 35 deletions(-) diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl index 6e006ed..1ebaec8 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl @@ -1,5 +1,5 @@ // Buffer C — Garden state. iChannel0 = Buffer B, iChannel1 = Buffer C (itself), iChannel2 = blobs -// Layout: see common.glsl (rows 0–3 garden state, rows 4+ the near-body grid). Each texel reads the +// Layout: see common.glsl (rows 0–3 garden state and footprints, rows 4+ the near-body grid). Each texel reads the // previous frame of every other texel; texels that need the same derived value (PERSON and BASE // both need this frame's base) compute it from the same inputs. @@ -11,6 +11,7 @@ vec4 initial(ivec2 c) { if (c.y == 1) return vec4(0.0, 0.0, T_CLAMP, S_EMPTY); // BASE if (c.y == 2) return vec4(0.0, 0.0, 0.0, T_CLAMP); // GHOST + if (c.y == 3 && c.x > 0) return vec4(0.0, 0.0, T_CLAMP, 0.0); // FOOT: none return vec4(0.0); } @@ -185,6 +186,39 @@ vec4 ghostNext(int k, float dt) { return g; } +// ── FOOT ────────────────────────────────────────────────────────────────────── +// The slot of the walker who drops a footprint this frame, or -1. A seen person who is walking drops one +// where their feet are once they're FOOT_GAP from every live footprint: the first such slot, one a frame +// (another stepping the same frame drops next frame). Only into the ring's head, and only once the +// footprint there has gone, so a live one never pops. The SUN texel (which keeps the head) and every +// FOOT texel compute this from the same last frame, so they agree. +int footDropper() { + if (cFoot(C, int(cSun(C).z)).z < FOOT_LIFE) return -1; + for (int k = 0; k < SLOTS; k++) { + if (!seen(recIdn(BLOBS, k).y) || fresh(k)) continue; + vec4 b = cBase(C, k); + if (!walking(b.z)) continue; + bool clear = true; + for (int f = 0; f < FOOTPRINTS; f++) { + vec4 ft = cFoot(C, f); + if (ft.z < FOOT_LIFE && length(toScreen(ft.xy - b.xy)) < FOOT_GAP) { clear = false; break; } + } + if (clear) return k; + } + return -1; +} + +vec4 footNext(int f, float dt) { + int k = footDropper(); + if (k >= 0 && f == int(cSun(C).z)) { + vec2 at = cBase(C, k).xy; + return vec4(at, 0.0, fract(hash12(at * 1000.0 + float(iFrame % 997)) * 0.999) * 8.0); + } + vec4 ft = cFoot(C, f); + ft.z = min(ft.z + dt, T_CLAMP); + return ft; +} + // ── NEAR ────────────────────────────────────────────────────────────────────── // Is there body (or a held shape: anything a vine could grow on) within NEAR_PAD of cell (i, j)? // Reads this frame's B, so the grid never lags the plants. @@ -202,6 +236,15 @@ float nearNext(ivec2 cell) { if (bOwner(t) > 0 && bHold(t) > 0.0) return 1.0; } } + // A footprint's plants reach into the cell (last frame's footprints: a new one starts bare). + vec2 c0 = vec2(cell) * NEAR_CELL, c1 = c0 + NEAR_CELL; + for (int f = 0; f < FOOTPRINTS; f++) { + vec4 ft = cFoot(C, f); + if (ft.z >= FOOT_LIFE) continue; + vec2 at = vec2(ft.x * aspect, ft.y); + if (at.x + FOOT_REACH_X >= c0.x && at.x - FOOT_REACH_X <= c1.x + && at.y + FOOT_REACH_UP >= c0.y && at.y - FOOT_REACH_DOWN <= c1.y) return 1.0; + } return 0.0; } @@ -219,7 +262,9 @@ vec4 sunNext(float dt, vec2 fc) { // full: round it stochastically to GROW_Q, as growth is, so the expected easing is exact. float h = hash12(fc + vec2(113.0, 57.0) * float(iFrame % 997)); lvl = clamp(lvl + sign(step) * floor(abs(step) / GROW_Q + h) * GROW_Q, 0.0, 1.0); - return vec4(lvl, raw, 0.0, 0.0); + float head = cSun(C).z; // the footprint ring's head moves on past a drop + if (footDropper() >= 0) head = mod(head + 1.0, float(FOOTPRINTS)); + return vec4(lvl, raw, head, 0.0); } void mainImage(out vec4 o, in vec2 fc) { @@ -229,12 +274,13 @@ void mainImage(out vec4 o, in vec2 fc) { if (c.y < 4 + NEAR_ROWS && float(c.x) * NEAR_CELL < iResolution.x / iResolution.y) o.x = nearNext(c - ivec2(0, 4)); return; } - if (c.x >= SLOTS || (c.y == 3 && c.x > 0)) return; + if (c.y == 3 ? c.x > FOOTPRINTS : c.x >= SLOTS) return; if (iFrame == 0) { o = initial(c); return; } float dt = clamp(iTimeDelta, 0.0, T_CLAMP); if (c.y == 0) o = personNext(c.x, dt, fc); else if (c.y == 1) o = baseNext(c.x, dt); else if (c.y == 2) o = ghostNext(c.x, dt); - else o = sunNext(dt, fc); + else if (c.x == 0) o = sunNext(dt, fc); + else o = footNext(c.x - 1, dt); } diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl index e723cdd..1c8e2d2 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl @@ -10,8 +10,10 @@ // (k, 1) BASE base x, base y (UV, smoothed), seconds since last walking step (negative: re-planting, // -seconds to go), record state last frame // (k, 2) GHOST spot x, y, growth at loss, seconds since loss (T_CLAMP = no ghost) -// (0, 3) SUN sun level 0–1 (eased), raw total growth, —, — -// (i, 4 + j) NEAR 1 if body (or a held shape) lies within NEAR_PAD of grid cell (i, j), else 0; +// (0, 3) SUN sun level 0–1 (eased), raw total growth, next footprint f (the ring's head), — +// (1 + f, 3) FOOT footprint f = 0–FOOTPRINTS-1: x, y (UV), seconds since dropped (T_CLAMP none), seed +// (i, 4 + j) NEAR 1 if body (or a held shape) lies within NEAR_PAD of grid cell (i, j), or a footprint's plants +// reach it, else 0; // cell (i, j) is screen [i, i + 1] × [j, j + 1] · NEAR_CELL (screen heights). See cNear(). const int SLOTS = 8; @@ -87,6 +89,21 @@ const float REACH = FLOWER_R * (1.0 + PETAL_R); // a flower's outer radius (FL // the Image taps B at ±REACH and ±REACH / 2 above and below each pixel const float SHORT_BLOOM_FROM = 0.5, SHORT_BLOOM_FULL = 0.75; // a run shorter than these × REACH keeps a bud, not a flower // (the taps are REACH / 2 apart: they don't find it from every row a flower covers) +// Footprints: a walker leaves plants behind on the floor where they step (Buffer C row 3, a ring). +const int FOOTPRINTS = 32; // live at once; when all are, walkers leave none until the oldest has gone +const float FOOT_GAP = 0.12; // a walker drops a footprint where their feet are this far (screen heights) + // from every live one +const float FOOT_GROW_S = 1.5, FOOT_HOLD_S = 4.0, FOOT_FADE_S = 2.0; // grows in fast, holds, rewinds away +const float FOOT_LIFE = FOOT_GROW_S + FOOT_HOLD_S + FOOT_FADE_S; +const int FOOT_SPROUTS = 3; // little branches in a footprint's clump +const float FOOT_H = 0.08; // the tallest stands this high (screen heights) +const float FOOT_SPREAD = 0.025; // they stand across this either side of the footprint +const float FOOT_LEAN = 0.35; // and lean up to this far sideways per unit height +const float FOOT_FLOWER_FROM = 0.5, FOOT_FLOWER_R = 5.0/360.0; // the middle one's daisy opens from this growth; its size +// How far a footprint's plants reach from it: sideways, below, above (for the near grid and the Image's bounds). +const float FOOT_REACH_X = FOOT_SPREAD * 1.2 + FOOT_LEAN * FOOT_H + 0.7 * TWIG_LEN + SPRIG_LEN + INK_W; +const float FOOT_REACH_DOWN = SPRIG_LEN + INK_W; +const float FOOT_REACH_UP = FOOT_H + max(SPRIG_LEN, FOOT_FLOWER_R * (1.0 + PETAL_R)) + INK_W; // Near-body grid (Buffer C rows 4+): the Image skips cells with no body near, so empty paper costs one fetch. const float NEAR_CELL = 1.0/32.0; // cell size; 4 + 1/NEAR_CELL rows of C must fit the render height (36 ≤ 72 at test size) const float NEAR_STEP = 0.01; // B is sampled this far apart across a padded cell: finer than the thinnest body @@ -143,6 +160,13 @@ vec4 cPerson(sampler2D c, int k) { return texelFetch(c, ivec2(k, 0), 0); } vec4 cBase (sampler2D c, int k) { return texelFetch(c, ivec2(k, 1), 0); } vec4 cGhost (sampler2D c, int k) { return texelFetch(c, ivec2(k, 2), 0); } vec4 cSun (sampler2D c) { return texelFetch(c, ivec2(0, 3), 0); } +vec4 cFoot (sampler2D c, int f) { return texelFetch(c, ivec2(1 + f, 3), 0); } +// A footprint's growth at `age` seconds: fast in (easing out), holding, then rewinding away; 0 when gone. +float footGrowth(float age) { + if (age >= FOOT_LIFE) return 0.0; + if (age < FOOT_GROW_S) { float u = 1.0 - age / FOOT_GROW_S; return 1.0 - u * u; } + return clamp((FOOT_LIFE - age) / FOOT_FADE_S, 0.0, 1.0); +} bool cNear (sampler2D c, vec2 screenPos) { // screenPos in screen heights; is any body near? return texelFetch(c, ivec2(floor(screenPos / NEAR_CELL)) + ivec2(0, 4), 0).x > 0.5; } diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl index 2a7e0ca..1398336 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl @@ -221,39 +221,20 @@ void sprigs(inout vec4 acc, vec2 p, int i, Run r, int nodes, int j0) { } } -// The trunk's tip: a sprig, or on DAISY_FRAC of branches a bud, which late in growth opens into a daisy. -void bloom(inout vec4 acc, vec2 p, int i, Run r) { - float open = clamp((r.f - FLOWER_FROM) / (FLOWER_FULL - FLOWER_FROM), 0.0, 1.0); - // The taps are REACH / 2 apart, so a run shorter than that isn't found from every row an open - // flower covers: on one (a ragged edge, a fragment) the flower stays a bud. Only the bloom. - open *= smoothstep(SHORT_BLOOM_FROM * REACH, SHORT_BLOOM_FULL * REACH, r.b - r.g); - // The bud sits on the stem's tip. Opening, the flower drifts to the vine's own x (on the body), - // so the wobble can't carry it further past the edge than its own size. - vec2 tip = vec2(vineX(i, r.tip, r), r.tip); - vec2 c = vec2(mix(tip.x, float(i) * VINE_SPACING + swayX(i, r.tip, r), open), r.tip); - if (length(p - c) > REACH + INK_W + 2.0 * AA && length(p - tip) > BUD_R + INK_W + 2.0 * AA) return; - float k = sprout(r.tip - r.g); - if (hash12(vec2(float(i), 13.9)) >= DAISY_FRAC) { // no daisy: the branch ends in a sprig - sprig(acc, p, tip, vec2(0.0, 1.0), k, float(i) * 31.0 - 7.0, leafColour(r.sat)); - return; - } - inked(acc, leafColour(r.sat), length(p - mix(tip, c, open)) - BUD_R * k, k); // the bud, later the calyx - if (open <= 0.0) return; - vec2 q = p - c; - float fr = mix(BUD_R, FLOWER_R, open); - float e = smoothstep(0.0, 0.25, open); // petals swell out of the bud - vec3 pc = desaturate(flowerColour(i), r.sat); - float a0 = TAU * hash12(vec2(float(i), 11.3)); +// A daisy at q = 0 of radius fr (its petals' ring; they reach fr × (1 + PETAL_R)), petals swollen e of +// the way out, in petal colour pc; seed picks its turn and petal jitter. +void daisy(inout vec4 acc, vec2 q, float fr, float e, vec3 pc, float sat, float seed) { + float a0 = TAU * hash12(vec2(seed, 11.3)); float da = TAU / float(DAISY_N); float r0 = 0.55 * fr, r1 = mix(r0, fr * (1.0 + PETAL_R), e), pw = DAISY_PETAL_W * fr * e; - // Only the three petals nearest p's angle can cover it (the centre hides where more overlap). + // Only the three petals nearest q's angle can cover it (the centre hides where more overlap). // Even petals lie under odd ones, the same at every pixel, so the overlaps agree. int nn = int(floor((atan(q.y, q.x) - a0) / da + 0.5)); for (int pass = 0; pass < 2; pass++) for (int m = nn - 1; m <= nn + 1; m++) { float id = mod(float(m), float(DAISY_N)); if (int(mod(id, 2.0)) != pass) continue; - float a = a0 + da * float(m) + 0.15 * (hash12(vec2(float(i), id)) - 0.5); + float a = a0 + da * float(m) + 0.15 * (hash12(vec2(seed, id)) - 0.5); vec2 u = vec2(cos(a), sin(a)); vec2 lq = vec2(dot(q, vec2(-u.y, u.x)), dot(q, u)); // across, along float sd = sdTaper(lq, vec2(0.0, r0), vec2(0.0, r1 - pw), 0.7 * pw, pw); @@ -264,14 +245,35 @@ void bloom(inout vec4 acc, vec2 p, int i, Run r) { } // The centre: an oval, hatched on its shaded side. vec2 rad = HEART_R * fr * e * vec2(0.8, 1.05); - float hs = length(q / rad) - 1.0; - float sd = hs * min(rad.x, rad.y); - inked(acc, desaturate(YELLOW, r.sat), sd, 1.0); - float step_ = 0.22 * rad.y; + float sd = (length(q / rad) - 1.0) * min(rad.x, rad.y); + inked(acc, desaturate(YELLOW, sat), sd, 1.0); + float step_ = 0.22 * rad.y; float stripe = abs(fract(q.y / step_) - 0.5) * step_; over(acc, INK, lineA(stripe, 0.5 * INK_W) * fillA(sd + 0.15 * rad.x) * smoothstep(0.0, -0.4 * rad.x, q.x) * e); } +// The trunk's tip: a sprig, or on DAISY_FRAC of branches a bud, which late in growth opens into a daisy. +void bloom(inout vec4 acc, vec2 p, int i, Run r) { + float open = clamp((r.f - FLOWER_FROM) / (FLOWER_FULL - FLOWER_FROM), 0.0, 1.0); + // The taps are REACH / 2 apart, so a run shorter than that isn't found from every row an open + // flower covers: on one (a ragged edge, a fragment) the flower stays a bud. Only the bloom. + open *= smoothstep(SHORT_BLOOM_FROM * REACH, SHORT_BLOOM_FULL * REACH, r.b - r.g); + // The bud sits on the stem's tip. Opening, the flower drifts to the vine's own x (on the body), + // so the wobble can't carry it further past the edge than its own size. + vec2 tip = vec2(vineX(i, r.tip, r), r.tip); + vec2 c = vec2(mix(tip.x, float(i) * VINE_SPACING + swayX(i, r.tip, r), open), r.tip); + if (length(p - c) > REACH + INK_W + 2.0 * AA && length(p - tip) > BUD_R + INK_W + 2.0 * AA) return; + float k = sprout(r.tip - r.g); + if (hash12(vec2(float(i), 13.9)) >= DAISY_FRAC) { // no daisy: the branch ends in a sprig + sprig(acc, p, tip, vec2(0.0, 1.0), k, float(i) * 31.0 - 7.0, leafColour(r.sat)); + return; + } + inked(acc, leafColour(r.sat), length(p - mix(tip, c, open)) - BUD_R * k, k); // the bud, later the calyx + if (open <= 0.0) return; + float fr = mix(BUD_R, FLOWER_R, open); + daisy(acc, p - c, fr, smoothstep(0.0, 0.25, open), desaturate(flowerColour(i), r.sat), r.sat, float(i)); +} + // The plants over transparent, premultiplied. Branches vine by vine, then buds and flowers on top. vec4 plants(vec2 fc) { vec2 p = fc / iResolution.y; @@ -331,6 +333,61 @@ vec4 plants(vec2 fc) { return acc; } +// ── Footprints ──────────────────────────────────────────────────────────────── +// Where a walker steps, Buffer C leaves a footprint: a clump of FOOT_SPROUTS little branches grows up +// off the floor there, each with a twig and a sprig, the middle one ending in a small daisy. It grows +// fast, holds, then rewinds away (footGrowth). Drawn as the body's plants are, but stands on its own. + +void footprint(inout vec4 acc, vec2 p, vec2 at, float g, float seed) { + if (abs(p.x - at.x) > FOOT_REACH_X || p.y < at.y - FOOT_REACH_DOWN || p.y > at.y + FOOT_REACH_UP) return; + float sat = mix(SAT_MIN, SAT_MAX, g); + float wd = 1e3; + vec2 tips[FOOT_SPROUTS], twigs[FOOT_SPROUTS], dirs[FOOT_SPROUTS]; + float ks[FOOT_SPROUTS]; + for (int s = 0; s < FOOT_SPROUTS; s++) { // the wood: stems and twigs, one shape + float h = hash12(vec2(seed, float(s) + 0.5)); + float x = at.x + FOOT_SPREAD * (2.0 * (float(s) + 0.5) / float(FOOT_SPROUTS) - 1.0 + 0.4 * (h - 0.5)); + float ht = FOOT_H * (0.6 + 0.4 * hash12(vec2(seed, float(s) + 3.5))) * g; + float lean = FOOT_LEAN * (2.0 * hash12(vec2(seed, float(s) + 6.5)) - 1.0); + vec2 o = vec2(x, at.y); + tips[s] = o + vec2(lean * ht, ht); + float tw = 0.5 * STEM_W * min(1.0, 3.0 * g), tip = 0.5 * STEM_TIP_W * g, thin = (1.0 - min(1.0, 3.0 * g)) * 2.0 * INK_W; + wd = smin(wd, sdTaper(p, o, tips[s], tw, tip) + thin, WOOD_FILLET); + // One twig, halfway up, on alternating sides. + float side = mod(float(s), 2.0) < 0.5 ? 1.0 : -1.0; + vec2 n = mix(o, tips[s], 0.5); + dirs[s] = vec2(side * cos(TWIG_RISE), sin(TWIG_RISE)); + ks[s] = smoothstep(0.3, 0.7, g); + twigs[s] = n + dirs[s] * TWIG_LEN * 0.7 * ks[s]; + wd = smin(wd, sdTaper(p, n, twigs[s], 0.5 * TWIG_W * ks[s], tip) + (1.0 - min(1.0, 3.0 * ks[s])) * 2.0 * INK_W, WOOD_FILLET); + } + if (wd < INK_W + 2.0 * AA) inked(acc, stemColour(sat), wd, 1.0); + for (int s = 0; s < FOOT_SPROUTS; s++) { // then sprigs, and the middle one's daisy + float sd = seed * 17.0 + float(s); + if (ks[s] > 0.0) sprig(acc, p, twigs[s], dirs[s], ks[s], sd, leafColour(sat)); + if (s == FOOT_SPROUTS / 2) { + float open = smoothstep(FOOT_FLOWER_FROM, 1.0, g); + if (open > 0.0 && length(p - tips[s]) < FOOT_FLOWER_R * (1.0 + PETAL_R) + INK_W + 2.0 * AA) + daisy(acc, p - tips[s], FOOT_FLOWER_R * open, smoothstep(0.0, 0.25, open), + desaturate(flowerColour(int(seed * 97.0)), sat), sat, sd); + } else sprig(acc, p, tips[s], vec2(0.0, 1.0), g, sd + 0.5, leafColour(sat)); + } +} + +// Every live footprint near p, over transparent, premultiplied. +vec4 footprints(vec2 fc) { + vec2 p = fc / iResolution.y; + vec4 acc = vec4(0.0); + if (!cNear(iChannel1, p)) return acc; // the near grid counts footprints too + float aspect = iResolution.x / iResolution.y; + for (int f = 0; f < FOOTPRINTS; f++) { + vec4 ft = cFoot(iChannel1, f); + float g = footGrowth(ft.z); + if (g > 0.0) footprint(acc, p, vec2(ft.x * aspect, ft.y), g, ft.w); + } + return acc; +} + // The crayon sun: a waxy, grain-filled disc with a noise-broken edge, and rays in the same hand. // Level 0 is a muted, paper-mixed yellow with no rays; level 1 is full yellow with its rays out. vec3 sunColour(vec2 fc) { @@ -367,6 +424,8 @@ vec3 sunColour(vec2 fc) { void mainImage(out vec4 o, in vec2 fc) { AA = 1.0 / iResolution.y; vec3 col = sunColour(fc); // the sun, under the plants + vec4 fp = footprints(fc); // behind the people + col = fp.rgb + col * (1.0 - fp.a); vec4 pl = plants(fc); o = vec4(pl.rgb + col * (1.0 - pl.a), 1.0); } diff --git a/ShowControl/FundingCAPTCHA/test_watering_can.py b/ShowControl/FundingCAPTCHA/test_watering_can.py index e339f54..ef746ff 100644 --- a/ShowControl/FundingCAPTCHA/test_watering_can.py +++ b/ShowControl/FundingCAPTCHA/test_watering_can.py @@ -887,6 +887,67 @@ def test_lost_person_plants_rewind_without_popping(ctx): rig.release() +# ── Footprints: a walker leaves plants on the floor where they step ────────── + +FOOTPRINTS, FOOT_GAP, FOOT_LIFE = K["FOOTPRINTS"], K["FOOT_GAP"], K["FOOT_LIFE"] + + +def footprints(rig): + """The live footprints: (x, y UV, seconds since dropped) of each.""" + c = rig.buf("buffer_c") + return [tuple(c[3, 1 + f, :3]) for f in range(FOOTPRINTS) if c[3, 1 + f, 2] < FOOT_LIFE] + + +def test_walker_leaves_footprints_along_their_path(ctx): + rig = Rig(ctx) + rig.run([person(x=WALK_X)] * 30) + rig.run(walking(30, x=WALK_X)) # 30 px right in 3 s + fps = sorted(footprints(rig)) + assert len(fps) >= 4, fps + feet = 1 - 44 / BODY[1] # the feet's row, UV y + aspect = SIZE[0] / SIZE[1] + xs = [x for x, _, _ in fps] + assert all(abs(y - feet) < 0.04 for _, y, _ in fps), fps + assert WALK_X / BODY[0] - 0.05 < xs[0] and xs[-1] < (WALK_X + 42) / BODY[0], fps # on the path + assert all((b - a) * aspect >= FOOT_GAP - 1e-3 for a, b in zip(xs, xs[1:])), fps # spaced + ages = [t for _, _, t in sorted(fps)] + assert ages == sorted(ages, reverse=True), ages # dropped in walking order: oldest first + rig.release() + + +@pytest.mark.parametrize("kind", ["planted", "waving"]) +def test_no_footprints_without_walking(ctx, kind): + rig = Rig(ctx) + if kind == "planted": + rig.run(flickering(100)) + else: + rig.run([person(arm=("down", "out", "up")[k // 5 % 3]) for k in range(100)]) + assert footprints(rig) == [] + rig.release() + + +def test_footprints_grow_where_the_walker_was_then_rewind_away(ctx): + rig = Rig(ctx, size=IMG) + rig.run([person(x=WALK_X)] * 30) + rig.run(walking(30, x=WALK_X)) + rig.run([EMPTY] * 15) # the walker leaves; their own plants rewind away + assert footprints(rig) + # Off the body, where the walker passed: footprint plants, standing up off the floor at the feet's row. + x, y = screen_xy(IMG) + feet = 1 - 44 / BODY[1] + floor = (y > feet - 0.03) & (y < feet + 0.12) + n0 = drawn(rig.image(), floor).sum() + assert n0 > 200, n0 + counts = [] + for _ in range(int(FOOT_LIFE * 10)): # then they rewind away, without popping + rig.run([EMPTY]) + counts.append(drawn(rig.image()).sum()) + assert footprints(rig) == [] and counts[-1] == 0, counts + prev = [n0] + counts[:-1] + assert all(p - c <= max(0.3 * p, 0.1 * n0) for p, c in zip(prev, counts)), (n0, counts) + rig.release() + + # ── Task 4: the crayon sun (Image) ──────────────────────────────────────────── def sun_patch(img): diff --git a/docs/superpowers/specs/2026-11-06-watering-can-design.md b/docs/superpowers/specs/2026-11-06-watering-can-design.md index 01ec02d..1d2ffbb 100644 --- a/docs/superpowers/specs/2026-11-06-watering-can-design.md +++ b/docs/superpowers/specs/2026-11-06-watering-can-design.md @@ -123,3 +123,9 @@ The walking threshold, and how often the real tracker drops or re-IDs a still pe - How vines are generated procedurally: placement, wobble, leaf rhythm, flower shapes, bud-to-bloom animation, and how vines follow a moving silhouette without shimmering. - The crayon texture technique for the sun, and the ray style. - Exact line weights and default constants beyond those stated. + +## Revisions after the first build + +- **Look:** the vines are drawn after the concept art's branches. Trunks taper and are ink-outlined, and they rise in pairs and merge where they meet. Forking twigs end in sprigs of pointed petals. About half the branch tips open into hatched daisies. The palette and ripening are unchanged. Density is about three quarters of the mockup's Dense panel. +- **Growth curve:** growth starts fast, reaching `GROW_EARLY` in `GROW_EARLY_S`, then slows so full bloom still comes at about `GROW_S`. +- **Footprints:** a walker leaves plants on the floor where they step. Each footprint is a small clump of branches with a daisy that grows in fast, holds, then rewinds away (`FOOT_*` in `common.glsl`). This is the one exception to "a person's plants leave with them". A person's own plants still ride on their body and rewind while they walk. Live footprints are never overwritten. When all `FOOTPRINTS` are live, walkers leave none until the oldest has gone. From 92700f98ff44e1817635c9ad2b0afa3478c22cf6 Mon Sep 17 00:00:00 2001 From: ii-impy <314283863+ii-impy@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:48:08 -0500 Subject: [PATCH 19/22] Watering can: style-guide flower colours; unfilled ink branches Daisies in the pale yellow (#fbe9a0), light blue (#719aad, the site's band) and orange (#e8862f), centres picked to stand out from each. Branches are ink outlines on paper, as the concept art's, so the flowers carry the colour. Co-Authored-By: Claude Opus 5.5 --- .../shaders/watering_can/common.glsl | 5 ++--- .../FundingCAPTCHA/shaders/watering_can/image.glsl | 14 +++++++------- ShowControl/FundingCAPTCHA/test_watering_can.py | 4 ++-- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl index 1c8e2d2..d13dd4a 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl @@ -143,9 +143,8 @@ const vec3 PAPER = vec3(0.965, 0.945, 0.894); // #f6f1e4 const vec3 INK = vec3(0.067); // #111111 const vec3 YELLOW = vec3(1.000, 0.949, 0.000); // #fff200 const vec3 ORANGE = vec3(0.910, 0.525, 0.184); // #e8862f -const vec3 PINK = vec3(0.910, 0.584, 0.498); // #e8957f -const vec3 VIOLET = vec3(0.420, 0.357, 0.584); // #6b5b95 -const vec3 RUST = vec3(0.737, 0.298, 0.149); // #bc4c26 +const vec3 PALE = vec3(0.984, 0.914, 0.627); // #fbe9a0 pale yellow +const vec3 SKY = vec3(0.443, 0.604, 0.678); // #719aad light blue: the site's band, the concept art's sky // ── Blob records (ADR-0022) ─────────────────────────────────────────────────── const float S_EMPTY = 0., S_TENTATIVE = 1., S_CONFIRMED = 2., S_COASTING = 3., S_LOST = 4.; diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl index 1398336..bda1d1c 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl @@ -42,11 +42,11 @@ vec3 hsl(float h, float s, float l) { // h in degrees return l - s * min(l, 1.0 - l) * clamp(min(k - 3.0, 9.0 - k), -1.0, 1.0); } vec3 desaturate(vec3 c, float sat) { return mix(vec3(dot(c, vec3(0.299, 0.587, 0.114))), c, sat / SAT_MAX); } -vec3 stemColour(float sat) { return hsl(95.0, sat, 0.34); } vec3 leafColour(float sat) { return hsl(100.0, sat, 0.58); } +// The style guide's pale yellow, light blue and orange; and a centre that stands out from each. vec3 flowerColour(int i) { float h = hash12(vec2(float(i), 9.7)); - return h < 0.25 ? PINK : h < 0.5 ? ORANGE : h < 0.75 ? VIOLET : RUST; + return h < 0.34 ? PALE : h < 0.67 ? SKY : ORANGE; } // iq's vesica: a pointed leaf along y, half-length sqrt(r² − d²), half-width r − d. @@ -246,7 +246,8 @@ void daisy(inout vec4 acc, vec2 q, float fr, float e, vec3 pc, float sat, float // The centre: an oval, hatched on its shaded side. vec2 rad = HEART_R * fr * e * vec2(0.8, 1.05); float sd = (length(q / rad) - 1.0) * min(rad.x, rad.y); - inked(acc, desaturate(YELLOW, sat), sd, 1.0); + vec3 heart = dot(abs(pc - desaturate(PALE, sat)), vec3(1.0)) < 0.05 ? ORANGE : YELLOW; + inked(acc, desaturate(heart, sat), sd, 1.0); float step_ = 0.22 * rad.y; float stripe = abs(fract(q.y / step_) - 0.5) * step_; over(acc, INK, lineA(stripe, 0.5 * INK_W) * fillA(sd + 0.15 * rad.x) * smoothstep(0.0, -0.4 * rad.x, q.x) * e); @@ -305,7 +306,7 @@ vec4 plants(vec2 fc) { vec4 acc = vec4(0.0); if (!found) return acc; // nothing grows near: paper // All the wood near p is one shape, inked once: branches that meet merge, as the art's forks. - float wd = 1e3, bark = 0.0, sat = SAT_MIN; + float wd = 1e3, bark = 0.0; int nodes[NV * TAPS], j0s[NV * TAPS]; for (int v = 0; v < NV; v++) for (int t = 0; t < TAPS; t++) { @@ -314,12 +315,11 @@ vec4 plants(vec2 fc) { if (!has[s]) continue; float bk; float d = wood(p, i0 + v - VINE_NEIGHBOURS, runs[s], bk, nodes[s], j0s[s]); - if (d < wd) sat = runs[s].sat; wd = smin(wd, d, WOOD_FILLET); bark = max(bark, bk); } if (wd < INK_W + 2.0 * AA) { - inked(acc, stemColour(sat), wd, 1.0); + inked(acc, PAPER, wd, 1.0); // unfilled: ink on paper, as the art over(acc, INK, bark * fillA(wd)); } for (int v = 0; v < NV; v++) @@ -361,7 +361,7 @@ void footprint(inout vec4 acc, vec2 p, vec2 at, float g, float seed) { twigs[s] = n + dirs[s] * TWIG_LEN * 0.7 * ks[s]; wd = smin(wd, sdTaper(p, n, twigs[s], 0.5 * TWIG_W * ks[s], tip) + (1.0 - min(1.0, 3.0 * ks[s])) * 2.0 * INK_W, WOOD_FILLET); } - if (wd < INK_W + 2.0 * AA) inked(acc, stemColour(sat), wd, 1.0); + if (wd < INK_W + 2.0 * AA) inked(acc, PAPER, wd, 1.0); for (int s = 0; s < FOOT_SPROUTS; s++) { // then sprigs, and the middle one's daisy float sd = seed * 17.0 + float(s); if (ks[s] > 0.0) sprig(acc, p, twigs[s], dirs[s], ks[s], sd, leafColour(sat)); diff --git a/ShowControl/FundingCAPTCHA/test_watering_can.py b/ShowControl/FundingCAPTCHA/test_watering_can.py index ef746ff..05279a6 100644 --- a/ShowControl/FundingCAPTCHA/test_watering_can.py +++ b/ShowControl/FundingCAPTCHA/test_watering_can.py @@ -639,7 +639,7 @@ def test_state_stays_bounded_when_idle(ctx): # Review Focus FLOWER_R, REACH = K["FLOWER_R"], K["REACH"] TWIG_REACH = (1 + K["FORK_LEN"]) * K["TWIG_LEN"] + K["SPRIG_LEN"] # a forked twig and its sprig, from its node SUN_POS, SUN_R, RAY_LEN = K["SUN_POS"], K["SUN_R"], K["RAY_LEN"] -FLOWER_RGB = np.array([[232, 149, 127], [232, 134, 47], [107, 91, 149], [188, 76, 38]]) # pink, orange, violet, rust +FLOWER_RGB = np.array([[251, 233, 160], [113, 154, 173], [232, 134, 47]]) # pale yellow, light blue, orange IMG = (256, 144) # image tests: big enough that petals have pure-colour pixels @@ -816,7 +816,7 @@ def test_colour_ripens(ctx): img = rig.image() sats.append(saturation(img[..., :3][drawn(img)]).mean()) rig.release() - assert sats[0] < sats[1] - 0.25, sats + assert sats[1] > 2 * sats[0], sats # at least doubles: the palette is pastel and the wood is ink on paper def test_flowers_only_late(ctx): From 6ef60b7cecd9fda66d3bb1f58682e8146beb77e5 Mon Sep 17 00:00:00 2001 From: ii-impy <314283863+ii-impy@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:54:59 -0500 Subject: [PATCH 20/22] Watering can: flowers bloom all through the growth; leaves unfilled Half the twigs end in a daisy that opens as the growing tip climbs past it: small and pale low on a run, larger and bolder higher up (so later). Flowers pale toward the paper, keeping their hue, rather than greying. The tips' late daisies stay the largest. Leaves and buds are ink on paper, as the branches. Co-Authored-By: Claude Opus 5.5 --- .../shaders/watering_can/common.glsl | 8 +++ .../shaders/watering_can/image.glsl | 66 ++++++++++++++----- .../FundingCAPTCHA/test_watering_can.py | 34 +++++++--- 3 files changed, 82 insertions(+), 26 deletions(-) diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl index d13dd4a..e2ba175 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl @@ -83,6 +83,14 @@ const float SPROUT_H = 18.0/360.0; // a trunk fades in, and a twig or bud g const float PETAL_R = 0.8, HEART_R = 0.7; // petals reach FLOWER_R × (1 + PETAL_R); centre radius, × FLOWER_R const int DAISY_N = 12; const float DAISY_FRAC = 0.5; // the fraction of branch tips that open into a daisy; the rest end in a sprig +// Flowers bloom all through the growth: TWIG_FLOWER_FRAC of twigs end in a daisy instead of a sprig, opening +// once the growing tip has climbed TWIG_FLOWER_OPEN_H past it. Low on a run they are small and pale; higher +// (so later, as growth climbs and slows) larger and bolder. The tips' late daisies are the largest and boldest. +const float TWIG_FLOWER_FRAC = 0.5; +const float TWIG_FLOWER_OPEN_H = 30.0/360.0; +const float TWIG_FLOWER_R_MIN = 2.5/360.0, TWIG_FLOWER_R_MAX = 6.5/360.0; // radius (petals' ring) at a run's bottom, top +const float BOLD_MIN = 0.35; // how much colour a flower at a run's bottom has (the rest is paper); 1 at its top +const float BOLD_YOUNG = 0.6; // ... times this while the person's growth is young, rising to 1 at full bloom const float DAISY_PETAL_W = 0.3; // a petal's half-width at its tip, × the flower radius const float PEN_MIN = 1.5; // thinnest line, render pixels: thinner ones are drawn this wide but fainter const float REACH = FLOWER_R * (1.0 + PETAL_R); // a flower's outer radius (FLOWER_R is its petals' ring, as the mockup's r); diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl index bda1d1c..b91eb10 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl @@ -41,13 +41,18 @@ vec3 hsl(float h, float s, float l) { // h in degrees vec3 k = mod(vec3(0.0, 8.0, 4.0) + h / 30.0, 12.0); return l - s * min(l, 1.0 - l) * clamp(min(k - 3.0, 9.0 - k), -1.0, 1.0); } -vec3 desaturate(vec3 c, float sat) { return mix(vec3(dot(c, vec3(0.299, 0.587, 0.114))), c, sat / SAT_MAX); } -vec3 leafColour(float sat) { return hsl(100.0, sat, 0.58); } // The style guide's pale yellow, light blue and orange; and a centre that stands out from each. -vec3 flowerColour(int i) { - float h = hash12(vec2(float(i), 9.7)); +vec3 flowerColour(float seed) { + float h = hash12(vec2(seed, 9.7)); return h < 0.34 ? PALE : h < 0.67 ? SKY : ORANGE; } +vec3 heartColour(float seed) { return hash12(vec2(seed, 9.7)) < 0.34 ? ORANGE : YELLOW; } // stands out from its petals +// A flower colour as drawn: paled toward the paper, keeping its hue, by how low it is (bold 0–1) and how +// young the person's growth is (sat, from SAT_MIN to SAT_MAX as it ripens): pale, never grey. +vec3 tint(vec3 c, float sat, float bold) { + float ripe = clamp((sat - SAT_MIN) / (SAT_MAX - SAT_MIN), 0.0, 1.0); + return mix(PAPER, c, bold * mix(BOLD_YOUNG, 1.0, ripe)); +} // iq's vesica: a pointed leaf along y, half-length sqrt(r² − d²), half-width r − d. float sdVesica(vec2 p, float r, float d) { @@ -188,7 +193,8 @@ float wood(vec2 p, int i, Run r, out float bark, out int nodes, out int j0) { float off = 0.5 + 0.5 * hash12(vec2(float(i), 3.1)); // the first twig, in steps above g float flip = step(0.5, hash12(vec2(float(i), 5.3))); - float up = (1.0 + FORK_LEN) * TWIG_LEN + SPRIG_LEN; // a twig reaches at most this far above its node + float up = max((1.0 + FORK_LEN) * TWIG_LEN + SPRIG_LEN, // a twig (or its flower) reaches at most this far + TWIG_LEN + TWIG_FLOWER_R_MAX * (1.0 + PETAL_R)); // above its node float down = SPRIG_LEN; // ... and this far below j0 = max(int(ceil((p.y - up - r.g) / TWIG_STEP - off)), 0); nodes = 0; @@ -206,24 +212,26 @@ float wood(vec2 p, int i, Run r, out float bark, out int nodes, out int j0) { return sd; } +bool twigFlower(int i, int j) { return hash12(vec2(float(i), float(j) + 9.1)) < TWIG_FLOWER_FRAC; } + // The sprigs on vine i's twigs near p (as wood() found them), top twig first, so lower ones overlap it. void sprigs(inout vec4 acc, vec2 p, int i, Run r, int nodes, int j0) { float off = 0.5 + 0.5 * hash12(vec2(float(i), 3.1)); float flip = step(0.5, hash12(vec2(float(i), 5.3))); - vec3 col = leafColour(r.sat); + vec3 col = PAPER; // unfilled: ink on paper, as the art for (int n = 2; n >= 0; n--) { if ((nodes & (1 << n)) == 0) continue; int j = j0 + n; Twig t = twigOf(i, j, r, off, flip); float seed = float(i) * 31.0 + float(j); - sprig(acc, p, t.e, t.dir, t.k, seed, col); + if (!twigFlower(i, j)) sprig(acc, p, t.e, t.dir, t.k, seed, col); if (t.fork) sprig(acc, p, t.f, normalize(t.f - t.m + 1e-6), 0.8 * t.k, seed + 0.5, col); } } // A daisy at q = 0 of radius fr (its petals' ring; they reach fr × (1 + PETAL_R)), petals swollen e of -// the way out, in petal colour pc; seed picks its turn and petal jitter. -void daisy(inout vec4 acc, vec2 q, float fr, float e, vec3 pc, float sat, float seed) { +// the way out, petals pc and centre hc; seed picks its turn and petal jitter. +void daisy(inout vec4 acc, vec2 q, float fr, float e, vec3 pc, vec3 hc, float seed) { float a0 = TAU * hash12(vec2(seed, 11.3)); float da = TAU / float(DAISY_N); float r0 = 0.55 * fr, r1 = mix(r0, fr * (1.0 + PETAL_R), e), pw = DAISY_PETAL_W * fr * e; @@ -246,13 +254,31 @@ void daisy(inout vec4 acc, vec2 q, float fr, float e, vec3 pc, float sat, float // The centre: an oval, hatched on its shaded side. vec2 rad = HEART_R * fr * e * vec2(0.8, 1.05); float sd = (length(q / rad) - 1.0) * min(rad.x, rad.y); - vec3 heart = dot(abs(pc - desaturate(PALE, sat)), vec3(1.0)) < 0.05 ? ORANGE : YELLOW; - inked(acc, desaturate(heart, sat), sd, 1.0); + inked(acc, hc, sd, 1.0); float step_ = 0.22 * rad.y; float stripe = abs(fract(q.y / step_) - 0.5) * step_; over(acc, INK, lineA(stripe, 0.5 * INK_W) * fillA(sd + 0.15 * rad.x) * smoothstep(0.0, -0.4 * rad.x, q.x) * e); } +// The daisies on vine i's twigs near p: sized and coloured by how high up the run their twig is. +void twigFlowers(inout vec4 acc, vec2 p, int i, Run r, int nodes, int j0) { + float off = 0.5 + 0.5 * hash12(vec2(float(i), 3.1)); + float flip = step(0.5, hash12(vec2(float(i), 5.3))); + for (int n = 2; n >= 0; n--) { + int j = j0 + n; + if ((nodes & (1 << n)) == 0 || !twigFlower(i, j)) continue; + Twig t = twigOf(i, j, r, off, flip); + float up = clamp((t.o.y - r.g) / max(r.b - r.g, 1e-3), 0.0, 1.0); + float open = smoothstep(0.0, TWIG_FLOWER_OPEN_H, r.tip - t.o.y - SPROUT_H); + float fr = mix(TWIG_FLOWER_R_MIN, TWIG_FLOWER_R_MAX, up) * open; + if (fr <= 0.0 || length(p - t.e) > fr * (1.0 + PETAL_R) + INK_W + 2.0 * AA) continue; + float seed = float(i) * 31.0 + float(j); + float bold = mix(BOLD_MIN, 1.0, up); + daisy(acc, p - t.e, fr, smoothstep(0.0, 0.4, open), tint(flowerColour(seed), r.sat, bold), + tint(heartColour(seed), r.sat, bold), seed); + } +} + // The trunk's tip: a sprig, or on DAISY_FRAC of branches a bud, which late in growth opens into a daisy. void bloom(inout vec4 acc, vec2 p, int i, Run r) { float open = clamp((r.f - FLOWER_FROM) / (FLOWER_FULL - FLOWER_FROM), 0.0, 1.0); @@ -266,13 +292,14 @@ void bloom(inout vec4 acc, vec2 p, int i, Run r) { if (length(p - c) > REACH + INK_W + 2.0 * AA && length(p - tip) > BUD_R + INK_W + 2.0 * AA) return; float k = sprout(r.tip - r.g); if (hash12(vec2(float(i), 13.9)) >= DAISY_FRAC) { // no daisy: the branch ends in a sprig - sprig(acc, p, tip, vec2(0.0, 1.0), k, float(i) * 31.0 - 7.0, leafColour(r.sat)); + sprig(acc, p, tip, vec2(0.0, 1.0), k, float(i) * 31.0 - 7.0, PAPER); return; } - inked(acc, leafColour(r.sat), length(p - mix(tip, c, open)) - BUD_R * k, k); // the bud, later the calyx + inked(acc, PAPER, length(p - mix(tip, c, open)) - BUD_R * k, k); // the bud, later the calyx if (open <= 0.0) return; float fr = mix(BUD_R, FLOWER_R, open); - daisy(acc, p - c, fr, smoothstep(0.0, 0.25, open), desaturate(flowerColour(i), r.sat), r.sat, float(i)); + daisy(acc, p - c, fr, smoothstep(0.0, 0.25, open), tint(flowerColour(float(i)), r.sat, 1.0), + tint(heartColour(float(i)), r.sat, 1.0), float(i)); } // The plants over transparent, premultiplied. Branches vine by vine, then buds and flowers on top. @@ -327,6 +354,11 @@ vec4 plants(vec2 fc) { int s = v * TAPS + t; if (nodes[s] != 0) sprigs(acc, p, i0 + v - VINE_NEIGHBOURS, runs[s], nodes[s], j0s[s]); } + for (int v = 0; v < NV; v++) // flowers over every vine's leaves + for (int t = 0; t < TAPS; t++) { + int s = v * TAPS + t; + if (nodes[s] != 0) twigFlowers(acc, p, i0 + v - VINE_NEIGHBOURS, runs[s], nodes[s], j0s[s]); + } for (int v = 0; v < NV; v++) for (int t = 0; t < TAPS; t++) if (has[v * TAPS + t]) bloom(acc, p, i0 + v - VINE_NEIGHBOURS, runs[v * TAPS + t]); @@ -364,13 +396,13 @@ void footprint(inout vec4 acc, vec2 p, vec2 at, float g, float seed) { if (wd < INK_W + 2.0 * AA) inked(acc, PAPER, wd, 1.0); for (int s = 0; s < FOOT_SPROUTS; s++) { // then sprigs, and the middle one's daisy float sd = seed * 17.0 + float(s); - if (ks[s] > 0.0) sprig(acc, p, twigs[s], dirs[s], ks[s], sd, leafColour(sat)); + if (ks[s] > 0.0) sprig(acc, p, twigs[s], dirs[s], ks[s], sd, PAPER); if (s == FOOT_SPROUTS / 2) { float open = smoothstep(FOOT_FLOWER_FROM, 1.0, g); if (open > 0.0 && length(p - tips[s]) < FOOT_FLOWER_R * (1.0 + PETAL_R) + INK_W + 2.0 * AA) daisy(acc, p - tips[s], FOOT_FLOWER_R * open, smoothstep(0.0, 0.25, open), - desaturate(flowerColour(int(seed * 97.0)), sat), sat, sd); - } else sprig(acc, p, tips[s], vec2(0.0, 1.0), g, sd + 0.5, leafColour(sat)); + tint(flowerColour(seed), sat, 1.0), tint(heartColour(seed), sat, 1.0), sd); + } else sprig(acc, p, tips[s], vec2(0.0, 1.0), g, sd + 0.5, PAPER); } } diff --git a/ShowControl/FundingCAPTCHA/test_watering_can.py b/ShowControl/FundingCAPTCHA/test_watering_can.py index 05279a6..fac0385 100644 --- a/ShowControl/FundingCAPTCHA/test_watering_can.py +++ b/ShowControl/FundingCAPTCHA/test_watering_can.py @@ -819,15 +819,31 @@ def test_colour_ripens(ctx): assert sats[1] > 2 * sats[0], sats # at least doubles: the palette is pastel and the wood is ink on paper -def test_flowers_only_late(ctx): - found = [] - for frames, g in ((round(10 * seconds_to(0.4)), 0.4), (220, 1.0)): - rig = grown_rig(ctx, frames) - assert abs(rig.person(0)[1] - g) < 0.05, rig.person(0) - img = rig.image() - found.append((flower_pixels(img) & drawn(img)).sum()) - rig.release() - assert found[0] == 0 and found[1] > 50, found # only DAISY_FRAC of tips bloom, each petal partly hatched +def coloured(img): + """Flower pixels: drawn and not grey. The wood and leaves are ink on paper; only flowers carry colour.""" + rgb = img[..., :3].astype(int) + return drawn(img) & (rgb.max(axis=-1) - rgb.min(axis=-1) > 20) + + +def test_flowers_bloom_pale_low_then_bold_high(ctx): + fig = on_screen(person(), IMG) + _, y = screen_xy(IMG) + top, bottom = y[fig].max(), y[fig].min() + low = y < bottom + (top - bottom) / 3 # the figure's lower and upper thirds + high = y > top - (top - bottom) / 3 + early = grown_rig(ctx, round(10 * seconds_to(0.3))) # 2 s in + img = early.image() + c = coloured(img) + assert c[low].sum() > 20, c[low].sum() # flowers already open, low on the body ... + assert (flower_pixels(img) & drawn(img)).sum() == 0 # ... and pale: none in full colour yet + early.release() + full = grown_rig(ctx, 220) + img = full.image() + c = coloured(img) + sat = saturation(img[..., :3]) + assert (flower_pixels(img) & drawn(img)).sum() > 50 # bold flowers at full bloom + assert sat[c & high].mean() > sat[c & low].mean() + 0.1, (sat[c & high].mean(), sat[c & low].mean()) + full.release() def test_vines_follow_the_arm(ctx): From d8150f45ee5fe128df8a3155de5147bb85d6279c Mon Sep 17 00:00:00 2001 From: ii-impy <314283863+ii-impy@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:17:55 -0500 Subject: [PATCH 21/22] Watering can: one tree per person, rooted between the feet Replaces the per-column vines, each rooted at its own run bottom, with a single tree. Buffer C keeps a 1/48-screen-height grid in place of the near grid: each body cell's parent on the cheapest path back to the person's root (dearer near the silhouette's edge, so paths keep to each limb's middle), and how many seed cells' paths run through it. Branches are the seeds' paths, curved through their nodes and as wide as their seed count; seeds carry the sprigs and daisies. Growth is a front of path distance: up both legs from the root on the floor between the feet, through the torso, out the arms and up the neck. Nothing roots anywhere else, and waving keeps an arm's growth. Co-Authored-By: Claude Opus 5.5 --- .../shaders/watering_can/buffer_c.glsl | 134 +++++-- .../shaders/watering_can/common.glsl | 70 +++- .../shaders/watering_can/image.glsl | 342 ++++++------------ .../FundingCAPTCHA/test_watering_can.py | 80 +++- .../specs/2026-11-06-watering-can-design.md | 1 + 5 files changed, 335 insertions(+), 292 deletions(-) diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl index 1ebaec8..b9f2fd1 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl @@ -1,5 +1,5 @@ // Buffer C — Garden state. iChannel0 = Buffer B, iChannel1 = Buffer C (itself), iChannel2 = blobs -// Layout: see common.glsl (rows 0–3 garden state and footprints, rows 4+ the near-body grid). Each texel reads the +// Layout: see common.glsl (rows 0–3 garden state and footprints, rows 4+ the tree grid). Each texel reads the // previous frame of every other texel; texels that need the same derived value (PERSON and BASE // both need this frame's base) compute it from the same inputs. @@ -11,7 +11,7 @@ vec4 initial(ivec2 c) { if (c.y == 1) return vec4(0.0, 0.0, T_CLAMP, S_EMPTY); // BASE if (c.y == 2) return vec4(0.0, 0.0, 0.0, T_CLAMP); // GHOST - if (c.y == 3 && c.x > 0) return vec4(0.0, 0.0, T_CLAMP, 0.0); // FOOT: none + if (c.y == 3 && c.x > 0 && c.x <= FOOTPRINTS) return vec4(0.0, 0.0, T_CLAMP, 0.0); // FOOT: none return vec4(0.0); } @@ -219,25 +219,23 @@ vec4 footNext(int f, float dt) { return ft; } -// ── NEAR ────────────────────────────────────────────────────────────────────── -// Is there body (or a held shape: anything a vine could grow on) within NEAR_PAD of cell (i, j)? -// Reads this frame's B, so the grid never lags the plants. -float nearNext(ivec2 cell) { +// ── TREE ────────────────────────────────────────────────────────────────────── +// The B texel under screen point sp (screen heights). +vec4 tapB(vec2 sp) { float aspect = iResolution.x / iResolution.y; - vec2 lo = vec2(cell) * NEAR_CELL - NEAR_PAD; - vec2 bRes = iChannelResolution[0].xy; - for (int y = 0; y < NEAR_TAPS; y++) { - float sy = lo.y + float(y) * NEAR_STEP; - if (sy < 0.0 || sy >= 1.0) continue; - for (int x = 0; x < NEAR_TAPS; x++) { - float sx = lo.x + float(x) * NEAR_STEP; - if (sx < 0.0 || sx >= aspect) continue; - vec4 t = texelFetch(BB, ivec2(vec2(sx / aspect, sy) * bRes), 0); - if (bOwner(t) > 0 && bHold(t) > 0.0) return 1.0; - } - } - // A footprint's plants reach into the cell (last frame's footprints: a new one starts bare). - vec2 c0 = vec2(cell) * NEAR_CELL, c1 = c0 + NEAR_CELL; + if (sp.x < 0.0 || sp.y < 0.0 || sp.x >= aspect || sp.y >= 1.0) return vec4(0.0); + return texelFetch(BB, ivec2(vec2(sp.x / aspect, sp.y) * iChannelResolution[0].xy), 0); +} +bool onBody(vec4 t) { return bOwner(t) > 0 && bHold(t) > 0.0; } + +// Is anything a pixel in this (non-body) cell might draw near: a body cell within TREE_R (last frame's +// grid), or a footprint's plants? +float treeNear(ivec2 cell) { + for (int y = -TREE_R; y <= TREE_R; y++) + for (int x = -TREE_R; x <= TREE_R; x++) + if (cTree(C, cell + ivec2(x, y), iResolution.xy).x >= 0.0) return 1.0; + float aspect = iResolution.x / iResolution.y; + vec2 c0 = vec2(cell) * TREE_CELL, c1 = c0 + TREE_CELL; for (int f = 0; f < FOOTPRINTS; f++) { vec4 ft = cFoot(C, f); if (ft.z >= FOOT_LIFE) continue; @@ -248,6 +246,94 @@ float nearNext(ivec2 cell) { return 0.0; } +vec4 treeNext(ivec2 cell) { + float aspect = iResolution.x / iResolution.y; + vec2 lo = vec2(cell) * TREE_CELL; + // Which person's body covers the cell, how much, how firmly held, and where its body sits in the cell. + int own = 0; + float n = 0.0, hold = 0.0; + vec2 sum = vec2(0.0); + for (int y = 0; y < 4; y++) + for (int x = 0; x < 4; x++) { + vec2 sp = lo + (vec2(x, y) + 0.5) * 0.25 * TREE_CELL; + vec4 t = tapB(sp); + if (!onBody(t)) continue; + if (own == 0) own = bOwner(t); + if (bOwner(t) != own) continue; + n += 1.0; sum += sp; + hold = max(hold, t.r >= BODY_ON ? 1.0 : bHold(t)); + } + if (n < TREE_FILL * 16.0 || own > SLOTS) return vec4(-1.0, treeNear(cell), 0.0, 0.0); + int k = own - 1; + vec2 node = sum / n; + vec2 q = clamp(floor(((node - lo) / TREE_CELL) * 16.0), 0.0, 15.0); + float nodeQ = q.x * 16.0 + q.y; + vec4 prev = cTree(C, cell, iResolution.xy); + vec4 b = cBase(C, k); + // A person whose track is gone has no base: their held shape keeps the tree it had, as it rewinds away. + if (isState(b.w, S_EMPTY) || b.w > S_COASTING + 0.5) { + if (treeOwner(prev) == own) return vec4(prev.x, prev.y, prev.z, nodeQ); + return vec4(TREE_FAR, 0.0, float(own), nodeQ); + } + // A step costs more near the body's edge, so paths keep to the middle. + // How central: the body on rings round the cell, near and further out, so a wide torso has a middle too. + float ring = 0.0; + for (int r = 1; r <= 2; r++) + for (int i = 0; i < 8; i++) { + float a = 0.7853982 * (float(i) + 0.5 * float(r)); + vec4 t = tapB(node + vec2(cos(a), sin(a)) * TREE_EDGE_R * float(r) * TREE_CELL); + ring += (onBody(t) && bOwner(t) == own) ? 1.0 : 0.0; + } + float cost = TREE_CELL * (1.0 + TREE_EDGE_COST * (1.0 - ring / 16.0)); + // The cheapest way back to the root: straight there along the floor, for a cell at the base, or via a neighbour. + float d = TREE_FAR; + int parent = -1; + vec4 tpk = cTreeP(C, k); + vec2 root = vec2(tpk.w > 0.0 ? tpk.w : b.x * aspect, b.y); + if (lo.y <= root.y + TREE_ROOT_BAND && abs(node.x - root.x) < TREE_ROOT_WIN) { + d = length(node - root); + parent = 0; + } + for (int i = 0; i < 8; i++) { + vec4 t = cTree(C, cell + TREE_DIRS[i], iResolution.xy); + if (treeOwner(t) != own || t.x >= TREE_FAR) continue; + float cand = t.x + cost * length(vec2(TREE_DIRS[i])); + if (cand < d) { d = cand; parent = i + 1; } + } + if (parent < 0) return vec4(TREE_FAR, 0.0, float(own), nodeQ); + // Seeds whose paths run through this cell: its own, and its children's (last frame's, so a branch settles a + // cell a frame from its seed down). + float seeds = treeSeed(cell) ? 1.0 : 0.0; + for (int i = 0; i < 8; i++) { + vec4 t = cTree(C, cell + TREE_DIRS[i], iResolution.xy); + int pa = treeParent(t); + if (treeOwner(t) == own && t.x < TREE_FAR && pa > 0 && TREE_DIRS[pa - 1] == -TREE_DIRS[i]) seeds += t.y; + } + return vec4(d, seeds, float(own) + float(parent) / 16.0, nodeQ); +} + +// TREE PERSON k: the farthest path distance on their body (eased), their growth front, their growth, and +// their root's x: the middle of their lowest cells, so the root sits between the feet and both legs join it. +// (BASE follows one foot when the feet are apart, which is right for telling a step, not for a root.) +vec4 treePersonNext(int k, float dt) { + float raw = 0.0, n = 0.0, sx = 0.0; + float baseY = cBase(C, k).y; + int cols = treeCols(iResolution.xy); + for (int j = 0; j < TREE_ROWS; j++) + for (int i = 0; i < TREE_COLS_MAX; i++) { + if (i >= cols) break; + vec4 t = cTree(C, ivec2(i, j), iResolution.xy); + if (treeOwner(t) != k + 1) continue; + if (t.x < TREE_FAR) raw = max(raw, t.x); + if (float(j) * TREE_CELL <= baseY + TREE_ROOT_BAND) { n += 1.0; sx += (float(i) + 0.5) * TREE_CELL; } + } + vec4 pr = cTreeP(C, k); + float farD = pr.x <= 0.0 ? raw : mix(pr.x, raw, 1.0 - exp(-dt / TREE_MAX_EASE_S)); + float g = growthOf(C, k); + float rootX = n > 0.0 ? sx / n : cBase(C, k).x * iResolution.x / iResolution.y; + return vec4(farD, g * farD, g, rootX); +} + // ── SUN ─────────────────────────────────────────────────────────────────────── vec4 sunNext(float dt, vec2 fc) { float raw = 0.0; @@ -270,17 +356,19 @@ vec4 sunNext(float dt, vec2 fc) { void mainImage(out vec4 o, in vec2 fc) { ivec2 c = ivec2(fc); o = vec4(0.0); - if (c.y >= 4) { // the near-body grid - if (c.y < 4 + NEAR_ROWS && float(c.x) * NEAR_CELL < iResolution.x / iResolution.y) o.x = nearNext(c - ivec2(0, 4)); + if (c.y >= 4) { // the tree grid + if (c.y < 4 + TREE_ROWS && c.x < treeCols(iResolution.xy)) o = iFrame == 0 ? vec4(-1.0, 0.0, 0.0, 0.0) : treeNext(c - ivec2(0, 4)); return; } - if (c.y == 3 ? c.x > FOOTPRINTS : c.x >= SLOTS) return; + bool treeP = c.y == 3 && c.x >= TREE_P_X && c.x < TREE_P_X + SLOTS; + if (!treeP && (c.y == 3 ? c.x > FOOTPRINTS : c.x >= SLOTS)) return; if (iFrame == 0) { o = initial(c); return; } float dt = clamp(iTimeDelta, 0.0, T_CLAMP); if (c.y == 0) o = personNext(c.x, dt, fc); else if (c.y == 1) o = baseNext(c.x, dt); else if (c.y == 2) o = ghostNext(c.x, dt); + else if (treeP) o = treePersonNext(c.x - TREE_P_X, dt); else if (c.x == 0) o = sunNext(dt, fc); else o = footNext(c.x - 1, dt); } diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl index e2ba175..7b2afdc 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl @@ -12,9 +12,13 @@ // (k, 2) GHOST spot x, y, growth at loss, seconds since loss (T_CLAMP = no ghost) // (0, 3) SUN sun level 0–1 (eased), raw total growth, next footprint f (the ring's head), — // (1 + f, 3) FOOT footprint f = 0–FOOTPRINTS-1: x, y (UV), seconds since dropped (T_CLAMP none), seed -// (i, 4 + j) NEAR 1 if body (or a held shape) lies within NEAR_PAD of grid cell (i, j), or a footprint's plants -// reach it, else 0; -// cell (i, j) is screen [i, i + 1] × [j, j + 1] · NEAR_CELL (screen heights). See cNear(). +// (TREE_P_X + k, 3) TREE PERSON farthest path distance on k's body (eased), growth front, growth 0–1, +// root x (screen heights: the middle of their lowest cells) +// (i, 4 + j) TREE grid cell (i, j) = screen [i, i + 1] × [j, j + 1] · TREE_CELL (screen heights): +// on a body: path distance to its person's root, seeds whose paths run through it (0: no +// branch here), owner + parent / 16 (parent 0 the root, 1–8 TREE_DIRS), node offset in the +// cell (qx * 16 + qy, q 0–15); +// off a body: -1, 1 if a body or a footprint's plants are near else 0, 0, 0. See cTree(). const int SLOTS = 8; const float T_CLAMP = 99.0; // every "seconds since" counter clamps here @@ -112,14 +116,35 @@ const float FOOT_FLOWER_FROM = 0.5, FOOT_FLOWER_R = 5.0/360.0; // the middle o const float FOOT_REACH_X = FOOT_SPREAD * 1.2 + FOOT_LEAN * FOOT_H + 0.7 * TWIG_LEN + SPRIG_LEN + INK_W; const float FOOT_REACH_DOWN = SPRIG_LEN + INK_W; const float FOOT_REACH_UP = FOOT_H + max(SPRIG_LEN, FOOT_FLOWER_R * (1.0 + PETAL_R)) + INK_W; -// Near-body grid (Buffer C rows 4+): the Image skips cells with no body near, so empty paper costs one fetch. -const float NEAR_CELL = 1.0/32.0; // cell size; 4 + 1/NEAR_CELL rows of C must fit the render height (36 ≤ 72 at test size) -const float NEAR_STEP = 0.01; // B is sampled this far apart across a padded cell: finer than the thinnest body -// Furthest a pixel's B taps reach from it: sideways to its outermost vine, up and down to ±REACH. A pixel whose -// taps find no grown run draws nothing, so a cell whose padded box holds no body is paper. Plus a step of slack. -const float NEAR_PAD = max((float(VINE_NEIGHBOURS) + 0.5) * VINE_SPACING, REACH) + NEAR_STEP; -const int NEAR_ROWS = int(ceil(1.0 / NEAR_CELL)); -const int NEAR_TAPS = int(ceil((NEAR_CELL + 2.0 * NEAR_PAD) / NEAR_STEP)) + 1; // B samples per side of a padded cell +// The tree (Buffer C rows 4+). Each person's plants are one tree, rooted on the floor at their base (feet). +// Every body cell's parent is its neighbour on the cheapest path back to the root through that person's body; +// paths cost more near the silhouette's edge, so they run up the middle of the legs, torso and arms. A cell is +// grown once its path distance is within growth × the farthest on the body: growth climbs from the root, up +// the legs, then out along the arms and up the neck, and never starts a new root. Only the paths of a +// scattering of seed cells (TREE_SEED_FRAC) are branches, so the tree is sparse; a branch's width comes from how +// many seeds lie above it (thick at the root, thin at the tips), and every seed carries a sprig or a daisy. +// The branches don't depend on growth, so growth runs along them: tips extend, nothing pops in. An arm's path through the shoulder +// doesn't change as it waves, so waving keeps its growth. +const float TREE_CELL = 1.0/48.0; // cell size; 4 + 1/TREE_CELL rows of C must fit the render height (52 ≤ 72 at test size) +const int TREE_ROWS = 48; // = 1 / TREE_CELL +const int TREE_COLS_MAX = 96; // widest grid (aspect ≤ 2) +const float TREE_FILL = 0.25; // a cell is body when this much of it is (of 16 samples) +const float TREE_EDGE_COST = 8.0; // a step costs 1 + this × (1 − how much of a ring round it is body): stay central +const float TREE_EDGE_R = 1.6; // the rings' radii: this and twice this, in cells +const float TREE_ROOT_BAND = 1.5 * TREE_CELL; // cells this close above the base are the root's (they join it on the floor) +const float TREE_ROOT_WIN = 0.35; // ... if within this of it sideways (screen heights): both feet, however wide +const float TREE_FAR = 50.0; // the path distance of a body cell no path reaches +const float TREE_MAX_EASE_S = 1.0; // the farthest path distance eases over this (so a raised arm doesn't jolt the front) +const int TREE_P_X = 34; // TREE PERSON texels: row 3, from x = this (after the footprints) +const float TREE_SEED_FRAC = 0.35; // the share of body cells that are seeds: a branch runs from each to the root +const float TREE_ROOT_W = 7.0/360.0, TREE_W_K = 1.1/360.0; // branch width: TREE_W_K × sqrt(seeds above), up to TREE_ROOT_W +const float TREE_JITTER = 0.5; // nodes are jittered this much (cells) so the grid doesn't show +const float TREE_FILLET = 4.0/360.0; // the tree's branches blend over this where they meet (closes slits between near paths) +const int TREE_R = 3; // the Image draws the cells within this many of a pixel's +const int MAX_TIPS = 12; // sprigs and flowers a pixel can see at once +const float TREE_OPEN_D = 3.0 * TREE_CELL; // a flower opens as the growth front passes this far beyond it +const ivec2 TREE_DIRS[8] = ivec2[8](ivec2(-1, -1), ivec2(0, -1), ivec2(1, -1), ivec2(-1, 0), + ivec2(1, 0), ivec2(-1, 1), ivec2(0, 1), ivec2(1, 1)); // Sun const vec2 SUN_POS = vec2(-0.16, 0.84); // x from the right edge (screen heights), y as a fraction of height const float SUN_R = 0.09, RAY_LEN = 0.07; @@ -174,8 +199,25 @@ float footGrowth(float age) { if (age < FOOT_GROW_S) { float u = 1.0 - age / FOOT_GROW_S; return 1.0 - u * u; } return clamp((FOOT_LIFE - age) / FOOT_FADE_S, 0.0, 1.0); } -bool cNear (sampler2D c, vec2 screenPos) { // screenPos in screen heights; is any body near? - return texelFetch(c, ivec2(floor(screenPos / NEAR_CELL)) + ivec2(0, 4), 0).x > 0.5; +vec4 cTreeP (sampler2D c, int k) { return texelFetch(c, ivec2(TREE_P_X + k, 3), 0); } +int treeCols(vec2 res) { return min(int(ceil(res.x / res.y / TREE_CELL)), TREE_COLS_MAX); } +vec4 cTree (sampler2D c, ivec2 cell, vec2 res) { // a cell off the grid is empty, far from anything + if (cell.x < 0 || cell.y < 0 || cell.x >= treeCols(res) || cell.y >= TREE_ROWS) return vec4(-1.0, 0.0, 0.0, 0.0); + return texelFetch(c, cell + ivec2(0, 4), 0); +} +int treeOwner (vec4 t) { return t.x < 0.0 ? 0 : int(floor(t.z)); } +int treeParent(vec4 t) { return int(floor(fract(t.z) * 16.0 + 0.5)); } +vec2 treeOffset(vec4 t) { return (vec2(floor(t.w / 16.0), mod(t.w, 16.0)) + 0.5) / 16.0 - 0.5; } // in cells +bool cNear(sampler2D c, vec2 screenPos, vec2 res) { // is a body, or a footprint's plants, near? + vec4 t = cTree(c, ivec2(floor(screenPos / TREE_CELL)), res); + return t.x >= 0.0 || t.y > 0.5; +} +// Growth for owner slot k: the live person's, or while their plants rewind away, their ghost's. +float growthOf(sampler2D c, int k) { + vec4 pr = cPerson(c, k); + if (abs(pr.x) > 0.5) return pr.y; + vec4 gh = cGhost(c, k); + return gh.w < LEAVE_S ? gh.z : 0.0; } // ── Body memory accessors (Buffer B .a = owner + 0.9 * hold) ────────────────── @@ -191,6 +233,8 @@ float hash12(vec2 p) { p3 += dot(p3, p3.yzx + 33.33); return fract((p3.x + p3.y) * p3.z); } +// Is tree cell `cell` a seed (a branch runs from it to the root, and it carries a sprig or a daisy)? +bool treeSeed(ivec2 cell) { return hash12(vec2(cell) * 1.618 + 0.37) < TREE_SEED_FRAC; } float noise(vec2 p) { // value noise, 0…1 vec2 i = floor(p), f = fract(p); f = f * f * (3.0 - 2.0 * f); diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl index b91eb10..1819151 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl @@ -1,25 +1,13 @@ -// Image — paper, the crayon sun and plants. iChannel0 = Buffer B, iChannel1 = Buffer C, iChannel2 = blobs -// p is in screen heights, origin bottom left, y up; screen-height y and UV y are the same number. +// Image — paper, the crayon sun, footprints and each person's tree. iChannel0 = Buffer B, iChannel1 = Buffer C, +// iChannel2 = blobs. p is in screen heights, origin bottom left, y up; screen-height y and UV y are the same number. // -// Vine i stands at screen x i * VINE_SPACING. Where it crosses a run of body (B), it grows from -// the run's bottom g toward its top b, drawn as the concept art's branches: a trunk tapering to its -// tip, twigs forking up off it on alternating sides with sprigs of petals at their ends, and at the -// tip a sprig, or on some branches a bud that opens into a daisy late. Branches rise in pairs from -// one root, and all the wood near a pixel is one inked shape, so they merge and fork as the art's. -// The drawing derives from B and the owner's growth every frame, so the plants ride along on the body. -// -// Each pixel looks at VINE_NEIGHBOURS vines each side and taps B on the vine's own x: at the -// pixel's height, and a flower's reach above and below, so twigs and flowers reaching past the -// edge of the body still find their run; halfway between too, so a run thinner than a flower -// (an outstretched arm, a hand) is found from every row its plants cover. The taps don't follow the -// wobble: a vine's runs must be the same from one pixel row to the next, or its twigs and -// flowers would be cut into strips (B's runs agree along a column for the same reason). A twig -// taps B at its node, only on the pixels it covers, so twigs grow only from nodes on the body. +// Buffer C grows each person's tree on a grid (see common.glsl, "The tree"): every body cell has a node and a +// parent on the way back to the root; the cells on a seed's path are branches. A pixel draws the branch cells +// within TREE_R of its own: the segment from each node to its parent's, as far as growth has reached, as ink +// wood (all of it one shape, inked once, so forks are seamless), and a sprig or a daisy on each seed. Flowers are small and pale near the +// root, and larger and bolder further along the tree (so later, as growth climbs and slows). const float TAU = 6.2831853; -const int NV = 2 * VINE_NEIGHBOURS + 1; -const int TAPS = 5; // offsets 0, ±REACH, ±REACH/2 (see plants()) -const float SAME_RUN = 0.5 * RUN_STEP_H; // two taps whose extents agree this closely found the same run float AA; // one render pixel, in screen heights @@ -61,8 +49,6 @@ float sdVesica(vec2 p, float r, float d) { return ((p.y - b) * d > p.x * b) ? length(p - vec2(0.0, b)) : length(p - vec2(-d, 0.0)) - r; } -// ── Body memory and growth ──────────────────────────────────────────────────── - vec4 tapB(float x, float y) { // B under screen point (x, y); 0 off screen float aspect = iResolution.x / iResolution.y; if (y < 0.0 || y >= 1.0 || x < 0.0 || x >= aspect) return vec4(0.0); @@ -72,56 +58,6 @@ vec4 tapB(float x, float y) { // B under screen point (x, y) // still; a softer test on raw presence here would bring the flicker back. bool onBody(vec4 t) { return bOwner(t) > 0 && bHold(t) > 0.0; } -// Growth for owner slot k (R7): the live person's, or while their plants rewind away, their ghost's. -float growthOf(int k) { - vec4 pr = cPerson(iChannel1, k); - if (abs(pr.x) > 0.5) return pr.y; - vec4 gh = cGhost(iChannel1, k); - return gh.w < LEAVE_S ? gh.z : 0.0; -} - -struct Run { float g, b, tip, f, sat; }; // bottom, top, tip (y), reach fraction G·hold, saturation - -// The run under screen point (x, y), if anything has grown on it. -bool tapRun(float x, float y, out Run r) { - r = Run(0.0, 0.0, 0.0, 0.0, 0.0); - vec4 t = tapB(x, y); - int own = bOwner(t); - if (own <= 0 || own > SLOTS || t.b < t.g) return false; - float h = t.r >= BODY_ON ? 1.0 : bHold(t); // present body is fully held (its stored hold is ≈ 0.998) - if (h <= 0.0) return false; - float G = growthOf(own - 1); - r = Run(t.g, t.b, 0.0, G * h, mix(SAT_MIN, SAT_MAX, G)); - r.tip = mix(r.g, r.b, r.f); - return r.tip > r.g; -} - -// ── Branches ────────────────────────────────────────────────────────────────── - -float vineHash(int i) { return hash12(vec2(float(i), 1.7)); } -float wobbleX(int i, float y) { // a slow wave and a faster kink: gnarled wood - float ph = TAU * vineHash(i); - return float(i) * VINE_SPACING - + VINE_WOBBLE * (sin(y * WOBBLE_FREQ + ph) + KINK_AMP * sin(y * WOBBLE_FREQ * KINK_FREQ + 2.0 * ph)); -} -float wobbleSlope(int i, float y) { - float ph = TAU * vineHash(i); - return VINE_WOBBLE * WOBBLE_FREQ * (cos(y * WOBBLE_FREQ + ph) + KINK_AMP * KINK_FREQ * cos(y * WOBBLE_FREQ * KINK_FREQ + 2.0 * ph)); -} -float swayX(int i, float y, Run r) { // grows up the run - float up = clamp((y - r.g) / max(r.b - r.g, 1e-3), 0.0, 1.0); - return SWAY_AMP * up * sin(TAU * SWAY_HZ * iTime - 3.0 * float(i) * VINE_SPACING + vineHash(i)); -} -// Branches come up in pairs from one root: at the run's bottom both lean to their pair's middle, -// and part over PAIR_H, so the wood forks in a Y as the art's. -float pairX(int i, float y, Run r) { - float pr = floor(float(i) / 2.0); - float h = PAIR_H * (0.6 + 0.8 * hash12(vec2(pr, 4.4))); - return (2.0 * pr + 0.5 - float(i)) * VINE_SPACING * (1.0 - smoothstep(0.0, h, y - r.g)); -} -float vineX(int i, float y, Run r) { return wobbleX(i, y) + pairX(i, y, r) + swayX(i, y, r); } - -float sprout(float len) { return smoothstep(0.0, SPROUT_H, len); } float smin(float a, float b, float k) { // polynomial smooth min: the fillet where a twig leaves the trunk float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0); @@ -135,25 +71,6 @@ float sdTaper(vec2 p, vec2 a, vec2 b, float ra, float rb) { } vec2 rot(vec2 v, float a) { float c = cos(a), s = sin(a); return vec2(c * v.x - s * v.y, s * v.x + c * v.y); } -// Twig j of vine i: where it leaves the trunk (o), its tip (e), and its fork's tip (f; f == m when -// it doesn't fork, m being where the fork leaves the twig). k is how far it has grown in, 0–1. -struct Twig { vec2 o, e, m, f, dir; float k; bool fork; }; - -Twig twigOf(int i, int j, Run r, float off, float flip) { - Twig t; - float yj = r.g + (float(j) + off) * TWIG_STEP; - t.k = sprout(r.tip - yj); // grows in as the tip passes - float side = mod(float(j) + flip, 2.0) < 0.5 ? 1.0 : -1.0; - float a = TWIG_RISE + TWIG_RISE_VAR * (2.0 * hash12(vec2(float(i), float(j) + 0.5)) - 1.0); - t.dir = vec2(side * cos(a), sin(a)); - t.o = vec2(vineX(i, yj, r), yj); - t.e = t.o + t.dir * TWIG_LEN * t.k; - t.m = mix(t.o, t.e, FORK_AT); - t.fork = hash12(vec2(float(i), float(j) + 7.3)) < TWIG_FORK; - t.f = t.fork ? t.m + rot(t.dir, side * FORK_ANG) * TWIG_LEN * FORK_LEN * t.k : t.m; - return t; -} - // A sprig: SPRIG petals fanned round direction d from point s, at scale k, with an ink vein each. void sprig(inout vec4 acc, vec2 p, vec2 s, vec2 d, float k, float seed, vec3 col) { if (length(p - s) > SPRIG_LEN * k + INK_W + 2.0 * AA) return; @@ -177,58 +94,6 @@ void sprig(inout vec4 acc, vec2 p, vec2 s, vec2 d, float k, float seed, vec3 col } } -// Vine i's wood near p: its trunk and the twigs near p, blended into one -// shape, as a signed distance. Out: bark, the ink of the trunk's bark stroke at p; sat, the wood's -// saturation; nodes, a bit per twig j0 + n (n = 0–2) near p, for sprigs(); j0. -float wood(vec2 p, int i, Run r, out float bark, out int nodes, out int j0) { - float o = sprout(r.tip - r.g); - float y = clamp(p.y, r.g, r.tip); - float dx = (p.x - vineX(i, y, r)) / sqrt(1.0 + pow(wobbleSlope(i, y), 2.0)); - float w = mix(STEM_TIP_W, STEM_W, clamp((r.tip - y) / STEM_TAPER_H, 0.0, 1.0)); - // A sprouting trunk is thinned past nothing, so its outline doesn't draw a line where it hasn't grown. - float trunk = length(vec2(dx, p.y - y)) - 0.5 * w + (1.0 - o) * 2.0 * INK_W; - float dash = smoothstep(0.45, 0.55, noise(vec2(y * BARK_FREQ, float(i) * 7.1))); - bark = lineA(dx - BARK_HATCH * w, 0.5 * INK_W) * fillA(trunk) * dash; - float sd = trunk; - - float off = 0.5 + 0.5 * hash12(vec2(float(i), 3.1)); // the first twig, in steps above g - float flip = step(0.5, hash12(vec2(float(i), 5.3))); - float up = max((1.0 + FORK_LEN) * TWIG_LEN + SPRIG_LEN, // a twig (or its flower) reaches at most this far - TWIG_LEN + TWIG_FLOWER_R_MAX * (1.0 + PETAL_R)); // above its node - float down = SPRIG_LEN; // ... and this far below - j0 = max(int(ceil((p.y - up - r.g) / TWIG_STEP - off)), 0); - nodes = 0; - for (int n = 0; n < 3; n++) { - int j = j0 + n; - float yj = r.g + (float(j) + off) * TWIG_STEP; - if (yj > p.y + down || yj >= r.tip || abs(p.x - vineX(i, yj, r)) > up) continue; - if (!onBody(tapB(wobbleX(i, yj), yj))) continue; // twigs grow from nodes on the body - nodes |= 1 << n; - Twig t = twigOf(i, j, r, off, flip); - float tw = 0.5 * TWIG_W * t.k, tip = 0.5 * STEM_TIP_W * t.k, thin = (1.0 - min(1.0, 3.0 * t.k)) * 2.0 * INK_W; - sd = smin(sd, sdTaper(p, t.o, t.e, tw, tip) + thin, WOOD_FILLET); - if (t.fork) sd = smin(sd, sdTaper(p, t.m, t.f, mix(tw, tip, FORK_AT), tip) + thin, WOOD_FILLET); - } - return sd; -} - -bool twigFlower(int i, int j) { return hash12(vec2(float(i), float(j) + 9.1)) < TWIG_FLOWER_FRAC; } - -// The sprigs on vine i's twigs near p (as wood() found them), top twig first, so lower ones overlap it. -void sprigs(inout vec4 acc, vec2 p, int i, Run r, int nodes, int j0) { - float off = 0.5 + 0.5 * hash12(vec2(float(i), 3.1)); - float flip = step(0.5, hash12(vec2(float(i), 5.3))); - vec3 col = PAPER; // unfilled: ink on paper, as the art - for (int n = 2; n >= 0; n--) { - if ((nodes & (1 << n)) == 0) continue; - int j = j0 + n; - Twig t = twigOf(i, j, r, off, flip); - float seed = float(i) * 31.0 + float(j); - if (!twigFlower(i, j)) sprig(acc, p, t.e, t.dir, t.k, seed, col); - if (t.fork) sprig(acc, p, t.f, normalize(t.f - t.m + 1e-6), 0.8 * t.k, seed + 0.5, col); - } -} - // A daisy at q = 0 of radius fr (its petals' ring; they reach fr × (1 + PETAL_R)), petals swollen e of // the way out, petals pc and centre hc; seed picks its turn and petal jitter. void daisy(inout vec4 acc, vec2 q, float fr, float e, vec3 pc, vec3 hc, float seed) { @@ -260,108 +125,107 @@ void daisy(inout vec4 acc, vec2 q, float fr, float e, vec3 pc, vec3 hc, float se over(acc, INK, lineA(stripe, 0.5 * INK_W) * fillA(sd + 0.15 * rad.x) * smoothstep(0.0, -0.4 * rad.x, q.x) * e); } -// The daisies on vine i's twigs near p: sized and coloured by how high up the run their twig is. -void twigFlowers(inout vec4 acc, vec2 p, int i, Run r, int nodes, int j0) { - float off = 0.5 + 0.5 * hash12(vec2(float(i), 3.1)); - float flip = step(0.5, hash12(vec2(float(i), 5.3))); - for (int n = 2; n >= 0; n--) { - int j = j0 + n; - if ((nodes & (1 << n)) == 0 || !twigFlower(i, j)) continue; - Twig t = twigOf(i, j, r, off, flip); - float up = clamp((t.o.y - r.g) / max(r.b - r.g, 1e-3), 0.0, 1.0); - float open = smoothstep(0.0, TWIG_FLOWER_OPEN_H, r.tip - t.o.y - SPROUT_H); - float fr = mix(TWIG_FLOWER_R_MIN, TWIG_FLOWER_R_MAX, up) * open; - if (fr <= 0.0 || length(p - t.e) > fr * (1.0 + PETAL_R) + INK_W + 2.0 * AA) continue; - float seed = float(i) * 31.0 + float(j); - float bold = mix(BOLD_MIN, 1.0, up); - daisy(acc, p - t.e, fr, smoothstep(0.0, 0.4, open), tint(flowerColour(seed), r.sat, bold), - tint(heartColour(seed), r.sat, bold), seed); - } -} +// ── The tree ───────────────────────────────────────────────────────────────── -// The trunk's tip: a sprig, or on DAISY_FRAC of branches a bud, which late in growth opens into a daisy. -void bloom(inout vec4 acc, vec2 p, int i, Run r) { - float open = clamp((r.f - FLOWER_FROM) / (FLOWER_FULL - FLOWER_FROM), 0.0, 1.0); - // The taps are REACH / 2 apart, so a run shorter than that isn't found from every row an open - // flower covers: on one (a ragged edge, a fragment) the flower stays a bud. Only the bloom. - open *= smoothstep(SHORT_BLOOM_FROM * REACH, SHORT_BLOOM_FULL * REACH, r.b - r.g); - // The bud sits on the stem's tip. Opening, the flower drifts to the vine's own x (on the body), - // so the wobble can't carry it further past the edge than its own size. - vec2 tip = vec2(vineX(i, r.tip, r), r.tip); - vec2 c = vec2(mix(tip.x, float(i) * VINE_SPACING + swayX(i, r.tip, r), open), r.tip); - if (length(p - c) > REACH + INK_W + 2.0 * AA && length(p - tip) > BUD_R + INK_W + 2.0 * AA) return; - float k = sprout(r.tip - r.g); - if (hash12(vec2(float(i), 13.9)) >= DAISY_FRAC) { // no daisy: the branch ends in a sprig - sprig(acc, p, tip, vec2(0.0, 1.0), k, float(i) * 31.0 - 7.0, PAPER); - return; - } - inked(acc, PAPER, length(p - mix(tip, c, open)) - BUD_R * k, k); // the bud, later the calyx - if (open <= 0.0) return; - float fr = mix(BUD_R, FLOWER_R, open); - daisy(acc, p - c, fr, smoothstep(0.0, 0.25, open), tint(flowerColour(float(i)), r.sat, 1.0), - tint(heartColour(float(i)), r.sat, 1.0), float(i)); +// A cell's node, in screen heights, swaying a little more the further it is along the tree (u 0–1). +vec2 nodeOf(ivec2 cell, vec4 t, float u) { + vec2 j = vec2(hash12(vec2(cell) + 5.1), hash12(vec2(cell) + 8.3)) - 0.5; // a fixed jitter: no grid shows + vec2 n = (vec2(cell) + 0.5 + treeOffset(t) + TREE_JITTER * j) * TREE_CELL; + n.x += SWAY_AMP * u * sin(TAU * SWAY_HZ * iTime + 3.0 * n.y + float(treeOwner(t))); + return n; } +// Wood along A → B (path distances sA < sB, radii rA, rB), as far as the growth front has reached. +float woodSeg(vec2 p, vec2 A, vec2 B, float sA, float sB, float rA, float rB, float front) { + if (front <= sA) return 1e3; + float f = clamp((front - sA) / max(sB - sA, 1e-4), 0.0, 1.0); + return sdTaper(p, A, mix(A, B, f), rA, mix(rA, rB, f)); +} +float branchW(float seeds) { return clamp(TREE_W_K * sqrt(max(seeds, 1.0)), STEM_TIP_W, TREE_ROOT_W); } -// The plants over transparent, premultiplied. Branches vine by vine, then buds and flowers on top. -vec4 plants(vec2 fc) { - vec2 p = fc / iResolution.y; - if (!cNear(iChannel1, p)) return vec4(0.0); // no body near (Buffer C's grid): paper, one fetch - int i0 = int(floor(p.x / VINE_SPACING + 0.5)); - Run runs[NV * TAPS]; - bool has[NV * TAPS]; - bool found = false; - for (int v = 0; v < NV; v++) { - float x = float(i0 + v - VINE_NEIGHBOURS) * VINE_SPACING; - for (int t = 0; t < TAPS; t++) { - int s = v * TAPS + t; - has[s] = false; - // The halfway taps only matter when the outer two aren't on one run: if they are, so is - // everything between (a run is unbroken), and the halfway taps would find it again. - if (t >= 3 && has[v * TAPS + 1] && has[v * TAPS + 2] - && abs(runs[v * TAPS + 1].g - runs[v * TAPS + 2].g) < SAME_RUN - && abs(runs[v * TAPS + 1].b - runs[v * TAPS + 2].b) < SAME_RUN) continue; - float dy = t == 0 ? 0.0 : t == 1 ? REACH : t == 2 ? -REACH : t == 3 ? 0.5 * REACH : -0.5 * REACH; - has[s] = tapRun(x, p.y + dy, runs[s]); - for (int u = 0; u < t; u++) { // taps on the same run draw it once - int o = v * TAPS + u; - if (has[s] && has[o] && abs(runs[s].g - runs[o].g) < SAME_RUN && abs(runs[s].b - runs[o].b) < SAME_RUN) - has[s] = false; +// Every tree near p, over transparent, premultiplied: the wood, then sprigs, then flowers. +vec4 tree(vec2 fc) { + vec2 p = fc / iResolution.y; + vec4 acc = vec4(0.0); + if (!cNear(iChannel1, p, iResolution.xy)) return acc; // nothing near: paper, one fetch + float aspect = iResolution.x / iResolution.y; + ivec2 c0 = ivec2(floor(p / TREE_CELL)); + float wood = 1e3; + float slack = 2.5 * TREE_CELL + FLOWER_R * (1.0 + PETAL_R) + INK_W; // a node's drawing reaches at most this far + int nt = 0; + vec4 tipA[MAX_TIPS], tipB[MAX_TIPS]; // pos, dir · tips: scale, u, open, sat + float tipS[MAX_TIPS]; // seed + for (int dy = -TREE_R; dy <= TREE_R; dy++) + for (int dx = -TREE_R; dx <= TREE_R; dx++) { + ivec2 cell = c0 + ivec2(dx, dy); + vec4 t = cTree(iChannel1, cell, iResolution.xy); + int own = treeOwner(t); + if (own == 0 || own > SLOTS || t.x >= TREE_FAR || t.y < 0.5) continue; // no branch here + vec4 tp = cTreeP(iChannel1, own - 1); + if (tp.x <= 0.0) continue; + float u = clamp(t.x / tp.x, 0.0, 1.0); + vec2 P = nodeOf(cell, t, u); + if (length(p - P) > slack) continue; + vec4 bt = tapB(P.x, P.y); + float front = tp.y * (bt.r >= BODY_ON ? 1.0 : bHold(bt)); + // The branch runs P → parent Q → grandparent R. Corners are cut: this cell draws the curve from + // the middle of R–Q round Q to the middle of Q–P (its children's curves carry on from there), and + // a tip the straight half on to P. Path distance s along the way says how far the growth reaches. + vec2 Q, R; + float dQ, dR, sQ, sR; + vec4 b = cBase(iChannel1, own - 1); + vec2 root = vec2(tp.w > 0.0 ? tp.w : b.x * aspect, b.y); + int pa = treeParent(t); + if (pa == 0) { Q = root; dQ = 0.0; sQ = t.y; R = root; dR = 0.0; sR = t.y; } + else { + ivec2 c2 = cell + TREE_DIRS[pa - 1]; + vec4 t2 = cTree(iChannel1, c2, iResolution.xy); + if (treeOwner(t2) != own || t2.x >= TREE_FAR) continue; // its parent just left the body: re-settling + Q = nodeOf(c2, t2, clamp(t2.x / tp.x, 0.0, 1.0)); dQ = t2.x; sQ = t2.y; + int pa2 = treeParent(t2); + if (pa2 == 0) { R = root; dR = 0.0; sR = sQ; } + else { + ivec2 c3 = c2 + TREE_DIRS[pa2 - 1]; + vec4 t3 = cTree(iChannel1, c3, iResolution.xy); + if (treeOwner(t3) != own || t3.x >= TREE_FAR) { R = Q; dR = dQ; sR = sQ; } // as above: no corner + else { R = nodeOf(c3, t3, clamp(t3.x / tp.x, 0.0, 1.0)); dR = t3.x; sR = t3.y; } + } + } + if (dQ >= front && pa != 0) continue; // the growth hasn't reached this branch + float rP = 0.5 * branchW(t.y), rQ = 0.5 * branchW(sQ), rR = 0.5 * branchW(sR); + vec2 M1 = 0.5 * (P + Q), M2 = 0.5 * (Q + R), C = 0.25 * M2 + 0.5 * Q + 0.25 * M1; + float s1 = 0.5 * (t.x + dQ), s2 = 0.5 * (dQ + dR); + if (pa == 0) wood = smin(wood, woodSeg(p, root, P, 0.0, t.x, rQ * 1.2, rP, front), TREE_FILLET); + else { + wood = smin(wood, woodSeg(p, M2, C, s2, dQ, 0.5 * (rQ + rR), rQ, front), TREE_FILLET); + wood = smin(wood, woodSeg(p, C, M1, dQ, s1, rQ, 0.5 * (rP + rQ), front), TREE_FILLET); + } + bool tip = t.y - (treeSeed(cell) ? 1.0 : 0.0) < 0.5; // no seed above: the branch ends here + if (tip && pa != 0) wood = smin(wood, woodSeg(p, M1, P, s1, t.x, 0.5 * (rP + rQ), rP, front), TREE_FILLET); + float k = smoothstep(t.x - TREE_CELL, t.x + 0.5 * TREE_CELL, front); // a seed's sprig grows in as growth reaches it + if (treeSeed(cell) && k > 0.0 && nt < MAX_TIPS && length(p - P) < FLOWER_R * (1.0 + PETAL_R) + SPRIG_LEN + INK_W) { + tipA[nt] = vec4(P, normalize(P - Q + vec2(0.0, 1e-4))); + tipB[nt] = vec4(k, u, smoothstep(0.0, TREE_OPEN_D, front - t.x), mix(SAT_MIN, SAT_MAX, tp.z)); + tipS[nt] = hash12(vec2(cell) + 0.37); + nt++; } - found = found || has[s]; } + if (wood < INK_W + 2.0 * AA) inked(acc, PAPER, wood, 1.0); // unfilled: ink on paper, as the art + for (int n = 0; n < MAX_TIPS; n++) { // sprigs, on the nodes without a flower + if (n >= nt) break; + if (tipS[n] < TWIG_FLOWER_FRAC && tipB[n].z > 0.0) continue; + sprig(acc, p, tipA[n].xy, tipA[n].zw, tipB[n].x, tipS[n] * 97.0, PAPER); } - vec4 acc = vec4(0.0); - if (!found) return acc; // nothing grows near: paper - // All the wood near p is one shape, inked once: branches that meet merge, as the art's forks. - float wd = 1e3, bark = 0.0; - int nodes[NV * TAPS], j0s[NV * TAPS]; - for (int v = 0; v < NV; v++) - for (int t = 0; t < TAPS; t++) { - int s = v * TAPS + t; - nodes[s] = 0; - if (!has[s]) continue; - float bk; - float d = wood(p, i0 + v - VINE_NEIGHBOURS, runs[s], bk, nodes[s], j0s[s]); - wd = smin(wd, d, WOOD_FILLET); - bark = max(bark, bk); - } - if (wd < INK_W + 2.0 * AA) { - inked(acc, PAPER, wd, 1.0); // unfilled: ink on paper, as the art - over(acc, INK, bark * fillA(wd)); + for (int n = 0; n < MAX_TIPS; n++) { // flowers on top + if (n >= nt) break; + if (tipS[n] >= TWIG_FLOWER_FRAC || tipB[n].z <= 0.0) continue; + float u = tipB[n].y, open = tipB[n].z; + float fr = mix(TWIG_FLOWER_R_MIN, FLOWER_R, u * u) * open; + vec2 q = p - tipA[n].xy; + if (length(q) > fr * (1.0 + PETAL_R) + INK_W + 2.0 * AA) continue; + float bold = mix(BOLD_MIN, 1.0, u * u), seed = tipS[n] * 97.0; + daisy(acc, q, fr, smoothstep(0.0, 0.4, open), tint(flowerColour(seed), tipB[n].w, bold), + tint(heartColour(seed), tipB[n].w, bold), seed); } - for (int v = 0; v < NV; v++) - for (int t = 0; t < TAPS; t++) { - int s = v * TAPS + t; - if (nodes[s] != 0) sprigs(acc, p, i0 + v - VINE_NEIGHBOURS, runs[s], nodes[s], j0s[s]); - } - for (int v = 0; v < NV; v++) // flowers over every vine's leaves - for (int t = 0; t < TAPS; t++) { - int s = v * TAPS + t; - if (nodes[s] != 0) twigFlowers(acc, p, i0 + v - VINE_NEIGHBOURS, runs[s], nodes[s], j0s[s]); - } - for (int v = 0; v < NV; v++) - for (int t = 0; t < TAPS; t++) - if (has[v * TAPS + t]) bloom(acc, p, i0 + v - VINE_NEIGHBOURS, runs[v * TAPS + t]); return acc; } @@ -410,7 +274,7 @@ void footprint(inout vec4 acc, vec2 p, vec2 at, float g, float seed) { vec4 footprints(vec2 fc) { vec2 p = fc / iResolution.y; vec4 acc = vec4(0.0); - if (!cNear(iChannel1, p)) return acc; // the near grid counts footprints too + if (!cNear(iChannel1, p, iResolution.xy)) return acc; // the tree grid's near flag counts footprints too float aspect = iResolution.x / iResolution.y; for (int f = 0; f < FOOTPRINTS; f++) { vec4 ft = cFoot(iChannel1, f); @@ -458,6 +322,6 @@ void mainImage(out vec4 o, in vec2 fc) { vec3 col = sunColour(fc); // the sun, under the plants vec4 fp = footprints(fc); // behind the people col = fp.rgb + col * (1.0 - fp.a); - vec4 pl = plants(fc); + vec4 pl = tree(fc); o = vec4(pl.rgb + col * (1.0 - pl.a), 1.0); } diff --git a/ShowControl/FundingCAPTCHA/test_watering_can.py b/ShowControl/FundingCAPTCHA/test_watering_can.py index fac0385..b0cb7b1 100644 --- a/ShowControl/FundingCAPTCHA/test_watering_can.py +++ b/ShowControl/FundingCAPTCHA/test_watering_can.py @@ -749,24 +749,71 @@ def test_run_ends_agree_along_a_column(ctx): # else the plants tear int rig.release() -NEAR_CELL, NEAR_PAD = K["NEAR_CELL"], K["NEAR_PAD"] +TREE_CELL = K["TREE_CELL"] -def test_near_grid_marks_body_neighbourhood(ctx): - rig = Rig(ctx) # SIZE: 4 + 32 grid rows fit in 72 +def test_tree_grid_marks_body_and_neighbourhood(ctx): + rig = Rig(ctx) # SIZE: 4 + 48 grid rows fit in 72 rig.run([person()] * 10) c = rig.buf("buffer_c") aspect = SIZE[0] / SIZE[1] + reach = K["TREE_R"] * TREE_CELL - def near(x_sh, y_sh): # the cell holding screen point (x, y), screen heights - return c[4 + int(y_sh / NEAR_CELL), int(x_sh / NEAR_CELL), 0] + def cell(x_sh, y_sh): # the tree cell holding screen point (x, y), screen heights + return c[4 + int(y_sh / TREE_CELL), int(x_sh / TREE_CELL)] y = 1 - 18 / BODY[1] # torso height edge = 38 / BODY[0] * aspect # the figure's right edge (arm hanging at x 35..38) - assert near(32 / BODY[0] * aspect, y) == 1 # under the torso - assert near(edge + 0.5 * NEAR_PAD, y) == 1 # just outside, within NEAR_PAD - assert near(edge + NEAR_PAD + 1.5 * NEAR_CELL, y) == 0 # beyond NEAR_PAD - assert near(0.05, 0.05) == 0 and near(aspect - 0.05, 0.5) == 0 + torso = cell(32 / BODY[0] * aspect, y) + assert torso[0] >= 0 and owner(torso[[0, 1, 3, 2]]) == 1 # on the body, owned by slot 0 + assert torso[0] < K["TREE_FAR"] # a path reaches it from the root + near = cell(edge + 0.5 * reach, y) + assert near[0] < 0 and near[1] == 1 # just outside: no body, but near one + far = cell(edge + reach + 2.5 * TREE_CELL, y) + assert far[0] < 0 and far[1] == 0 # beyond what a pixel there could draw + assert cell(0.05, 0.05)[1] == 0 and cell(aspect - 0.05, 0.5)[1] == 0 + rig.release() + + +def feet_apart_arm_up(): + """A figure standing feet apart (a gap between the legs to the crotch), right arm raised overhead.""" + m = rect(28, 4, 33, 11) # head, touching the torso + m |= rect(26, 10, 36, 26) # torso + m |= rect(24, 26, 28, 44) | rect(34, 26, 38, 44) # legs, 6 px apart + m |= rect(36, 0, 39, 12) # right arm, raised (hand at the top of the frame) + return m + + +def tree_cells(rig, mask): + """Path distances of slot 0's tree cells whose centres fall in Body mask region `mask`.""" + c = rig.buf("buffer_c")[4:4 + K["TREE_ROWS"]] + aspect = SIZE[0] / SIZE[1] + out = [] + for j in range(c.shape[0]): + for i in range(int(aspect / TREE_CELL)): + t = c[j, i] + if t[0] < 0 or int(np.floor(t[2])) != 1: + continue + x, y = (i + 0.5) * TREE_CELL / aspect, (j + 0.5) * TREE_CELL # UV + r, col = int((1 - y) * BODY[1]), int(x * BODY[0]) + if 0 <= r < BODY[1] and 0 <= col < BODY[0] and mask[r, col]: + out.append(t[0]) + return np.array(out) + + +def test_one_root_at_the_feet(ctx): # one tree from the base: no roots in the arms or elsewhere + rig = Rig(ctx) + rig.run([feet_apart_arm_up()] * 40) + far = rig.buf("buffer_c")[3, K["TREE_P_X"], 0] + left, right = tree_cells(rig, rect(24, 40, 28, 44)), tree_cells(rig, rect(34, 40, 38, 44)) + assert len(left) and len(right) + assert left.min() < 0.15 * far and right.min() < 0.15 * far, (left.min(), right.min(), far) # both feet root + above = tree_cells(rig, rect(0, 0, BODY[0], 38)) + assert above.min() > 0.15 * far, (above.min(), far) # nothing above the feet is a root + hand = tree_cells(rig, rect(36, 0, 39, 3)) + assert hand.min() > 0.8 * far, (hand.min(), far) # the raised hand is reached last, via the shoulder + front = rig.buf("buffer_c")[3, K["TREE_P_X"], 1] + assert left.max() < front < hand.min(), (left.max(), front, hand.min()) # mid growth: legs grown, hand not rig.release() @@ -803,8 +850,8 @@ def test_growth_climbs_from_the_feet(ctx): assert abs(rig.person(0)[1] - 0.3) < 0.05, rig.person(0) d, fig = drawn(rig.image()), person() top, bottom = on_screen(fig & rows(6, 19), IMG), on_screen(fig & rows(31, 44), IMG) # thirds of rows 6..44 - assert d[bottom].mean() > 0.5, d[bottom].mean() - assert d[top].mean() < 0.1, d[top].mean() + assert d[bottom].mean() > 0.25, d[bottom].mean() # a sparse tree, but clearly there ... + assert d[top].mean() < 0.05, d[top].mean() # ... and the top still bare rig.release() @@ -816,7 +863,7 @@ def test_colour_ripens(ctx): img = rig.image() sats.append(saturation(img[..., :3][drawn(img)]).mean()) rig.release() - assert sats[1] > 2 * sats[0], sats # at least doubles: the palette is pastel and the wood is ink on paper + assert sats[1] > 1.5 * sats[0], sats # rises clearly: the palette is pastel and the wood is ink on paper def coloured(img): @@ -835,14 +882,13 @@ def test_flowers_bloom_pale_low_then_bold_high(ctx): img = early.image() c = coloured(img) assert c[low].sum() > 20, c[low].sum() # flowers already open, low on the body ... - assert (flower_pixels(img) & drawn(img)).sum() == 0 # ... and pale: none in full colour yet + assert (flower_pixels(img) & drawn(img)).sum() < 5 # ... and pale: hardly a pixel in full colour yet early.release() full = grown_rig(ctx, 220) img = full.image() - c = coloured(img) - sat = saturation(img[..., :3]) - assert (flower_pixels(img) & drawn(img)).sum() > 50 # bold flowers at full bloom - assert sat[c & high].mean() > sat[c & low].mean() + 0.1, (sat[c & high].mean(), sat[c & low].mean()) + bold = flower_pixels(img) & drawn(img) + assert bold.sum() > 30 # bold flowers at full bloom (only the outer ones: few at this size) + assert bold[high].sum() > 2 * bold[low].sum(), (bold[high].sum(), bold[low].sum()) # and mostly high up full.release() diff --git a/docs/superpowers/specs/2026-11-06-watering-can-design.md b/docs/superpowers/specs/2026-11-06-watering-can-design.md index 1d2ffbb..00dd04b 100644 --- a/docs/superpowers/specs/2026-11-06-watering-can-design.md +++ b/docs/superpowers/specs/2026-11-06-watering-can-design.md @@ -129,3 +129,4 @@ The walking threshold, and how often the real tracker drops or re-IDs a still pe - **Look:** the vines are drawn after the concept art's branches. Trunks taper and are ink-outlined, and they rise in pairs and merge where they meet. Forking twigs end in sprigs of pointed petals. About half the branch tips open into hatched daisies. The palette and ripening are unchanged. Density is about three quarters of the mockup's Dense panel. - **Growth curve:** growth starts fast, reaching `GROW_EARLY` in `GROW_EARLY_S`, then slows so full bloom still comes at about `GROW_S`. - **Footprints:** a walker leaves plants on the floor where they step. Each footprint is a small clump of branches with a daisy that grows in fast, holds, then rewinds away (`FOOT_*` in `common.glsl`). This is the one exception to "a person's plants leave with them". A person's own plants still ride on their body and rewind while they walk. Live footprints are never overwritten. When all `FOOTPRINTS` are live, walkers leave none until the oldest has gone. +- **One tree per person:** the per-column vines are replaced by a single tree. It is rooted on the floor between the feet and grows up the silhouette along the cheapest paths through the body, which keep to the middle of each limb. Buffer C keeps a 1/48-screen-height grid: each body cell's parent on its way back to the root, and how many seed cells' paths run through it. Branches are the seeds' paths; their width comes from that count, and the seeds carry the sprigs and daisies. Growth is a front of path distance (growth × the farthest on the body). It climbs from the root up both legs, through the torso, then out along the arms and up the neck. Nothing roots anywhere else. An arm's path distance through the shoulder doesn't change as it waves, so waving keeps its growth. Flower size and boldness follow distance along the tree. From 35fbd1c99ab6955554cf0c7e124b79764e2b59d2 Mon Sep 17 00:00:00 2001 From: ii-impy <314283863+ii-impy@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:24:20 -0500 Subject: [PATCH 22/22] Watering can: steady root and trunk The root point (the middle of the lowest cells) jumped as a foot's edge flickered, and seed counts, which set branch widths, rippled down the trunk every frame. The root now eases (TREE_ROOT_EASE_S), as do seed counts (TREE_WIDTH_EASE_S; children pass on eased counts, so a settled tree adds up). Cells join the body at TREE_FILL and stay until half that, and keep their way back to the root unless another is TREE_PARENT_HYST cheaper. A still person's lower body changes about a quarter as many pixels a frame as before. Co-Authored-By: Claude Opus 5.5 --- .../shaders/watering_can/buffer_c.glsl | 27 ++++++++++++++----- .../shaders/watering_can/common.glsl | 9 ++++--- .../shaders/watering_can/image.glsl | 4 +-- .../FundingCAPTCHA/test_watering_can.py | 27 ++++++++++++++++++- 4 files changed, 55 insertions(+), 12 deletions(-) diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl index b9f2fd1..c9e002b 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/buffer_c.glsl @@ -263,7 +263,11 @@ vec4 treeNext(ivec2 cell) { n += 1.0; sum += sp; hold = max(hold, t.r >= BODY_ON ? 1.0 : bHold(t)); } - if (n < TREE_FILL * 16.0 || own > SLOTS) return vec4(-1.0, treeNear(cell), 0.0, 0.0); + // Hysteresis: a cell joins the body at TREE_FILL, and stays until under half that, so a flickering edge + // doesn't take its branch (and sprig, or flower) with it every other frame. + vec4 was = cTree(C, cell, iResolution.xy); + float need = treeOwner(was) == own && own > 0 ? 0.5 * TREE_FILL : TREE_FILL; + if (n < need * 16.0 || own > SLOTS) return vec4(-1.0, treeNear(cell), 0.0, 0.0); int k = own - 1; vec2 node = sum / n; vec2 q = clamp(floor(((node - lo) / TREE_CELL) * 16.0), 0.0, 15.0); @@ -289,17 +293,23 @@ vec4 treeNext(ivec2 cell) { float d = TREE_FAR; int parent = -1; vec4 tpk = cTreeP(C, k); - vec2 root = vec2(tpk.w > 0.0 ? tpk.w : b.x * aspect, b.y); + vec2 root = tpk.w > 0.0 ? tpk.wy : vec2(b.x * aspect, b.y); if (lo.y <= root.y + TREE_ROOT_BAND && abs(node.x - root.x) < TREE_ROOT_WIN) { d = length(node - root); parent = 0; } + float keep = TREE_FAR; // the way it went last frame, if still open + int keepP = treeOwner(prev) == own ? treeParent(prev) : -1; for (int i = 0; i < 8; i++) { vec4 t = cTree(C, cell + TREE_DIRS[i], iResolution.xy); if (treeOwner(t) != own || t.x >= TREE_FAR) continue; float cand = t.x + cost * length(vec2(TREE_DIRS[i])); if (cand < d) { d = cand; parent = i + 1; } + if (i + 1 == keepP) keep = cand; } + if (keepP == 0 && parent == 0) keep = d; + // Hysteresis: keep last frame's way unless another is clearly cheaper, so near-equal routes don't trade places. + if (keepP > 0 && keep < TREE_FAR && keep <= d * (1.0 + TREE_PARENT_HYST)) { d = keep; parent = keepP; } if (parent < 0) return vec4(TREE_FAR, 0.0, float(own), nodeQ); // Seeds whose paths run through this cell: its own, and its children's (last frame's, so a branch settles a // cell a frame from its seed down). @@ -309,11 +319,14 @@ vec4 treeNext(ivec2 cell) { int pa = treeParent(t); if (treeOwner(t) == own && t.x < TREE_FAR && pa > 0 && TREE_DIRS[pa - 1] == -TREE_DIRS[i]) seeds += t.y; } + // Eased (TREE_WIDTH_EASE_S), so a seed flickering in and out at an edge doesn't ripple down the trunk's width. + // Children pass on their eased counts, so a settled tree still adds up exactly. + if (treeOwner(prev) == own && prev.x < TREE_FAR) seeds = mix(prev.y, seeds, 1.0 - exp(-iTimeDelta / TREE_WIDTH_EASE_S)); return vec4(d, seeds, float(own) + float(parent) / 16.0, nodeQ); } -// TREE PERSON k: the farthest path distance on their body (eased), their growth front, their growth, and -// their root's x: the middle of their lowest cells, so the root sits between the feet and both legs join it. +// TREE PERSON k: the farthest path distance on their body (eased), their root's y, their growth, and their +// root's x: the middle of their lowest cells, so the root sits between the feet and both legs join it. // (BASE follows one foot when the feet are apart, which is right for telling a step, not for a root.) vec4 treePersonNext(int k, float dt) { float raw = 0.0, n = 0.0, sx = 0.0; @@ -330,8 +343,10 @@ vec4 treePersonNext(int k, float dt) { vec4 pr = cTreeP(C, k); float farD = pr.x <= 0.0 ? raw : mix(pr.x, raw, 1.0 - exp(-dt / TREE_MAX_EASE_S)); float g = growthOf(C, k); - float rootX = n > 0.0 ? sx / n : cBase(C, k).x * iResolution.x / iResolution.y; - return vec4(farD, g * farD, g, rootX); + // The root eases (TREE_ROOT_EASE_S), so a foot's flickering edge doesn't shuffle it and the trunk with it. + vec2 raw2 = vec2(n > 0.0 ? sx / n : cBase(C, k).x * iResolution.x / iResolution.y, baseY); + vec2 root = pr.w <= 0.0 ? raw2 : mix(pr.wy, raw2, 1.0 - exp(-dt / TREE_ROOT_EASE_S)); + return vec4(farD, root.y, g, root.x); } // ── SUN ─────────────────────────────────────────────────────────────────────── diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl index 7b2afdc..7c4b57c 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/common.glsl @@ -12,8 +12,8 @@ // (k, 2) GHOST spot x, y, growth at loss, seconds since loss (T_CLAMP = no ghost) // (0, 3) SUN sun level 0–1 (eased), raw total growth, next footprint f (the ring's head), — // (1 + f, 3) FOOT footprint f = 0–FOOTPRINTS-1: x, y (UV), seconds since dropped (T_CLAMP none), seed -// (TREE_P_X + k, 3) TREE PERSON farthest path distance on k's body (eased), growth front, growth 0–1, -// root x (screen heights: the middle of their lowest cells) +// (TREE_P_X + k, 3) TREE PERSON farthest path distance on k's body (eased), root y, growth 0–1, root x +// (screen heights; the root is the middle of their lowest cells, eased) // (i, 4 + j) TREE grid cell (i, j) = screen [i, i + 1] × [j, j + 1] · TREE_CELL (screen heights): // on a body: path distance to its person's root, seeds whose paths run through it (0: no // branch here), owner + parent / 16 (parent 0 the root, 1–8 TREE_DIRS), node offset in the @@ -128,12 +128,15 @@ const float FOOT_REACH_UP = FOOT_H + max(SPRIG_LEN, FOOT_FLOWER_R * (1.0 + PETAL const float TREE_CELL = 1.0/48.0; // cell size; 4 + 1/TREE_CELL rows of C must fit the render height (52 ≤ 72 at test size) const int TREE_ROWS = 48; // = 1 / TREE_CELL const int TREE_COLS_MAX = 96; // widest grid (aspect ≤ 2) -const float TREE_FILL = 0.25; // a cell is body when this much of it is (of 16 samples) +const float TREE_FILL = 0.25; // a cell is body when this much of it is (of 16 samples); stays until half that const float TREE_EDGE_COST = 8.0; // a step costs 1 + this × (1 − how much of a ring round it is body): stay central const float TREE_EDGE_R = 1.6; // the rings' radii: this and twice this, in cells const float TREE_ROOT_BAND = 1.5 * TREE_CELL; // cells this close above the base are the root's (they join it on the floor) const float TREE_ROOT_WIN = 0.35; // ... if within this of it sideways (screen heights): both feet, however wide const float TREE_FAR = 50.0; // the path distance of a body cell no path reaches +const float TREE_ROOT_EASE_S = 1.0; // the root point eases over this +const float TREE_WIDTH_EASE_S = 1.0; // branch widths (seed counts) ease over this +const float TREE_PARENT_HYST = 0.05; // a cell changes its way back to the root only for one this much cheaper const float TREE_MAX_EASE_S = 1.0; // the farthest path distance eases over this (so a raised arm doesn't jolt the front) const int TREE_P_X = 34; // TREE PERSON texels: row 3, from x = this (after the footprints) const float TREE_SEED_FRAC = 0.35; // the share of body cells that are seeds: a branch runs from each to the root diff --git a/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl b/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl index 1819151..23c253d 100644 --- a/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/watering_can/image.glsl @@ -166,14 +166,14 @@ vec4 tree(vec2 fc) { vec2 P = nodeOf(cell, t, u); if (length(p - P) > slack) continue; vec4 bt = tapB(P.x, P.y); - float front = tp.y * (bt.r >= BODY_ON ? 1.0 : bHold(bt)); + float front = tp.z * tp.x * (bt.r >= BODY_ON ? 1.0 : bHold(bt)); // growth × the farthest // The branch runs P → parent Q → grandparent R. Corners are cut: this cell draws the curve from // the middle of R–Q round Q to the middle of Q–P (its children's curves carry on from there), and // a tip the straight half on to P. Path distance s along the way says how far the growth reaches. vec2 Q, R; float dQ, dR, sQ, sR; vec4 b = cBase(iChannel1, own - 1); - vec2 root = vec2(tp.w > 0.0 ? tp.w : b.x * aspect, b.y); + vec2 root = tp.w > 0.0 ? tp.wy : vec2(b.x * aspect, b.y); int pa = treeParent(t); if (pa == 0) { Q = root; dQ = 0.0; sQ = t.y; R = root; dR = 0.0; sR = t.y; } else { diff --git a/ShowControl/FundingCAPTCHA/test_watering_can.py b/ShowControl/FundingCAPTCHA/test_watering_can.py index b0cb7b1..7f5701e 100644 --- a/ShowControl/FundingCAPTCHA/test_watering_can.py +++ b/ShowControl/FundingCAPTCHA/test_watering_can.py @@ -812,11 +812,36 @@ def test_one_root_at_the_feet(ctx): # one tree from the base: no assert above.min() > 0.15 * far, (above.min(), far) # nothing above the feet is a root hand = tree_cells(rig, rect(36, 0, 39, 3)) assert hand.min() > 0.8 * far, (hand.min(), far) # the raised hand is reached last, via the shoulder - front = rig.buf("buffer_c")[3, K["TREE_P_X"], 1] + tp = rig.buf("buffer_c")[3, K["TREE_P_X"]] + front = tp[2] * tp[0] # growth × the farthest path distance assert left.max() < front < hand.min(), (left.max(), front, hand.min()) # mid growth: legs grown, hand not rig.release() +def test_still_trunk_and_root_hold_steady_under_flicker(ctx): # no fidgeting root or trunk + rig = Rig(ctx, size=(240, 180)) + rig.run(flickering(150, seed=3)) + rows = K["TREE_ROWS"] + + def state(): + c = rig.buf("buffer_c") + return c[4:4 + rows, :int(240 / 180 / TREE_CELL)].copy(), c[3, K["TREE_P_X"]].copy() + + prev, root_moves, width_moves, flips = state(), [], [], [] + for k in range(20): + rig.run(flickering(1, seed=100 + k), frames_per_camera=1) + (g, tp), (pg, ptp) = state(), prev + trunk = (g[..., 0] >= 0) & (pg[..., 0] >= 0) & (pg[..., 1] >= 8) # 8+ seeds above: trunk and main branches + root_moves.append(np.hypot(tp[3] - ptp[3], tp[1] - ptp[1])) + width_moves.append(np.abs(g[..., 1] - pg[..., 1])[trunk].mean()) + flips.append((np.round(np.modf(g[..., 2])[0] * 16) != np.round(np.modf(pg[..., 2])[0] * 16))[trunk].sum()) + prev = (g, tp) + assert max(root_moves) < 0.5 / 180, root_moves # under half a pixel a frame + assert max(width_moves) < 0.2, width_moves # seed counts (widths) barely move + assert sum(flips) == 0, flips # the trunk keeps its way back to the root + rig.release() + + def test_sun_region_is_top_right(): r, (w, h) = sun_region(IMG), IMG assert r[int(0.84 * h), int(w - 0.16 * h)] # its centre