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
3 changes: 2 additions & 1 deletion ShowControl/FundingCAPTCHA/captcha-settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,9 @@
},

"shader_app": {
"_comment": "shader_app.py (ADR-0021). render_scale: fraction of display resolution the passes render at.",
"_comment": "shader_app.py (ADR-0021). render_scale: fraction of display resolution the passes render at. display: which monitor to go fullscreen on (0 = first; --display N overrides).",
"render_scale": 0.5,
"display": 0,
"fps": 30,
"blobs": {
"_comment": "Blob records for Setups that read the \"blobs\" source (ADR-0022). Pixels are Body pixels; frames are camera frames.",
Expand Down
2 changes: 1 addition & 1 deletion ShowControl/FundingCAPTCHA/playlist.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"_comment": "playlist_app.py: one shuffle bag of shader Setups (shaders/<name>/) and videos (files in videos/, which is gitignored — copy them onto the show PC). Each Setup plays setup_duration_s, overriding its own duration_s; each video plays to the end of its file, silent and letterboxed. A missing or unreadable item is skipped.",
"setup_duration_s": 300,
"setups": ["ripples", "endless_sunset", "hunter_bird", "watering_can", "rainbow_poop"],
"setups": ["ripples", "endless_sunset", "hunter_bird", "watering_can", "rainbow_poop", "watercolor_smudge"],
"videos": []
}
18 changes: 12 additions & 6 deletions ShowControl/FundingCAPTCHA/playlist_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@

from app import DIR, load_settings
from arc import ShuffleBag
from shader_app import (SHADER_DIR, ShaderStage, add_mode_args, drain_camera,
open_display, start_camera)
from shader_app import (SHADER_DIR, SetupImages, ShaderStage, add_mode_args, display_index,
drain_camera, open_display, start_camera)
from shadertoy import Renderer, load_setup

log = logging.getLogger("playlist_app")
Expand Down Expand Up @@ -233,8 +233,10 @@ def __init__(self, renderer: Renderer, items: list[Item], setup_duration_s: floa
shader_dir: Path = SHADER_DIR, video_dir: Path = VIDEO_DIR,
open_video: Callable[[Path], VideoPlayer] = VideoPlayer,
clock: Callable[[], float] = time.monotonic,
on_load: Callable[[], None] | None = None) -> None:
on_load: Callable[[], None] | None = None,
image_max: tuple[int, int] = (4096, 4096), image_max_side: int = 16384) -> None:
self.renderer = renderer
self._images = SetupImages(image_max, image_max_side)
self.items = list(items)
self.setup_duration_s = setup_duration_s
self._bag = ShuffleBag(self.items)
Expand Down Expand Up @@ -320,7 +322,9 @@ def _start(self, item: Item) -> bool:

def _load_setup(self, name: str) -> bool:
try:
self.renderer.load(load_setup(self._shaders / name))
setup = load_setup(self._shaders / name)
image = self._images.next(setup) if setup.uses_image else None
self.renderer.load(setup, image=image)
except Exception as exc: # team-authored content: never fatal
log.error("Setup %s skipped: %s", name, exc)
return False
Expand Down Expand Up @@ -353,11 +357,13 @@ def main() -> None:
if item.kind == "video" and not (VIDEO_DIR / item.name).is_file():
log.warning("Video %s not found in %s — it will be skipped", item.name, VIDEO_DIR)

ctx, win = open_display(args.windowed, "Playlist player", args.test_input)
ctx, win = open_display(args.windowed, "Playlist player", args.test_input,
display_index(args, settings))
stage = ShaderStage(ctx, win, settings, args.test_input)
screen = VideoScreen(ctx, win, stage.renderer.vbo)
playlist = Playlist(stage.renderer, playlist_cfg.items, playlist_cfg.setup_duration_s,
on_load=stage.blobs.reset)
on_load=stage.blobs.reset,
image_max=win, image_max_side=stage.max_texture_side)
playlist.next()
cam_q, stop = start_camera(args, settings)

Expand Down
85 changes: 75 additions & 10 deletions ShowControl/FundingCAPTCHA/shader_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,14 @@
os.environ.setdefault("MESA_GLSL_VERSION_OVERRIDE", "330")

