Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions ShowControl/FundingCAPTCHA/shaders/hunter_bird/common.glsl
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,23 @@ 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;
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;

Expand Down Expand Up @@ -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;
167 changes: 86 additions & 81 deletions ShowControl/FundingCAPTCHA/shaders/hunter_bird/image.glsl
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 ───────────────────────────────────────────────────────────
Expand All @@ -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
Expand All @@ -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);
}
43 changes: 29 additions & 14 deletions ShowControl/FundingCAPTCHA/test_hunter_bird.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ─────────────────────────────────────────
Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand All @@ -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")

Expand Down
Loading
Loading