diff --git a/IIVision/__init__.py b/IIVision/__init__.py index 380e778..915bcb4 100644 --- a/IIVision/__init__.py +++ b/IIVision/__init__.py @@ -13,7 +13,7 @@ """ from .pipeline import PreStabilizeFilter, build_calibration, filter_positions_near_points, run_pipeline -from .blob_stabilizer import BlobStabilizer, StabilizerConfig, TrackedBlob +from .blob_stabilizer import BlobStabilizer, StabilizerConfig, TrackedBlob, TrackState from .blob_tracker import BlobTracker, Calibration, Calibrator, DetectionConfig, FramePacket from .camera import MockCamera, OrbbecCamera, OrbbecRGBCamera, RGBFrame from .transforms import Rotator, Transform, look_yaw_degrees, orbbec_to_world, rotator_to_matrix, transform_position @@ -27,6 +27,7 @@ "BlobStabilizer", "StabilizerConfig", "TrackedBlob", + "TrackState", "BlobTracker", "Calibration", "Calibrator", diff --git a/IIVision/blob_stabilizer.py b/IIVision/blob_stabilizer.py index 7512ba9..0a9d922 100644 --- a/IIVision/blob_stabilizer.py +++ b/IIVision/blob_stabilizer.py @@ -43,6 +43,16 @@ class TrackedBlob: world_pos_cm: np.ndarray +@dataclass +class TrackState: + """Every live track after update_tracks(), confirmed or not, seen or not.""" + track_id: int + det_idx: int | None # index into this frame's detections; None when missed + seen_frames: int + miss_frames: int + smoothed_pos: np.ndarray + + class _Track: __slots__ = ("track_id", "smoothed_pos", "miss_frames", "seen_frames") @@ -94,6 +104,23 @@ def update(self, detections: list[np.ndarray]) -> list[TrackedBlob]: Returns a list of TrackedBlob with stable IDs and smoothed positions. """ cfg = self.config + tracks, _ = self.update_tracks(detections) + return [ + TrackedBlob(stable_id=t.track_id, world_pos_cm=t.smoothed_pos) + for t in tracks + if t.seen_frames >= cfg.min_confirm_frames and t.miss_frames == 0 + ] + + def update_tracks(self, detections: list[np.ndarray]) -> tuple[list[TrackState], list[int]]: + """ + As update(), but returns every live track (including unconfirmed and + missed ones, with the detection each matched) and the IDs that expired + this frame. For consumers that show the track lifecycle. + + Matching is unit-agnostic: positions and max_match_dist_cm only need + to share a unit. update() callers pass world cm. + """ + cfg = self.config max_dist_sq = cfg.max_match_dist_cm ** 2 matched: list[tuple[int, int]] = [] # (track_id, det_idx) @@ -145,9 +172,11 @@ def update(self, detections: list[np.ndarray]) -> list[TrackedBlob]: self._tracks[track_id].mark_missing() # Spawn new tracks for unmatched detections. + det_of = {tid: di for tid, di in matched} for det_idx in range(len(detections)): if det_idx not in matched_det_idxs: self._tracks[self._next_id] = _Track(self._next_id, detections[det_idx]) + det_of[self._next_id] = det_idx self._next_id += 1 # Expire stale tracks. @@ -158,9 +187,10 @@ def update(self, detections: list[np.ndarray]) -> list[TrackedBlob]: for tid in expired: del self._tracks[tid] - # Return only confirmed, currently-visible tracks. - return [ - TrackedBlob(stable_id=t.track_id, world_pos_cm=t.smoothed_pos.copy()) + tracks = [ + TrackState(track_id=t.track_id, det_idx=det_of.get(t.track_id), + seen_frames=t.seen_frames, miss_frames=t.miss_frames, + smoothed_pos=t.smoothed_pos.copy()) for t in self._tracks.values() - if t.seen_frames >= cfg.min_confirm_frames and t.miss_frames == 0 ] + return tracks, expired diff --git a/IIVision/test_blob_stabilizer.py b/IIVision/test_blob_stabilizer.py new file mode 100644 index 0000000..9f34f9b --- /dev/null +++ b/IIVision/test_blob_stabilizer.py @@ -0,0 +1,45 @@ +"""BlobStabilizer.update_tracks(): every live track, its detection, and expiries.""" +from __future__ import annotations + +import numpy as np + +from IIVision.blob_stabilizer import BlobStabilizer, StabilizerConfig + + +def _p(x, y=0.0): + return np.array([x, y, 0.0]) + + +def test_update_tracks_reports_unconfirmed_and_detection_index(): + s = BlobStabilizer(StabilizerConfig(min_confirm_frames=3)) + tracks, expired = s.update_tracks([_p(0), _p(500)]) + assert expired == [] + assert sorted((t.track_id, t.det_idx, t.seen_frames) for t in tracks) == [(0, 0, 1), (1, 1, 1)] + assert s.update([_p(0), _p(500)]) == [] # update() still hides unconfirmed tracks + + +def test_update_tracks_matches_by_distance_not_order(): + s = BlobStabilizer(StabilizerConfig(smoothing_alpha=1.0)) + s.update_tracks([_p(0), _p(500)]) + tracks, _ = s.update_tracks([_p(505), _p(3)]) + assert {t.track_id: t.det_idx for t in tracks} == {0: 1, 1: 0} + + +def test_update_tracks_reports_misses_then_expiry(): + s = BlobStabilizer(StabilizerConfig(max_miss_frames=2)) + s.update_tracks([_p(0)]) + for miss in (1, 2): + tracks, expired = s.update_tracks([]) + assert [(t.det_idx, t.miss_frames) for t in tracks] == [(None, miss)] + assert expired == [] + tracks, expired = s.update_tracks([]) + assert tracks == [] and expired == [0] + + +def test_update_unchanged_for_confirmed_visible_tracks(): + s = BlobStabilizer(StabilizerConfig(min_confirm_frames=2, smoothing_alpha=0.5)) + s.update([_p(0)]) + out = s.update([_p(10)]) + assert [b.stable_id for b in out] == [0] + assert out[0].world_pos_cm[0] == 5.0 + assert s.update([]) == [] # missed: tracked, not reported diff --git a/ShowControl/FundingCAPTCHA/body_blobs.py b/ShowControl/FundingCAPTCHA/body_blobs.py new file mode 100644 index 0000000..d2782eb --- /dev/null +++ b/ShowControl/FundingCAPTCHA/body_blobs.py @@ -0,0 +1,207 @@ +"""Blob records for the shader player's "blobs" source (ADR-0022). + +Blobs are found in the Body itself — the mask Setups see in Buffer A — so a +box always lines up with the silhouette it belongs to, whatever reprojection +and ROI crop made that Body. IDs come from IIVision's BlobStabilizer, run in +Body pixels rather than world cm. + + blobs = BodyBlobs(BlobConfig.from_dict(settings["shader_app"].get("blobs", {}))) + blobs.update(presence, nearness) # per camera frame; arrays row 0 = bottom + renderer.upload_blobs(blobs.records()) # per render frame + +Lifecycle per slot: tentative (seen < min_confirm_frames) → confirmed → +coasting (missed; the box drifts on its last velocity) → lost (held for +lost_hold_s, then the slot empties). A tentative track that expires empties +its slot without passing through lost. +""" +from __future__ import annotations + +import math +import sys +import time +from dataclasses import dataclass, fields as _dc_fields +from pathlib import Path + +import numpy as np +from scipy import ndimage + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent)) # IIVision, as app.py + +from IIVision.blob_stabilizer import BlobStabilizer, StabilizerConfig +from shadertoy import (BLOB_COASTING, BLOB_CONFIRMED, BLOB_LOST, BLOB_ROWS, + BLOB_SLOTS, BLOB_TENTATIVE) + +_VEL_ALPHA = 0.5 # EMA on velocity, per camera frame +_COAST_DECAY = 0.85 # velocity kept per missed camera frame, so coasting boxes slow down + + +@dataclass +class BlobConfig: + min_blob_pixels: int = 500 # smaller connected regions are ignored (Body pixels) + min_confirm_frames: int = 4 # camera frames a track stays tentative + max_miss_frames: int = 8 # camera frames a track coasts before it is lost + max_match_dist_px: float = 80.0 # furthest a centroid may jump between frames (Body pixels) + smoothing_alpha: float = 0.5 # EMA on the centroid, per camera frame + lost_hold_s: float = 1.0 # how long a lost record stays in its slot + ease_s: float = 0.08 # render-rate easing between camera frames; 0 = raw + + @classmethod + def from_dict(cls, raw: dict) -> "BlobConfig": + known = {f.name for f in _dc_fields(cls)} + return cls(**{k: v for k, v in raw.items() if k in known}) + + +@dataclass +class Detection: + bbox: np.ndarray # x0, y0, x1, y1 in Body pixels, max exclusive + centroid: np.ndarray # x, y in Body pixels + nearness: float # mean .g over the blob, 0–1 + pixels: int + + +def find_blobs(presence: np.ndarray, nearness: np.ndarray, min_pixels: int) -> list[Detection]: + """8-connected regions of `presence` with at least `min_pixels` texels.""" + labeled, n = ndimage.label(presence, structure=np.ones((3, 3), dtype=np.int32)) + if n == 0: + return [] + counts = np.bincount(labeled.ravel(), minlength=n + 1) + out = [] + # Stats inside each kept blob's own slice: ndimage's whole-image measurements + # cost ~10 ms a frame at camera resolution. + for i, sl in enumerate(ndimage.find_objects(labeled), start=1): + if sl is None or counts[i] < min_pixels: + continue + sy, sx = sl + mask = labeled[sl] == i + ys, xs = np.nonzero(mask) + out.append(Detection( + bbox = np.array([sx.start, sy.start, sx.stop, sy.stop], dtype=np.float64), + centroid = np.array([xs.mean() + sx.start + 0.5, ys.mean() + sy.start + 0.5]), + nearness = float(nearness[sl][mask].mean()), + pixels = int(counts[i]), + )) + return out + + +def display_id(track_id: int) -> int: + """Stable IDs grow all day; Setups get 1–999, exact in a 16-bit float buffer.""" + return 1 + track_id % 999 + + +class _Record: + __slots__ = ("slot", "id", "state", "since", "target", "shown", "centroid", "shown_c", + "vel", "nearness") + + def __init__(self, slot: int, track_id: int, now: float, det: Detection) -> None: + self.slot = slot + self.id = display_id(track_id) + self.state = BLOB_TENTATIVE + self.since = now + self.target = det.bbox.copy() + self.shown = det.bbox.copy() + self.centroid = det.centroid.copy() + self.shown_c = det.centroid.copy() + self.vel = np.zeros(2) + self.nearness = det.nearness + + +class BodyBlobs: + def __init__(self, config: BlobConfig | None = None, clock=time.monotonic) -> None: + self.config = config or BlobConfig() + self._clock = clock + self.reset() + + def reset(self) -> None: + cfg = self.config + self._stab = BlobStabilizer(StabilizerConfig( + max_match_dist_cm = cfg.max_match_dist_px, # unit-agnostic: Body pixels here + smoothing_alpha = cfg.smoothing_alpha, + max_miss_frames = cfg.max_miss_frames, + min_confirm_frames = cfg.min_confirm_frames, + )) + self._recs: dict[int, _Record] = {} # track id → record + self._size = (1, 1) + self._last_update: float | None = None + self._last_records: float | None = None + + @property + def active(self) -> bool: + return bool(self._recs) or self._last_update is not None + + def update(self, presence: np.ndarray, nearness: np.ndarray, now: float | None = None) -> None: + """One camera frame of Body: presence bool (h, w), nearness 0–1 (h, w), row 0 = bottom.""" + now = self._clock() if now is None else now + cfg = self.config + h, w = presence.shape + self._size = (w, h) + dt = now - self._last_update if self._last_update is not None else 0.0 + self._last_update = now + + dets = find_blobs(presence, nearness, cfg.min_blob_pixels) + tracks, expired = self._stab.update_tracks([d.centroid for d in dets]) + + for t in tracks: + r = self._recs.get(t.track_id) + if r is None: + if t.det_idx is None: + continue # never got a slot; nothing to show + slot = self._free_slot() + if slot is None: + continue # all slots taken; it may get one later + r = self._recs[t.track_id] = _Record(slot, t.track_id, now, dets[t.det_idx]) + elif t.det_idx is not None: + d = dets[t.det_idx] + if dt > 0: + r.vel = r.vel + _VEL_ALPHA * ((t.smoothed_pos - r.centroid) / dt - r.vel) + r.centroid = t.smoothed_pos.copy() + r.target = d.bbox.copy() + r.nearness = d.nearness + else: # missed: drift on the last velocity + step = r.vel * dt + r.centroid = r.centroid + step + r.target = r.target + np.concatenate([step, step]) + r.vel = r.vel * _COAST_DECAY + + state = (BLOB_TENTATIVE if t.seen_frames < cfg.min_confirm_frames + else BLOB_COASTING if t.miss_frames else BLOB_CONFIRMED) + if state != r.state: + r.state, r.since = state, now + + for tid in expired: + r = self._recs.get(tid) + if r is None: + continue + if r.state == BLOB_TENTATIVE: + del self._recs[tid] + else: + r.state, r.since, r.vel = BLOB_LOST, now, np.zeros(2) + self._drop_lost(now) + + def records(self, now: float | None = None) -> np.ndarray: + """(BLOB_ROWS, BLOB_SLOTS, 4) float32 for Renderer.upload_blobs(); call per render frame.""" + now = self._clock() if now is None else now + dt = now - self._last_records if self._last_records is not None else 0.0 + self._last_records = now + ease = self.config.ease_s + k = 1.0 if ease <= 0 else 1.0 - math.exp(-max(dt, 0.0) / ease) + self._drop_lost(now) + + w, h = self._size + out = np.zeros((BLOB_ROWS, BLOB_SLOTS, 4), dtype=np.float32) + for r in self._recs.values(): + r.shown += (r.target - r.shown) * k + r.shown_c += (r.centroid - r.shown_c) * k + out[0, r.slot] = r.shown / (w, h, w, h) + out[1, r.slot] = (r.shown_c[0] / w, r.shown_c[1] / h, r.vel[0] / w, r.vel[1] / h) + out[2, r.slot] = (r.id, r.state, now - r.since, r.nearness) + return out + + def _free_slot(self) -> int | None: + used = {r.slot for r in self._recs.values()} + return next((s for s in range(BLOB_SLOTS) if s not in used), None) + + def _drop_lost(self, now: float) -> None: + hold = self.config.lost_hold_s + for tid in [t for t, r in self._recs.items() + if r.state == BLOB_LOST and now - r.since >= hold]: + del self._recs[tid] diff --git a/ShowControl/FundingCAPTCHA/captcha-settings.json b/ShowControl/FundingCAPTCHA/captcha-settings.json index 3987d5a..148a233 100644 --- a/ShowControl/FundingCAPTCHA/captcha-settings.json +++ b/ShowControl/FundingCAPTCHA/captcha-settings.json @@ -49,7 +49,17 @@ "shader_app": { "_comment": "shader_app.py (ADR-0021). render_scale: fraction of display resolution the passes render at.", "render_scale": 0.5, - "fps": 30 + "fps": 30, + "blobs": { + "_comment": "Blob records for Setups that read the \"blobs\" source (ADR-0022). Pixels are Body pixels; frames are camera frames.", + "min_blob_pixels": 500, + "min_confirm_frames": 4, + "max_miss_frames": 8, + "max_match_dist_px": 80, + "smoothing_alpha": 0.5, + "lost_hold_s": 1.0, + "ease_s": 0.08 + } }, "camera": { diff --git a/ShowControl/FundingCAPTCHA/requirements.txt b/ShowControl/FundingCAPTCHA/requirements.txt index cf4a1de..cd26ae7 100644 --- a/ShowControl/FundingCAPTCHA/requirements.txt +++ b/ShowControl/FundingCAPTCHA/requirements.txt @@ -11,7 +11,7 @@ numpy>=1.24 python-osc>=1.8 Pillow>=9.0 # JPEG fallback for SDL_image builds without JPEG (Pi) moderngl>=5.13 # shader_app.py (ADR-0021) +scipy>=1.10 # shader_app.py blob records (ADR-0022), and the CV pipeline # ── CV pipeline (required only for --camera or --mock-camera) ───────────────── -scipy>=1.10 pyorbbecsdk2 diff --git a/ShowControl/FundingCAPTCHA/shader_app.py b/ShowControl/FundingCAPTCHA/shader_app.py index fa2d24d..6b620e2 100644 --- a/ShowControl/FundingCAPTCHA/shader_app.py +++ b/ShowControl/FundingCAPTCHA/shader_app.py @@ -11,6 +11,10 @@ python3 shader_app.py --test-input --setup ripples # pin one Setup Keys: N / → next Setup · R reload current Setup from disk · Esc quit. + +While the current Setup reads the "blobs" source (ADR-0022), blobs are tracked +in the Body at camera rate — in --test-input, in the painted Body read back +from the GPU — and their records uploaded every render frame. """ from __future__ import annotations @@ -32,6 +36,7 @@ from app import DIR, _camera_thread, load_settings from arc import ShuffleBag +from body_blobs import BlobConfig, BodyBlobs from shadertoy import BodyMouse, Renderer, VERT, js_keycode, load_setup, pack_body try: @@ -43,6 +48,7 @@ log = logging.getLogger("shader_app") SHADER_DIR = DIR / "shaders" +PAINTER_TRACK_S = 0.1 # --test-input: read the painted Body back at camera rate _BLIT_FRAG = """ #version 330 @@ -85,8 +91,9 @@ class Rotation: """ def __init__(self, renderer: Renderer, names: list[str], - shader_dir: Path = SHADER_DIR, clock=time.monotonic) -> None: + shader_dir: Path = SHADER_DIR, clock=time.monotonic, on_load=None) -> None: self.renderer = renderer + self._on_load = on_load # called after every successful load self.names = list(names) self._bag = ShuffleBag(self.names) self._dir = shader_dir @@ -132,6 +139,8 @@ def _load(self, name: str) -> bool: log.error("Setup %s skipped: %s", name, exc) return False self.started = self._clock() + if self._on_load is not None: + self._on_load() return True @@ -197,7 +206,11 @@ def main() -> None: renderer.output_tex.filter = (moderngl.LINEAR, moderngl.LINEAR) # ── Setups ──────────────────────────────────────────────────────────────── - rotation = Rotation(renderer, [args.setup] if args.setup else _setup_names()) + # A load (or R reload) restarts the Setup's buffers, so the tracker restarts + # with them: Setups that remember people by id must not meet ids from before. + blobs = BodyBlobs(BlobConfig.from_dict(cfg.get("blobs", {}))) + rotation = Rotation(renderer, [args.setup] if args.setup else _setup_names(), + on_load=blobs.reset) rotation.next() # ── Camera ──────────────────────────────────────────────────────────────── @@ -220,6 +233,7 @@ def main() -> None: "mock" if args.mock_camera else "Orbbec", standback) body_mouse = BodyMouse(int(settings.get("min_foreground_pixels", 1))) + painter_read_t = 0.0 test_mouse = _TestMouse() mouse = (0.0, 0.0, 0.0, 0.0) @@ -265,12 +279,21 @@ def main() -> None: log.info("Calibration done — Body live") elif msg["type"] == "error": log.error("Camera: %s", msg["msg"]) + uses_blobs = renderer.setup is not None and renderer.setup.uses_blobs if fg is not None: body = pack_body(fg, roi, slabs) renderer.upload_body(body) mouse = body_mouse.update(body, *res) + if uses_blobs: + blobs.update(body[..., 0] > 0, body[..., 1] / 255.0) if args.test_input: mouse = test_mouse.update(win, res) + if uses_blobs and time.monotonic() - painter_read_t >= PAINTER_TRACK_S: + painter_read_t = time.monotonic() + painted = renderer.read_body() + blobs.update(painted[..., 0] > 0.5, painted[..., 1]) + if uses_blobs: + renderer.upload_blobs(blobs.records()) try: renderer.render(dt, mouse) diff --git a/ShowControl/FundingCAPTCHA/shaders/README.md b/ShowControl/FundingCAPTCHA/shaders/README.md index fc62f28..470dae9 100644 --- a/ShowControl/FundingCAPTCHA/shaders/README.md +++ b/ShowControl/FundingCAPTCHA/shaders/README.md @@ -18,7 +18,7 @@ Mouse controls in Buffer A: **hold left button** = a person at the cursor · **h ## Rules (so it runs on the Pi) -- Read the Body with `texture()` and normalised UVs, never `texelFetch` — on the Pi it's camera resolution. +- Read the Body with `texture()` and normalised UVs, never `texelFetch` — on the Pi it's camera resolution. (Blob records are the exception: always `texelFetch`.) - Only `.r` and `.g` of the Body mean anything. On the Pi the camera's square view is stretched to the screen, so people look wider than the painter's. - Buffers are 16-bit float on the Pi (Shadertoy uses 32-bit). Keep stored values small: past ~2048, whole numbers start rounding. Store positions as UVs (0–1), not pixels. - `HW_PERFORMANCE` is 0 on the Pi — the low-quality branch. @@ -27,6 +27,30 @@ Mouse controls in Buffer A: **hold left button** = a person at the cursor · **h - Channel filter/wrap settings are ignored: always linear, clamp. - The Pi renders at half resolution by default. Keep it cheap; avoid long raymarch loops. +## Blobs: boxes, IDs and lifecycle (optional) + +To draw the tracker itself (a box per person, a number that follows them, whether the tracker is sure of them), read the **blobs** source ([ADR-0022](../../../docs/adr/0022-shader-player-blob-records.md)). + +In Shadertoy: paste [`blob_painter.glsl`](blob_painter.glsl) into a spare buffer (D unless you use it). Set that buffer's **iChannel0 = itself**, **iChannel1 = Keyboard**, and don't change it. Bind it to any tab that reads blobs. It tracks the painter's people: the held mouse is one person, and each **Space** parks another. + +Read records with `texelFetch` (the one exception to the rule below), slot 0–7: + +```glsl +vec4 box = texelFetch(iChannel1, ivec2(slot, 0), 0); // x0, y0, x1, y1 Body UV +vec4 mot = texelFetch(iChannel1, ivec2(slot, 1), 0); // cx, cy, vx, vy UV, UV per second +vec4 idn = texelFetch(iChannel1, ivec2(slot, 2), 0); // id, state, age_s, nearness +// state: 0 empty, 1 tentative, 2 confirmed, 3 coasting (missed, drifting), 4 lost (about to go) +// id: 1–999, follows the person · age_s: seconds in this state +``` + +Slots are stable: a person keeps their slot until their record empties, so per-slot state in your own buffers stays theirs. UV is the Body's space, so `box * iResolution.xyxy` lines up with the silhouette. [`blob_debug/`](blob_debug/) is a minimal reader. + +When handing in, don't copy the blob painter tab. In `setup.json`, write `"blobs"` where Shadertoy has that buffer: + +```json +"channels": { "image": ["A", "blobs"] } +``` + ## Hand it in Copy [`ripples/`](ripples/) to `shaders//` and replace: @@ -37,6 +61,7 @@ Copy [`ripples/`](ripples/) to `shaders//` and replace: | Buffer B / C / D | `buffer_b.glsl` … (optional) | | Image | `image.glsl` | | Buffer A | — don't copy; it's the Body | +| The blob painter's buffer | — don't copy; it's `"blobs"` | `setup.json` records the iChannel bindings per tab and how long it plays: @@ -50,7 +75,7 @@ Copy [`ripples/`](ripples/) to `shaders//` and replace: } ``` -Sources: `"A"` (Body), `"B"`, `"C"`, `"D"`, `"keyboard"`, or `null`. List position = iChannel number. +Sources: `"A"` (Body), `"B"`, `"C"`, `"D"`, `"keyboard"`, `"blobs"` (blob records, above), or `null`. List position = iChannel number. Add the folder name to [`setups.json`](setups.json) to put it in the rotation. `make test` compiles every listed Setup. diff --git a/ShowControl/FundingCAPTCHA/shaders/blob_debug/image.glsl b/ShowControl/FundingCAPTCHA/shaders/blob_debug/image.glsl new file mode 100644 index 0000000..47e7531 --- /dev/null +++ b/ShowControl/FundingCAPTCHA/shaders/blob_debug/image.glsl @@ -0,0 +1,44 @@ +// Blob debug — the Body plus every blob record (ADR-0022). +// iChannel0 = Body (Buffer A), iChannel1 = blobs. +// dashed slate = tentative · ink = confirmed · orange, fading = coasting · rust = lost + +const int SLOTS = 8; +const vec3 PAPER = vec3(0.965, 0.945, 0.894), INK = vec3(0.067), + SLATE = vec3(0.443, 0.604, 0.678), ORANGE = vec3(0.910, 0.525, 0.184), + RUST = vec3(0.737, 0.298, 0.149), DEEP = vec3(0.125, 0.463, 0.639); + +vec4 blob(int slot, int row) { return texelFetch(iChannel1, ivec2(slot, row), 0); } + +// Distance (px) from p to the border of the box lo–hi. +float border(vec2 p, vec2 lo, vec2 hi) { + vec2 q = abs(p - (lo + hi) * 0.5) - (hi - lo) * 0.5; + return abs(length(max(q, 0.0)) + min(max(q.x, q.y), 0.0)); +} + +float segment(vec2 p, vec2 a, vec2 b) { + vec2 pa = p - a, ba = b - a; + return length(pa - ba * clamp(dot(pa, ba) / max(dot(ba, ba), 1e-6), 0.0, 1.0)); +} + +void mainImage(out vec4 o, in vec2 fc) { + vec2 uv = fc / iResolution.xy; + vec3 col = mix(PAPER, vec3(0.82), texture(iChannel0, uv).r); + + for (int s = 0; s < SLOTS; s++) { + vec4 box = blob(s, 0), mot = blob(s, 1), idn = blob(s, 2); + float state = idn.y; + if (state < 0.5) continue; + vec2 lo = box.xy * iResolution.xy, hi = box.zw * iResolution.xy; + vec2 cc = mot.xy * iResolution.xy; + + vec3 ink = state < 1.5 ? SLATE : state < 2.5 ? INK : state < 3.5 ? ORANGE : RUST; + float alpha = state > 2.5 && state < 3.5 ? 1.0 - 0.6 * clamp(idn.z, 0.0, 1.0) : 1.0; + float line = 1.0 - smoothstep(1.0, 2.0, border(fc, lo, hi)); + if (state < 1.5) line *= step(mod(fc.x + fc.y, 10.0), 5.0); + col = mix(col, ink, line * alpha); + + col = mix(col, DEEP, 1.0 - smoothstep(1.0, 2.0, segment(fc, cc, cc + mot.zw * iResolution.xy * 0.5))); + col = mix(col, ink, (1.0 - smoothstep(2.5, 3.5, length(fc - cc))) * alpha); + } + o = vec4(col, 1.0); +} diff --git a/ShowControl/FundingCAPTCHA/shaders/blob_debug/setup.json b/ShowControl/FundingCAPTCHA/shaders/blob_debug/setup.json new file mode 100644 index 0000000..c8a2d46 --- /dev/null +++ b/ShowControl/FundingCAPTCHA/shaders/blob_debug/setup.json @@ -0,0 +1,7 @@ +{ + "_comment": "Reference reader for the blobs source (ADR-0022): the Body in grey, each tracked blob's box coloured by state, its centroid and velocity. Not in the rotation; run with --setup blob_debug.", + "duration_s": 60, + "channels": { + "image": ["A", "blobs"] + } +} diff --git a/ShowControl/FundingCAPTCHA/shaders/blob_painter.glsl b/ShowControl/FundingCAPTCHA/shaders/blob_painter.glsl new file mode 100644 index 0000000..36c3ac3 --- /dev/null +++ b/ShowControl/FundingCAPTCHA/shaders/blob_painter.glsl @@ -0,0 +1,108 @@ +// Blob painter — Shadertoy stand-in for the "blobs" source. Do not edit per Setup. +// +// Paste into a spare buffer tab (D unless your Setup uses it) and bind that +// buffer wherever your Setup reads blobs. Don't copy this tab into the Setup +// folder: in setup.json write "blobs" where Shadertoy has that buffer. +// +// This buffer's channels: iChannel0 = this buffer, iChannel1 = Keyboard +// +// It tracks the people body_painter.glsl draws, using the same controls: the +// person under the held mouse is slot 0; each Space press parks a person into +// the next free slot 1–7; C sends everyone to lost. On the show Pi the real +// tracker fills these records instead (ADR-0022). +// +// Records — read with texelFetch(iChannelN, ivec2(slot, row), 0), slot 0–7: +// row 0 x0, y0, x1, y1 bounding box, Body UV (0–1, y up) +// row 1 cx, cy, vx, vy centroid (UV), velocity (UV per second) +// row 2 id, state, age_s, near id 1–999 · state 0 empty, 1 tentative, +// 2 confirmed, 3 coasting, 4 lost · seconds +// in this state · nearness 0 far … 1 near +// Rows 3+ and columns 8+ are this painter's own state. + +const int SLOTS = 8; +const float EMPTY = 0.0, TENTATIVE = 1.0, CONFIRMED = 2.0, COASTING = 3.0, LOST = 4.0; +const float CONFIRM_S = 0.4; // roughly the Pi's defaults at 10 fps +const float COAST_S = 0.8; +const float LOST_S = 1.0; + +const int KEY_SHIFT = 16; +const int KEY_SPACE = 32; +const int KEY_C = 67; + +float key(int code, int row) { return texelFetch(iChannel1, ivec2(code, row), 0).x; } +vec4 rec(int slot, int row) { return texelFetch(iChannel0, ivec2(slot, row), 0); } + +// Box of body_painter.glsl's person at `at` (pixels), in UV. +vec4 personBox(vec2 at, float near) { + float s = mix(0.75, 1.4, near) * iResolution.y; + vec2 lo = at + vec2(-0.075, -0.375) * s; + vec2 hi = at + vec2( 0.075, 0.285) * s; + return clamp(vec4(lo, hi) / iResolution.xyxy, 0.0, 1.0); +} + +void mainImage(out vec4 o, in vec2 fc) { + ivec2 c = ivec2(fc); + o = vec4(0.0); + if (iFrame == 0 || c.y > 3 || c.x > SLOTS) return; + + bool down = iMouse.z > 0.0; + bool clear = key(KEY_C, 1) > 0.5; + float near = key(KEY_SHIFT, 0) > 0.5 ? 1.0 : 0.35; + + // Births this frame: the live person (new press, or held with an empty + // slot 0), then a parked one into the first empty slot. + bool liveBirth = down && !clear && (iMouse.w > 0.0 || rec(0, 2).y == EMPTY); + int parkSlot = -1; + if (key(KEY_SPACE, 1) > 0.5 && !clear) + for (int k = 1; k < SLOTS; k++) + if (rec(k, 2).y == EMPTY) { parkSlot = k; break; } + float nextId = rec(SLOTS, 0).x; + float liveId = nextId; + float parkId = nextId + (liveBirth ? 1.0 : 0.0); + + if (c.x == SLOTS) { // id counter + if (c.y == 0) o.x = nextId + (liveBirth ? 1.0 : 0.0) + (parkSlot >= 0 ? 1.0 : 0.0); + return; + } + + int slot = c.x; + vec4 box = rec(slot, 0), mot = rec(slot, 1), idn = rec(slot, 2); + float state = idn.y, t0 = rec(slot, 3).x; + float dt = max(iTimeDelta, 1e-3); + + bool born = (slot == 0 && liveBirth) || slot == parkSlot; + if (born) { + box = personBox(iMouse.xy, near); + mot = vec4((box.xy + box.zw) * 0.5, 0.0, 0.0); + idn = vec4(mod(slot == 0 ? liveId : parkId, 999.0) + 1.0, TENTATIVE, 0.0, near); + state = TENTATIVE; + t0 = iTime; + } else if (slot == 0 && down && (state == TENTATIVE || state == CONFIRMED)) { + box = personBox(iMouse.xy, near); + vec2 cc = (box.xy + box.zw) * 0.5; + mot = vec4(cc, mix(mot.zw, (cc - mot.xy) / dt, 0.3)); + idn.w = near; + } else if (slot == 0 && !down && state == TENTATIVE) { + state = EMPTY; // never confirmed: gone without a trace + } else if (slot == 0 && !down && state == CONFIRMED) { + state = COASTING; t0 = iTime; + } + + if (state == COASTING) { // drift on the last velocity, slowing down + box += mot.zwzw * dt; + mot.xy += mot.zw * dt; + mot.zw *= exp(-2.0 * dt); + } + if (!born && clear && (state == CONFIRMED || state == COASTING)) { state = LOST; t0 = iTime; } + if (!born && clear && state == TENTATIVE) state = EMPTY; + + float age = iTime - t0; + if (state == TENTATIVE && age >= CONFIRM_S) { state = CONFIRMED; t0 = iTime; } + if (state == COASTING && age >= COAST_S) { state = LOST; t0 = iTime; mot.zw = vec2(0.0); } + if (state == LOST && age >= LOST_S) state = EMPTY; + if (state == EMPTY) return; + + idn.y = state; + idn.z = iTime - t0; + o = c.y == 0 ? box : c.y == 1 ? mot : c.y == 2 ? idn : vec4(t0, 0.0, 0.0, 0.0); +} diff --git a/ShowControl/FundingCAPTCHA/shaders/body_painter.glsl b/ShowControl/FundingCAPTCHA/shaders/body_painter.glsl index 8ba1eea..c93e043 100644 --- a/ShowControl/FundingCAPTCHA/shaders/body_painter.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/body_painter.glsl @@ -29,7 +29,7 @@ float person(vec2 fc, vec2 at, float near) { vec2 a = vec2(0.0, -0.30), b = vec2(0.0, 0.10); float h = clamp(dot(p - a, b - a) / dot(b - a, b - a), 0.0, 1.0); float torso = length(p - a - h * (b - a)) - 0.075; - float head = length(p - vec2(0.0, 0.24)) - 0.06; + float head = length(p - vec2(0.0, 0.225)) - 0.06; // overlaps the torso: one blob, like a person return step(min(torso, head), 0.0); } diff --git a/ShowControl/FundingCAPTCHA/shaders/hunter_bird/buffer_b.glsl b/ShowControl/FundingCAPTCHA/shaders/hunter_bird/buffer_b.glsl new file mode 100644 index 0000000..8353c6e --- /dev/null +++ b/ShowControl/FundingCAPTCHA/shaders/hunter_bird/buffer_b.glsl @@ -0,0 +1,263 @@ +// Buffer B — Bird state. iChannel0 = this buffer, iChannel1 = blobs. +// Layout: see common.glsl. Each texel reads the previous frame of every other texel. + +#define B iChannel0 +#define BLOBS iChannel1 + +// The starting value of every texel: "seconds since" counters start long ago. +vec4 initial(ivec2 c) { + if (c.y == 2 && c.x < SLOTS) return vec4(0.0, 0.0, 0.0, T_CLAMP); // HEAD + if (c.y == 3 && c.x == 0) return vec4(0.0, 0.0, T_CLAMP, 0.0); // ATTN + if (c.y == 3 && c.x == 1) return vec4(0.5, 0.5, RET_R_WANDER, 0.0); // RETICLE + if (c.y == 3 && c.x == 2) return vec4(LID_DROWSY, 0.0, 0.5, 0.5); // MOOD + if (c.y == 3 && c.x == 3) return vec4(T_CLAMP, IDLE_BLINK_MIN, 0.0, 0.0);// BLINK + if (c.y == 3 && c.x == 4) return vec4(0.5, 0.5, 0.0, 0.0); // STAMP + if (c.y == 4 && c.x < SLOTS * PRINTS) return vec4(0.0, 0.0, T_CLAMP, 0.0); // PRINT + return vec4(0.0); +} + +// The record in slot k carries a different person than SLOT k remembers (or none). +bool isFresh(int k) { + vec4 idn = recIdn(BLOBS, k); + return isState(idn.y, S_EMPTY) || abs(idn.x - bSlot(B, k).x) > 0.5; +} + +// ── SLOT ────────────────────────────────────────────────────────────────────── +vec4 slotNext(int k, float dt) { + vec4 idn = recIdn(BLOBS, k); + if (isFresh(k)) return vec4(isState(idn.y, S_EMPTY) ? 0.0 : idn.x, 0.0, 0.0, 0.0); + + vec4 s = bSlot(B, k); + bool confirmed = isState(idn.y, S_CONFIRMED); + + // Motion: speed plus how fast the box's size changes, minus the noise floor. + float target = 0.0; + if (confirmed) { + vec4 box = recBox(BLOBS, k), prev = bPrev(B, k); + float speed = length(toScreen(recMot(BLOBS, k).zw)); + vec2 sz = toScreen(box.zw - box.xy), psz = toScreen(prev.zw - prev.xy); + float grow = abs((sz.x + sz.y) - (psz.x + psz.y)) / BOX_EMA_S; // d/dt of the low-passed size + target = clamp(min(speed / SPEED_FULL, SPEED_CAP) + grow / SIZE_FULL - MOTION_FLOOR, 0.0, 1.0); + } + float tau = target > s.y ? MOTION_RISE_S : MOTION_FALL_S; + s.y += (target - s.y) * (1.0 - exp(-dt / tau)); + + // Stamped: the strike named this id (matched on id, so a reused slot is safe). + vec4 at = bAttn(B); + if (abs(at.w - s.x) < 0.5 && at.z < STRIKE_ACK_S) s.w = 1.0; + + // Interest: rises with motion while confirmed; tentative, coasting and lost drain. + // It settles at the motion level, so whoever moves most can win the bird. + if (confirmed && s.y > MOTION_ON) s.z = min(s.z + dt * INTEREST_GAIN * s.y, max(s.z - dt * INTEREST_DRAIN, s.y)); + else s.z -= dt * INTEREST_DRAIN; + s.z = s.w > 0.5 ? 0.0 : clamp(s.z, 0.0, 1.0); + return s; +} + +// ── Footprints: HEAD and PRINT ──────────────────────────────────────────────── +// HEAD and every PRINT texel of slot k evaluate this on the same inputs, so they agree. +// Here, not in Common: toScreen needs iResolution, which Shadertoy's Common can't see. +bool emitsPrint(vec4 head, vec4 idn, vec4 mot, float dt) { + return isState(idn.y, S_CONFIRMED) + && length(toScreen(mot.zw)) >= PRINT_MIN_SPEED + && head.w + dt >= PRINT_EVERY_S; +} +bool emits(int k, float dt) { + return !isFresh(k) && emitsPrint(bHead(B, k), recIdn(BLOBS, k), recMot(BLOBS, k), dt); +} + +vec4 headNext(int k, float dt) { + if (isFresh(k)) return vec4(0.0, 0.0, 0.0, T_CLAMP); + vec4 h = bHead(B, k); + if (emits(k, dt)) { + vec4 box = recBox(BLOBS, k); + return vec4((box.x + box.z) * 0.5, box.y, 1.0 - h.z, 0.0); + } + h.w = min(h.w + dt, T_CLAMP); + return h; +} + +vec4 printNext(int k, int j, float dt) { + if (emits(k, dt)) { + if (j > 0) { + vec4 p = bPrint(B, k, j - 1); + p.z = min(p.z + dt, T_CLAMP); + return p; + } + vec4 box = recBox(BLOBS, k); + vec2 v = toScreen(recMot(BLOBS, k).zw); + float side = 1.0 - bHead(B, k).z; // the side HEAD takes this frame + vec2 perp = normalize(vec2(-v.y, v.x)) * (side > 0.5 ? 3.0 : -3.0) / 360.0; + vec2 at = vec2((box.x + box.z) * 0.5, box.y - 3.0 / 360.0) + + perp * vec2(iResolution.y / iResolution.x, 1.0); // screen heights → UV + return vec4(at, 0.0, atan(v.y, v.x)); + } + vec4 p = bPrint(B, k, j); + p.z = min(p.z + dt, T_CLAMP); + return p; +} + +// ── Who is there (all from the previous frame's SLOTs and this frame's records) ── +// Can slot k be the bird's target? Confirmed, unstamped, interesting, and the +// SLOT still holds this record's person. +bool candidate(int k) { + vec4 s = bSlot(B, k), idn = recIdn(BLOBS, k); + return isState(idn.y, S_CONFIRMED) && abs(idn.x - s.x) < 0.5 && s.w < 0.5 && s.z > TARGET_MIN; +} +// The current target's blob was missed for a camera frame or two: keep it, so +// mask flicker doesn't hand the bird to a rival (and halve the lock twice). +bool heldTarget(int k) { + vec4 s = bSlot(B, k), idn = recIdn(BLOBS, k); + return isState(idn.y, S_COASTING) && abs(idn.x - s.x) < 0.5 && s.w < 0.5; +} +bool present(int k) { + float st = recIdn(BLOBS, k).y; + return isState(st, S_TENTATIVE) || isState(st, S_CONFIRMED) || isState(st, S_COASTING); +} +// Present and not yet stamped (a new id in a slot is never stamped). +bool unstampedPresent(int k) { + return present(k) && (isFresh(k) || bSlot(B, k).w < 0.5); +} +bool anyPresent() { for (int k = 0; k < SLOTS; k++) if (present(k)) return true; return false; } +bool anyUnstamped() { for (int k = 0; k < SLOTS; k++) if (unstampedPresent(k)) return true; return false; } +// A record carries an id no SLOT holds yet. +bool newId() { + for (int k = 0; k < SLOTS; k++) + if (!isState(recIdn(BLOBS, k).y, S_EMPTY) && isFresh(k)) return true; + return false; +} + +// ── ATTN: target, lock, strike ──────────────────────────────────────────────── +// Every texel that depends on this frame's attention calls this; same inputs, same answer. +vec4 attnNext(float dt) { + vec4 at = bAttn(B); + int cur = int(at.x + 0.5) - 1; + + int best = -1; float bestI = 0.0; + for (int k = 0; k < SLOTS; k++) + if (candidate(k) && bSlot(B, k).z > bestI) { best = k; bestI = bSlot(B, k).z; } + + int tgt = best; + if (cur >= 0 && candidate(cur) && !(best != cur && bestI > bSlot(B, cur).z + TARGET_SWITCH)) + tgt = cur; // keep: no rival beats it by enough + if (cur >= 0 && heldTarget(cur)) tgt = cur; // keep through a dropped frame + float lock = at.y; + if (tgt >= 0 && tgt != cur && cur >= 0) lock *= SWITCH_KEEP; + + // Lock builds on motion in place: a coasting target and a walker don't add to it. + float m = tgt >= 0 && !heldTarget(tgt) ? bSlot(B, tgt).y : 0.0; + m *= tgt >= 0 ? 1.0 - smoothstep(DRIFT_STILL, DRIFT_WALK, length(bDrift(B, tgt).xy)) : 0.0; + if (tgt >= 0 && m > LOCK_ON) lock += dt * LOCK_RATE * m * m; + else lock -= dt * LOCK_DRAIN; + lock = clamp(lock, 0.0, 1.0); + + at.z = min(at.z + dt, T_CLAMP); + if (tgt >= 0 && lock >= 1.0) { // strike + at.z = 0.0; + at.w = recIdn(BLOBS, tgt).x; + lock = 0.0; + } + return vec4(float(tgt + 1), lock, at.z, at.w); +} +bool struck(vec4 at) { return at.z == 0.0; } +int target(vec4 at) { return int(at.x + 0.5) - 1; } + +// ── STAMP ───────────────────────────────────────────────────────────────────── +vec4 stampNext(vec4 at) { + vec4 st = bStamp(B); + for (int k = 0; k < SLOTS; k++) { // follow that id's box while it lives + vec4 idn = recIdn(BLOBS, k); + if (!isState(idn.y, S_EMPTY) && abs(idn.x - at.w) < 0.5 && at.w > 0.5) { + vec4 box = recBox(BLOBS, k); + st.xy = (box.xy + box.zw) * 0.5; + } + } + return st; +} + +// ── RETICLE ─────────────────────────────────────────────────────────────────── +vec4 reticleNext(vec4 at, float dt) { + vec4 r = bReticle(B); + int tgt = target(at), cur = target(bAttn(B)); + vec2 goal = r.xy; float rad = RET_R_WANDER, alpha = 0.0, k = RET_K_WANDER; + if (tgt >= 0) { + vec4 box = recBox(BLOBS, tgt); + goal = vec2((box.x + box.z) * 0.5, box.w - 0.3 * (box.w - box.y)); + k = RET_K_MIN + RET_K_GAIN * bSlot(B, tgt).z; + rad = mix(RET_R_LOOSE, RET_R_LOCKED, at.y); + alpha = 1.0; + } else if (anyUnstamped()) { + vec2 anchor = cur >= 0 ? r.xy : bMood(B).zw; // the anchor is set where the target was lost + float aspect = iResolution.x / iResolution.y; + goal = anchor + vec2(vnoise(iTime * WANDER_RATE) * WANDER_X / aspect, + vnoise(iTime * WANDER_RATE + 40.0) * WANDER_Y); + goal = clamp(goal, vec2(60.0 / 360.0 / aspect, 80.0 / 360.0), + vec2(1.0 - 60.0 / 360.0 / aspect, 1.0 - 60.0 / 360.0)); + alpha = 0.55; + } + r.xy += (goal - r.xy) * (1.0 - exp(-dt * k)); + r.z += (rad - r.z) * (1.0 - exp(-dt * RET_K_RADIUS)); + r.w += (alpha - r.w) * (1.0 - exp(-dt * RET_K_ALPHA)); + return r; +} + +// ── MOOD: lids, shiver, wander anchor ───────────────────────────────────────── +vec4 moodNext(vec4 at, float dt) { + vec4 m = bMood(B); + float lid; + if (at.z < STRIKE_WIDE_S) lid = LID_STRIKE; + else if (at.z < STRIKE_SMUG_S) lid = LID_SMUG; + else if (target(at) >= 0) lid = LID_WIDE; + else if (anyUnstamped()) lid = LID_SQUINT; + else if (anyPresent() && bBlink(B).z < ALL_STAMPED_DROWSY_S) lid = LID_SMUG; + else lid = LID_DROWSY + LID_DROOP * sin(iTime * 0.6); + m.x += (lid - m.x) * (1.0 - exp(-dt * (lid > m.x ? LID_OPEN_RATE : LID_CLOSE_RATE))); + + m.y = newId() ? 1.0 : max(0.0, m.y - dt * SHIVER_DECAY); + if (target(bAttn(B)) >= 0 && target(at) < 0) m.zw = bReticle(B).xy; + return m; +} + +// ── BLINK ───────────────────────────────────────────────────────────────────── +vec4 blinkNext(vec4 at, float dt) { + vec4 b = bBlink(B); + b.x = min(b.x + dt, T_CLAMP); + if (newId()) b.x = -BLINK_NEW_DELAY; + if (struck(at)) b.x = -BLINK_STRIKE_DELAY; + b.y -= dt; + if (b.y <= 0.0) { + if (b.x > BLINK_S) b.x = 0.0; // idle blink, unless one is due + b.y = IDLE_BLINK_MIN + IDLE_BLINK_SPAN * hash11(floor(iTime * 10.0)); + } + b.y = clamp(b.y, 0.0, T_CLAMP); + b.z = anyPresent() && !anyUnstamped() ? min(b.z + dt, T_CLAMP) : 0.0; + return b; +} + +void mainImage(out vec4 o, in vec2 fc) { + ivec2 c = ivec2(fc); + o = vec4(0.0); + if (c.y > 5) return; + if (iFrame == 0) { o = initial(c); return; } + float dt = clamp(iTimeDelta, 1e-3, DT_MAX); + + if (c.x < SLOTS && c.y == 0) o = slotNext(c.x, dt); + else if (c.x < SLOTS && c.y == 1) { // PREV: the box, low-passed + vec4 box = recBox(BLOBS, c.x); + o = isFresh(c.x) ? box : mix(bPrev(B, c.x), box, 1.0 - exp(-dt / BOX_EMA_S)); + } + else if (c.x < SLOTS && c.y == 2) o = headNext(c.x, dt); + else if (c.y == 3 && c.x < 5) { + vec4 at = attnNext(dt); + if (c.x == 0) o = at; + else if (c.x == 1) o = reticleNext(at, dt); + else if (c.x == 2) o = moodNext(at, dt); + else if (c.x == 3) o = blinkNext(at, dt); + else o = stampNext(at); + } + else if (c.y == 4 && c.x < SLOTS * PRINTS) o = printNext(c.x / PRINTS, c.x % PRINTS, dt); + else if (c.x < SLOTS && c.y == 5) { // DRIFT: the velocity, low-passed + vec2 v = isState(recIdn(BLOBS, c.x).y, S_CONFIRMED) ? toScreen(recMot(BLOBS, c.x).zw) : vec2(0.0); + o = isFresh(c.x) ? vec4(0.0) : vec4(mix(bDrift(B, c.x).xy, v, 1.0 - exp(-dt / DRIFT_S)), 0.0, 0.0); + } +} diff --git a/ShowControl/FundingCAPTCHA/shaders/hunter_bird/common.glsl b/ShowControl/FundingCAPTCHA/shaders/hunter_bird/common.glsl new file mode 100644 index 0000000..d53e12a --- /dev/null +++ b/ShowControl/FundingCAPTCHA/shaders/hunter_bird/common.glsl @@ -0,0 +1,253 @@ +// Common — every tunable for the hunter bird lives here (edit, save, press R). +// Lengths are in screen heights unless noted; mockup px / 360 = screen heights. +// Spec: docs/superpowers/specs/2026-11-04-hunter-bird-design.md +// +// Bird state (Buffer B) layout, texel (x, y), k = slot 0–7: +// (k, 0) SLOT id, motion 0–1, interest 0–1, stamped 0/1 +// (k, 1) PREV this slot's box, low-passed over BOX_EMA_S (Body UV) +// (k, 2) HEAD last print x, y, side 0/1, seconds since last print +// (0, 3) ATTN target slot + 1 (0 none), lock 0–1, seconds since strike, id struck last +// (1, 3) RETICLE x, y (screen UV), radius (screen heights), alpha +// (2, 3) MOOD lid openness, shiver 0–1, wander anchor x, y +// (3, 3) BLINK seconds since blink start (negative = scheduled), idle countdown s, +// seconds everyone present has been stamped +// (4, 3) STAMP x, y (screen UV) +// (k, 5) DRIFT this slot's velocity, low-passed over DRIFT_S (screen heights/s), 0, 0 +// (k·PRINTS + j, 4) PRINT x, y (Body UV), age s, angle — j = 0 newest + +const int SLOTS = 8, PRINTS = 12; +const float T_CLAMP = 99.0; // every "seconds since" counter clamps here +const float DT_MAX = 0.25; // a render hiccup longer than this is treated as this +// motion (per confirmed slot) +const float SPEED_FULL = 0.39; // screen heights/s of speed that alone reads as motion 1 +const float SIZE_FULL = 0.61; // screen heights/s of (w + h) change that alone reads as motion 1 +const float MOTION_FLOOR = 0.15; // subtracted: eased-box breathing and flicker live below this +const float SPEED_CAP = 0.7; // the speed term alone stops here: walking never reaches LOCK_ON +const float BOX_EMA_S = 0.15; // PREV is the box low-passed over this; size change is measured against it +const float MOTION_RISE_S = 0.15, MOTION_FALL_S = 0.3; +// interest, target, lock +const float MOTION_ON = 0.2; // above: interest rises; below: drains +const float INTEREST_GAIN = 0.9; // per second, × motion +const float INTEREST_DRAIN = 0.45; // per second +const float TARGET_MIN = 0.25, TARGET_SWITCH = 0.2, SWITCH_KEEP = 0.3; +const float LOCK_ON = 0.65; // lock builds only above this motion: flailing, not walking +// A walker's stride swings the box's size as much as waving arms do, so lock also +// needs the person to stay put: their velocity, low-passed, must be small. A +// flailer's centroid wobbles but averages out; a walker's keeps going. +const float DRIFT_S = 1.5; // s; the velocity low-pass +const float DRIFT_STILL = 0.03; // screen heights/s of drift below which lock builds fully +const float DRIFT_WALK = 0.06; // and above which it doesn't build (a slow walk is ~0.08) +const float LOCK_RATE = 0.7; // per second, × motion² +const float LOCK_DRAIN = 0.3; +const float STRIKE_ACK_S = 0.2; // window in which a SLOT accepts the strike for its id +// reticle +const float RET_R_LOOSE = 58.0/360.0, RET_R_LOCKED = 20.0/360.0, RET_R_WANDER = 66.0/360.0; +const float RET_K_MIN = 3.0, RET_K_GAIN = 6.0, RET_K_WANDER = 1.2; // spring rates, 1/s +const float RET_K_RADIUS = 5.0, RET_K_ALPHA = 3.0; // 1/s +const float WANDER_X = 90.0/360.0, WANDER_Y = 40.0/360.0, WANDER_RATE = 0.6; +// mood (lid openness 0–1) and blinks +const float LID_DROWSY = 0.42, LID_DROOP = 0.05, LID_SQUINT = 0.7, LID_WIDE = 0.96, LID_STRIKE = 1.0, LID_SMUG = 0.58; +const float LID_OPEN_RATE = 9.0, LID_CLOSE_RATE = 3.0; +const float STRIKE_WIDE_S = 0.9, STRIKE_SMUG_S = 3.2, ALL_STAMPED_DROWSY_S = 6.0; +const float SHIVER_DECAY = 1.5; // per second +const float BLINK_S = 0.32, BLINK_NEW_DELAY = 0.15, BLINK_STRIKE_DELAY = 2.3, IDLE_BLINK_MIN = 6.0, IDLE_BLINK_SPAN = 4.0; +// strike sequence +const float PUNCH_ZOOM = 0.38, PUNCH_RISE_S = 0.12, PUNCH_HOLD_S = 0.9, PUNCH_END_S = 1.7; +const float STAMP_SLAM_S = 0.12, STAMP_FADE_START_S = 2.6, STAMP_END_S = 3.2; +// footprints +const float PRINT_EVERY_S = 0.33, PRINT_MIN_SPEED = 25.0/360.0, PRINT_LIFE_S = 4.0; +// eye +const float IRIS_R_OPEN = 345.0/360.0, IRIS_R_LOCKED = 250.0/360.0, IRIS_R_STRIKE = 380.0/360.0, IRIS_BAND = 34.0/360.0; +// line weights +const float INK_W = 2.2/360.0, INK_W_HEAVY = 2.6/360.0, INK_W_LIGHT = 1.6/360.0; + +// ── Palette ─────────────────────────────────────────────────────────────────── +const vec3 PAPER = vec3(0.965, 0.945, 0.894); // #f6f1e4 +const vec3 INK = vec3(0.067); // #111111 +const vec3 SLATE = vec3(0.443, 0.604, 0.678); // #719aad +const vec3 DEEP = vec3(0.125, 0.463, 0.639); // #2076a3 +const vec3 RUST = vec3(0.737, 0.298, 0.149); // #bc4c26 +const vec3 YELLOW = vec3(1.000, 0.949, 0.000); // #fff200 +const vec3 ORANGE = vec3(0.910, 0.525, 0.184); // #e8862f +const vec3 VIOLET = vec3(0.420, 0.357, 0.584); // #6b5b95 +const vec3 WARM_WHITE = vec3(1.000, 0.980, 0.941); // #fffaf0 + +// ── 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; } + +// ── Bird state accessors ────────────────────────────────────────────────────── +vec4 bSlot (sampler2D s, int k) { return texelFetch(s, ivec2(k, 0), 0); } +vec4 bPrev (sampler2D s, int k) { return texelFetch(s, ivec2(k, 1), 0); } +vec4 bHead (sampler2D s, int k) { return texelFetch(s, ivec2(k, 2), 0); } +vec4 bAttn (sampler2D s) { return texelFetch(s, ivec2(0, 3), 0); } +vec4 bReticle(sampler2D s) { return texelFetch(s, ivec2(1, 3), 0); } +vec4 bMood (sampler2D s) { return texelFetch(s, ivec2(2, 3), 0); } +vec4 bBlink (sampler2D s) { return texelFetch(s, ivec2(3, 3), 0); } +vec4 bStamp (sampler2D s) { return texelFetch(s, ivec2(4, 3), 0); } +vec4 bPrint (sampler2D s, int k, int j) { return texelFetch(s, ivec2(k * PRINTS + j, 4), 0); } +vec4 bDrift (sampler2D s, int k) { return texelFetch(s, ivec2(k, 5), 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 hash11(float n) { return fract(sin(n * 127.1 + 311.7) * 43758.5453); } +float vnoise(float x) { // value noise, −1…1 + float i = floor(x), f = fract(x); + return mix(hash11(i), hash11(i + 1.0), f * f * (3.0 - 2.0 * f)) * 2.0 - 1.0; +} + +// ── Drawing helpers (lengths in screen heights) ─────────────────────────────── +float sdSegment(vec2 p, vec2 a, vec2 b) { + vec2 pa = p - a, ba = b - a; + return length(pa - ba * clamp(dot(pa, ba) / max(dot(ba, ba), 1e-9), 0.0, 1.0)); +} +// Coverage of a pen stroke `w` wide at distance `d`, antialiased over one pixel `px`. +float pen(float d, float w, float px) { + w = max(w, px); + return 1.0 - smoothstep(0.5 * (w - px), 0.5 * (w + px), d); +} +vec2 rot(vec2 p, float a) { float c = cos(a), s = sin(a); return vec2(c * p.x + s * p.y, -s * p.x + c * p.y); } +vec2 bezier(vec2 a, vec2 b, vec2 c, float u) { return mix(mix(a, b, u), mix(b, c, u), u); } +// Distance to a quadratic Bézier, via 12 chords (good to well under a pixel here). +float sdBezier(vec2 p, vec2 a, vec2 b, vec2 c) { + float d = 1e9; vec2 q = a; + for (int i = 1; i <= 12; i++) { + vec2 r = bezier(a, b, c, float(i) / 12.0); + d = min(d, sdSegment(p, q, r)); q = r; + } + return d; +} +// Diagonal hatch lines `gap` apart, `w` wide; 1 on a line. +float hatch(vec2 p, float gap, float w, float px) { + float d = (p.x - p.y) * 0.70710678; // "/" lines, as the mockup + return pen(abs(fract(d / gap) - 0.5) * gap, w, px); +} +// A leaf: a lens from two quadratics (0,0)→(L/2, ±0.42L)→(L,0), in leaf space. Signed distance. +float sdLeaf(vec2 q, float L) { + float t = q.x / L; + if (t <= 0.0) return length(q); + if (t >= 1.0) return length(q - vec2(L, 0.0)); + float half_ = 0.84 * L * t * (1.0 - t), slope = 0.84 * (1.0 - 2.0 * t); + return (abs(q.y) - half_) / sqrt(1.0 + slope * slope); +} + +// ── Canopy layout (mockup px / 360, y up) ───────────────────────────────────── +// Branches: quadratic Bézier start, control, end, and width. A negative x is +// measured from the right edge, so the right branch hugs it at any aspect. +const vec2 BR_L0 = vec2(-20.0, 338.0) / 360.0, BR_L1 = vec2(120.0, 330.0) / 360.0, BR_L2 = vec2(270.0, 354.0) / 360.0; +const vec2 BR_R0 = vec2( 20.0, 326.0) / 360.0, BR_R1 = vec2(-100.0, 330.0) / 360.0, BR_R2 = vec2(-220.0, 356.0) / 360.0; +const vec2 PERCH0 = vec2(-30.0, -18.0) / 360.0, PERCH1 = vec2(130.0, 30.0) / 360.0, PERCH2 = vec2(310.0, 8.0) / 360.0; +const float BR_W_L = 8.0 / 360.0, BR_W_R = 7.0 / 360.0, PERCH_W = 17.0 / 360.0, BR_RIM = 5.0 / 360.0; +const float CANOPY_BAND = 1.0 - 80.0 / 360.0; // leaves and branches above this y … +const float CORNER_W = 70.0 / 360.0, CORNER_Y = 1.0 - 180.0 / 360.0; // … in the corner columns above this … +const vec2 PERCH_BOX = vec2(340.0, 66.0) / 360.0; // … and in the perch box at the bottom left +const float LEAF_SWAY = 0.07, LEAF_SHIVER = 0.16; + +const int LEAVES = 29; +// x, y (screen heights; x is from the right edge when LEAF_K.z = 1), angle, length. +// Generated from the mockup's layout with its hash, side leaves thinned to three per side. +const vec4 LEAF[LEAVES] = vec4[LEAVES]( + vec4(0.0233, 1.0129, -1.3093, 0.0980), vec4(0.0836, 0.9815, -2.2006, 0.0903), + vec4(0.2222, 1.0012, -2.1594, 0.0864), vec4(0.3128, 0.9821, -2.3154, 0.0691), + vec4(0.4210, 0.9880, -0.8551, 0.0975), vec4(0.5090, 0.9696, -0.8890, 0.0658), + vec4(0.6323, 0.9761, -1.9491, 0.0934), vec4(0.7288, 0.9895, -2.2288, 0.0679), + vec4(0.8235, 1.0063, -1.8559, 0.0637), vec4(0.9305, 0.9787, -0.8539, 0.0979), + vec4(1.0194, 1.0180, -1.9458, 0.0689), vec4(1.1139, 0.9887, -1.5588, 0.0894), + vec4(1.1892, 1.0210, -1.2196, 0.0653), vec4(1.3298, 0.9991, -1.9469, 0.0764), + vec4(1.3845, 0.9527, -2.2598, 0.0942), vec4(1.5239, 1.0080, -1.7574, 0.0700), + vec4(1.5874, 1.0171, -1.1023, 0.0818), vec4(1.6815, 0.9902, -2.0489, 0.0838), + vec4(1.8303, 0.9914, -1.6160, 0.0802), + vec4(0.0097, 0.8889, -0.4041, 0.0824), vec4(0.0147, 0.7944, 0.3367, 0.0852), + vec4(0.0221, 0.7000, 0.5565, 0.0768), + vec4(-0.0211, 0.9167, -2.5672, 0.0884), vec4(-0.0313, 0.8222, -3.1907, 0.0608), + vec4(-0.0123, 0.7278, -3.3886, 0.0561), + vec4(0.0690, 0.0614, 1.5462, 0.0606), vec4(0.1828, 0.0362, 1.0376, 0.0501), + vec4(0.3173, 0.0674, 1.0927, 0.0652), vec4(0.4428, 0.0626, 2.1848, 0.0709)); +// fill (0 paper, 1 slate, 2 deep, 3 violet), hatched, anchored to the right edge +const vec3 LEAF_K[LEAVES] = vec3[LEAVES]( + vec3(2, 0, 0), vec3(3, 0, 0), vec3(0, 0, 0), vec3(1, 1, 0), vec3(2, 0, 0), vec3(0, 0, 0), + vec3(2, 1, 0), vec3(0, 0, 0), vec3(0, 0, 0), vec3(1, 0, 0), vec3(0, 1, 0), vec3(0, 0, 0), + vec3(1, 0, 0), vec3(1, 0, 0), vec3(0, 0, 0), vec3(1, 1, 0), vec3(0, 1, 0), vec3(0, 1, 0), + vec3(0, 0, 0), vec3(0, 1, 0), vec3(0, 1, 0), vec3(0, 0, 0), + vec3(3, 0, 1), vec3(1, 0, 1), vec3(0, 1, 1), + vec3(2, 0, 0), vec3(0, 0, 0), vec3(0, 0, 0), vec3(0, 0, 0)); +vec3 leafFill(float k) { return k < 0.5 ? PAPER : k < 1.5 ? SLATE : k < 2.5 ? DEEP : VIOLET; } + +// ── Field notes ─────────────────────────────────────────────────────────────── +const float COAST_FADE = 0.8; // s; a look, not a contract: ≈ the tracker's max_miss_frames at + // 10 fps (if that changes, a box just reaches its alpha floor sooner or later) +const float LOST_FADE_START = 0.4, LOST_FADE_S = 0.5, SCRIBBLE_S = 0.25; +const float DASH_ON = 4.0 / 360.0, DASH_OFF = 5.0 / 360.0, DASH_CRAWL = 20.0 / 360.0; // tentative +const float ARROW_MIN_SPEED = 22.0 / 360.0, ARROW_S = 0.35; +const float NOTE_REACH = 60.0 / 360.0; // tag and glyphs reach this far beyond a box + +// ── Glyphs ──────────────────────────────────────────────────────────────────── +// Glyph space is the mockup's: pixels, y down. Time `t` is passed in, since +// Shadertoy's Common can't see iTime. + +// Distance to a hand-drawn segment: the line wobbles sideways by up to `amp`. +float sdWobble(vec2 p, vec2 a, vec2 b, float seed, float amp, float t) { + vec2 ba = b - a; + float u = clamp(dot(p - a, ba) / max(dot(ba, ba), 1e-9), 0.0, 1.0); + vec2 n = normalize(vec2(-ba.y, ba.x) + 1e-9); + float taper = mix(0.4, 1.0, smoothstep(0.0, 0.15, min(u, 1.0 - u))); + return length(p - (a + ba * u + n * vnoise(seed + u * 3.0 + t * 1.1) * amp * taper)); +} + +// Seven-segment digit `d` in a 6 × 10 box at the origin, wobbling by ±0.5. +const int DIGIT_SEGS[10] = int[10](63, 6, 91, 79, 102, 109, 125, 7, 127, 111); // bits a…g +float sdDigit(vec2 q, int d, float seed, float t) { + vec4 S[7] = vec4[7](vec4(0, 0, 6, 0), vec4(6, 0, 6, 5), vec4(6, 5, 6, 10), vec4(0, 10, 6, 10), + vec4(0, 5, 0, 10), vec4(0, 0, 0, 5), vec4(0, 5, 6, 5)); + int m = DIGIT_SEGS[d]; + float r = 1e9; + for (int i = 0; i < 7; i++) + if (((m >> i) & 1) == 1) r = min(r, sdWobble(q, S[i].xy, S[i].zw, seed + float(i) * 7.0, 0.5, t)); + return r; +} +// An id 1–999 as digits 8.5 apart, from the origin. +float sdNumber(vec2 q, float id, float t) { + int n = int(id + 0.5); + int nd = n >= 100 ? 3 : n >= 10 ? 2 : 1; + float r = 1e9; + for (int k = 0; k < 3; k++) { + if (k >= nd) break; + int p10 = nd - 1 - k == 2 ? 100 : nd - 1 - k == 1 ? 10 : 1; + int dg = (n / p10) % 10; + r = min(r, sdDigit(q - vec2(8.5 * float(k), 0.0), dg, float(k) * 17.0, t)); + } + return r; +} + +// "?" 12 tall: a hook, a stem and a dot, with its foot at the origin. Returns +// the stroke distance; the dot is folded in as a filled disc. +float sdQuestion(vec2 q) { + vec2 c = vec2(0.0, -8.6); float r = 3.4; + vec2 d = q - c; + float a = atan(-d.y, d.x); // y down: angle as on screen + float arc = (a > -1.5708 && a < 2.6) ? abs(length(d) - r) + : min(length(q - (c + r * vec2(cos(2.6), -sin(2.6)))), length(q - (c + vec2(0.0, r)))); + float stem = sdSegment(q, vec2(0.0, -5.2), vec2(0.0, -3.2)); + return min(min(arc, stem), max(length(q - vec2(0.0, -0.6)) - 0.6, 0.0)); +} + +// A tick ✓ about the origin, 20 wide: the mockup's stamp tick. +float sdTick(vec2 q) { + return min(sdSegment(q, vec2(-14.0, 1.0), vec2(-4.0, 12.0)), sdSegment(q, vec2(-4.0, 12.0), vec2(16.0, -12.0))); +} + +// ── Eye (mockup px, y down, centred on the screen) ──────────────────────────── +const float LID_SPAN = 0.76; // × screen width: where the lid curve meets the eye's corners +const float LID_TOP = 0.64, LID_BOT = 0.6; +const float LID_RING_W = 5.0, LID_RING_IN = 4.0, LID_RIM_W = 3.0; +const float SCALLOP_R = 9.0, SCALLOP_EVERY = 22.0, SCALLOP_IN = 22.0; +const float HATCH_GAP = 7.07 / 360.0, HATCH_W = 1.4 / 360.0, EYE_HATCH = 0.55; +const int STRIATIONS = 110; +const float MEMBRANE_ALPHA = 0.82, MEMBRANE_STREAK_GAP = 46.0; +const int MEMBRANE_STREAKS = 7; diff --git a/ShowControl/FundingCAPTCHA/shaders/hunter_bird/image.glsl b/ShowControl/FundingCAPTCHA/shaders/hunter_bird/image.glsl new file mode 100644 index 0000000..e94c8bf --- /dev/null +++ b/ShowControl/FundingCAPTCHA/shaders/hunter_bird/image.glsl @@ -0,0 +1,419 @@ +// Image — the bird's view. iChannel0 = Body (A), iChannel1 = bird state (B), iChannel2 = blobs. +// Back to front: world (zooms on a strike) → field notes → attention → the eye (screen space). +// Positions: `p` is in screen heights (x 0…aspect, y 0…1); records and Body are in UV. + +#define BODY iChannel0 +#define B iChannel1 +#define BLOBS iChannel2 + +float PX; // one pixel, in screen heights +float ASPECT; + +vec2 uvToP(vec2 uv) { return uv * vec2(ASPECT, 1.0); } +vec2 pToUv(vec2 p) { return p / vec2(ASPECT, 1.0); } +vec2 anchorX(vec2 q) { return vec2(q.x < 0.0 ? ASPECT + q.x : q.x, q.y); } // negative x: from the right edge +vec2 anchorR(vec2 q) { return vec2(ASPECT + q.x, q.y); } + +// Paint `c` over `col` with coverage `a`. +void over(inout vec3 col, vec3 c, float a) { col = mix(col, c, clamp(a, 0.0, 1.0)); } + +// ── World ───────────────────────────────────────────────────────────────────── +float present(vec2 uv) { return step(0.5, texture(BODY, uv).r); } + +void drawGround(inout vec3 col, vec2 p) { + if (p.y > 70.0 / 360.0) return; + float x = p.x * 360.0; + vec4 Y = vec4(60.0, 44.0, 22.0, -2.0), A = vec4(2.0, 2.5, 3.0, 3.5); // mockup rows 300…362, y up + for (int i = 0; i < 4; i++) { + float y = (Y[i] - sin(x * 0.035 + (360.0 - Y[i])) * A[i]) / 360.0; + over(col, SLATE, pen(abs(p.y - y), 1.3 / 360.0, PX)); + } +} + +void drawPrints(inout vec3 col, vec2 p) { + for (int i = 0; i < SLOTS * PRINTS; i++) { + vec4 pr = texelFetch(B, ivec2(i, 4), 0); + if (pr.z >= PRINT_LIFE_S) { // ages grow with j: the rest of this slot is dead + i = (i / PRINTS + 1) * PRINTS - 1; + continue; + } + vec2 q = p - uvToP(pr.xy); + if (dot(q, q) > 36.0 / (360.0 * 360.0)) continue; // early-out: 6 px + q = rot(q, pr.w) / vec2(4.5 / 360.0, 2.6 / 360.0); + float d = (length(q) - 1.0) * 2.6 / 360.0; + over(col, SLATE, 0.85 * (1.0 - pr.z / PRINT_LIFE_S) * (1.0 - smoothstep(-0.5 * PX, 0.5 * PX, d))); + } +} + +void drawPeople(inout vec3 col, vec2 p, vec2 uv) { + vec4 body = texture(BODY, uv); + float in_ = step(0.5, body.r); + // Outline: outside the silhouette, within a heavy pen's width of it. + float r = max(INK_W_HEAVY, PX); + float edge = 0.0; + for (int i = 0; i < 8; i++) { + float a = float(i) * 0.78539816; + edge = max(edge, present(pToUv(p + r * vec2(cos(a), sin(a))))); + } + if (in_ < 0.5) { over(col, INK, edge); return; } + col = WARM_WHITE; + // Hatching on the lower 40% of any tracked box that holds this pixel, denser when near. + for (int k = 0; k < SLOTS; k++) { + if (isState(recIdn(BLOBS, k).y, S_EMPTY)) continue; + vec4 box = recBox(BLOBS, k); + if (uv.x < box.x || uv.x > box.z || uv.y < box.y || uv.y > box.y + 0.4 * (box.w - box.y)) continue; + float gap = mix(11.0, 6.0, body.g) / 360.0; + over(col, SLATE, 0.8 * hatch(p, gap, 1.4 / 360.0, PX)); + break; + } +} + +void drawBranch(inout vec3 col, vec2 p, vec2 a, vec2 b, vec2 c, float w) { + float d = sdBezier(p, a, b, c); + if (d > w) return; + over(col, INK, pen(d, w + BR_RIM, PX)); + over(col, PAPER, pen(d, w, PX)); + for (int i = 0; i < 13; i++) { // short bark strokes along it + vec2 q = bezier(a, b, c, 0.05 + 0.07 * float(i)); + over(col, INK, pen(sdSegment(p, q + vec2(-0.25, 0.15) * w, q + vec2(0.15, -0.25) * w), 1.2 / 360.0, PX)); + } +} + +void drawLeaf(inout vec3 col, vec2 p, int i, float shiver) { + vec4 l = LEAF[i]; vec3 k = LEAF_K[i]; + vec2 at = k.z > 0.5 ? anchorR(l.xy) : l.xy; + float L = l.w; + if (length(p - at) > L + 3.0 * PX) return; + float fi = float(i); + float a = l.z + LEAF_SWAY * sin(iTime * 0.9 + fi * 1.7) + shiver * LEAF_SHIVER * sin(iTime * 28.0 + fi); + vec2 q = rot(p - at, a); + float d = sdLeaf(q, L); + float fill = 1.0 - smoothstep(-0.5 * PX, 0.5 * PX, d); + over(col, leafFill(k.x), fill); + if (k.y > 0.5) over(col, SLATE, fill * hatch(p, 10.0 / 360.0, 1.4 / 360.0, PX)); + over(col, INK, pen(abs(d), 1.8 / 360.0, PX)); + // midrib and two pairs of veins + float v = sdSegment(q, vec2(0.0), vec2(0.85 * L, 0.0)); + for (int j = 0; j < 2; j++) { + float u = j == 0 ? 0.35 : 0.6; + v = min(v, sdSegment(q, vec2(u * L, 0.0), vec2((u + 0.14) * L, -0.16 * L))); + v = min(v, sdSegment(q, vec2(u * L, 0.0), vec2((u + 0.14) * L, 0.16 * L))); + } + over(col, INK, fill * pen(v, 1.0 / 360.0, PX)); +} + +void drawCanopy(inout vec3 col, vec2 p, float shiver) { + bool band = p.y > CANOPY_BAND; + bool corner = p.y > CORNER_Y && (p.x < CORNER_W || p.x > ASPECT - CORNER_W); + bool perch = p.x < PERCH_BOX.x && p.y < PERCH_BOX.y; + if (!band && !corner && !perch) return; // early-out: most of the screen + if (band) { + drawBranch(col, p, BR_L0, BR_L1, BR_L2, BR_W_L); + drawBranch(col, p, anchorR(BR_R0), anchorR(BR_R1), anchorR(BR_R2), BR_W_R); + } + if (perch) drawBranch(col, p, PERCH0, PERCH1, PERCH2, PERCH_W); + for (int i = 0; i < LEAVES; i++) drawLeaf(col, p, i, shiver); +} + +vec3 world(vec2 p, float shiver) { + vec2 uv = pToUv(p); + vec3 col = PAPER; + drawGround(col, p); + drawPrints(col, p); + drawPeople(col, p, uv); + drawCanopy(col, p, shiver); + return col; +} + + +// ── Field notes (world space, over the canopy) ──────────────────────────────── +const float G = 1.0 / 360.0; // one glyph-space unit (mockup px), in screen heights + +// Glyph space at `origin`: mockup pixels, y down. +vec2 glyph(vec2 p, vec2 origin) { return (p - origin) * vec2(360.0, -360.0); } + +// Distance along a box's border, for dashes: runs round the rim. +float rimCoord(vec2 p, vec2 lo, vec2 hi) { + vec2 c = (lo + hi) * 0.5, h = (hi - lo) * 0.5, q = p - c; + return abs(q.x) / h.x > abs(q.y) / h.y ? (q.x > 0.0 ? h.x * 2.0 + (h.y - q.y) : h.x * 4.0 + h.y * 2.0 + (q.y + h.y)) + : (q.y > 0.0 ? q.x + h.x : h.x * 2.0 + h.y * 2.0 + (h.x - q.x)); +} +float sdRim(vec2 p, vec2 lo, vec2 hi) { + vec2 q = abs(p - (lo + hi) * 0.5) - (hi - lo) * 0.5; + return abs(length(max(q, 0.0)) + min(max(q.x, q.y), 0.0)); +} + +// The eight arms of the corner brackets, wobbling. +float sdBrackets(vec2 p, vec2 lo, vec2 hi, float id) { + vec2 sz = hi - lo; + float L = clamp(0.22 * min(sz.x, sz.y), 10.0 * G, 24.0 * G); + float d = 1e9; + for (int k = 0; k < 4; k++) { + vec2 c = vec2(k % 2 == 0 ? lo.x : hi.x, k < 2 ? hi.y : lo.y); + vec2 s = vec2(k % 2 == 0 ? 1.0 : -1.0, k < 2 ? -1.0 : 1.0); + float sd = id * 31.0 + float(k) * 7.0; + d = min(d, sdWobble(p, c, c + vec2(s.x * L, 0.0), sd, 1.2 * G, iTime)); + d = min(d, sdWobble(p, c, c + vec2(0.0, s.y * L), sd + 3.0, 1.2 * G, iTime)); + } + return d; +} + +void drawQuestion(inout vec3 col, vec2 p, vec2 foot, float scale, vec3 ink, float alpha) { + vec2 q = glyph(p, foot) / scale; + if (dot(q, q) > 400.0) return; + over(col, ink, alpha * pen(sdQuestion(q) * scale * G, 1.9 * scale * G, PX)); +} + +void drawTag(inout vec3 col, vec2 p, vec2 corner, vec4 mot, float id, bool isTarget, bool stamped) { + float sw = clamp(-mot.z * ASPECT * 360.0 * 0.003, -0.5, 0.5) + sin(iTime * 2.6 + id) * 0.08; + vec2 q = rot(glyph(p, corner + vec2(-2.0, -4.0) * G), -sw); // y down: canvas rotate(sw) + float n = id >= 100.0 ? 3.0 : id >= 10.0 ? 2.0 : 1.0; + float tw = 12.0 + 9.0 * n; + if (q.x < -4.0 || q.x > tw + 26.0 || q.y < -4.0 || q.y > 34.0) return; + // string + over(col, INK, pen(sdBezier(q, vec2(0.0), vec2(4.0, 8.0), vec2(8.0, 12.0)) * G, 1.2 * G, PX)); + vec2 t = q - vec2(8.0, 12.0); + // body: a pentagon (0,0) (tw,0) (tw,17) (5,17) (0,12) + float e = max(max(-t.x, t.x - tw), max(-t.y, t.y - 17.0)); + e = max(e, (t.x * -0.7071 + (t.y - 12.0) * 0.7071) - 3.5355 * 0.0); // the clipped corner + float edge = min(min(sdSegment(t, vec2(0.0), vec2(tw, 0.0)), sdSegment(t, vec2(tw, 0.0), vec2(tw, 17.0))), + min(min(sdSegment(t, vec2(tw, 17.0), vec2(5.0, 17.0)), sdSegment(t, vec2(5.0, 17.0), vec2(0.0, 12.0))), + sdSegment(t, vec2(0.0, 12.0), vec2(0.0)))); + float inside = step(e, 0.0); + over(col, isTarget ? YELLOW : PAPER, inside); + over(col, INK, pen(edge * G, 1.6 * G, PX)); + over(col, INK, pen(abs(length(t - vec2(4.0)) - 1.6) * G, 1.0 * G, PX)); // the hole + over(col, INK, pen(sdNumber(t - vec2(9.0, 3.5), id, iTime) * G, 1.8 * G, PX)); + if (stamped) over(col, RUST, pen(sdTick((t - vec2(tw + 8.0, 9.0)) * 2.4) / 2.4 * G, 2.6 * G, PX)); +} + +void drawArrow(inout vec3 col, vec2 p, vec2 from, vec2 vel, float id) { + vec2 to = from + vel * ARROW_S; + vec2 dir = normalize(vel); + float d = sdWobble(p, from, to, id * 13.0, 0.8 * G, iTime); + vec2 a1 = to + 8.0 * G * rot(dir, -2.6), a2 = to + 8.0 * G * rot(dir, 2.6); + d = min(d, min(sdSegment(p, to, a1), sdSegment(p, to, a2))); + over(col, DEEP, pen(d, 2.0 * G, PX)); +} + +void drawNotes(inout vec3 col, vec2 p, int tgt) { + for (int k = 0; k < SLOTS; k++) { + vec4 idn = recIdn(BLOBS, k); + float st = idn.y; + if (isState(st, S_EMPTY)) continue; + vec4 box = recBox(BLOBS, k), mot = recMot(BLOBS, k); + vec2 lo = uvToP(box.xy), hi = uvToP(box.zw); + vec2 vel = uvToP(mot.zw); + float reach = NOTE_REACH + length(vel) * ARROW_S; + if (any(lessThan(p, lo - reach)) || any(greaterThan(p, hi + reach))) continue; // early-out + float age = idn.z; + + if (isState(st, S_TENTATIVE)) { + float dash = step(mod(rimCoord(p, lo, hi) + iTime * DASH_CRAWL, DASH_ON + DASH_OFF), DASH_ON); + over(col, SLATE, dash * pen(sdRim(p, lo, hi), INK_W_LIGHT, PX)); + drawQuestion(col, p, vec2(hi.x - 2.0 * G, hi.y + 4.0 * G), 1.0, SLATE, 1.0); + continue; + } + bool coasting = isState(st, S_COASTING), lost = isState(st, S_LOST), isT = k == tgt; + float alpha = coasting ? clamp(1.0 - age / COAST_FADE, 0.15, 1.0) + : lost ? clamp(1.0 - (age - LOST_FADE_START) / LOST_FADE_S, 0.0, 1.0) : 1.0; + if (alpha <= 0.0) continue; + float w = isT ? INK_W_HEAVY : INK_W_LIGHT; + float a = alpha * (isT || coasting ? 1.0 : 0.75); + float br = pen(sdBrackets(p, lo, hi, idn.x), w, PX); + if (coasting) br *= step(mod(rimCoord(p, lo, hi), 7.0 * G), 3.0 * G); + over(col, INK, a * br); + + if (!lost && length(vel) > ARROW_MIN_SPEED) drawArrow(col, p, uvToP(mot.xy), vel, idn.x); + vec3 before = col; + drawTag(col, p, hi, mot, idn.x, isT, bSlot(B, k).w > 0.5 && abs(bSlot(B, k).x - idn.x) < 0.5); + col = mix(before, col, alpha); + if (coasting) drawQuestion(col, p, vec2(hi.x + 8.0 * G, hi.y - 14.0 * G), 1.1, INK, alpha); + if (lost) { // scribbled through, drawn over 0.25 s + float u = clamp(age / SCRIBBLE_S, 0.0, 1.0), h = hi.y - lo.y, d = 1e9; + vec2 prev = vec2(lo.x, hi.y - 0.1 * h); + for (int i = 1; i <= 7; i++) { + if (float(i) > floor(7.0 * u)) break; + vec2 nxt = vec2(i % 2 == 1 ? hi.x : lo.x, hi.y - h * (0.1 + 0.8 * float(i) / 7.0)); + d = min(d, sdSegment(p, prev, nxt)); prev = nxt; + } + over(col, INK, alpha * pen(d, INK_W, PX)); + } + } +} + +// ── Attention (world space) ─────────────────────────────────────────────────── +void drawReticle(inout vec3 col, vec2 p, vec4 ret, float lock, bool hasTarget) { + if (ret.w < 0.02) return; + float tremble = lock * 1.4 * G; + vec2 c = uvToP(ret.xy) + vec2(sin(iTime * 41.0), cos(iTime * 37.0)) * tremble; + float r = ret.z; + vec2 q = p - c; + float len = length(q); + if (len > r + 16.0 * G) return; + float ang = atan(q.y, q.x); + float ring = abs(len - (r + vnoise(ang / 6.2831853 * 48.0 * 0.7 + iTime * 1.5) * 1.4 * G)); + float d = ring; + for (int k = 0; k < 4; k++) { + vec2 dir = rot(vec2(1.0, 0.0), -(float(k) * 1.5707963 + 0.7853982)); + d = min(d, sdWobble(p, c + dir * (r - 6.0 * G), c + dir * (r + 9.0 * G), float(k) * 5.0, 0.6 * G, iTime)); + } + float a = pen(d, INK_W, PX); + // lock arc, clockwise from 12 o'clock + float cw = mod(atan(q.x, q.y), 6.2831853); + if (lock > 0.01 && cw < lock * 6.2831853) a = max(a, pen(abs(len - (r + 7.0 * G)), 5.0 * G, PX)); + over(col, RUST, ret.w * a); + if (!hasTarget) drawQuestion(col, p, c + vec2(0.0, -9.0 * G), 2.0, RUST, ret.w); +} + +void drawStamp(inout vec3 col, vec2 p, vec2 at, float since) { + if (since >= STAMP_END_S) return; + float s = since < STAMP_SLAM_S ? 2.4 - 1.4 * since / STAMP_SLAM_S : 1.0; + float alpha = 0.92 * (since < STAMP_FADE_START_S ? 1.0 : 1.0 - (since - STAMP_FADE_START_S) / (STAMP_END_S - STAMP_FADE_START_S)); + vec2 q = rot(glyph(p, at), 0.22) / s; // y down: canvas rotate(−0.22) + float len = length(q); + if (len > 56.0) return; + float k = s * G; // glyph units → screen heights + float a = pen(abs(len - 36.0) * k, 4.5 * k, PX); + a = max(a, pen(abs(len - 28.0) * k, 1.6 * k, PX)); + float an = atan(q.y, q.x), sector = 6.2831853 / 12.0; + float ra = (floor(an / sector + 0.5)) * sector; + vec2 dir = vec2(cos(ra), sin(ra)); + a = max(a, pen(sdSegment(q, dir * 42.0, dir * 50.0) * k, 1.6 * k, PX)); + a = max(a, pen(sdTick(q) * k, 7.0 * k, PX)); + over(col, RUST, alpha * a); +} + + +// ── The eye (screen space, never zooms) ─────────────────────────────────────── +// Mockup pixels, y down: X across the screen, Y down from the top, eye centre C. +struct Eye { vec2 q, c; float W, pxp; }; + +Eye eyeAt(vec2 p) { + Eye e; + e.q = vec2(p.x, 1.0 - p.y) * 360.0; + e.W = ASPECT * 360.0; + e.c = vec2(e.W, 360.0) * 0.5; + e.pxp = PX * 360.0; + return e; +} + +float irisR(float lock, float since) { + float r = mix(IRIS_R_OPEN, IRIS_R_LOCKED, smoothstep(0.0, 1.0, lock)); + float dil = since < STRIKE_WIDE_S ? 1.0 - since / STRIKE_WIDE_S : 0.0; + return mix(r, IRIS_R_STRIKE, dil) * 360.0; +} + +// Lid rims (y down) and their slopes at X. +vec4 lidRims(Eye e, float x, float open) { + float wx = e.W * LID_SPAN, u = (x - e.c.x) / wx; + float prof = max(0.0, 1.0 - u * u), dprof = prof > 0.0 ? -2.0 * u / wx : 0.0; + float top = e.c.y - 360.0 * LID_TOP * open * prof + sin(x * 0.05) * 1.2; + float bot = e.c.y + 360.0 * LID_BOT * open * prof + sin(x * 0.05 + 2.0) * 1.2; + float sT = -360.0 * LID_TOP * open * dprof + 0.06 * cos(x * 0.05); + float sB = 360.0 * LID_BOT * open * dprof + 0.06 * cos(x * 0.05 + 2.0); + return vec4(top, bot, sT, sB); +} + +// How much of this pixel the iris socket and lids hide (1 = the world can't show through). +float eyeCover(vec2 p, float open, float lock, float since) { + Eye e = eyeAt(p); + float Ri = irisR(lock, since); + float r = length(e.q - e.c); + float a = smoothstep(Ri - 2.0 * e.pxp, Ri - 1.0 * e.pxp, r); + vec4 rim = lidRims(e, e.q.x, open); + float out_ = max(rim.x - e.q.y, e.q.y - rim.y); // > 0 inside a lid (y down: top < bottom) + return max(a, step(2.0 * e.pxp, out_)); // well inside, past the rim's antialiasing +} + +void drawIris(inout vec3 col, vec2 p, float lock, float since) { + Eye e = eyeAt(p); + float Ri = irisR(lock, since), band = IRIS_BAND * 360.0; + vec2 d = e.q - e.c; + float r = length(d); + if (r < Ri - 3.0) return; + float px = e.pxp; + vec3 c = r < Ri + band ? YELLOW : mix(PAPER, SLATE, EYE_HATCH * hatch(p, HATCH_GAP, HATCH_W, PX)); + over(col, c, smoothstep(Ri - 0.5 * px, Ri + 0.5 * px, r)); + if (r < Ri + band + 2.0) { // short ink striations + float n = float(STRIATIONS); + float i = mod(floor(atan(d.y, d.x) / 6.2831853 * n + 0.5), n); + float ai = i / n * 6.2831853 + hash11(i) * 0.02; + float l = 8.0 + hash11(i + 7.0) * (band - 12.0); + vec2 dir = vec2(cos(ai), sin(ai)); + over(col, INK, pen(sdSegment(e.q, e.c + dir * (Ri + 3.0), e.c + dir * (Ri + l)), 1.2, px)); + } + over(col, INK, pen(abs(r - Ri), 3.0, px)); + over(col, INK, pen(abs(r - Ri - band), 2.0, px)); +} + +// One lid. dir −1 = top (the lid lies above its rim), +1 = bottom. +void drawLid(inout vec3 col, vec2 p, Eye e, float open, float dir) { + vec4 rim = lidRims(e, e.q.x, open); + float y = dir < 0.0 ? rim.x : rim.y, s = dir < 0.0 ? rim.z : rim.w; + float sd = dir * (y - e.q.y) / sqrt(1.0 + s * s); // < 0 inside the lid + float px = e.pxp; + if (sd > LID_RIM_W) return; + float fill = smoothstep(0.5 * px, -0.5 * px, sd); + over(col, mix(PAPER, SLATE, EYE_HATCH * hatch(p, HATCH_GAP, HATCH_W, PX)), fill); + over(col, YELLOW, fill * pen(abs(sd + LID_RING_IN), LID_RING_W, px)); + // feather scallops along the crease: half-circles cupped toward the rim + float xn = 4.0 + SCALLOP_EVERY * floor((e.q.x - 4.0) / SCALLOP_EVERY + 0.5); + vec4 rn = lidRims(e, xn, open); + vec2 sc = vec2(xn, (dir < 0.0 ? rn.x : rn.y) + dir * SCALLOP_IN); + vec2 v = e.q - sc; + float arc = -dir * v.y >= 0.0 ? abs(length(v) - SCALLOP_R) + : min(length(v - vec2(SCALLOP_R, 0.0)), length(v + vec2(SCALLOP_R, 0.0))); + over(col, INK, fill * pen(arc, 1.4, px)); + over(col, INK, pen(abs(sd), LID_RIM_W, px)); +} + +void drawMembrane(inout vec3 col, vec2 p, float since) { + if (since < 0.0 || since > BLINK_S) return; + Eye e = eyeAt(p); + float u = since / BLINK_S; + float ex = -30.0 + sin(3.14159265 * u) * (e.W + 90.0); + float edge = ex + (e.q.y - 180.0) * 0.28 + sin(e.q.y * 0.04 + u * 4.0) * 9.0; + float sd = e.q.x - edge, px = e.pxp; + if (sd > 3.0) return; + over(col, PAPER, MEMBRANE_ALPHA * smoothstep(0.5 * px, -0.5 * px, sd)); + float k = floor(-sd / MEMBRANE_STREAK_GAP + 0.5); // nearest streak behind the edge + if (k >= 1.0 && k <= float(MEMBRANE_STREAKS)) + over(col, SLATE, 0.5 * pen(abs(sd + k * MEMBRANE_STREAK_GAP), 1.0, px)); + over(col, INK, pen(abs(sd), 2.2, px)); +} + +// ── Strike punch-in ─────────────────────────────────────────────────────────── +float punch(float since) { + if (since < PUNCH_RISE_S) return smoothstep(0.0, 1.0, since / PUNCH_RISE_S); + if (since < PUNCH_HOLD_S) return 1.0; + return 1.0 - smoothstep(0.0, 1.0, (since - PUNCH_HOLD_S) / (PUNCH_END_S - PUNCH_HOLD_S)); +} + +void mainImage(out vec4 o, in vec2 fc) { + float px = 1.0 / iResolution.y; + ASPECT = iResolution.x / iResolution.y; + vec2 p = fc / iResolution.y; + vec4 at = bAttn(B), mood = bMood(B); + int tgt = int(at.x + 0.5) - 1; + + vec3 col = PAPER; + PX = px; + if (eyeCover(p, mood.x, at.y, at.z) < 1.0) { // skip the world where the eye hides it + vec2 sp = uvToP(bStamp(B).xy); + float zoom = 1.0 + PUNCH_ZOOM * punch(at.z); + vec2 wp = sp + (p - sp) / zoom; // world point under this pixel + PX = px / zoom; + col = world(wp, mood.y); + drawNotes(col, wp, tgt); + drawReticle(col, wp, bReticle(B), at.y, tgt >= 0); + drawStamp(col, wp, sp, at.z); + } + PX = px; + drawIris(col, p, at.y, at.z); + Eye e = eyeAt(p); + drawLid(col, p, e, mood.x, -1.0); + drawLid(col, p, e, mood.x, 1.0); + drawMembrane(col, p, bBlink(B).x); + o = vec4(col, 1.0); +} diff --git a/ShowControl/FundingCAPTCHA/shaders/hunter_bird/setup.json b/ShowControl/FundingCAPTCHA/shaders/hunter_bird/setup.json new file mode 100644 index 0000000..52ba5a0 --- /dev/null +++ b/ShowControl/FundingCAPTCHA/shaders/hunter_bird/setup.json @@ -0,0 +1,8 @@ +{ + "_comment": "Hunter bird: the blob tracker's notes drawn as a bird hunting visitors. Spec: docs/superpowers/specs/2026-11-04-hunter-bird-design.md", + "duration_s": 120, + "channels": { + "buffer_b": ["B", "blobs"], + "image": ["A", "B", "blobs"] + } +} diff --git a/ShowControl/FundingCAPTCHA/shaders/setups.json b/ShowControl/FundingCAPTCHA/shaders/setups.json index d67a1a8..0dba8b2 100644 --- a/ShowControl/FundingCAPTCHA/shaders/setups.json +++ b/ShowControl/FundingCAPTCHA/shaders/setups.json @@ -1 +1 @@ -["ripples", "endless_sunset"] +["ripples", "endless_sunset", "hunter_bird"] diff --git a/ShowControl/FundingCAPTCHA/shadertoy.py b/ShowControl/FundingCAPTCHA/shadertoy.py index 8f768fd..73c9546 100644 --- a/ShowControl/FundingCAPTCHA/shadertoy.py +++ b/ShowControl/FundingCAPTCHA/shadertoy.py @@ -14,7 +14,16 @@ in Shadertoy (and in --test-input) it is shaders/body_painter.glsl driven by the mouse. Body texel: .r presence 0/1, .g nearness 0 (far) – 1 (near). -Channel sources: "A" "B" "C" "D" "keyboard" or null. Buffers are linear- +Source "blobs" is the tracker's blob records (ADR-0022): BLOB_SLOTS columns × +BLOB_ROWS rows of RGBA32F, read with texelFetch(ch, ivec2(slot, row), 0): + row 0 x0, y0, x1, y1 bounding box, Body UV (y up) + row 1 cx, cy, vx, vy centroid (UV) and velocity (UV/s) + row 2 id, state, age_s, near stable id 1–999, BLOB_* state, seconds in + that state, mean Body nearness +A slot keeps its track for the track's whole life. In Shadertoy the source is +shaders/blob_painter.glsl in a spare buffer. + +Channel sources: "A" "B" "C" "D" "keyboard" "blobs" or null. Buffers are linear- filtered and clamped like Shadertoy's, but RGBA16F where Shadertoy's are 32-bit: the Pi's V3D can't linear-filter 32-bit float. Values above ~2048 lose integer precision. @@ -36,10 +45,16 @@ log = logging.getLogger("shader_app") BUFFERS = ("buffer_b", "buffer_c", "buffer_d") # render order, before image -SOURCES = {"A", "B", "C", "D", "keyboard"} +SOURCES = {"A", "B", "C", "D", "keyboard", "blobs"} _BUF_KEY = {"buffer_b": "B", "buffer_c": "C", "buffer_d": "D"} PAINTER_PATH = Path(__file__).parent / "shaders" / "body_painter.glsl" +BLOB_PAINTER_PATH = Path(__file__).parent / "shaders" / "blob_painter.glsl" + +# Blob records contract (ADR-0022). Changing any of these breaks Setups. +BLOB_SLOTS = 8 +BLOB_ROWS = 3 +BLOB_EMPTY, BLOB_TENTATIVE, BLOB_CONFIRMED, BLOB_COASTING, BLOB_LOST = range(5) PAINTER_CHANNELS = ["A", "keyboard", None, None] VERT = """ @@ -88,6 +103,10 @@ class Setup: passes: dict[str, str] # pass → source, render order channels: dict[str, list[str | None]] = field(default_factory=dict) + @property + def uses_blobs(self) -> bool: + return any("blobs" in chans for chans in self.channels.values()) + def load_setup(path: Path) -> Setup: path = Path(path) @@ -268,9 +287,14 @@ def __init__(self, ctx, size: tuple[int, int], body_mode: str = "camera") -> Non self.body_mode = body_mode self.vbo = ctx.buffer(struct.pack("8f", -1, -1, 1, -1, -1, 1, 1, 1)) self.keyboard = Keyboard() - self._kb_tex = ctx.texture((256, 3), 1, dtype="u1") + self._kb_tex = ctx.texture((256, 3), 1, dtype="f1") # normalised; "u1" is integer and samples as 0 self._kb_tex.filter = (moderngl.NEAREST, moderngl.NEAREST) self._blank = ctx.texture((1, 1), 4, data=bytes(4)) # unbound channels read 0 + # Read with texelFetch; NEAREST keeps a 32-bit float texture complete on V3D. + self._blobs_tex = ctx.texture((BLOB_SLOTS, BLOB_ROWS), 4, dtype="f4") + self._blobs_tex.filter = (moderngl.NEAREST, moderngl.NEAREST) + self._blobs_tex.repeat_x = self._blobs_tex.repeat_y = False + self.upload_blobs(np.zeros((BLOB_ROWS, BLOB_SLOTS, 4), dtype=np.float32)) self.output_tex = ctx.texture(size, 4) self.output = ctx.framebuffer(color_attachments=[self.output_tex]) @@ -346,11 +370,27 @@ def upload_body(self, rgba: np.ndarray) -> None: self._body_tex.repeat_x = self._body_tex.repeat_y = False self._body_tex.write(rgba.tobytes()) + def upload_blobs(self, records: np.ndarray) -> None: + """(BLOB_ROWS, BLOB_SLOTS, 4) float records; row 0 of the array is texel row 0.""" + if records.shape != (BLOB_ROWS, BLOB_SLOTS, 4): + raise ValueError(f"blob records must be {(BLOB_ROWS, BLOB_SLOTS, 4)}, got {records.shape}") + self._blobs_tex.write(np.ascontiguousarray(records, dtype=np.float32).tobytes()) + + def read_body(self) -> np.ndarray | None: + """Painter mode: the painted Body as float (h, w, 4), row 0 = bottom. None otherwise.""" + if self._painter_buf is None: + return None + tex = self._painter_buf.read_tex + data = np.frombuffer(tex.read(), dtype=np.float16) + return data.reshape(tex.size[1], tex.size[0], 4).astype(np.float32) + def _source_tex(self, src: str | None): if src is None: return None if src == "keyboard": return self._kb_tex + if src == "blobs": + return self._blobs_tex if src == "A": return self._painter_buf.read_tex if self._painter_buf else self._body_tex return self._buffers[f"buffer_{src.lower()}"].read_tex @@ -412,8 +452,8 @@ def _draw(self, pv: tuple, chans: list, uniforms: dict, fbo) -> None: def release(self) -> None: self._release_setup() - for o in (self.output_tex, self.output, self._kb_tex, self._blank, self.vbo, - self._body_tex): + for o in (self.output_tex, self.output, self._kb_tex, self._blank, self._blobs_tex, + self.vbo, self._body_tex): if o is not None: o.release() if self._painter: diff --git a/ShowControl/FundingCAPTCHA/test_body_blobs.py b/ShowControl/FundingCAPTCHA/test_body_blobs.py new file mode 100644 index 0000000..a9409b9 --- /dev/null +++ b/ShowControl/FundingCAPTCHA/test_body_blobs.py @@ -0,0 +1,148 @@ +"""Blob records for the shader player's "blobs" source (body_blobs.py, ADR-0022).""" +from __future__ import annotations + +import numpy as np +import pytest + +from body_blobs import BlobConfig, BodyBlobs, display_id, find_blobs +from shadertoy import (BLOB_COASTING, BLOB_CONFIRMED, BLOB_EMPTY, BLOB_LOST, BLOB_ROWS, + BLOB_SLOTS, BLOB_TENTATIVE) + +W, H = 64, 40 +CFG = dict(min_blob_pixels=10, min_confirm_frames=3, max_miss_frames=2, + max_match_dist_px=20.0, smoothing_alpha=1.0, lost_hold_s=0.5, ease_s=0.0) + + +def _body(*rects): + """rects: (x0, y0, x1, y1, nearness) in Body pixels, row 0 = bottom.""" + pres = np.zeros((H, W), dtype=bool) + near = np.zeros((H, W), dtype=np.float32) + for x0, y0, x1, y1, n in rects: + pres[y0:y1, x0:x1] = True + near[y0:y1, x0:x1] = n + return pres, near + + +def _run(bb, frames, t0=0.0, dt=0.1): + t = t0 + for rects in frames: + bb.update(*_body(*rects), now=t) + t += dt + return t + + +def _slot(rec, s): + return {"box": tuple(rec[0, s]), "mot": tuple(rec[1, s]), + "id": rec[2, s, 0], "state": rec[2, s, 1], "age": rec[2, s, 2], "near": rec[2, s, 3]} + + +def test_find_blobs_boxes_centroids_nearness(): + pres, near = _body((2, 3, 12, 23, 0.5), (40, 0, 50, 10, 1.0), (60, 30, 62, 32, 1.0)) + blobs = sorted(find_blobs(pres, near, min_pixels=10), key=lambda b: b.bbox[0]) + assert len(blobs) == 2 # the 2×2 speck is below min_pixels + assert tuple(blobs[0].bbox) == (2, 3, 12, 23) + assert tuple(blobs[0].centroid) == pytest.approx((7.0, 13.0)) + assert blobs[0].nearness == pytest.approx(0.5) + assert blobs[1].pixels == 100 + + +def test_find_blobs_diagonal_touch_is_one_blob(): + pres, near = _body((0, 0, 5, 5, 1.0), (5, 5, 10, 10, 1.0)) + assert len(find_blobs(pres, near, 1)) == 1 + + +def test_records_layout_in_uv(): + bb = BodyBlobs(BlobConfig(**CFG)) + bb.update(*_body((16, 0, 32, 20, 0.25)), now=0.0) + r = _slot(bb.records(now=0.0), 0) + assert r["box"] == pytest.approx((0.25, 0.0, 0.5, 0.5)) + assert r["mot"][:2] == pytest.approx((24 / W, 10 / H)) + assert r["near"] == pytest.approx(0.25) + assert r["state"] == BLOB_TENTATIVE + assert bb.records(now=0.0).shape == (BLOB_ROWS, BLOB_SLOTS, 4) + + +def test_lifecycle_tentative_confirmed_coasting_lost_empty(): + bb = BodyBlobs(BlobConfig(**CFG)) + seen = [[(10, 10, 20, 30, 0.5)]] * 3 + t = _run(bb, seen) + assert _slot(bb.records(now=t - 0.1), 0)["state"] == BLOB_CONFIRMED + t = _run(bb, [[]], t0=t) + assert _slot(bb.records(now=t - 0.1), 0)["state"] == BLOB_COASTING + t = _run(bb, [[], []], t0=t) # third miss > max_miss_frames + lost = _slot(bb.records(now=t - 0.1), 0) + assert lost["state"] == BLOB_LOST and lost["age"] == pytest.approx(0.0) + assert _slot(bb.records(now=t - 0.1 + 0.5), 0)["state"] == BLOB_EMPTY + + +def test_tentative_that_expires_is_never_lost(): + bb = BodyBlobs(BlobConfig(**CFG)) + t = _run(bb, [[(10, 10, 20, 30, 0.5)], [], [], []]) + assert _slot(bb.records(now=t), 0)["state"] == BLOB_EMPTY + + +def test_age_counts_seconds_in_state(): + bb = BodyBlobs(BlobConfig(**CFG)) + _run(bb, [[(10, 10, 20, 30, 0.5)]] * 3) # confirmed at t=0.2 + assert _slot(bb.records(now=0.75), 0)["age"] == pytest.approx(0.55) + + +def test_slots_are_stable_and_reused_after_lost_hold(): + bb = BodyBlobs(BlobConfig(**CFG)) + a, b = (2, 2, 10, 20, 0.5), (40, 2, 48, 20, 0.5) + t = _run(bb, [[a, b]] * 3) + ids = {s: _slot(bb.records(now=t), s)["id"] for s in (0, 1)} + t = _run(bb, [[b]] * 3, t0=t) # a leaves: slot 0 lost, b stays in 1 + rec = bb.records(now=t) + assert _slot(rec, 0)["state"] == BLOB_LOST + assert _slot(rec, 1)["state"] == BLOB_CONFIRMED and _slot(rec, 1)["id"] == ids[1] + c = (20, 2, 28, 20, 0.5) + _run(bb, [[b, c]], t0=t) # slot 0 still held for the lost record + assert _slot(bb.records(now=t), 2)["state"] == BLOB_TENTATIVE + t = _run(bb, [[b], [b], [b], [b], [b], [b]], t0=t + 0.1) + _run(bb, [[b, c]], t0=t) + assert _slot(bb.records(now=t), 0)["state"] == BLOB_TENTATIVE # freed, reused + + +def test_velocity_and_coasting_drift(): + bb = BodyBlobs(BlobConfig(**{**CFG, "max_miss_frames": 5})) + t = _run(bb, [[(10 + 4 * i, 10, 20 + 4 * i, 30, 0.5)] for i in range(4)]) + vx = _slot(bb.records(now=t), 0)["mot"][2] + assert vx > 0 # moving right (UV/s) + box_before = _slot(bb.records(now=t), 0)["box"][0] + _run(bb, [[]], t0=t) + assert _slot(bb.records(now=t), 0)["box"][0] > box_before + + +def test_y_up_matches_body_rows(): + bb = BodyBlobs(BlobConfig(**CFG)) + bb.update(*_body((0, 30, 10, 40, 1.0)), now=0.0) # rows 30–39: the top in GL terms + assert _slot(bb.records(now=0.0), 0)["box"][1] == pytest.approx(0.75) + + +def test_easing_moves_shown_box_toward_target(): + bb = BodyBlobs(BlobConfig(**{**CFG, "ease_s": 0.1})) + bb.update(*_body((0, 0, 10, 20, 1.0)), now=0.0) + bb.records(now=0.0) + bb.update(*_body((20, 0, 30, 20, 1.0)), now=0.1) + x0 = _slot(bb.records(now=0.05), 0)["box"][0] * W + assert 0 < x0 < 20 + + +def test_display_id_range(): + assert display_id(0) == 1 and display_id(998) == 999 and display_id(999) == 1 + + +def test_overflow_beyond_slots_is_dropped(): + bb = BodyBlobs(BlobConfig(**CFG)) + rects = [(i * 7, 0, i * 7 + 5, 5, 1.0) for i in range(BLOB_SLOTS + 1)] + bb.update(*_body(*rects), now=0.0) + assert np.count_nonzero(bb.records(now=0.0)[2, :, 1]) == BLOB_SLOTS + + +def test_reset_clears_everything(): + bb = BodyBlobs(BlobConfig(**CFG)) + bb.update(*_body((0, 0, 10, 20, 1.0)), now=0.0) + assert bb.active + bb.reset() + assert not bb.active and not bb.records(now=0.0).any() diff --git a/ShowControl/FundingCAPTCHA/test_hunter_bird.py b/ShowControl/FundingCAPTCHA/test_hunter_bird.py new file mode 100644 index 0000000..4a815be --- /dev/null +++ b/ShowControl/FundingCAPTCHA/test_hunter_bird.py @@ -0,0 +1,652 @@ +"""Behaviour tests for the hunter_bird Setup (shaders/hunter_bird/). + +Records come from the real BodyBlobs tracker run on synthetic masks with a +fake clock (camera 10 Hz, render 30 Hz), so record noise is realistic. +GL tests need a headless context (EGL / llvmpipe) and skip without one. +""" +from __future__ import annotations + +import os +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" / "hunter_bird" +SIZE = (128, 72) # render; ≥ 96 wide for the PRINT row +BODY = (64, 40) # camera ROI (w, h) +SLABS = [{"near_mm": 1000, "far_mm": 4000}] +DT = 1 / 30 +# The shipped defaults, except a smaller min_blob_pixels for the small test Body. +BLOBS = BlobConfig(min_blob_pixels=20) + + +@pytest.fixture(scope="module") +def ctx(): + moderngl = pytest.importorskip("moderngl") + for kw in ({"backend": "egl"}, {}): + try: + c = moderngl.create_standalone_context(**kw) + break + except Exception: + continue + else: + if os.environ.get("CI"): + pytest.fail("no headless OpenGL context in CI — GL tests would silently skip") + pytest.skip("no headless OpenGL context") + yield c + c.release() + + +class Rig: + """Renderer + the real BodyBlobs on a fake clock: camera at 10 Hz, render at 30 Hz.""" + + def __init__(self, ctx, size=SIZE): + self.size = size + self.r = Renderer(ctx, size, body_mode="camera") + self.r.load(load_setup(SETUP)) + self.blobs = BodyBlobs(BLOBS) + self.t = 0.0 + + 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(DT, (0, 0, 0, 0)) + self.t += DT + + def b(self) -> np.ndarray: + """Buffer B as float32 (h, w, 4), row 0 = texel y 0.""" + t = self.r._buffers["buffer_b"].read_tex + return np.frombuffer(t.read(), dtype=np.float16).astype(np.float32).reshape(t.size[1], t.size[0], 4) + + def slot(self, k): return self.b()[0, k] # id, motion, interest, stamped + def head(self, k): return self.b()[2, k] # last print x, y, side, since + def attn(self): return self.b()[3, 0] # target+1, lock, since strike, id struck + def reticle(self): return self.b()[3, 1] # x, y, radius, alpha + def mood(self): return self.b()[3, 2] # lids, shiver, anchor x, y + def blink(self): return self.b()[3, 3] # since blink, idle countdown, all-stamped s + def stamp(self): return self.b()[3, 4] # x, y + def prints(self): return self.b()[4, :8 * 12] # x, y, age, angle + + 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[y0:y1, x0:x1] = True + return m + + +EMPTY = np.zeros(BODY[::-1], dtype=bool) + + +def standing(n, x=36): + return [rect(x, 8, x + 10, 36)] * n + + +def walking(n, step=1): + """~0.28 screen heights/s here: motion ≈ 0.57, so lock stays < 1 for 6 s at the defaults.""" + return [rect(6 + (k * step) % 40, 8, 16 + (k * step) % 40, 36) for k in range(n)] + + +def flailing(n, x=30): + """Arms out and in: the box width swings by 12 px at ~2 Hz.""" + return [rect(x - 6 * (k % 5 in (1, 2, 3)), 8, x + 10 + 6 * (k % 5 in (1, 2, 3)), 36) for k in range(n)] + + +def flickering(n, seed=0): + """A still person whose 1-px border toggles randomly each camera frame.""" + rng = np.random.default_rng(seed) + base = rect(36, 8, 46, 36) + edge = rect(35, 7, 47, 37) & ~rect(37, 9, 45, 35) + out = [] + for _ in range(n): + m = base.copy() + m[edge] = rng.random(edge.sum()) < 0.5 + out.append(m) + return out + + +# ── Task 1: per-slot state ──────────────────────────────────────────────────── + +def test_slot_tracks_record_id(ctx): + rig = Rig(ctx) + rig.run(standing(8)) + assert rig.slot(0)[0] == 1 + + +def test_walking_has_motion(ctx): + rig = Rig(ctx) + rig.run(walking(20)) + assert rig.slot(0)[1] > 0.3 + + +def test_flickering_still_person_has_no_motion(ctx): # Review Focus 1 + rig = Rig(ctx) + rig.run(flickering(40)) + assert rig.slot(0)[1] < 0.05 + + +def test_interest_rises_with_motion_and_drains_still(ctx): + rig = Rig(ctx) + rig.run(walking(30)) + hi = rig.slot(0)[2] + rig.run([walking(31)[-1]] * 40) + assert hi > 0.5 and rig.slot(0)[2] < hi - 0.5 + + +def test_tentative_has_no_motion_or_interest(ctx): + rig = Rig(ctx) + rig.run(walking(2)) # still tentative (min_confirm_frames 4) + assert rig.slot(0)[1] == 0 and rig.slot(0)[2] == 0 + + +# ── Task 2: attention ───────────────────────────────────────────────────────── + +LEFT, RIGHT = (4, 8, 14, 36), (48, 8, 58, 36) + + +def both(a, b): + """Two people, animated independently: frame-wise union of two mask lists.""" + return [ma | mb for ma, mb in zip(a, b)] + + +def pacing(n, at, span=6): + """A person walking back and forth by `span` px at 1 px per camera frame.""" + x0, y0, x1, y1 = at + out = [] + for k in range(n): + o = abs(k % (2 * span) - span) + out.append(rect(x0 + o, y0, x1 + o, y1)) + return out + + +def still(n, at): + return [rect(*at)] * n + + +def stamped_slot(rig): + hits = [k for k in range(8) if rig.slot(k)[3] == 1] + assert len(hits) == 1, hits + return hits[0] + + +def slot_of_id(rig, ident): + return next(k for k in range(8) if rig.slot(k)[0] == ident) + + +def test_flailing_becomes_target_and_strikes(ctx): + rig = Rig(ctx) + rig.run(flailing(45)) # 4.5 s + a = rig.attn() + assert a[3] == 1 # id 1 struck + assert rig.slot(0)[3] == 1 # stamped + + +def test_walking_alone_never_strikes(ctx): + rig = Rig(ctx) + rig.run(walking(60)) + assert rig.attn()[3] == 0 and rig.attn()[1] < 1 + + +def test_freezing_loses_the_bird(ctx): # Review Focus 1 + rig = Rig(ctx) + rig.run(walking(30)) + assert rig.attn()[0] == 1 + rig.run(flickering(40)) # a flickering still person: target dropped + assert rig.attn()[0] == 0 + + +def test_strike_once_per_person(ctx): # Review Focus 2 + rig = Rig(ctx) + rig.run(flailing(45)) + struck_at = rig.attn()[2] + rig.run(flailing(90)) + a = rig.attn() + assert a[0] == 0 and rig.slot(0)[2] == 0 # ignored after the stamp, despite flailing + assert a[2] > struck_at + 8.5 # and never struck again + + +def test_reused_slot_resets_stamp(ctx): # Review Focus 2 + rig = Rig(ctx) + rig.run(flailing(45)) + rig.run([EMPTY] * 30) # leaves; lost; slot frees + assert rig.slot(0)[0] == 0 + rig.run(flailing(45)) + assert rig.slot(0)[0] == 2 and rig.slot(0)[3] == 1 # new id, stamped anew + + +def test_only_confirmed_unstamped_can_be_target(ctx): # Review Focus 4 + rig = Rig(ctx) + rig.run(flailing(2)) # tentative: never a target + assert rig.attn()[0] == 0 and rig.slot(0)[2] == 0 + rig = Rig(ctx) + rig.run(both(flailing(45, x=10), still(45, RIGHT))) # the left one is stamped + done = stamped_slot(rig) + rig.run(both(flailing(30, x=10), pacing(30, RIGHT))) # left keeps flailing, right paces + a = rig.attn() + assert a[0] != 0 and a[0] - 1 != done + assert rig.slot(int(a[0]) - 1)[3] == 0 + + +def test_mood(ctx): + rig = Rig(ctx) + rig.run([EMPTY] * 30) + assert abs(rig.mood()[0] - 0.42) < 0.06 # drowsy + rig.run(walking(30)) + assert rig.attn()[0] != 0 and rig.mood()[0] > 0.9 # hunting: wide + rig.run(flickering(50)) + assert rig.attn()[0] == 0 + assert abs(rig.mood()[0] - 0.7) < 0.03 # suspicious squint + + +def test_smug_after_strike_then_drowsy_when_all_stamped(ctx): + rig = Rig(ctx) + rig.run(flailing(45)) + since = rig.attn()[2] + assert since < 3.2 and rig.mood()[0] >= 0.58 - 0.05 + rig.run(flailing(90)) # everyone present is stamped + assert abs(rig.mood()[0] - 0.42) < 0.06 + + +def test_blink_scheduled_for_new_id(ctx): + rig = Rig(ctx) + rig.run([EMPTY] * 3) + rig.run(standing(1), frames_per_camera=1) # the id appears this frame + assert -0.16 < rig.blink()[0] < 0.0 + assert rig.mood()[1] > 0.9 # leaves shiver + + +def test_reticle_follows_target_and_hides_when_empty(ctx): + rig = Rig(ctx) + rig.run([EMPTY] * 10) + assert rig.reticle()[3] < 0.02 + rig.run(walking(30)) + r = rig.reticle() + assert r[3] > 0.9 + box = rig.blobs.records(now=rig.t)[0, 0] + assert abs(r[0] - (box[0] + box[2]) / 2) < 0.08 + assert abs(r[1] - (box[3] - 0.3 * (box[3] - box[1]))) < 0.08 + + +def test_stamp_lands_on_target(ctx): + rig = Rig(ctx) + rig.run(flailing(45)) + box = rig.blobs.records(now=rig.t)[0, 0] + s = rig.stamp() + assert abs(s[0] - (box[0] + box[2]) / 2) < 0.02 and abs(s[1] - (box[1] + box[3]) / 2) < 0.02 + + +def test_state_stays_bounded_when_idle(ctx): # Review Focus 3 + rig = Rig(ctx) + 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) + assert np.isfinite(rig.b()).all() and np.abs(rig.b()).max() <= 99.0 + + +# ── Task 3: footprints ──────────────────────────────────────────────────────── + +PRINT_LIFE_S = 4.0 + + +def test_walking_leaves_prints(ctx): + rig = Rig(ctx) + rig.run(walking(30)) + p = rig.prints() + fresh = p[p[:, 2] < PRINT_LIFE_S] + assert len(fresh) >= 6 + box = rig.blobs.records(now=rig.t)[0, 0] + assert (fresh[:, 1] < box[1] + 0.02).all() # at the bottom of the box + assert len(set(np.round(fresh[:, 1], 3))) >= 2 # alternating sides + + +def test_standing_leaves_no_prints(ctx): + rig = Rig(ctx) + rig.run(flickering(30)) + assert (rig.prints()[:, 2] >= PRINT_LIFE_S).all() + + +def test_prints_age_out(ctx): + rig = Rig(ctx) + rig.run(walking(30)) + rig.run([EMPTY] * 50) + assert (rig.prints()[:, 2] >= PRINT_LIFE_S).all() + + +# ── Task 4: the world ───────────────────────────────────────────────────────── +# Image tests render larger, so pen widths (sized in screen heights) are ≥ 1 px. + +BIG = (320, 180) +PALETTE = { + "paper": (246, 241, 228), "ink": (17, 17, 17), "slate": (113, 154, 173), + "deep": (32, 118, 163), "rust": (188, 76, 38), "yellow": (255, 242, 0), + "white": (255, 250, 240), +} + + +def near(px, name, tol=40): + return all(abs(int(a) - b) <= tol for a, b in zip(px[:3], PALETTE[name])) + + +def to_px(u, v, size=BIG): + """Body UV (y up) → image (row, col), row 0 = bottom.""" + return int(v * size[1]), int(u * size[0]) + + +def test_person_has_white_fill_and_ink_outline(ctx): + rig = Rig(ctx, BIG) + rig.run(standing(10)) # rect(36, 8, 46, 36): UV x 0.5625–0.719, y 0.1–0.8 + img = rig.image() + assert near(img[to_px(0.64, 0.62)], "white") # upper body: fill + row = to_px(0.64, 0.62)[0] + left = to_px(0.5625, 0.62)[1] + assert any(near(img[row, c], "ink") for c in range(left - 3, left + 1)) # outline just outside + + +def test_hatching_only_in_lower_box(ctx): + rig = Rig(ctx, BIG) + rig.run(standing(10)) + img = rig.image() + r0, r1 = to_px(0, 0.14)[0], to_px(0, 0.36)[0] # lower 40% of the box, inside its edges + c0, c1 = to_px(0.58, 0)[1], to_px(0.70, 0)[1] + lower = img[r0:r1, c0:c1].reshape(-1, 4) + upper = img[to_px(0, 0.45)[0]:to_px(0, 0.75)[0], c0:c1].reshape(-1, 4) + assert any(near(p, "slate", 30) for p in lower) + assert not any(near(p, "slate", 30) for p in upper) + + +def test_canopy_stays_in_its_band(ctx): + rig = Rig(ctx, BIG) + rig.run([EMPTY] * 5) + img = rig.image() + assert near(img[BIG[1] // 2, BIG[0] // 2], "paper", 12) # the screen centre is the clearing + rig = Rig(ctx, BIG) + rig.run(walking(30)) # lids wide: the top centre is in view + top = rig.image()[BIG[1] - 4, int(BIG[0] * 0.3):int(BIG[0] * 0.7)] + assert any(near(p, "ink") for p in top) # the canopy along the top + + +# ── Task 5: field notes and attention ───────────────────────────────────────── + +def fake_records(box, state, ident=7, vel=(0.0, 0.0), age=0.0): + """One record in slot 0: box (x0, y0, x1, y1) Body UV. No Body behind it, so only notes draw.""" + from shadertoy import BLOB_ROWS, BLOB_SLOTS + rec = np.zeros((BLOB_ROWS, BLOB_SLOTS, 4), dtype=np.float32) + rec[0, 0] = box + rec[1, 0] = ((box[0] + box[2]) / 2, (box[1] + box[3]) / 2, *vel) + rec[2, 0] = (ident, state, age, 0.5) + return rec + + +def render_records(rig, rec, frames=5): + fg = np.zeros(BODY[::-1], dtype=np.uint16) + rig.r.upload_body(pack_body(fg, {"x": 0, "y": 0, "w": BODY[0], "h": BODY[1]}, SLABS)) + for _ in range(frames): + rig.r.upload_blobs(rec) + rig.r.render(DT, (0, 0, 0, 0)) + return rig.image() + + +def count(img, name, tol=40): + px = img[..., :3].reshape(-1, 3).astype(int) + return int((np.abs(px - PALETTE[name]) <= tol).all(axis=1).sum()) + + +def test_confirmed_box_has_ink_brackets(ctx): + rig = Rig(ctx, BIG) + box = (0.30, 0.30, 0.60, 0.80) + img = render_records(rig, fake_records(box, 2)) + row, c0 = to_px(box[0], box[3]) + arm = range(c0 + 2, c0 + 8) # along the top-left bracket's arm + assert min(int(img[r, c, :3].sum()) for r in range(row - 2, row + 2) for c in arm) < 3 * 140 # dark ink + mid = to_px((box[0] + box[2]) / 2, box[3]) # the middle of the top edge is open + assert near(img[mid], "paper", 12) + + +def test_tentative_box_is_slate_and_dashed(ctx): + rig = Rig(ctx, BIG) + box = (0.30, 0.30, 0.60, 0.80) + img = render_records(rig, fake_records(box, 1)) + col = to_px(box[0], 0)[1] + edge = [img[r, c] for r in range(to_px(0, 0.38)[0], to_px(0, 0.72)[0]) for c in (col - 1, col)] + assert any(int(p[2]) - int(p[0]) > 12 and p[0] < 225 for p in edge) # slate: bluish, not ink or paper + assert any(near(p, "paper", 12) for p in edge[::2]) # gaps in the dashes + + +def test_target_tag_is_yellow(ctx): + rig = Rig(ctx, BIG) + rig.run(walking(30)) + assert rig.attn()[0] == 1 + box = rig.blobs.records(now=rig.t)[0, 0] + r1, c1 = to_px(box[2], box[3]) + patch = rig.image()[r1 - 25:r1 + 2, c1 - 4:c1 + 30] + assert count(patch, "yellow", 30) > 10 + + +def test_stamp_appears_after_strike_then_clears(ctx): + rig = Rig(ctx, BIG) + for m in flailing(60): + rig.run([m]) + if rig.attn()[3] == 1: + break + assert rig.attn()[3] == 1 + rig.run(flailing(5)) # 0.5 s after the strike + sr, sc = to_px(*rig.stamp()[:2]) + assert count(rig.image()[sr - 25:sr + 25, sc - 25:sc + 25], "rust") > 30 + rig.run(flailing(40)) # 4.5 s after: gone + sr, sc = to_px(*rig.stamp()[:2]) + assert count(rig.image()[sr - 25:sr + 25, sc - 25:sc + 25], "rust") == 0 + + +def test_reticle_hidden_when_empty(ctx): + rig = Rig(ctx, BIG) + rig.run([EMPTY] * 10) + assert count(rig.image(), "rust") == 0 + + +def test_reticle_drawn_on_target(ctx): + rig = Rig(ctx, BIG) + rig.run(walking(30)) + r = rig.reticle() + rr, rc = to_px(r[0], r[1]) + assert count(rig.image()[rr - 30:rr + 30, rc - 30:rc + 30], "rust") > 20 + + +# ── Task 6: the eye ─────────────────────────────────────────────────────────── + +def lid_patch(rig): + """Pixels 0.35 H above the screen centre.""" + img = rig.image() + r, c = int(BIG[1] * 0.85), BIG[0] // 2 + return img[r - 3:r + 3, c - 12:c + 12] + + +def bluish(patch): + px = patch[..., :3].reshape(-1, 3).astype(int) + return int(((px[:, 2] - px[:, 0] > 5) & (px[:, 0] < 235)).sum()) # paper is b − r = −18 + + +def test_lids_drowsy_when_empty(ctx): + rig = Rig(ctx, BIG) + rig.run([EMPTY] * 10) + assert bluish(lid_patch(rig)) > 5 # lid: paper with slate hatching + + +def test_lids_open_on_target(ctx): + rig = Rig(ctx, BIG) + rig.run(walking(30)) + assert rig.mood()[0] > 0.9 + patch = lid_patch(rig) + assert bluish(patch) == 0 and all(near(p, "paper", 12) for p in patch.reshape(-1, 4)) + + +def iris_patch(rig): + """Pixels 0.72–0.80 H left of the screen centre, inside the lids when they're wide.""" + img = rig.image() + c = BIG[0] // 2 + return img[BIG[1] // 2 - 2:BIG[1] // 2 + 2, c - int(0.80 * BIG[1]):c - int(0.72 * BIG[1])] + + +def test_iris_band_narrows_view_on_lock(ctx): + rig = Rig(ctx, BIG) + rig.run(flailing(9)) + assert rig.attn()[1] < 0.2 + patch = iris_patch(rig) + assert count(patch, "yellow", 30) == 0 and bluish(patch) == 0 # world at lock 0 + for m in flailing(60)[9:]: + rig.run([m]) + if rig.attn()[1] > 0.9: + break + assert rig.attn()[1] > 0.9 + patch = iris_patch(rig) + assert count(patch, "yellow", 30) + bluish(patch) > 10 # iris band or socket + + +def test_membrane_sweeps_during_blink(ctx): + rig = Rig(ctx, BIG) + rec = fake_records((0.02, 0.35, 0.12, 0.6), 1) # a new id at the far left, no Body + def right_half(): + img = rig.image() + return img[BIG[1] // 2 - 3:BIG[1] // 2 + 3, BIG[0] // 2:] + render_records(rig, rec, frames=2) + assert -0.16 < rig.blink()[0] < 0.0 # scheduled + assert bluish(right_half()) == 0 + for _ in range(15): + render_records(rig, rec, frames=1) + if 0.1 < rig.blink()[0] < 0.22: # mid-blink: the sheet's streaks cross the view + assert bluish(right_half()) > 3 + return + pytest.fail("no blink") + + +# ── Review fixes ────────────────────────────────────────────────────────────── + +def test_brisk_walking_never_strikes(ctx): # "walking about never does" (spec) + rig = Rig(ctx) + rig.run(walking(100, step=2)) # ~0.56 screen heights/s for 10 s + assert rig.attn()[3] == 0 and rig.attn()[1] < 0.2 + + +def test_flailer_steals_target_from_moving_walker(ctx): + rig = Rig(ctx) + rig.run(both(walking(30, step=1)[:30], still(30, RIGHT))) + walker = int(rig.attn()[0]) - 1 + assert walker >= 0 + moving = [rect(4 + (k % 12), 8, 14 + (k % 12), 36) for k in range(40)] # keeps walking on the left + rig.run(both(moving, flailing(40, x=48))) + a = rig.attn() + assert a[0] - 1 not in (walker, -1) or a[3] == rig.slot(1 - walker)[0] # stolen (or already stamped) + + +class BigRig(Rig): + """The shipped scale: a 480 × 480 Body (camera_roi) and the default BlobConfig.""" + def __init__(self, ctx): + self.size = SIZE + self.r = Renderer(ctx, SIZE, body_mode="camera") + self.r.load(load_setup(SETUP)) + self.blobs = BodyBlobs(BlobConfig()) + self.t = 0.0 + + def run(self, masks, frames_per_camera=3): + for m in masks: + fg = np.where(m, 2000, 0).astype(np.uint16) + body = pack_body(fg, {"x": 0, "y": 0, "w": 480, "h": 480}, SLABS) + self.r.upload_body(body) + self.blobs.update(body[..., 0] > 0, body[..., 1] / 255.0, now=self.t) + for _ in range(frames_per_camera): + self.r.upload_blobs(self.blobs.records(now=self.t)) + self.r.render(DT, (0, 0, 0, 0)) + self.t += DT + + +def jittery_still(n, k, seed=0): + """A still person at 480 × 480 whose every box side jitters by ±k px each camera frame.""" + rng = np.random.default_rng(seed) + out = [] + for _ in range(n): + j = rng.integers(-k, k + 1, 4) + m = np.zeros((480, 480), bool) + m[100 + j[1]:400 + j[3], 200 + j[0]:280 + j[2]] = True + out.append(m) + return out + + +def test_still_person_with_box_jitter_escapes(ctx): # Review Focus 1, with the box breathing + rig = BigRig(ctx) + rig.run(jittery_still(60, 6)) + s = rig.slot(0) + assert s[1] < 0.2 and s[2] < 0.25 and rig.attn()[0] == 0 and rig.attn()[3] == 0 + + +# ── Adversarial review fixes ────────────────────────────────────────────────── + +def big_walker(n, px_per_frame, stride, x0=40, legs_hz=2.0): + """480 × 480: an 80 × 300 person walking right whose box widens by up to + `stride` px as the legs swing apart, twice per gait cycle (camera 10 Hz).""" + out = [] + for k in range(n): + x = x0 + px_per_frame * k + w = 80 + stride * abs(np.sin(np.pi * legs_hz * k / 10.0)) + m = np.zeros((480, 480), bool) + m[100:400, max(int(x), 0):min(int(x + w), 480)] = True + out.append(m) + return out + + +def big_flailer(n, arm, one_sided=False, cx=240): + """480 × 480: an 80 × 300 person standing still, arms swinging out by `arm` px + (one arm only if one_sided, so the centroid wobbles too).""" + out = [] + for k in range(n): + out_now = k % 5 in (1, 2, 3) + left = arm * out_now * (not one_sided) + right = arm * out_now + m = np.zeros((480, 480), bool) + m[100:400, cx - 40:cx + 40] = True + m[150:180, cx - 40 - left:cx + 40 + right] = True # arms at shoulder height + out.append(m) + return out + + +@pytest.mark.parametrize("speed,stride", [(4, 40), (8, 20), (8, 40), (8, 80), (14, 20), (14, 60)]) +def test_striding_walker_never_strikes(ctx, speed, stride): # "walking about never does" (spec) + rig = BigRig(ctx) + n = min(60, int((480 - 40 - 80 - stride) / speed)) # stay inside the frame + rig.run(big_walker(n, speed, stride)) + assert rig.attn()[3] == 0 and rig.attn()[1] < 0.3, (rig.t, rig.attn()) + + +@pytest.mark.parametrize("one_sided", [False, True]) +def test_big_flailer_strikes(ctx, one_sided): # "flail and it locks on and strikes" + rig = BigRig(ctx) + rig.run(big_flailer(50, 60, one_sided)) # 5 s + assert rig.attn()[3] != 0 + + +def test_target_keeps_lock_through_a_dropped_frame(ctx): # mask flicker with a rival present + rig = Rig(ctx) + flail = flailing(40, x=12) + walk = [rect(40 + (k % 12), 8, 50 + (k % 12), 36) for k in range(40)] + rig.run(both(flail[:18], walk[:18])) + a = rig.attn() + assert a[3] == 0 and a[1] > 0.2, a # flailer targeted, not yet struck + tgt, before = int(a[0]) - 1, a[1] + rig.run([walk[18]]) # the flailer's blob is missed once + rig.run(both(flail[19:21], walk[19:21])) + a = rig.attn() + assert int(a[0]) - 1 == tgt and (a[1] > before - 0.1 or a[3] != 0), (before, a) diff --git a/ShowControl/FundingCAPTCHA/test_shadertoy.py b/ShowControl/FundingCAPTCHA/test_shadertoy.py index bd10974..e93b296 100644 --- a/ShowControl/FundingCAPTCHA/test_shadertoy.py +++ b/ShowControl/FundingCAPTCHA/test_shadertoy.py @@ -11,8 +11,9 @@ import numpy as np import pytest -from shadertoy import (BodyMouse, Keyboard, Renderer, ShaderError, js_keycode, - load_setup, pack_body) +from shadertoy import (BLOB_CONFIRMED, BLOB_COASTING, BLOB_EMPTY, BLOB_LOST, BLOB_PAINTER_PATH, + BLOB_ROWS, BLOB_SLOTS, BLOB_TENTATIVE, BodyMouse, Keyboard, Renderer, + ShaderError, js_keycode, load_setup, pack_body) SHADER_DIR = Path(__file__).parent / "shaders" @@ -54,6 +55,14 @@ def test_load_setup_rejects_reading_absent_buffer(tmp_path): load_setup(d) +def test_load_setup_accepts_blobs_source(tmp_path): + d = _write_setup(tmp_path, {"image.glsl": SOLID}, {"channels": {"image": ["A", "blobs"]}}) + s = load_setup(d) + assert s.channels["image"][1] == "blobs" and s.uses_blobs + (tmp_path / "x").mkdir() + assert not load_setup(_write_setup(tmp_path / "x", {"image.glsl": SOLID}, {})).uses_blobs + + def test_load_setup_rejects_unknown_source(tmp_path): d = _write_setup(tmp_path, {"image.glsl": SOLID}, {"channels": {"image": ["webcam"]}}) with pytest.raises(ShaderError, match="unknown source"): @@ -179,7 +188,7 @@ def test_listed_setups_compile(ctx, name): def test_compile_error_reports_tab_line(ctx, tmp_path): d = _write_setup(tmp_path, {"image.glsl": "void mainImage(out vec4 o, in vec2 f) {\n o = nope;\n}"}) r = Renderer(ctx, (8, 8)) - with pytest.raises(ShaderError, match=r"0:2"): + with pytest.raises(ShaderError, match=r"\b0(:2\b|\(2\))"): # Mesa "0:2", NVIDIA "0(2)" r.load(load_setup(d)) r.release() @@ -261,6 +270,18 @@ def test_shadertoy_idioms_render(ctx, tmp_path, src): r.release() +def test_keyboard_reaches_shaders(ctx, tmp_path): + d = _write_setup(tmp_path, {"image.glsl": "void mainImage(out vec4 o, in vec2 f) {" + " o = vec4(texelFetch(iChannel0, ivec2(32, 0), 0).x, 0.0, 0.0, 1.0); }"}, + {"channels": {"image": ["keyboard"]}}) + r = Renderer(ctx, (4, 4)) + r.load(load_setup(d)) + r.keyboard.key_down(32) + r.render(1 / 30, (0, 0, 0, 0)) + assert _pixel(r, 1, 1)[0] == 255 + r.release() + + def test_itime_starts_at_zero(ctx, tmp_path): d = _write_setup(tmp_path, {"image.glsl": "void mainImage(out vec4 o, in vec2 f) {" " o = vec4(iTime, 0.0, 0.0, 1.0); }"}) @@ -319,3 +340,126 @@ def test_render_failure_drops_setup(ctx, tmp_path): assert rot.names == [n for n in ("a", "b") if n != bad] assert r.setup is not None and r.setup.name != bad r.release() + + +# ── Blob records (ADR-0022) ─────────────────────────────────────────────────── + +def _blob_setup(tmp_path, files, channels): + return load_setup(_write_setup(tmp_path, files, {"channels": channels})) + + +def test_uploaded_blobs_reach_texelfetch(ctx, tmp_path): + # Image paints slot 5 row 2's state into red, as 8-bit. + r = Renderer(ctx, (8, 8)) + r.load(_blob_setup(tmp_path, {"image.glsl": "void mainImage(out vec4 o, in vec2 f) {" + " o = vec4(texelFetch(iChannel0, ivec2(5, 2), 0).y / 4.0, 0.0, 0.0, 1.0); }"}, + {"image": ["blobs"]})) + rec = np.zeros((BLOB_ROWS, BLOB_SLOTS, 4), dtype=np.float32) + rec[2, 5] = (17, BLOB_COASTING, 0.5, 1.0) + r.upload_blobs(rec) + r.render(1 / 30, (0, 0, 0, 0)) + assert _pixel(r, 3, 3)[0] == pytest.approx(round(BLOB_COASTING / 4 * 255), abs=1) + with pytest.raises(ValueError): + r.upload_blobs(np.zeros((BLOB_SLOTS, BLOB_ROWS, 4), dtype=np.float32)) + r.release() + + +def test_blobs_channel_resolution_is_slots_by_rows(ctx, tmp_path): + r = Renderer(ctx, (8, 8)) + r.load(_blob_setup(tmp_path, {"image.glsl": "void mainImage(out vec4 o, in vec2 f) {" + " o = vec4(iChannelResolution[0].xy / 32.0, 0.0, 1.0); }"}, + {"image": ["blobs"]})) + r.render(1 / 30, (0, 0, 0, 0)) + assert _pixel(r, 1, 1)[:2] == (round(BLOB_SLOTS / 32 * 255), round(BLOB_ROWS / 32 * 255)) # no .5 ties: drivers round those either way + r.release() + + +def test_read_body_returns_painted_body(ctx, tmp_path): + r = Renderer(ctx, (160, 90), body_mode="painter") + r.load(load_setup(_write_setup(tmp_path, {"image.glsl": SOLID}))) + r.render(1 / 30, (40.0, 45.0, 40.0, 45.0)) + body = r.read_body() + assert body.shape == (90, 160, 4) + assert body[45, 40, 0] == 1.0 and body[45, 130, 0] == 0.0 + assert Renderer(ctx, (8, 8)).read_body() is None + + +def _painter_records(r) -> np.ndarray: + tex = r._buffers["buffer_d"].read_tex + data = np.frombuffer(tex.read(), dtype=np.float16).reshape(tex.size[1], tex.size[0], 4) + return data[:BLOB_ROWS, :BLOB_SLOTS].astype(np.float32) + + +def test_blob_painter_lifecycle(ctx, tmp_path): + """The Shadertoy stand-in walks slot 0 through the same states as the Pi tracker.""" + r = Renderer(ctx, (160, 90), body_mode="painter") + r.load(_blob_setup(tmp_path, {"buffer_d.glsl": BLOB_PAINTER_PATH.read_text(), "image.glsl": SOLID}, + {"buffer_d": ["D", "keyboard"], "image": ["A", "D"]})) + dt = 0.05 + r.render(dt, (40.0, 45.0, 40.0, 45.0)) # frame 0 clears + r.render(dt, (40.0, 45.0, 40.0, 45.0)) # click: born + rec = _painter_records(r) + assert rec[2, 0, 1] == BLOB_TENTATIVE and rec[2, 0, 0] == 1 + x0, y0, x1, y1 = rec[0, 0] + assert x0 < 40 / 160 < x1 and y0 < 45 / 90 < y1 + for _ in range(10): + r.render(dt, (40.0, 45.0, 40.0, -45.0)) # held + assert _painter_records(r)[2, 0, 1] == BLOB_CONFIRMED + r.render(dt, (40.0, 45.0, -40.0, -45.0)) # released + assert _painter_records(r)[2, 0, 1] == BLOB_COASTING + for _ in range(18): + r.render(dt, (40.0, 45.0, -40.0, -45.0)) + assert _painter_records(r)[2, 0, 1] == BLOB_LOST + for _ in range(21): + r.render(dt, (40.0, 45.0, -40.0, -45.0)) + assert _painter_records(r)[2, 0, 1] == BLOB_EMPTY + r.render(dt, (40.0, 45.0, 40.0, 45.0)) # a new person gets a new id + assert _painter_records(r)[2, 0, 0] == 2 + r.release() + + +def test_blob_painter_space_parks_into_next_slot(ctx, tmp_path): + r = Renderer(ctx, (160, 90), body_mode="painter") + r.load(_blob_setup(tmp_path, {"buffer_d.glsl": BLOB_PAINTER_PATH.read_text(), "image.glsl": SOLID}, + {"buffer_d": ["D", "keyboard"]})) + r.render(1 / 30, (40.0, 45.0, -40.0, -45.0)) + for x in (40.0, 120.0): + r.keyboard.key_down(32) + r.render(1 / 30, (x, 45.0, -40.0, -45.0)) + r.keyboard.key_up(32) + rec = _painter_records(r) + assert rec[2, 0, 1] == BLOB_EMPTY # nobody under a held mouse + assert list(rec[2, 1:3, 1]) == [BLOB_TENTATIVE] * 2 + assert list(rec[2, 1:3, 0]) == [1, 2] + r.keyboard.key_down(67) # C: everyone still tentative → gone + r.render(1 / 30, (40.0, 45.0, -40.0, -45.0)) + assert not _painter_records(r)[2, :, 1].any() + r.release() + + +def test_blob_debug_reference_setup_renders(ctx): + r = Renderer(ctx, (64, 36), body_mode="painter") + r.load(load_setup(SHADER_DIR / "blob_debug")) + rec = np.zeros((BLOB_ROWS, BLOB_SLOTS, 4), dtype=np.float32) + rec[0, 0] = (0.1, 0.1, 0.9, 0.9) + rec[2, 0] = (1, BLOB_CONFIRMED, 0.0, 0.5) + r.upload_blobs(rec) + r.render(1 / 30, (32, 18, 32, 18)) + assert _pixel(r, int(0.1 * 64), 18)[:3] == (17, 17, 17) # ink on the box's left edge + r.release() + + +def test_rotation_calls_on_load_for_every_load(ctx, tmp_path): + """Buffer state resets on every load, so the blob tracker must too (stamps match on id).""" + from shader_app import Rotation + root = _setups(tmp_path, a=SOLID, b=SOLID) + r = Renderer(ctx, (4, 4)) + loads = [] + rot = Rotation(r, ["a", "b"], shader_dir=root, on_load=lambda: loads.append(r.setup.name)) + rot.next() + rot.reload() + assert loads == [r.setup.name] * 2 + (root / r.setup.name / "image.glsl").write_text("garbage") + rot.reload() # broken edit: nothing reloaded + assert len(loads) == 2 + r.release() diff --git a/docs/FundingCAPTCHA.md b/docs/FundingCAPTCHA.md index e5f11ad..b5aceb9 100644 --- a/docs/FundingCAPTCHA.md +++ b/docs/FundingCAPTCHA.md @@ -30,6 +30,7 @@ Key references: - ADR-0016 — `--test-input` mouse-paint dev mode - ADR-0019 — game UI shell - ADR-0021 — shader player (`shader_app.py`, Shadertoy Setups) +- ADR-0022 — shader player blob records (`blobs` channel source) - `app.py`, `body_grid.py`, `silhouette.py`, `games/`, `ScreenSavers/` --- @@ -57,7 +58,9 @@ 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`). +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 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`. | Flag | Effect | |---|---| @@ -77,7 +80,7 @@ To run it as the show, point `deploy/captcha.service`'s `ExecStart` at `shader_a Per-machine overrides go in `captcha-settings.local.json` (loaded second, shallow-merged on top — not gitignored, but per-host). Top-level fields: -- `shader_app` — shader player: `render_scale` (fraction of display resolution, default 0.5), `fps` (default 30) +- `shader_app` — shader player: `render_scale` (fraction of display resolution, default 0.5), `fps` (default 30), `blobs` (blob-record tracking for the `blobs` source, ADR-0022: `min_blob_pixels`, `min_confirm_frames`, `max_miss_frames`, `max_match_dist_px`, `smoothing_alpha`, `lost_hold_s`, `ease_s`; Body pixels and camera frames) - `camera` — Orbbec config (`serial`, `width`, `height`, `fps`, `pos_cm`, `rotation`) - `detection` — `depth_delta_mm`, `min_blob_pixels`, `min_depth_mm`, `max_depth_mm` - `depth_slabs[]` — `[{near_mm, far_mm, slab_id}, …]`; two slabs may share `slab_id` diff --git a/docs/adr/0021-fundingcaptcha-shader-player.md b/docs/adr/0021-fundingcaptcha-shader-player.md index 4e0e244..4ffb78a 100644 --- a/docs/adr/0021-fundingcaptcha-shader-player.md +++ b/docs/adr/0021-fundingcaptcha-shader-player.md @@ -1,5 +1,7 @@ # FundingCAPTCHA shader player — Shadertoy Setups driven by the depth camera +**Amended by:** [ADR-0022](0022-shader-player-blob-records.md) (a `blobs` channel source: boxes, stable IDs, lifecycle) + ## Context Some events want the FundingCAPTCHA screen to show procedural visuals that visitors bend by moving, instead of the Game Arc. Several people need to prototype those visuals in parallel, without the show hardware, and the results must run on the show Pi 5 unchanged. diff --git a/docs/adr/0022-shader-player-blob-records.md b/docs/adr/0022-shader-player-blob-records.md new file mode 100644 index 0000000..f446e47 --- /dev/null +++ b/docs/adr/0022-shader-player-blob-records.md @@ -0,0 +1,40 @@ +# ADR 0022 — Shader player blob records: boxes, stable IDs, lifecycle + +**Status:** Accepted +**Amends:** [ADR-0021](0021-fundingcaptcha-shader-player.md) + +## Context + +ADR-0021 gives Setups the Body (Buffer A: `.r` presence, `.g` nearness) and nothing else about the people in it. Some Setups want to draw *the tracker itself*: a box per person, a stable number that follows them, and whether the tracker is unsure, sure, guessing, or has given up on them. The first is a debug view of the tracker, styled as a bird hunting visitors ([mockup](../superpowers/specs/2026-11-04-hunter-bird-mockup.html)). A shader can't recover identity across frames from a mask. Faking it in a buffer would show tracking bugs the real tracker doesn't have, and would hide the ones it does. + +## Decision + +1. **A new channel source, `"blobs"`.** It's bound like any other source in `setup.json`. On the Pi it's a `BLOB_SLOTS` × `BLOB_ROWS` (8 × 3) RGBA32F texture with `NEAREST` filtering, read with `texelFetch(iChannelN, ivec2(slot, row), 0)`. This is the exception to "never `texelFetch`": records have a fixed layout, not a resolution. + + | Row | `.x` | `.y` | `.z` | `.w` | + |---|---|---|---|---| + | 0 — box | x0 | y0 | x1 | y1 | + | 1 — motion | cx | cy | vx | vy | + | 2 — identity | id | state | age_s | nearness | + + Positions are Body UV (y up, the same space as sampling Buffer A); velocity is UV per second. `id` is 1–999, exact even if a Setup copies it into a 16-bit buffer. `state` is 0 empty, 1 tentative, 2 confirmed, 3 coasting, 4 lost. `age_s` is seconds in the current state. `nearness` is the blob's mean `.g`. + +2. **Slots are stable.** A track keeps its slot from birth to the end of its lost hold, so a Setup can keep per-slot state (trails, interest, lock) in its own buffers. When all eight slots are taken, further tracks aren't shown until a slot frees. + +3. **Blobs come from the Body, not the camera frame.** `body_blobs.py` finds 8-connected regions in the packed Body (after reprojection and ROI crop) and runs IIVision's `BlobStabilizer` on their centroids in Body pixels. Boxes line up with the silhouette Setups see by construction. `BlobStabilizer` gains `update_tracks()`, which reports every live track (unconfirmed and missed ones included) with its matched detection and the IDs that expired. `update()` is unchanged. + +4. **Lifecycle:** tentative until `min_confirm_frames` → confirmed → coasting while missed (the box drifts on its last velocity, slowing) → lost when the stabilizer drops it, held `lost_hold_s` → empty. A tentative track that expires goes straight to empty: it never was anyone. + +5. **Camera rate in, render rate out.** Tracking runs once per camera frame (about 10 fps). Records are rebuilt every render frame, with box and centroid eased toward the latest camera estimate over `ease_s`, so boxes don't step at 10 Hz. Tunables are in `captcha-settings.json` → `shader_app.blobs`. + +6. **Only while needed.** Tracking runs only while the current Setup binds `"blobs"`, and resets when a Setup that doesn't is playing. + +7. **Prototyping.** In Shadertoy, `shaders/blob_painter.glsl` in a spare buffer stands in for the tracker. It mirrors `body_painter.glsl`'s people and controls, with the same layout and lifecycle. On the Pi, `--test-input` reads the painted Body back from the GPU at camera rate and runs the real tracker on it, so the boxes there are the real tracker's. `shaders/blob_debug/` is the reference reader. + +## Consequences + +- `scipy` is now always required by `shader_app.py`, not only with a camera. +- The blob painter's stand-in boxes are analytic (the painter's person shape), so in Shadertoy overlapping people get separate boxes where the real tracker would merge them into one blob. +- In Shadertoy the records are read from a buffer tab the Setup folder doesn't contain; `setup.json` names `"blobs"` where Shadertoy names that buffer. Setups that already use all of Buffers B–D can't prototype blobs in Shadertoy. +- Fixed with this change: the keyboard texture was created as an integer texture and read as 0 by every shader, so the painter's Shift, Space and C never worked in `--test-input`. +- Not yet measured on the Pi 5: tracking cost per camera frame (about 2.5 ms on a desktop x86 at 480 × 480), and how often mask flicker splits or merges a still person into new IDs. Tune `min_blob_pixels`, `max_match_dist_px` and the frame counts on site. diff --git a/docs/superpowers/plans/2026-11-04-hunter-bird.md b/docs/superpowers/plans/2026-11-04-hunter-bird.md new file mode 100644 index 0000000..14e9806 --- /dev/null +++ b/docs/superpowers/plans/2026-11-04-hunter-bird.md @@ -0,0 +1,470 @@ +# Hunter Bird 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, `hunter_bird`: the blob tracker's real boxes, IDs and lifecycle, drawn as a bird in a tree hunting visitors. You see it only as its eye. Move and it notices you; freeze and it loses interest; flail and it locks on and stamps you, once. + +**Architecture:** A Shadertoy-style Setup folder with three tabs: +- **Buffer B** is "Bird state". It's a small grid of texels holding: + - per slot: tracked ID, motion, interest, stamped, previous box, footprint head + - global: target, lock, strike, reticle, lids, blinks + - a footprint ring +- **Image** draws the world from the Body, the field notes from the `blobs` records, the attention from B, and the eye from B. +- **Common** holds every tunable constant, the palette, the record and state accessors, the glyph SDFs and the canopy layout. + +The tests drive the Setup headlessly with moderngl. Records come from the real `BodyBlobs` tracker run on synthetic masks with a fake clock, so record noise is realistic. + +**Tech Stack:** GLSL (Shadertoy dialect, wrapped to GLSL 330 by `shadertoy.py`), Python 3, pytest, moderngl, numpy. + +**Spec:** `docs/superpowers/specs/2026-11-04-hunter-bird-design.md`. The mockup is `docs/superpowers/specs/2026-11-04-hunter-bird-mockup.html`. The `blobs` contract is `docs/adr/0022-shader-player-blob-records.md` and `shaders/README.md` → "Blobs". Read all three before starting. Open the mockup in a browser and scrub it. + +## 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. Keep stored values small, positions in UV, IDs ≤ 999, and time as bounded "seconds since" counters clamped at `T_CLAMP`, never absolute `iTime`. + - `HW_PERFORMANCE` is 0. No textures. Filtering is linear and clamped. +- **The `blobs` contract is read-only.** If you need more from the tracker, stop and raise it; don't work around it. +- **Shadertoy:** every tab must paste unchanged, with `body_painter.glsl` in Buffer A and `blob_painter.glsl` in Buffer D. In Shadertoy, B reads D from the previous frame (render order A, B, C, D); on the Pi, blobs are current. Behaviour must not depend on that one-frame difference. +- **Tunables:** every threshold, gain and timing is a named constant at the top of `common.glsl`, so it can be retuned on site with **R** (reload). +- **Units:** size everything in screen heights (`iResolution.y`). Mockup value N px → `N / 360.0`. Records are in Body UV. Convert with `uv * iResolution.xy`, then divide by `iResolution.y`. Glyphs, tags, reticle and stamp are sized in screen heights so they don't stretch. +- **Palette (exact):** + - paper `#f6f1e4` + - ink `#111111` + - slate `#719aad` + - deep blue `#2076a3` + - rust `#bc4c26` + - yellow `#fff200` + - orange `#e8862f` + - violet `#6b5b95` + - warm white `#fffaf0` +- **Repo rules:** never write a show, artist or album name into any file. Never commit anything under `ShowControl/FundingCAPTCHA/images/private/`. +- **Branch:** if PR "Hunter bird: design, blob-records contract, and build plan" (`claude/hunter-bird-mockup`) has merged, branch `claude/hunter-bird` off `main`. Otherwise branch off `claude/hunter-bird-mockup` and stack. +- **Tests:** run from `ShowControl/FundingCAPTCHA/`, e.g. `python3 -m pytest test_hunter_bird.py -v`. GL tests need a headless context. In a fresh container, `apt-get install -y libegl1 libgl1-mesa-dri` gives llvmpipe. Baseline: `make test` is all green, so it must stay that way. + +## Review Focus + +1. **A still person must be able to escape.** On the Pi, records ease toward each 10 Hz camera frame, so a still person's box breathes by a pixel or two, and edge flicker changes its size. That must read as motion 0, and interest must drain. Pinned by `test_flickering_still_person_has_no_motion` (Task 1) and `test_freezing_loses_the_bird` (Task 2). +2. **Once per person.** The same ID is never stamped twice, however long it flails. A new ID in a reused slot is stampable. Pinned by `test_strike_once_per_person` and `test_reused_slot_resets_stamp` (Task 2). +3. **Hours of running in 16-bit buffers.** Every counter stays bounded, and no absolute time is stored. Pinned by `test_state_stays_bounded_when_idle` (Task 2). +4. **Only confirmed, unstamped records can be the target.** Pinned by `test_only_confirmed_unstamped_can_be_target` (Task 2). +5. **Frame rate on the Pi.** Per-pixel work is bounded by early-outs: canopy only in its band, notes only near their box, footprints by distance. If fps suffers, cut in the spec's order: canopy leaves, membrane streaks, footprints. + +--- + +## File Structure + +All under `ShowControl/FundingCAPTCHA/`: + +| File | Responsibility | +|---|---| +| `shaders/hunter_bird/setup.json` | Channel bindings, `duration_s: 120` | +| `shaders/hunter_bird/common.glsl` | Constants, palette, record and state accessors, noise, glyph SDFs (seven-segment digits, `?`, tick), canopy layout | +| `shaders/hunter_bird/buffer_b.glsl` | Bird state (layout below) | +| `shaders/hunter_bird/image.glsl` | World, field notes, attention, eye | +| `shaders/setups.json` | Add `"hunter_bird"` | +| `test_hunter_bird.py` | GL behaviour tests | +| `docs/FundingCAPTCHA.md` | List `hunter_bird` among the Setups in rotation, linking the spec | + +**Channel bindings (`setup.json`):** +```json +{ + "_comment": "Hunter bird: the blob tracker's notes drawn as a bird hunting visitors. Spec: docs/superpowers/specs/2026-11-04-hunter-bird-design.md", + "duration_s": 120, + "channels": { + "buffer_b": ["B", "blobs"], + "image": ["A", "B", "blobs"] + } +} +``` +In Shadertoy, "blobs" is Buffer D running `blob_painter.glsl` (its channels: D, Keyboard). + +**Buffer B layout** (texel coordinates `(x, y)`; `k` = slot 0–7; every texel not listed writes `vec4(0)`): + +| Texel | `.x` | `.y` | `.z` | `.w` | +|---|---|---|---|---| +| `(k, 0)` SLOT | id (0 = none) | motion 0–1 | interest 0–1 | stamped 0/1 | +| `(k, 1)` PREV | x0 | y0 | x1 | y1 — this slot's box last frame (UV), for the size-change rate | +| `(k, 2)` HEAD | last print x | last print y | side 0/1 | seconds since last print | +| `(0, 3)` ATTN | target slot + 1 (0 = none) | lock 0–1 | seconds since strike | id struck last | +| `(1, 3)` RETICLE | x | y (screen UV) | radius (screen heights) | alpha | +| `(2, 3)` MOOD | lid openness | shiver 0–1 | wander anchor x | wander anchor y | +| `(3, 3)` BLINK | seconds since blink start (negative = scheduled) | idle countdown s | seconds everyone present has been stamped | — | +| `(4, 3)` STAMP | x | y (screen UV) | — | — | +| `(k·PRINTS + j, 4)` PRINT | x | y (Body UV) | age s | angle | + +In the PRINT rows, `j = 0` is the newest print. + +`PRINTS = 12`, so row 4 needs a buffer at least 96 texels wide. The Pi renders at 640×360; tests use 128×72. + +Cross-texel reads see the previous frame (B reads itself). So: +- A strike decided in ATTN on frame N is applied to its SLOT on frame N+1. +- SLOT matches on **id**, not slot: `stamped = 1` when `ATTN.w == SLOT.id && ATTN.z < STRIKE_ACK_S`. +- Lock is 0 right after a strike, so no second strike can happen in between. + +--- + +### Task 1: Skeleton, Common, per-slot state (motion, interest, id reset) + +**Files:** +- Create: `shaders/hunter_bird/setup.json` (content above) +- Create: `shaders/hunter_bird/common.glsl` +- Create: `shaders/hunter_bird/buffer_b.glsl` (SLOT, PREV and HEAD rows; the other texels pass through 0 for now) +- Create: `shaders/hunter_bird/image.glsl` (stub: paper) +- Modify: `shaders/setups.json` → `["ripples", "endless_sunset", "hunter_bird"]` +- Create: `test_hunter_bird.py` + +**Interfaces produced in `common.glsl`** (exact names; values are starting points from the mockup, and Task 7 tunes them): +```glsl +const int SLOTS = 8, PRINTS = 12; +const float T_CLAMP = 99.0; // every "seconds since" counter clamps here +// motion (per confirmed slot) +const float SPEED_FULL = 0.39; // screen heights/s of speed that alone reads as motion 1 +const float SIZE_FULL = 0.61; // screen heights/s of (w + h) change that alone reads as motion 1 +const float MOTION_FLOOR = 0.15; // subtracted: eased-box breathing and flicker live below this +const float MOTION_RISE_S = 0.15, MOTION_FALL_S = 0.3; +// interest, target, lock +const float MOTION_ON = 0.2; // above: interest rises; below: drains +const float INTEREST_GAIN = 0.9; // per second, × motion +const float INTEREST_DRAIN = 0.45; // per second +const float TARGET_MIN = 0.25, TARGET_SWITCH = 0.2, SWITCH_KEEP = 0.3; +const float LOCK_RATE = 0.32; // per second, × motion² +const float LOCK_DRAIN = 0.3; +const float STRIKE_ACK_S = 0.2; // window in which a SLOT accepts the strike for its id +// reticle +const float RET_R_LOOSE = 58.0/360.0, RET_R_LOCKED = 20.0/360.0, RET_R_WANDER = 66.0/360.0; +const float RET_K_MIN = 3.0, RET_K_GAIN = 6.0, RET_K_WANDER = 1.2; // spring rates, 1/s +const float WANDER_X = 90.0/360.0, WANDER_Y = 40.0/360.0, WANDER_RATE = 0.6; +// mood (lid openness 0–1) and blinks +const float LID_DROWSY = 0.42, LID_DROOP = 0.05, LID_SQUINT = 0.7, LID_WIDE = 0.96, LID_STRIKE = 1.0, LID_SMUG = 0.58; +const float LID_OPEN_RATE = 9.0, LID_CLOSE_RATE = 3.0; +const float STRIKE_WIDE_S = 0.9, STRIKE_SMUG_S = 3.2, ALL_STAMPED_DROWSY_S = 6.0; +const float BLINK_S = 0.32, BLINK_NEW_DELAY = 0.15, BLINK_STRIKE_DELAY = 2.3, IDLE_BLINK_MIN = 6.0, IDLE_BLINK_SPAN = 4.0; +// strike sequence +const float PUNCH_ZOOM = 0.38, PUNCH_RISE_S = 0.12, PUNCH_HOLD_S = 0.9, PUNCH_END_S = 1.7; +const float STAMP_SLAM_S = 0.12, STAMP_FADE_START_S = 2.6, STAMP_END_S = 3.2; +// footprints +const float PRINT_EVERY_S = 0.33, PRINT_MIN_SPEED = 25.0/360.0, PRINT_LIFE_S = 4.0; +// eye +const float IRIS_R_OPEN = 345.0/360.0, IRIS_R_LOCKED = 250.0/360.0, IRIS_R_STRIKE = 380.0/360.0, IRIS_BAND = 34.0/360.0; +// line weights +const float INK_W = 2.2/360.0, INK_W_HEAVY = 2.6/360.0, INK_W_LIGHT = 1.6/360.0; +``` +Also in `common.glsl`: +- Palette: `const vec3 PAPER, INK, SLATE, DEEP, RUST, YELLOW, ORANGE, VIOLET, WARM_WHITE`. +- State: `const float S_EMPTY = 0., S_TENTATIVE = 1., S_CONFIRMED = 2., S_COASTING = 3., S_LOST = 4.` +- Record accessors: `vec4 recBox(sampler2D b, int k)`, `vec4 recMot(...)`, `vec4 recIdn(...)` (rows 0–2). +- State accessors: `vec4 bSlot(sampler2D s, int k)`, `bPrev`, `bHead`, `bAttn`, `bReticle`, `bMood`, `bBlink`, `bStamp`, `bPrint(s, k, j)`. +- `vec2 toScreen(vec2 uv)`: UV → screen-height units (`uv * iResolution.xy / iResolution.y`). + +**SLOT rules (per frame):** +- If the record's id ≠ stored id, or the record state is empty: reset SLOT (id = record id, everything else 0), PREV = this box, and HEAD (`since = T_CLAMP`). +- Motion target: + - only for confirmed records; + - `raw = speed_h / SPEED_FULL + abs(d(w_h + h_h)/dt) / SIZE_FULL`, where `speed_h` is the record velocity in screen heights/s and `w_h`, `h_h` are box size in screen heights against PREV; + - `target = clamp(raw - MOTION_FLOOR, 0, 1)`; + - smoothed with the rise and fall time constants. +- Interest: `+= dt * INTEREST_GAIN * motion` when `motion > MOTION_ON`, else `-= dt * INTEREST_DRAIN`; clamp to 0–1. Held at 0 if stamped or not confirmed. +- Stamped: set when `ATTN.w == id && ATTN.z < STRIKE_ACK_S`, and kept until reset. +- Reset everything when `iFrame == 0`. + +- [ ] **Step 1: Branch** (see Global Constraints), then check `git status` is clean. + +- [ ] **Step 2: Write the test rig and the failing tests.** `test_hunter_bird.py`: + +```python +"""Behaviour tests for the hunter_bird Setup (shaders/hunter_bird/).""" +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" / "hunter_bird" +SIZE = (128, 72) # render; ≥ 96 wide for the PRINT row +BODY = (64, 40) # camera ROI (w, h) +SLABS = [{"near_mm": 1000, "far_mm": 4000}] +DT = 1 / 30 +# The shipped defaults, except a smaller min_blob_pixels for the small test Body. +BLOBS = BlobConfig(min_blob_pixels=20) + +# ctx fixture: copy test_shadertoy.py's module-scoped `ctx` fixture verbatim. + +class Rig: + """Renderer + the real BodyBlobs on a fake clock: camera at 10 Hz, render at 30 Hz.""" + def __init__(self, ctx): + 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(DT, (0, 0, 0, 0)); self.t += DT + + def b(self) -> np.ndarray: + """Buffer B as float32 (h, w, 4), row 0 = texel y 0.""" + t = self.r._buffers["buffer_b"].read_tex + return np.frombuffer(t.read(), dtype=np.float16).astype(np.float32).reshape(t.size[1], t.size[0], 4) + + def slot(self, k): return self.b()[0, k] # id, motion, interest, stamped + def attn(self): return self.b()[3, 0] # target+1, lock, since strike, id struck + def image(self): return np.frombuffer(self.r.output.read(components=4), np.uint8).reshape(SIZE[1], SIZE[0], 4) + +def rect(x0, y0, x1, y1): + m = np.zeros(BODY[::-1], dtype=bool); m[y0:y1, x0:x1] = True; return m + +def standing(n, x=36): return [rect(x, 8, x + 10, 36)] * n +def walking(n, step=1): # ~0.28 screen heights/s here: motion ≈ 0.57, so lock stays < 1 for 6 s at the defaults + return [rect(6 + (k * step) % 40, 8, 16 + (k * step) % 40, 36) for k in range(n)] +def flailing(n): # arms out and in: the box width swings by 12 px at ~2 Hz + return [rect(30 - 6 * (k % 5 in (1, 2, 3)), 8, 40 + 6 * (k % 5 in (1, 2, 3)), 36) for k in range(n)] +def flickering(n, seed=0): + """A still person whose 1-px border toggles randomly each camera frame.""" + rng = np.random.default_rng(seed); base = rect(36, 8, 46, 36); out = [] + for _ in range(n): + m = base.copy(); edge = rect(35, 7, 47, 37) & ~rect(37, 9, 45, 35) + m[edge] = rng.random(edge.sum()) < 0.5; out.append(m) + return out +``` + +Task 1 tests: +```python +def test_slot_tracks_record_id(ctx): + rig = Rig(ctx); rig.run(standing(8)); assert rig.slot(0)[0] == 1 + +def test_walking_has_motion(ctx): + rig = Rig(ctx); rig.run(walking(20)); assert rig.slot(0)[1] > 0.3 + +def test_flickering_still_person_has_no_motion(ctx): # Review Focus 1 + rig = Rig(ctx); rig.run(flickering(40)); assert rig.slot(0)[1] < 0.05 + +def test_interest_rises_with_motion_and_drains_still(ctx): + rig = Rig(ctx); rig.run(walking(30)); hi = rig.slot(0)[2] + rig.run([walking(31)[-1]] * 40); assert hi > 0.5 and rig.slot(0)[2] < hi - 0.5 + +def test_tentative_has_no_motion_or_interest(ctx): + rig = Rig(ctx); rig.run(walking(2)) # still tentative (min_confirm_frames 4) + assert rig.slot(0)[1] == 0 and rig.slot(0)[2] == 0 +``` + +- [ ] **Step 3: Run them to verify they fail**, then implement. +- [ ] **Step 4: Implement Task 1**, then run `python3 -m pytest test_hunter_bird.py test_shadertoy.py -v`. Expected: the new tests pass, including `test_listed_setups_compile[hunter_bird]`. + - If the flicker test fails, raise `MOTION_FLOOR`; if the walking test fails, lower it. + - Record the final value in the commit message. +- [ ] **Step 5: Commit** "Hunter bird Setup: skeleton and per-slot bird state". + +--- + +### Task 2: Attention — target, lock, strike once per person, reticle, mood, blinks + +**Files:** Modify `buffer_b.glsl` (the ATTN, RETICLE, MOOD, BLINK and STAMP texels) and `test_hunter_bird.py`. + +**Rules** (all from the spec's Behaviour section): +- **Target** (ATTN.x): + - Candidates are confirmed, unstamped slots with interest > `TARGET_MIN`. + - Keep the current target unless it drops out or a rival beats it by `TARGET_SWITCH`. + - On a switch, `lock *= SWITCH_KEEP`. +- **Lock:** `+= dt * LOCK_RATE * motion²` while the target's motion > `MOTION_ON`; otherwise `-= dt * LOCK_DRAIN`. +- **Strike** at lock ≥ 1: + - `ATTN.z = 0`, `ATTN.w = target id`, lock = 0; + - STAMP = the target's box centre (screen UV); + - schedule a blink at `-BLINK_STRIKE_DELAY`. + - STAMP then follows that id's box while its record lives. +- **Reticle:** + - **With a target:** springs to `(box centre x, y1 − 0.3·height)` at `RET_K_MIN + RET_K_GAIN · interest`. Radius goes from `RET_R_LOOSE` to `RET_R_LOCKED` with lock. Alpha → 1. + - **No target, someone unstamped present:** drifts around the wander anchor (set where the target was lost) by noise of `iTime · WANDER_RATE`, at `RET_K_WANDER`, alpha → 0.55. + - **Otherwise:** alpha → 0. +- **Mood** (lids): follow the spec's table. Open at `LID_OPEN_RATE`, close at `LID_CLOSE_RATE`. "Everyone present is stamped" uses BLINK.z. +- **Shiver** (MOOD.y): → 1 when a new id appears; decays at about 1.5/s. +- **Blinks** (BLINK.x is seconds since the blink started, negative while scheduled): + - `-BLINK_NEW_DELAY` when any record carries an id no SLOT holds yet; + - `-BLINK_STRIKE_DELAY` on a strike; + - idle when the countdown reaches 0, then reset the countdown to `IDLE_BLINK_MIN + IDLE_BLINK_SPAN · hash`. +- Clamp every counter at `T_CLAMP`. + +Tests (two people are `rect(4, 8, 14, 36)` and `rect(48, 8, 58, 36)`, animated independently): +```python +def test_flailing_becomes_target_and_strikes(ctx): + rig = Rig(ctx); rig.run(flailing(45)) # 4.5 s + a = rig.attn(); assert a[3] == 1 # id 1 struck + assert rig.slot(0)[3] == 1 # stamped + +def test_walking_alone_never_strikes(ctx): + rig = Rig(ctx); rig.run(walking(60)); assert rig.attn()[3] == 0 and rig.attn()[1] < 1 + +def test_freezing_loses_the_bird(ctx): # Review Focus 1 + rig = Rig(ctx); rig.run(walking(30)); assert rig.attn()[0] == 1 + rig.run(flickering(40)); assert rig.attn()[0] == 0 # a flickering still person: target dropped + +def test_strike_once_per_person(ctx): # Review Focus 2 + rig = Rig(ctx); rig.run(flailing(45)); rig.run(flailing(90)) + assert rig.attn()[0] == 0 and rig.slot(0)[2] == 0 # ignored after the stamp, despite flailing + +def test_reused_slot_resets_stamp(ctx): # Review Focus 2 + rig = Rig(ctx); rig.run(flailing(45)); rig.run([np.zeros(BODY[::-1], bool)] * 30) # leaves; lost; slot frees + rig.run(flailing(45)); assert rig.slot(0)[0] == 2 and rig.slot(0)[3] == 1 # new id, stamped anew + +def test_only_confirmed_unstamped_can_be_target(ctx): # Review Focus 4 + # Flail for 2 frames only (tentative): no target. Then two people, one stamped: the other gets targeted. + +def test_flailer_steals_target_from_walker(ctx): + # The walker at the left becomes the target; then the right one flails: the target switches to its slot. + +def test_mood(ctx): + # Empty: lids ≈ LID_DROWSY. Walker targeted: > 0.9. Flickering still, no target: ≈ LID_SQUINT. + +def test_state_stays_bounded_when_idle(ctx): # Review Focus 3 + rig = Rig(ctx); rig.r.render(200.0, (0, 0, 0, 0)) # one huge dt + rig.run([np.zeros(BODY[::-1], bool)] * 3) + assert np.isfinite(rig.b()).all() and np.abs(rig.b()).max() <= 99.0 +``` +Write the three stubbed tests out in full. Each needs a two-person mask helper. The flailing timings depend on the defaults; if `test_flailing_becomes_target_and_strikes` needs more than ~4 s, raise `LOCK_RATE` rather than lengthening the test. + +- [ ] Steps: write the tests → see them fail → implement → pass → commit "Hunter bird: attention, strike once per person, mood and blinks". + +--- + +### Task 3: Footprints + +**Files:** Modify `buffer_b.glsl` (the HEAD row and the PRINT row) and `common.glsl`. + +- `bool emitsPrint(vec4 head, vec4 idn, vec4 mot, float dt)`: the record is confirmed, the speed in screen heights/s ≥ `PRINT_MIN_SPEED`, and `head.w + dt ≥ PRINT_EVERY_S`. HEAD and every PRINT texel of that slot evaluate the same function on the same inputs, so they agree. +- On emit: + - HEAD = (box centre x, box y0, 1 − side, 0); + - PRINT j = 0 is the new print (offset by side, angle `atan(vy, vx)`); + - PRINT j > 0 takes j − 1's previous value. +- Otherwise, prints age by dt (clamp `T_CLAMP`). +- Prints persist across an id change and fade on their own. + +Tests: +- `test_walking_leaves_prints`: after 3 s of walking, at least 6 prints with age < `PRINT_LIFE_S`. +- `test_standing_leaves_no_prints`. +- `test_prints_age_out`: after walking and then 5 s empty, every print age is ≥ `PRINT_LIFE_S`. + +- [ ] Steps as before → commit "Hunter bird: footprints". + +--- + +### Task 4: Image — world (paper, ground, people, canopy) + +**Files:** Modify `image.glsl` and `common.glsl` (canopy layout, noise). + +- Punch-in zoom: + - `zoom = 1 + PUNCH_ZOOM · punch(ATTN.z)`, where `punch` rises over `PUNCH_RISE_S`, holds to `PUNCH_HOLD_S` and eases out by `PUNCH_END_S`; + - world UV = `stamp + (uv − stamp) / zoom`, computed in screen-height units so it zooms evenly; + - it applies to the world and the notes, never to the eye. +- **People:** + - The Body's `.r` gives fill (warm white) and outline (ink). The outline is where `.r` at any of 8 taps at radius `INK_W_HEAVY` differs from the centre. + - Hatching: slate diagonal lines inside the Body, in the lower 40% of the box of any non-empty slot containing the pixel. Spacing shrinks with Body `.g`. +- **Ground:** three or four wavy slate lines below ~0.17 screen heights from the bottom. +- **Canopy:** + - Branches across the top corners and a perch at the bottom left. + - About 20 leaves in the top band plus two or three per side near the top corners, as `const` arrays in `common.glsl`. + - Leaf: a two-quadratic lens SDF with a midrib. + - Sway: `0.07 · sin(iTime · 0.9 + i)` plus shiver. + - Early-out: skip the leaf loop unless the pixel is in the top band, the corner columns or the perch box. + +Tests (colour classes: `near(px, INK)` means within 40 per channel): +- `test_person_has_white_fill_and_ink_outline`: standing; the centre of the person ≈ warm white, the edge column ≈ ink. +- `test_hatching_only_in_lower_box`: standing; the lower-box interior contains slate pixels, the upper-box interior has none. +- `test_canopy_stays_in_its_band`: empty; the screen centre ≈ paper; the top row has ink somewhere. + +The eye (Task 6) will later cover the screen edges. Pick world sample points that stay inside the lids at every openness ≥ 0.42, for example within 0.2 H of the screen centre. Check the canopy on the top row now, and accept that Task 6 may move that check inward. + +- [ ] Steps as before → commit "Hunter bird: world". + +--- + +### Task 5: Image — field notes and attention + +**Files:** Modify `image.glsl` and `common.glsl` (glyph SDFs). + +Per slot, early-out unless the pixel is within (box expanded by tag size + `RET_R_LOOSE`): + +| State | Drawing | +|---|---| +| Tentative | dashed slate rectangle (dash ~4/5 px, crawling) and a small slate `?` above the top-right corner | +| Confirmed | ink corner brackets. Arm `clamp(0.22 · min(w, h), 10/360, 24/360)`, wobble ~1.2 px from noise. `INK_W_HEAVY` for the target, `INK_W_LIGHT` at 0.75 alpha for others | +| Coasting | dashed brackets, alpha `1 − age_s / COAST_FADE` (a constant, starting at 0.8 s, which matches the tracker's `max_miss_frames` at 10 fps), and an ink `?` | +| Lost | brackets fading from 0.4 s; zigzag scribble progressive over 0.25 s | + +Then, per slot: +- **Tag:** + - String from the top-right corner, swinging by `clamp(−vx · k, ±0.5) + small sine`. + - Body 12 + 9n px wide by 17 px; yellow fill for the target, paper otherwise; ink outline and a hole. + - Seven-segment digits of the id with ±0.5 px wobble. + - A small rust tick on stamped slots. +- **Arrow:** deep blue, from the centroid along `vel · 0.35 s`, drawn when speed > 22/360 screen heights/s, with a chevron head. +- **Prints:** slate ovals (4.5 × 2.6 px) rotated by angle, alpha `0.85 · (1 − age / PRINT_LIFE_S)`. Loop over all `SLOTS · PRINTS`, early-out on distance. +- **Reticle:** + - From RETICLE: a wobbly rust circle, four diagonal ticks, tremble `1.4 px · lock`. + - Lock arc: `5 px` thick at `r + 7 px`, from 12 o'clock. + - A rust `?` when there's no target. +- **Stamp** (while `ATTN.z < STAMP_END_S`): + - Double ring (36 and 28 px), 12 dashes from 42 to 50 px, tick; rotated −0.22 rad; rust. + - Scale from 2.4 to 1 over `STAMP_SLAM_S`; fade from `STAMP_FADE_START_S`. + +Tests: +- `test_confirmed_box_has_ink_brackets`: an ink pixel near the box's top-left corner. +- `test_tentative_box_is_slate_and_dashed`: slate pixels and paper gaps along one edge. +- `test_target_tag_is_yellow`: a yellow pixel near the top-right corner. +- `test_stamp_appears_after_strike_then_clears`: rust at the stamp point shortly after the strike; none after `STAMP_END_S`. +- `test_reticle_hidden_when_empty`: no rust anywhere in an empty room. + +- [ ] Steps as before → commit "Hunter bird: field notes, reticle and stamp". + +--- + +### Task 6: Image — the eye (iris band, eyelids, membrane) + +**Files:** Modify `image.glsl` and `common.glsl`. + +**Screen-space**, after the world and notes, centred on the screen, in screen-height units: +- **Iris:** + - Inner radius `Ri = mix(IRIS_R_OPEN, IRIS_R_LOCKED, smoothstep(0, 1, lock))`, blending to `IRIS_R_STRIKE` for `STRIKE_WIDE_S` after a strike. + - Yellow annulus `[Ri, Ri + IRIS_BAND]` with ~110 short ink striations of hashed length, and ink edges (3 px inner, 2 px outer). + - Beyond `Ri + IRIS_BAND`: paper with slate hatching at 0.55. +- **Lids:** + - Profile `prof(x) = max(0, 1 − ((x − cx) / (0.76·W))²)` in pixels. Top rim `cy + 0.64·H·open·prof`, bottom rim `cy − 0.6·H·open·prof` (GL y up). + - Paper with slate hatching; a yellow band 5 px wide, 4 px inside the rim; a 3 px ink rim; feather scallops (radius 9 px, every 22 px) 22 px beyond the rim. +- **Membrane:** + - While `0 ≤ BLINK.x ≤ BLINK_S`: `u = BLINK.x / BLINK_S`, edge `x = −30 + sin(π·u)·(W + 90)` px plus slant and wave. + - Fill warm paper at 0.82; seven slate streaks parallel to the edge; ink edge. + +Tests: +- `test_lids_drowsy_when_empty`: in an empty room, a pixel 0.35 H above centre is lid (not paper-only: hatching present). +- `test_lids_open_on_target`: walking; the same pixel is in the world. +- `test_iris_band_narrows_view_on_lock`: a pixel at radius 0.8 H from centre is world at lock 0 and yellow or socket near full lock. Drive lock by flailing for ~1.5 s. + +- [ ] Steps as before → commit "Hunter bird: the eye". + +--- + +### Task 7: Look and feel, Shadertoy check, frame rate, docs + +**Files:** `common.glsl` (constants only) and `docs/FundingCAPTCHA.md`. + +- [ ] **Step 1: By eye with the mouse.** Run `python3 shader_app.py --test-input --windowed --setup hunter_bird`. + - Walk the arc: empty (drowsy) → hold and drag (noticed, tagged) → hold still (lock drains, `?`) → shake the mouse hard (strike) → keep shaking (ignored: once per person) → release (coasting, lost). + - Use **Space** to park a second person and **C** to clear. + - Compare it with the mockup and tune constants only, using **R**. +- [ ] **Step 2: Mock camera.** Run `python3 shader_app.py --mock-camera --windowed --setup hunter_bird` for 2 minutes. Note the fps lines. + - If the frame rate falls below the target on the Pi, cut in the spec's order: leaf count, membrane streaks, footprints. +- [ ] **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: B, D) and `image.glsl` (channels: A, B, D). + - Confirm it compiles and behaves the same. If no browser is available, say so in the hand-off rather than claiming it was checked. +- [ ] **Step 4: Docs.** In `docs/FundingCAPTCHA.md` → "Shader player", add `hunter_bird` to "Setups in rotation" with a link to the spec. +- [ ] **Step 5: Full suite.** Run `make test` from the repo root. It must be all green. +- [ ] **Step 6: Commit** "Hunter bird: tuning pass and docs". The hand-off must state: + - the constant values that changed; + - the fps seen with `--mock-camera`, and on the Pi if it was run there; + - that "freeze and it loses you" (the noise floor and `MOTION_ON`) has **not** been verified with the real Orbbec camera, unless it was. That check needs `--camera` on site: a visitor stands still for 5 s and must lose the reticle; one who flails must be stamped within ~3 s. diff --git a/docs/superpowers/specs/2026-11-04-hunter-bird-design.md b/docs/superpowers/specs/2026-11-04-hunter-bird-design.md new file mode 100644 index 0000000..4a70359 --- /dev/null +++ b/docs/superpowers/specs/2026-11-04-hunter-bird-design.md @@ -0,0 +1,117 @@ +# Hunter Bird — shader player Setup + +A new FundingCAPTCHA shader player Setup (ADR-0021): a debug view of the blob tracker, drawn as a bird in a tree hunting the visitors. It reads the tracker's boxes, IDs and lifecycle through the `blobs` source (ADR-0022). + +## Intent + +Role reversal: the bird is birdwatching *you*. The screen is what the bird sees, framed by its eyelids. The tracker's real boxes, IDs and states are the bird's field notes on the specimens in the clearing. Its attention, a reticle, follows whoever moves most. Hold still and it loses interest. Let it lock on and it strikes, stamping you with a tick: verified. It's a CAPTCHA joke drawn in the storybook ink style of The Community Garden concept art. + +## Goals + +- An honest tracker debug view. Every box, number and state change on screen is the real tracker's record, not a dramatisation. +- A bird you only ever see as its eye: eyelids for mood, a nictitating membrane for blinks, a yellow iris band that closes in as it locks on. +- A play loop visitors discover on their own: + - **Move** and the bird notices you. + - **Freeze** and it loses interest. + - **Flail** and it locks on and strikes. + - **Everyone stamped** and it's smug, then drowsy. +- It runs on the show Pi 5 in the existing shader player, and can be prototyped in Shadertoy with `body_painter.glsl` and `blob_painter.glsl`. + +## Non-goals + +- No depth image or contour map. The background is the empty clearing on paper. +- No words, letters or numbers other than the IDs. Language-free: numbers and glyphs only (`?`, tick). +- The bird's body is never seen. Eyelids, membrane and iris only. +- No sound. + +## Anti-goals + +- Fake tracking. A box with no tracker record behind it, or a state the tracker didn't report. +- Mask flicker reading as movement. A visitor standing still must be able to escape the bird. +- A frame so busy the people get lost. Colour stays a sparse accent; the specimens and the reticle carry the scene. + +## Reference mockup + +[`2026-11-04-hunter-bird-mockup.html`](2026-11-04-hunter-bird-mockup.html) is the approved look and behaviour, as a runnable 2D-canvas sketch with a scrubber. Its stand-in figures and simulated tracker are not part of the spec. The Setup uses the Body and the `blobs` records. Its constants are the starting values. Where this spec and the mockup differ, this spec wins. The differences are listed under each section. + +## The look (decided) + +All sizes are in screen heights (mockup px / 360). Colour comes from the sunset palette: paper `#f6f1e4`, ink `#111111`, slate `#719aad`, deep blue `#2076a3`, rust `#bc4c26`, yellow `#fff200`, orange `#e8862f`, violet `#6b5b95`, warm white `#fffaf0`. + +Back to front: + +1. **World** (zooms on a strike): + 1. **Paper**: the clearing. + 2. **Ground**: a few thin wavy slate lines across the lower part of the screen. + 3. **People**: the Body as a warm-white fill with an ink outline (~2.6 px). Slate diagonal hatching fills the lower ~40% of each person's box, reading as legs, and gets denser with nearness. A Body pixel inside no box gets fill and outline but no hatching. + 4. **Canopy**: branches across the top corners and a perch in the bottom-left foreground, drawn as ink-outlined paper with short hatch strokes. Leaves are ink-outlined: mostly paper, some slate, a few deep blue, rarely violet, some hatched, all swaying gently, and they shiver briefly when someone new appears. *Differs from the mockup:* the side leaves are thinned to two or three per side near the top corners, so the canopy reads as a top band. +2. **Field notes** (the tracker; zooms with the world, drawn on top of the canopy): + - **Boxes** are wobbly hand-drawn corner brackets. Arm length is 22% of the box's shorter side, clamped to 10–24 px. + - **Tentative:** a dashed slate rectangle with a small slate `?`. + - **Confirmed:** ink brackets, heavier on the bird's target. + - **Coasting:** dashed ink brackets fading with `age_s`, plus a `?`. + - **Lost:** ink brackets scribbled through with a zigzag drawn over ~0.25 s, then fading. + - **Tag**: a bird-ringing tag hanging by a string from the box's top-right corner, swinging with velocity. The ID is drawn in wobbly seven-segment digits (1–3 digits). The fill is yellow for the bird's target and paper otherwise. *Differs from the mockup:* a stamped visitor's tag carries a small rust tick for as long as their track lives. + - **Velocity**: a deep-blue ink arrow from the centroid, about 0.35 s of travel long, shown above a minimum speed. + - **Footprints**: small slate ovals left behind a walking track at the bottom of its box, alternating sides, fading over 4 s. +3. **Attention**: + - **Reticle**: a wobbly rust circle with four diagonal ticks. Its radius shrinks from ~58 px to ~20 px as lock builds, and it trembles slightly near full lock. A thick rust arc around it fills with lock. When the bird has no target, the reticle wanders, half-transparent, with a rust `?` inside. + - **Stamp**: a rust double ring with radiating dashes and a bold tick, rotated about −13°. It slams down from 2.4× scale to 1× in 0.12 s on the target's box centre, follows the box, and fades out by 3.2 s. +4. **Eye** (screen space, never zooms): + - **Iris band**: a yellow annulus ~34 px wide, centred on the screen, with short ink striations and ink edges. Outside it is a hatched socket. The inner radius goes from ~345 px with no lock to ~250 px at full lock, and snaps wide to ~380 px on a strike before easing back. + - **Eyelids**: top and bottom lids with almond-curved rims. They're paper with slate hatching, an ink rim, a yellow eye-ring just inside the rim, and feather scallops along the crease. Their openness is the bird's mood (Behaviour). + - **Nictitating membrane**: a translucent warm-paper sheet with an ink leading edge and faint slate streaks. It sweeps across from the left and back in ~0.32 s. This is the bird's blink. + +## Behaviour (decided) + +The bird's attention is separate from the tracker. The tracker says who is there; the bird decides who is interesting. + +- **Motion**, per confirmed slot (0–1): speed plus how fast the box's size is changing (waving arms grow and shrink the box), minus a noise floor, smoothed. Only confirmed records have motion. *Differs from the mockup:* the mockup measured clean positions; this measures the eased records, so the noise floor is real and must be tuned (below). +- **Interest**, per slot (0–1): rises while motion is above an "on" threshold, in proportion to motion, and drains while it's below. Tentative, coasting and lost records drain. **A stamped visitor's interest is held at 0.** +- **Target**: the confirmed, unstamped slot with the highest interest above 0.25. The bird only switches if a rival beats the current target by 0.2, and a switch keeps 30% of the lock. +- **Lock** (0–1): builds while the target moves (∝ motion²), so flailing locks in ~2 s and walking about never does. It drains while the target is still or there is none. +- **Strike**, **once per person**: at full lock the slot is marked stamped. That triggers the punch-in zoom, iris dilation, the stamp and a smug blink, and lock resets. The bird then ignores that visitor for the rest of their track. A new ID in the same slot (a different person, or the same person re-acquired after the tracker lost them) can be stamped again. The latter is accepted. +- **Reticle**: springs to the target's upper chest (30% down its box), faster the more interested the bird is. With no target and someone present, it drifts slowly around where it last was. With nobody present, or only stamped visitors, it fades out. +- **Eyelid mood** (openness 0 closed … 1 wide): + + | Situation | Openness | + |---|---| + | empty room | drowsy, 0.42 with a slow ±0.05 droop | + | someone present, no target | suspicious squint, 0.7 | + | target held | wide, 0.96 | + | the 0.9 s after a strike | 1.0 | + | then for 3.2 s after a strike | smug, 0.58 | + | everyone present is stamped | smug, then drowsy after a few seconds | + + The lids open quickly and close slowly. +- **Blinks** (membrane): 0.15 s after a new ID appears, 2.3 s after a strike, and at irregular idle intervals of ~6–10 s. +- **Strike sequence**: punch-in zoom about the stamp point, up to 1.38× in 0.12 s, held to 0.9 s, eased out by 1.7 s. + +**Real camera** *(builder-check call)*: on the Pi, records ease toward each new camera frame, so a standing person's box breathes by a pixel or two. That must read as motion 0, so the noise floor and the on-threshold are named constants at the top of `common.glsl`. "Freeze and it loses you" is unverified until it has been run with the real camera (`--camera`; `--mock-camera` and `--test-input` are first approximations). The plan must include that pass and say so if it couldn't happen. + +**Stretch** *(accepted)*: Body UV is stretched to the screen on the Pi, so boxes and silhouettes stretch together and stay aligned. Glyphs, digits, tags, reticle and stamp are sized in screen heights, so they don't stretch. + +## Constraints + +- The shader player rules in `ShowControl/FundingCAPTCHA/shaders/README.md`: + - Read the Body with `texture()`, and the blob records with `texelFetch`. + - Buffers are RGBA16F: keep stored values small and positions in UV. + - `HW_PERFORMANCE` is 0; no textures. + - The Pi renders at half resolution. +- The `blobs` contract (ADR-0022) is read-only from here. If the Setup needs more from the tracker, that's a contract change, not a workaround. +- It must paste into Shadertoy with `body_painter.glsl` (Buffer A) and `blob_painter.glsl` (a spare buffer). +- It must hold a smooth frame rate on the Pi 5. If something has to give, cut in this order: canopy leaf count, membrane streaks, footprints. Never the tracker notes. +- No show, artist or album names in any file (repo rule). + +## Where it goes (decided) + +- A new Setup folder, `ShowControl/FundingCAPTCHA/shaders/hunter_bird/`. +- Added to `shaders/setups.json` alongside `ripples` and `endless_sunset`, with `duration_s` 120. +- `make test` compiles every listed Setup and must pass. + +## Left to the builder + +- How bird state is laid out across buffers (the plan proposes one). +- Antialiasing and exact line weights, within "hand-drawn pen, ~2–3 px at 1080p". +- Exact glyph shapes for `?` and the tick, and the seven-segment proportions. +- Leaf count and positions, within the top-band rule. diff --git a/docs/superpowers/specs/2026-11-04-hunter-bird-mockup.html b/docs/superpowers/specs/2026-11-04-hunter-bird-mockup.html new file mode 100644 index 0000000..e5c3d49 --- /dev/null +++ b/docs/superpowers/specs/2026-11-04-hunter-bird-mockup.html @@ -0,0 +1,502 @@ + + + + + +Hunter Bird Mockup + + + +
+

Hunter bird: the blob tracker, seen through a bird's eye

+

Stand-in figures and a simulated 10 Hz tracker. On the Pi the boxes, IDs and states come from the real tracker. Everything on the canvas is language-free: numbers and glyphs only.

+ +
+ + + +
+
+ + +
+
+
Eyelids
Mood. Drowsy when empty, wide when hunting, a suspicious squint when it has lost interest, a smug half-lid after a strike.
+
Nictitating blink
The quick sideways sweep of a translucent membrane, which is how birds really blink. It fires when someone new appears and after a strike, and hides state changes.
+
Corner brackets
Tracker boxes. Dashed slate means tentative; ink means confirmed; faint and drifting means coasting on predicted velocity; scribbled out means lost.
+
Tag
The stable ID on a hanging tag, like a bird-ringing band. Yellow means it's the bird's current target.
+
Blue arrow, slate prints
Velocity and track history.
+
Rust reticle
The bird's attention, which is separate from the tracker. It follows whoever moves most. The arc fills as lock builds. Hold still and it drains, the reticle wanders off and a ? appears.
+
Strike
When lock is full: a punch-in zoom, the pupil snaps wide, and a stamp marked with a tick lands on the target.
+
+
+ + +