import moderngl
import numpy as np
import pygame

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
from shadertoy import (BodyMouse, Renderer, ShaderError, VERT, decode_image, js_keycode,
load_setup, pack_body)

try:
from IIVision import MockCamera, OrbbecCamera
Expand Down Expand Up @@ -84,6 +86,40 @@ def update(self, win: tuple[int, int], res: tuple[int, int]) -> tuple:
return self.state


class SetupImages:
"""The "image" source's pictures (ADR-0024): a shuffle bag per Setup, one image per load.

Shared by Rotation and playlist_app.Playlist. Images keep enough pixels to
cover `cover` (the window), with no side beyond `max_side` (the GPU's limit).
"""

def __init__(self, cover: tuple[int, int] = (4096, 4096), max_side: int = 16384) -> None:
self._cover = cover
self._max_side = max_side
self._bags: dict[str, tuple] = {} # name → (files, ShuffleBag, undecodable)

def next(self, setup) -> np.ndarray | None:
"""Decode the next image from this Setup's bag; None if none decode."""
files, bag, bad = self._bags.get(setup.name, (None, None, set()))
if files != setup.images: # first load, or images/ changed (R)
files, bag = list(setup.images), ShuffleBag([p for p in setup.images if p not in bad])
for _ in range(len(files)):
path = bag.next()
if path is None:
break
try:
image = decode_image(path, self._cover, self._max_side)
except ShaderError as exc:
log.error("%s: image dropped: %s", setup.name, exc)
bad.add(path)
bag = ShuffleBag([p for p in files if p not in bad])
continue
self._bags[setup.name] = (files, bag, bad)
return image
self._bags[setup.name] = (files, bag, bad)
return None


class Rotation:
"""Which Setup plays: shuffle-bag order, each for its duration_s.

Expand All @@ -92,8 +128,10 @@ class Rotation:
"""

def __init__(self, renderer: Renderer, names: list[str],
shader_dir: Path = SHADER_DIR, clock=time.monotonic, on_load=None) -> None:
shader_dir: Path = SHADER_DIR, clock=time.monotonic, on_load=None,
image_max: tuple[int, int] = (4096, 4096), image_max_side: int = 16384) -> None:
self.renderer = renderer
self._images = SetupImages(image_max, image_max_side)
self._on_load = on_load # called after every successful load
self.names = list(names)
self._bag = ShuffleBag(self.names)
Expand Down Expand Up @@ -135,7 +173,9 @@ def failed(self, exc: Exception) -> None:

def _load(self, name: str) -> bool:
try:
self.renderer.load(load_setup(self._dir / name))
setup = load_setup(self._dir / name)
image = self._images.next(setup) if setup.uses_image else None
self.renderer.load(setup, image=image)
except Exception as exc: # team-authored content: never fatal
log.error("Setup %s skipped: %s", name, exc)
return False
Expand All @@ -145,6 +185,14 @@ def _load(self, name: str) -> bool:
return True


def pick_display(sizes: list[tuple[int, int]], index: int) -> tuple[int, tuple[int, int]]:
"""(display index, its desktop size). An index with no display falls back to the first."""
if not 0 <= index < len(sizes):
log.warning("No display %d (%d connected) — using display 0", index, len(sizes))
index = 0
return index, tuple(sizes[index])


def _setup_names() -> list[str]:
try:
return json.loads((SHADER_DIR / "setups.json").read_text())
Expand All @@ -162,10 +210,22 @@ def add_mode_args(ap: argparse.ArgumentParser) -> None:
mode.add_argument("--test-input", action="store_true",
help="Mouse paints the Body with shaders/body_painter.glsl, as in Shadertoy")
ap.add_argument("--windowed", action="store_true", help="1280×720 window instead of fullscreen")
ap.add_argument("--display", type=int, metavar="N",
help="Display to go fullscreen on (0 = first); overrides shader_app.display")


def display_index(args, settings: dict) -> int:
"""--display N, else shader_app.display, else the first display."""
if args.display is not None:
return args.display
return int(settings.get("shader_app", {}).get("display", 0))

def open_display(windowed: bool, caption: str, mouse_visible: bool):
"""pygame window with a GL 3.3 core context → (moderngl ctx, window size)."""

