From d2f7f6f9d98a9887603bfbe143dc8fce4ad9b545 Mon Sep 17 00:00:00 2001 From: ii-impy <314283863+ii-impy@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:46:42 -0500 Subject: [PATCH] Hunter bird: two merged eyes, with lids and blinks inside each eye One centred eye read oddly. The bird now looks through two overlapping elliptical eyes whose openings merge into one view, like wraparound sunglasses, meeting in a notch at the bridge. - Each eye owns its side of the centre line. There, its own ellipse is exactly the merged view, so the shader only needs the eye on a pixel's side. That costs no more than the single eye did. - The screen-wide lids are gone. Each eye has its own top and bottom lid, clipped to the eye, with its outer rim drawn over them. Mood still sets their openness. - Blinks shut both eyes' lids over BLINK_S, replacing the sweeping nictitating membrane. Buffer B is unchanged. - Lock shrinks the eyes' radius and separation to 0.8x, so the view narrows toward the centre. Strike dilation keeps its old ratio. - Tests: the lid and blink tests sample inside an eye, and the membrane test is now test_lids_shut_during_blink. The canopy and iris tests were tightened: they passed on the eye's own rim ink and on the edge of the locked opening. - The spec is updated. The tuning mockup is docs/superpowers/specs/2026-11-05-hunter-bird-two-eyes-mockup.html. Co-Authored-By: Claude Opus 5.5 --- .../shaders/hunter_bird/common.glsl | 19 +- .../shaders/hunter_bird/image.glsl | 167 ++--- .../FundingCAPTCHA/test_hunter_bird.py | 43 +- .../specs/2026-11-04-hunter-bird-design.md | 17 +- ...026-11-05-hunter-bird-two-eyes-mockup.html | 657 ++++++++++++++++++ 5 files changed, 794 insertions(+), 109 deletions(-) create mode 100644 docs/superpowers/specs/2026-11-05-hunter-bird-two-eyes-mockup.html diff --git a/ShowControl/FundingCAPTCHA/shaders/hunter_bird/common.glsl b/ShowControl/FundingCAPTCHA/shaders/hunter_bird/common.glsl index d53e12a..a103b1f 100644 --- a/ShowControl/FundingCAPTCHA/shaders/hunter_bird/common.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/hunter_bird/common.glsl @@ -50,6 +50,7 @@ const float LID_DROWSY = 0.42, LID_DROOP = 0.05, LID_SQUINT = 0.7, LID_WIDE = 0. 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 +// A blink (BLINK_S): both eyes' lids snap shut and open again. 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; @@ -57,7 +58,15 @@ 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; +// Two eyes, each an ellipse EYE_LENS wide per unit tall, side by side: the view is the two +// openings merged, like wraparound sunglasses. Mockup: docs/superpowers/specs/2026-11-05-hunter-bird-two-eyes-mockup.html +const float EYE_R = 150.0/360.0; // opening radius (tall) with no lock +const float EYE_SEP = 150.0/360.0; // half the distance between the eye centres, with no lock +const float EYE_LENS = 1.25; // width / height of each eye +const float EYE_Y = 0.5; // centre height, from the bottom +const float EYE_LOCK_K = 0.8; // radius and separation at full lock, × no lock: the eyes draw together +const float EYE_STRIKE_K = 380.0/345.0; // … and on a strike, before easing back +const float IRIS_BAND = 34.0/360.0; // the yellow band, outside the opening // line weights const float INK_W = 2.2/360.0, INK_W_HEAVY = 2.6/360.0, INK_W_LIGHT = 1.6/360.0; @@ -242,12 +251,10 @@ 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; +// ── Eye (mockup px unless noted) ────────────────────────────────────────────── +// Lids live inside each eye. Openness 1 puts a lid's rim on the eye's outer edge. +const float LID_BOT = 0.94; // the bottom lid's reach, × the top's 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 index e94c8bf..c71a712 100644 --- a/ShowControl/FundingCAPTCHA/shaders/hunter_bird/image.glsl +++ b/ShowControl/FundingCAPTCHA/shaders/hunter_bird/image.glsl @@ -1,5 +1,5 @@ // 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). +// Back to front: world (zooms on a strike) → field notes → attention → the two eyes (screen space). // Positions: `p` is in screen heights (x 0…aspect, y 0…1); records and Body are in UV. #define BODY iChannel0 @@ -285,102 +285,106 @@ void drawStamp(inout vec3 col, vec2 p, vec2 at, float since) { } -// ── 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; }; +// ── The eyes (screen space, never zoom) ────────────────────────────────────── +// Two mirror-image eyes, one each side of the bridge (the screen's centre line). +// On its own side, an eye's ellipse *is* the merged view, so every pixel only +// needs the eye on its side. Lengths here are screen heights; G is a mockup pixel. +struct Eye { vec2 c; float Ri, Rb; }; // centre, opening radius, outer radius (band's edge) -Eye eyeAt(vec2 p) { +Eye eyeAt(vec2 p, float lock, float since) { + float dil = since < STRIKE_WIDE_S ? 1.0 - since / STRIKE_WIDE_S : 0.0; + float f = mix(mix(1.0, EYE_LOCK_K, smoothstep(0.0, 1.0, lock)), EYE_STRIKE_K, dil); 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; + e.c = vec2(ASPECT * 0.5 + (p.x < ASPECT * 0.5 ? -1.0 : 1.0) * EYE_SEP * f, EYE_Y); + e.Ri = EYE_R * f; + e.Rb = e.Ri + IRIS_BAND; 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; +// Signed distance to the eye's ellipse of radius R (R tall, R·EYE_LENS wide), < 0 inside. +// The ellipse's level-set value over its gradient: good to well under a pixel near the rim. +float sdEye(vec2 p, Eye e, float R) { + vec2 L = vec2(EYE_LENS, 1.0); + vec2 q = (p - e.c) / L; + float r = length(q); + return (r - R) / max(length(q / max(r, 1e-6) / L), 1e-6); } -// Lid rims (y down) and their slopes at X. +// Openness after blinks: 1 − how shut the blink has the lids. +float lidOpen(float mood, float sinceBlink) { + float shut = sinceBlink >= 0.0 && sinceBlink <= BLINK_S ? sin(3.14159265 * sinceBlink / BLINK_S) : 0.0; + return mood * (1.0 - shut); +} + +// Lid rims (y up) at x: top and bottom, and their slopes. 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); + float rx = e.Rb * EYE_LENS, u = (x - e.c.x) / rx; + float prof = sqrt(max(0.0, 1.0 - u * u)); + float dprof = prof > 1e-3 ? -u / (rx * prof) : 0.0; + float xm = x * 360.0; // wobble, as the mockup: in its pixels + float top = e.c.y + e.Rb * open * prof - sin(xm * 0.05) * 1.2 * G; + float bot = e.c.y - e.Rb * LID_BOT * open * prof - sin(xm * 0.05 + 2.0) * 1.2 * G; + float sT = clamp( e.Rb * open * dprof - 0.06 * cos(xm * 0.05), -8.0, 8.0); + float sB = clamp(-e.Rb * LID_BOT * open * dprof - 0.06 * cos(xm * 0.05 + 2.0), -8.0, 8.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). +// Signed distance past a lid's rim (< 0 inside the lid). top: the lid above its rim. +float sdLid(vec2 p, Eye e, float open, bool top) { + vec4 rim = lidRims(e, p.x, open); + float y = top ? rim.x : rim.y, s = top ? rim.z : rim.w; + return (top ? y - p.y : p.y - y) / sqrt(1.0 + s * s); +} + +// How much of this pixel the 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 + Eye e = eyeAt(p, lock, since); + float a = smoothstep(-2.0 * PX, -1.0 * PX, sdEye(p, e, e.Ri)); + float lid = min(sdLid(p, e, open, true), sdLid(p, e, open, false)); + return max(a, step(lid, -2.0 * PX)); // 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 +void drawIris(inout vec3 col, vec2 p, Eye e) { + float d = sdEye(p, e, e.Ri); + if (d < -3.0 * PX) return; + float db = sdEye(p, e, e.Rb); + vec3 socket = mix(PAPER, SLATE, EYE_HATCH * hatch(p, HATCH_GAP, HATCH_W, PX)); + over(col, mix(YELLOW, socket, smoothstep(-0.5 * PX, 0.5 * PX, db)), smoothstep(-0.5 * PX, 0.5 * PX, d)); + if (db < 2.0 * G) { // short ink striations, radial in lens space + vec2 L = vec2(EYE_LENS, 1.0), q = (p - e.c) / L; float n = float(STRIATIONS); - float i = mod(floor(atan(d.y, d.x) / 6.2831853 * n + 0.5), n); + float i = mod(floor(atan(q.y, q.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)); + float l = (8.0 + hash11(i + 7.0) * (IRIS_BAND * 360.0 - 12.0)) * G; + vec2 dir = L * vec2(cos(ai), sin(ai)); + over(col, INK, step(0.0, d) * pen(sdSegment(p, e.c + dir * (e.Ri + 3.0 * G), e.c + dir * (e.Ri + l)), 1.2 * G, PX)); } - over(col, INK, pen(abs(r - Ri), 3.0, px)); - over(col, INK, pen(abs(r - Ri - band), 2.0, px)); + over(col, INK, pen(abs(d), 3.0 * G, PX)); +} + +// The outer rim: drawn over the lids, so the eyes' shape always reads. +void drawOuterRim(inout vec3 col, vec2 p, Eye e) { + over(col, INK, pen(abs(sdEye(p, e, e.Rb)), 2.0 * G, 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); +// One lid, clipped to its eye. +void drawLid(inout vec3 col, vec2 p, Eye e, float open, bool top) { + float sd = sdLid(p, e, open, top); + if (sd > LID_RIM_W * G) return; + float inEye = smoothstep(0.5 * PX, -0.5 * PX, sdEye(p, e, e.Rb)); + float fill = inEye * 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)); + over(col, YELLOW, fill * pen(abs(sd + LID_RING_IN * G), LID_RING_W * G, 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); + float every = SCALLOP_EVERY * G; + float xn = e.c.x + every * floor((p.x - e.c.x) / 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)); + vec2 sc = vec2(xn, top ? rn.x + SCALLOP_IN * G : rn.y - SCALLOP_IN * G); + vec2 v = p - sc; + float R = SCALLOP_R * G; + float arc = (top ? -v.y : v.y) >= 0.0 ? abs(length(v) - R) : min(length(v - vec2(R, 0.0)), length(v + vec2(R, 0.0))); + over(col, INK, fill * pen(arc, 1.4 * G, PX)); + over(col, INK, inEye * pen(abs(sd), LID_RIM_W * G, PX)); } // ── Strike punch-in ─────────────────────────────────────────────────────────── @@ -399,7 +403,8 @@ void mainImage(out vec4 o, in vec2 fc) { vec3 col = PAPER; PX = px; - if (eyeCover(p, mood.x, at.y, at.z) < 1.0) { // skip the world where the eye hides it + float open = lidOpen(mood.x, bBlink(B).x); + if (eyeCover(p, open, at.y, at.z) < 1.0) { // skip the world where the eyes hide 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 @@ -410,10 +415,10 @@ void mainImage(out vec4 o, in vec2 fc) { 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); + Eye e = eyeAt(p, at.y, at.z); + drawIris(col, p, e); + drawLid(col, p, e, open, true); + drawLid(col, p, e, open, false); + drawOuterRim(col, p, e); o = vec4(col, 1.0); } diff --git a/ShowControl/FundingCAPTCHA/test_hunter_bird.py b/ShowControl/FundingCAPTCHA/test_hunter_bird.py index 4a815be..f3cd2dd 100644 --- a/ShowControl/FundingCAPTCHA/test_hunter_bird.py +++ b/ShowControl/FundingCAPTCHA/test_hunter_bird.py @@ -348,6 +348,17 @@ def to_px(u, v, size=BIG): return int(v * size[1]), int(u * size[0]) +# The eyes at no lock (common.glsl: EYE_R, EYE_SEP, EYE_LENS, EYE_Y), in BIG pixels. +EYE_R, EYE_SEP, EYE_LENS = BIG[1] * 150 / 360, BIG[1] * 150 / 360, 1.25 +EYE_ROW = BIG[1] // 2 +EYE_COLS = (int(BIG[0] / 2 - EYE_SEP), int(BIG[0] / 2 + EYE_SEP)) # left, right eye centres + + +def in_opening(row, col, margin=0.0): + """Is (row, col) inside an eye's opening at no lock, by at least `margin` px (roughly)?""" + return any(((col - c) / EYE_LENS) ** 2 + (row - EYE_ROW) ** 2 < (EYE_R - margin) ** 2 for c in EYE_COLS) + + 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 @@ -376,9 +387,11 @@ def test_canopy_stays_in_its_band(ctx): 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 + rig.run(walking(30)) # lids wide, no lock: the eyes' tops are in view + img = rig.image() + top = [img[r, c] for r in range(int(BIG[1] * 0.82), BIG[1]) for c in range(BIG[0]) + if in_opening(r, c, margin=4)] # above the walker's brackets, clear of the eye's rim + assert top and any(not near(p, "paper", 12) for p in top) # the canopy, through the tops of the eyes # ── Task 5: field notes and attention ───────────────────────────────────────── @@ -470,10 +483,10 @@ def test_reticle_drawn_on_target(ctx): # ── Task 6: the eye ─────────────────────────────────────────────────────────── def lid_patch(rig): - """Pixels 0.35 H above the screen centre.""" + """Pixels 0.3 H above the left eye's centre: under the lid when drowsy, in view when wide.""" img = rig.image() - r, c = int(BIG[1] * 0.85), BIG[0] // 2 - return img[r - 3:r + 3, c - 12:c + 12] + r, c = int(BIG[1] * 0.8), EYE_COLS[0] + return img[r - 3:r + 3, c - 8:c + 8] def bluish(patch): @@ -496,10 +509,11 @@ def test_lids_open_on_target(ctx): def iris_patch(rig): - """Pixels 0.72–0.80 H left of the screen centre, inside the lids when they're wide.""" + """Pixels 0.78–0.86 H left of the screen centre, at eye height: the left eye's outer corner. + In view at no lock (the opening reaches 0.94 H); in the iris band at full lock (0.75–0.87 H).""" 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])] + return img[EYE_ROW - 2:EYE_ROW + 2, c - int(0.86 * BIG[1]):c - int(0.78 * BIG[1])] def test_iris_band_narrows_view_on_lock(ctx): @@ -517,19 +531,20 @@ def test_iris_band_narrows_view_on_lock(ctx): assert count(patch, "yellow", 30) + bluish(patch) > 10 # iris band or socket -def test_membrane_sweeps_during_blink(ctx): +def test_lids_shut_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(): + def right_eye(): # 0.15 H above its centre: in view between blinks img = rig.image() - return img[BIG[1] // 2 - 3:BIG[1] // 2 + 3, BIG[0] // 2:] + r, c = int(BIG[1] * 0.65), EYE_COLS[1] + return img[r - 3:r + 3, c - 8:c + 8] render_records(rig, rec, frames=2) assert -0.16 < rig.blink()[0] < 0.0 # scheduled - assert bluish(right_half()) == 0 + assert bluish(right_eye()) == 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 + if 0.1 < rig.blink()[0] < 0.22: # mid-blink: the lids are nearly shut + assert bluish(right_eye()) > 5 return pytest.fail("no blink") diff --git a/docs/superpowers/specs/2026-11-04-hunter-bird-design.md b/docs/superpowers/specs/2026-11-04-hunter-bird-design.md index 4a70359..0ff414c 100644 --- a/docs/superpowers/specs/2026-11-04-hunter-bird-design.md +++ b/docs/superpowers/specs/2026-11-04-hunter-bird-design.md @@ -4,12 +4,12 @@ A new FundingCAPTCHA shader player Setup (ADR-0021): a debug view of the blob tr ## 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. +Role reversal: the bird is birdwatching *you*. The screen is what the bird sees, through its two eyes. 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 bird you only ever see as its eyes: two merged lenses, eyelids for mood and 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. @@ -21,7 +21,7 @@ Role reversal: the bird is birdwatching *you*. The screen is what the bird sees, - 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. +- The bird's body is never seen. Eyes, eyelids and iris only. - No sound. ## Anti-goals @@ -58,9 +58,10 @@ Back to front: - **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. + - **Two eyes** (changed 2026-11-05, from one centred eye; mockup: `2026-11-05-hunter-bird-two-eyes-mockup.html`): two ellipses 1.25× as wide as tall, centres ~150 px either side of the screen's centre line. They overlap, and their openings merge into one view, like wraparound sunglasses. Each eye owns its side of the centre line, and the two meet in a notch at top and bottom. Outside the eyes is a hatched socket. + - **Iris band**: a yellow band ~34 px wide around each opening, with short ink striations and ink edges. The opening is ~150 px tall-radius with no lock. At full lock the radius and the eyes' separation both shrink to 0.8×, so the view narrows toward the centre. On a strike they snap to ~1.1× before easing back. + - **Eyelids**: each eye has its own top and bottom lid, inside the eye, 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. The eye's outer rim is drawn over them, so the shape always reads. Their openness is the bird's mood (Behaviour). At openness 1 a lid's rim sits on the eye's outer edge. + - **Blink**: both eyes' lids snap shut and open again over ~0.32 s. (This replaced a nictitating membrane that swept across the screen.) ## Behaviour (decided) @@ -84,7 +85,7 @@ The bird's attention is separate from the tracker. The tracker says who is there | 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. +- **Blinks** (lids): 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. @@ -100,7 +101,7 @@ The bird's attention is separate from the tracker. The tracker says who is there - 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. +- It must hold a smooth frame rate on the Pi 5. If something has to give, cut in this order: canopy leaf count, footprints. Never the tracker notes. - No show, artist or album names in any file (repo rule). ## Where it goes (decided) diff --git a/docs/superpowers/specs/2026-11-05-hunter-bird-two-eyes-mockup.html b/docs/superpowers/specs/2026-11-05-hunter-bird-two-eyes-mockup.html new file mode 100644 index 0000000..62241e5 --- /dev/null +++ b/docs/superpowers/specs/2026-11-05-hunter-bird-two-eyes-mockup.html @@ -0,0 +1,657 @@ + + + + + +Hunter Bird Two Eyes + + + +
+

Hunter bird: two eyes

+

The 2026-11-04 mockup with two overlapping eyes that merge into one view, shaped like wraparound sunglasses. The lids are shared and dip at the bridge, and the blink sweeps from the bridge outward. Tune the sliders, then copy the values line at the bottom.

+ +
+ + + +
+
+ + + + + + + +
+
+
+
+
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.
+
+
+ + +