def open_display(windowed: bool, caption: str, mouse_visible: bool, display: int = 0):
"""pygame window with a GL 3.3 core context → (moderngl ctx, window size).

Fullscreen goes on `display` (0 = first); a missing one falls back to the first.
"""
pygame.init()
pygame.display.gl_set_attribute(pygame.GL_CONTEXT_MAJOR_VERSION, 3)
pygame.display.gl_set_attribute(pygame.GL_CONTEXT_MINOR_VERSION, 3)
Expand All @@ -175,9 +235,11 @@ def open_display(windowed: bool, caption: str, mouse_visible: bool):
win = (1280, 720)
pygame.display.set_mode(win, pygame.OPENGL | pygame.DOUBLEBUF)
else:
info = pygame.display.Info()
win = (info.current_w, info.current_h)
pygame.display.set_mode(win, pygame.OPENGL | pygame.DOUBLEBUF | pygame.FULLSCREEN)
sizes = pygame.display.get_desktop_sizes()
display, win = pick_display(sizes, display)
pygame.display.set_mode(win, pygame.OPENGL | pygame.DOUBLEBUF | pygame.FULLSCREEN,
display=display)
log.info("Fullscreen on display %d of %d", display, len(sizes))
pygame.display.set_caption(caption)
pygame.mouse.set_visible(mouse_visible)
ctx = moderngl.create_context() # also attaches to SDL's EGL context (kmsdrm)
Expand Down Expand Up @@ -229,6 +291,7 @@ def __init__(self, ctx, win: tuple[int, int], settings: dict, test_input: bool)
cam_cfg = settings.get("camera", {})
scale = float(cfg.get("render_scale", 0.5))
self.ctx, self.win = ctx, win
self.max_texture_side = int(ctx.info["GL_MAX_TEXTURE_SIZE"])
self.res = (max(1, int(win[0] * scale)), max(1, int(win[1] * scale)))
self.slabs = settings.get("depth_slabs", [{"near_mm": 800, "far_mm": 2500}])
self.roi = settings.get("camera_roi",
Expand Down Expand Up @@ -312,10 +375,12 @@ def main() -> None:
if (args.camera or args.mock_camera) and not _CV_AVAILABLE:
sys.exit("ERROR: CV libraries not available — pip install -r requirements.txt")

ctx, win = open_display(args.windowed, "Shader player", args.test_input)
ctx, win = open_display(args.windowed, "Shader player", args.test_input,
display_index(args, settings))
stage = ShaderStage(ctx, win, settings, args.test_input)
rotation = Rotation(stage.renderer, [args.setup] if args.setup else _setup_names(),
on_load=stage.blobs.reset)
on_load=stage.blobs.reset,
image_max=win, image_max_side=stage.max_texture_side)
rotation.next()
cam_q, stop = start_camera(args, settings)

Expand Down
15 changes: 13 additions & 2 deletions ShowControl/FundingCAPTCHA/shaders/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Mouse controls in Buffer A: **hold left button** = a person at the cursor · **h
- 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.
- `iMouse` works: on the Pi it's the centre of everyone in frame, "pressed" while anyone is there.
- No textures, videos, sound, cubemaps, or webcam — procedural + Body only.
- No videos, sound, cubemaps, or webcam. The only texture is the `"image"` source (see Images); everything else is procedural + Body.
- Channel filter/wrap settings are ignored: always linear, clamp.
- The Pi renders at half resolution by default. Keep it cheap; avoid long raymarch loops.

Expand Down Expand Up @@ -51,6 +51,17 @@ When handing in, don't copy the blob painter tab. In `setup.json`, write `"blobs
"channels": { "image": ["A", "blobs"] }
```

## Images (optional)

A Setup can paint over a picture instead of generating everything ([ADR-0024](../../../docs/adr/0024-shader-player-image-channel.md)). Put `.png` or `.jpg` files in `shaders/<your-setup>/images/` and bind `"image"` in `setup.json`. Each time the Setup plays, the player shuffles to the next one. No repeats until all have shown.

- In Shadertoy, use one of its built-in textures in that channel as a stand-in.
- Sample it with `texture()` and its `iChannelResolution` for aspect. Row 0 is the bottom, as with every channel.
- **No images in the folder** leaves the channel unbound: it reads 0 and `iChannelResolution[n].x == 0.0`. Test for that and fall back to something procedural, so the Setup still plays in a fresh clone.
- Keep images near display size; larger ones are downscaled on load and add a hitch.
- Images are committed publicly. Nothing private goes here.
- For smear-type Setups, fairly saturated images with clear colour regions smear more legibly than soft, low-contrast ones.

## Hand it in

Copy [`ripples/`](ripples/) to `shaders/<your-setup>/` and replace:
Expand All @@ -75,7 +86,7 @@ Copy [`ripples/`](ripples/) to `shaders/<your-setup>/` and replace:
}
```

Sources: `"A"` (Body), `"B"`, `"C"`, `"D"`, `"keyboard"`, `"blobs"` (blob records, above), or `null`. List position = iChannel number.
Sources: `"A"` (Body), `"B"`, `"C"`, `"D"`, `"keyboard"`, `"blobs"` (blob records, above), `"image"` (images, 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.

Expand Down
2 changes: 1 addition & 1 deletion ShowControl/FundingCAPTCHA/shaders/setups.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
["ripples", "endless_sunset", "hunter_bird", "watering_can", "rainbow_poop"]
["ripples", "endless_sunset", "hunter_bird", "watering_can", "rainbow_poop", "watercolor_smudge"]
18 changes: 18 additions & 0 deletions ShowControl/FundingCAPTCHA/shaders/watercolor_smudge/buffer_b.glsl
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Buffer B — the original painting the canvas heals toward.
// iChannel0 = B (itself), iChannel1 = "image" (unbound → resolution 0 → procedural sunset).
// rgb = painting, a = smearable (1) vs. unpainted paper border (0).

void mainImage(out vec4 o, in vec2 f) {
vec2 uv = f / iResolution.xy;
if (iChannelResolution[1].x > 0.0) {
o = vec4(texture(iChannel1, coverUV(uv, iResolution.xy, iChannelResolution[1].xy)).rgb, 1.0);
return;
}
// Paint once; repaint if the buffer was reallocated (a Shadertoy resize clears it).
vec3 ctr = texelFetch(iChannel0, ivec2(iResolution.xy * 0.5), 0).rgb;
if (iFrame == 0 || ctr == vec3(0.0)) {
o = vec4(paintSunset(uv, iResolution.xy), paintMask(uv, iResolution.xy));
} else {
o = texelFetch(iChannel0, ivec2(f), 0);
}
}
30 changes: 30 additions & 0 deletions ShowControl/FundingCAPTCHA/shaders/watercolor_smudge/buffer_c.glsl
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Buffer C — motion. iChannel0 = A (Body), iChannel1 = C (itself, last frame).
// r = eased soft Body, g = last frame's eased Body, b = extremity weight (thin parts 1, torso 0)
// measured on last frame's eased Body,
// a at texel (0,0) = seconds the room has been empty (clamped at EMPTY_CLAMP_S), 0 elsewhere.

// The Body averaged over a small disc: a soft edge about 2 × BODY_SOFT wide.
float softBody(vec2 uv, float A) {
float s = texture(iChannel0, uv).r;
for (int i = 0; i < 8; i++) {
float a = float(i) * 0.7853982;
vec2 d = vec2(cos(a), sin(a)) / vec2(A, 1.0) * BODY_SOFT;
s += texture(iChannel0, uv + d * 0.5).r + texture(iChannel0, uv + d).r;
}
return s / 17.0;
}

void mainImage(out vec4 o, in vec2 f) {
vec2 uv = f / iResolution.xy;
float A = iResolution.x / iResolution.y;
float raw = softBody(uv, A);
vec4 prev = texelFetch(iChannel1, ivec2(f), 0);
float k = 1.0 - exp(-iTimeDelta / BODY_EASE_S);
float body = iFrame == 0 ? raw : mix(prev.r, raw, k);
float last = iFrame == 0 ? raw : prev.r;
float t = 0.0;
if (ivec2(f) == ivec2(0)) {
t = iMouse.z > 0.0 ? 0.0 : min(EMPTY_CLAMP_S, prev.a + iTimeDelta);
}
o = vec4(body, last, extremity(iChannel1, uv, A), t);
}
Loading
Loading