From 5e6bc54d086435a84faaff4939ec938f23019849 Mon Sep 17 00:00:00 2001 From: April John Date: Fri, 16 Oct 2026 14:34:05 +0200 Subject: [PATCH] Add treppe-gl shader renderer for treppe2000 Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 2 + README.md | 29 +++ shaders/flow.glsl | 10 + shaders/pulse.glsl | 7 + shell.nix | 16 ++ tests/test_treppe_gl.py | 61 ++++++ treppe_gl.py | 425 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 550 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 shaders/flow.glsl create mode 100644 shaders/pulse.glsl create mode 100644 shell.nix create mode 100644 tests/test_treppe_gl.py create mode 100644 treppe_gl.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c56ff1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +.pytest_cache/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..887d259 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# treppe-gl + +Render Shadertoy style GLSL shaders onto the treppe2000. + +The shader is rendered into a 16x1 float framebuffer (x = stair step) and +sent as RGBW16 UDP frames to the priority port (3124) of `trepped` on +`treppe.club.berlin.ccc.de`. While it runs it overrides whatever else the +stairs show. On exit it sends a black frame. The stairs power must be on +(Bluescreen > Stairs). + + nix-shell --run "python treppe_gl.py shaders/flow.glsl" + nix-shell --run "python treppe_gl.py shaders/pulse.glsl --dry-run" + +Options: `--host`, `--port`, `--fps`, `--dry-run` (send nothing), +`--no-window` (headless), `--white {extract,alpha,off}`. + +Shaders define `mainImage(out vec4 fragColor, in vec2 fragCoord)` and may +use `iResolution`, `iTime`, `iTimeDelta` and `iFrame`. The file is reloaded +when it changes; on a compile error the last working shader keeps running. + +White channel: `extract` (default) sets `w = min(r,g,b)` and subtracts it +from rgb, `alpha` uses the shader's alpha, `off` sends w = 0. + +Debug window: top is the shader at full resolution, bottom are the 16 steps +as sent (step numbers, colour, white bar). Steps 13-15 are greyed out, +`trepped` only drives 13 channels. The step to channel mapping is done by +`trepped`, frames are sent in step order. + +Tests: `nix-shell --run "python -m pytest tests"`. diff --git a/shaders/flow.glsl b/shaders/flow.glsl new file mode 100644 index 0000000..e9353eb --- /dev/null +++ b/shaders/flow.glsl @@ -0,0 +1,10 @@ +// Rainbow flowing up the stairs +vec3 hue(float h) { + return clamp(abs(mod(h * 6.0 + vec3(0.0, 4.0, 2.0), 6.0) - 3.0) - 1.0, + 0.0, 1.0); +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + float x = fragCoord.x / iResolution.x; + fragColor = vec4(hue(fract(x - iTime * 0.1)), 1.0); +} diff --git a/shaders/pulse.glsl b/shaders/pulse.glsl new file mode 100644 index 0000000..16cdcb3 --- /dev/null +++ b/shaders/pulse.glsl @@ -0,0 +1,7 @@ +// Warm white pulse travelling along the stairs +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + float x = fragCoord.x / iResolution.x; + float wave = 0.5 + 0.5 * sin(x * 6.2831 * 2.0 - iTime * 3.0); + vec3 warm = vec3(1.0, 0.45, 0.1); + fragColor = vec4(warm * wave, 1.0); +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..6777b8b --- /dev/null +++ b/shell.nix @@ -0,0 +1,16 @@ +let + pkgs = import {}; + py = pkgs.python3.withPackages (ps: with ps; [ + moderngl + glfw + numpy + pytest + ]); +in pkgs.mkShell { + buildInputs = [ + py + pkgs.libGL + pkgs.glfw + ]; + LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.libGL pkgs.glfw pkgs.libglvnd ]; +} diff --git a/tests/test_treppe_gl.py b/tests/test_treppe_gl.py new file mode 100644 index 0000000..7c982c9 --- /dev/null +++ b/tests/test_treppe_gl.py @@ -0,0 +1,61 @@ +import os +import socket +import sys + +import numpy as np +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import treppe_gl as t + + +def test_encode_frame_known_bytes(): + rgbw = np.zeros((16, 4), dtype=np.float32) + rgbw[0] = [1.0, 0.0, 0.5, 0.0] + frame = t.encode_frame(rgbw) + assert len(frame) == 2 + 16 * 8 + assert frame[:2] == bytes([0x42, 0x0A]) + # r=65535, g=0, b=round(0.5*65535)=32768, w=0 big endian + assert frame[2:10] == bytes([0xFF, 0xFF, 0, 0, 0x80, 0x00, 0, 0]) + + +def test_white_modes(): + px = np.array([[1.0, 0.5, 0.25, 1.0]] + [[0, 0, 0, 0]] * 15) + assert t.to_rgbw(px, "extract")[0].tolist() == [0.75, 0.25, 0.0, 0.25] + assert t.to_rgbw(px, "alpha")[0].tolist() == [1.0, 0.5, 0.25, 1.0] + assert t.to_rgbw(px, "off")[0].tolist() == [1.0, 0.5, 0.25, 0.0] + + +@pytest.fixture(scope="module") +def renderer(): + ctx, _ = t.create_context(window=False) + return t.Renderer(ctx) + + +def test_strip_pixels(renderer): + renderer.load(""" + void mainImage(out vec4 c, in vec2 p) { + c = vec4(p.x / iResolution.x, 0.0, iTime, 1.0); + }""") + px = renderer.render_strip(0.25) + assert px.shape == (16, 4) + np.testing.assert_allclose(px[:, 0], (np.arange(16) + 0.5) / 16, atol=1e-5) + np.testing.assert_allclose(px[:, 2], 0.25, atol=1e-5) + + +def test_bad_shader_keeps_old(renderer): + renderer.load("void mainImage(out vec4 c, in vec2 p) { c = vec4(1.0); }") + with pytest.raises(Exception): + renderer.load("void mainImage(out vec4 c, in vec2 p) { nope }") + px = renderer.render_strip(0.0) + np.testing.assert_allclose(px[:, 0], 1.0) + + +def test_udp_loopback(): + rx = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + rx.bind(("127.0.0.1", 0)) + rx.settimeout(2) + tx = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + tx.sendto(t.BLACK_FRAME, rx.getsockname()) + assert rx.recv(2048) == t.BLACK_FRAME diff --git a/treppe_gl.py b/treppe_gl.py new file mode 100644 index 0000000..061e214 --- /dev/null +++ b/treppe_gl.py @@ -0,0 +1,425 @@ +#!/usr/bin/env python3 + +""" +treppe-gl: render Shadertoy style GLSL shaders onto the treppe2000. + +The shader is rendered into a 16x1 float framebuffer (x = stair step), +packed as RGBW16 and sent to the priority UDP port of trepped. +An optional debug window shows the shader at full resolution and the +16 steps as they are sent. +""" + +import argparse +import os +import signal +import socket +import sys +import time + +import numpy as np +import moderngl + + +STEPS = 16 +STEPS_ACTIVE = 13 # trepped only drives 13 of the 16 channels + +HOST_DEFAULT = "treppe.club.berlin.ccc.de" +PORT_DEFAULT = 3124 # priority port, overrides 3123 for the gracetime + +CMD_FRAME = 0x42 +FLAG_RGBA = 0x02 +FLAG_BITS_16 = 0x08 + +WHITE_MODES = ("extract", "alpha", "off") + + +# --- Protocol -------------------------------------------------------------- + +def to_rgbw(pixels, white="extract"): + """ + Convert float RGBA pixels (STEPS x 4, 0..1) to RGBW (STEPS x 4). + + extract: w = min(r, g, b), subtracted from r, g, b + alpha: the shader's alpha channel is the white value + off: w = 0 + """ + px = np.clip(np.asarray(pixels, dtype=np.float32), 0.0, 1.0) + rgb = px[:, :3] + if white == "extract": + w = rgb.min(axis=1, keepdims=True) + return np.hstack([rgb - w, w]) + if white == "alpha": + return np.hstack([rgb, px[:, 3:4]]) + if white == "off": + return np.hstack([rgb, np.zeros((len(px), 1), dtype=np.float32)]) + raise ValueError("unknown white mode: {}".format(white)) + + +def encode_frame(rgbw): + """Encode STEPS x 4 floats as a trepped RGBW16 (big endian) frame.""" + data = np.clip(np.asarray(rgbw, dtype=np.float32), 0.0, 1.0) + data = np.floor(data * 65535.0 + 0.5).astype(">u2") + return bytes([CMD_FRAME, FLAG_RGBA | FLAG_BITS_16]) + data.tobytes() + + +BLACK_FRAME = encode_frame(np.zeros((STEPS, 4), dtype=np.float32)) + + +# --- Shaders --------------------------------------------------------------- + +VERTEX = """ +#version 330 +in vec2 pos; +out vec2 v_uv; +void main() { + v_uv = pos * 0.5 + 0.5; + gl_Position = vec4(pos, 0.0, 1.0); +} +""" + +PRELUDE = """#version 330 +uniform vec3 iResolution; +uniform float iTime; +uniform float iTimeDelta; +uniform int iFrame; +out vec4 _fragColor; +""" + +EPILOGUE = """ +void main() { + vec4 c = vec4(0.0, 0.0, 0.0, 1.0); + mainImage(c, gl_FragCoord.xy); + _fragColor = c; +} +""" + +# Draw a texture as is. +BLIT = """ +#version 330 +uniform sampler2D tex; +in vec2 v_uv; +out vec4 color; +void main() { color = vec4(texture(tex, v_uv).rgb, 1.0); } +""" + +# 16 blocks with step numbers, a bar for the white channel and +# the inactive steps greyed out. +STAIRS = """ +#version 330 +uniform sampler2D strip; +uniform vec2 res; +in vec2 v_uv; +out vec4 color; + +// 3x5 digit font, row major, top row in the highest bits +const int FONT[10] = int[10]( + 31599, 11415, 29671, 29647, 23497, + 31183, 31215, 29257, 31727, 31695); + +float digit(int d, vec2 p) { + ivec2 c = ivec2(floor(p)); + if (d < 0 || d > 9 || c.x < 0 || c.x > 2 || c.y < 0 || c.y > 4) { + return 0.0; + } + int bit = c.y * 3 + (2 - c.x); + return float((FONT[d] >> bit) & 1); +} + +float number(int n, vec2 p) { + // p in font pixels, two digits wide + if (n >= 10) { + return max(digit(n / 10, p), digit(n % 10, p - vec2(4.0, 0.0))); + } + return digit(n, p - vec2(2.0, 0.0)); +} + +void main() { + vec2 px = v_uv * res; + float cell = res.x / 16.0; + int i = int(floor(px.x / cell)); + vec2 local = vec2(px.x - float(i) * cell, px.y); + + vec3 bg = vec3(0.06); + vec3 col = bg; + bool is_on = i < 13; + + float bar_h = res.y * 0.12; + float num_h = res.y * 0.22; + float gap = 3.0; + + vec4 s = texelFetch(strip, ivec2(i, 0), 0); + float w = min(min(s.r, s.g), s.b); + + if (px.y > num_h + bar_h + gap) { + // colour block + if (local.x > gap && local.x < cell - gap) { + col = clamp(s.rgb, 0.0, 1.0); + if (!is_on) { + col = mix(col * 0.25, vec3(0.2), 0.5); + } + } + } else if (px.y > num_h) { + // white bar (extracted white = min(rgb)) + if (local.x > gap && local.x < cell - gap && local.y - num_h < bar_h) { + float f = (local.x - gap) / (cell - 2.0 * gap); + col = f < w ? vec3(0.95) : vec3(0.15); + if (!is_on) { col *= 0.4; } + } + } else { + // step number + float scale = max(1.0, floor(num_h / 7.0)); + vec2 p = (local - vec2(0.0, num_h * 0.5 - 2.5 * scale)) / scale; + p.x -= (cell / scale - 7.0) * 0.5; + float on = number(i, p); + col = mix(bg, is_on ? vec3(0.9) : vec3(0.35), on); + } + color = vec4(col, 1.0); +} +""" + + +def wrap_shader(source): + return PRELUDE + source + EPILOGUE + + +class Renderer: + """Renders a Shadertoy style shader into the stairs strip.""" + + def __init__(self, ctx, size=(512, 288)): + self.ctx = ctx + self.quad = ctx.buffer( + np.array([-1, -1, 3, -1, -1, 3], dtype="f4").tobytes()) + self.size = size + self.strip_tex = ctx.texture((STEPS, 1), 4, dtype="f4") + self.strip_tex.filter = (moderngl.NEAREST, moderngl.NEAREST) + self.strip_fbo = ctx.framebuffer([self.strip_tex]) + self.full_tex = None + self.full_fbo = None + self.resize(size) + self.program = None + self.vao_strip = None + self.vao_full = None + self.time = 0.0 + self.frame = 0 + + def resize(self, size): + size = (max(1, int(size[0])), max(1, int(size[1]))) + if self.full_fbo is not None and size == self.size: + return + self.size = size + if self.full_tex is not None: + self.full_fbo.release() + self.full_tex.release() + self.full_tex = self.ctx.texture(size, 4, dtype="f1") + self.full_tex.filter = (moderngl.LINEAR, moderngl.LINEAR) + self.full_fbo = self.ctx.framebuffer([self.full_tex]) + + def load(self, source): + """Compile a shader. Raises on error and keeps the old one.""" + program = self.ctx.program( + vertex_shader=VERTEX, + fragment_shader=wrap_shader(source)) + if self.program is not None: + self.program.release() + self.program = program + self.vao = self.ctx.vertex_array( + self.program, [(self.quad, "2f", "pos")]) + + def _set(self, name, value): + if name in self.program: + self.program[name].value = value + + def _draw(self, fbo, res, t, dt, frame): + fbo.use() + fbo.viewport = (0, 0, res[0], res[1]) + self._set("iResolution", (float(res[0]), float(res[1]), 1.0)) + self._set("iTime", t) + self._set("iTimeDelta", dt) + self._set("iFrame", frame) + self.vao.render(moderngl.TRIANGLES, vertices=3) + + def render_strip(self, t, dt=0.0, frame=0): + """Render the 16x1 strip, returns STEPS x 4 float pixels.""" + self._draw(self.strip_fbo, (STEPS, 1), t, dt, frame) + raw = self.strip_fbo.read(components=4, dtype="f4") + return np.frombuffer(raw, dtype="f4").reshape(STEPS, 4).copy() + + def render_full(self, t, dt=0.0, frame=0): + self._draw(self.full_fbo, self.size, t, dt, frame) + + +class DebugView: + """Composites the full resolution shader and the stairs strip.""" + + def __init__(self, ctx, renderer): + self.ctx = ctx + self.renderer = renderer + self.blit = ctx.program(vertex_shader=VERTEX, fragment_shader=BLIT) + self.stairs = ctx.program(vertex_shader=VERTEX, fragment_shader=STAIRS) + self.vao_blit = ctx.vertex_array( + self.blit, [(renderer.quad, "2f", "pos")]) + self.vao_stairs = ctx.vertex_array( + self.stairs, [(renderer.quad, "2f", "pos")]) + + def draw(self, width, height): + top_h = int(height * 0.65) + bottom_h = height - top_h + self.renderer.resize((width, top_h)) + + screen = self.ctx.screen + screen.use() + self.ctx.clear(0.0, 0.0, 0.0) + + screen.viewport = (0, bottom_h, width, top_h) + self.renderer.full_tex.use(0) + self.blit["tex"].value = 0 + self.vao_blit.render(moderngl.TRIANGLES, vertices=3) + + screen.viewport = (0, 0, width, bottom_h) + self.renderer.strip_tex.use(0) + self.stairs["strip"].value = 0 + self.stairs["res"].value = (float(width), float(bottom_h)) + self.vao_stairs.render(moderngl.TRIANGLES, vertices=3) + + +# --- Runtime --------------------------------------------------------------- + +class ShaderFile: + """Reloads a shader file when it changes.""" + + def __init__(self, path): + self.path = path + self.mtime = None + + def poll(self): + """Returns new source if the file changed, else None.""" + try: + mtime = os.stat(self.path).st_mtime + except OSError as err: + if self.mtime is None: + raise + print("cannot read {}: {}".format(self.path, err), file=sys.stderr) + return None + if mtime == self.mtime: + return None + self.mtime = mtime + with open(self.path) as f: + return f.read() + + +def create_context(window): + """Returns (ctx, glfw_window or None).""" + if not window: + return moderngl.create_standalone_context(backend="egl"), None + + import glfw + if not glfw.init(): + raise RuntimeError("could not initialise glfw") + glfw.window_hint(glfw.CONTEXT_VERSION_MAJOR, 3) + glfw.window_hint(glfw.CONTEXT_VERSION_MINOR, 3) + glfw.window_hint(glfw.OPENGL_PROFILE, glfw.OPENGL_CORE_PROFILE) + glfw.window_hint(glfw.OPENGL_FORWARD_COMPAT, True) + win = glfw.create_window(1024, 512, "treppe-gl", None, None) + if not win: + glfw.terminate() + raise RuntimeError("could not create window") + glfw.make_context_current(win) + glfw.swap_interval(0) + return moderngl.create_context(), win + + +def resolve(host, port): + info = socket.getaddrinfo(host, port, socket.AF_INET, socket.SOCK_DGRAM) + return info[0][4] + + +def run(args): + shader = ShaderFile(args.shader) + target = None + sock = None + if not args.dry_run: + target = resolve(args.host, args.port) + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + print("sending to {}:{} ({}), overriding the stairs".format( + args.host, target[1], target[0]), file=sys.stderr) + else: + print("dry run, not sending anything", file=sys.stderr) + + ctx, win = create_context(args.window) + renderer = Renderer(ctx) + view = DebugView(ctx, renderer) if win else None + + stop = [] + signal.signal(signal.SIGINT, lambda *_: stop.append(True)) + signal.signal(signal.SIGTERM, lambda *_: stop.append(True)) + + import glfw + t0 = time.time() + last = t0 + frame = 0 + next_reload = 0.0 + period = 1.0 / args.fps + + try: + while not stop: + now = time.time() + if now >= next_reload: + next_reload = now + 0.25 + source = shader.poll() + if source is not None: + try: + renderer.load(source) + print("loaded {}".format(args.shader), file=sys.stderr) + except Exception as err: + print("shader error:\n{}".format(err), file=sys.stderr) + if renderer.program is None: + raise SystemExit(1) + + t = now - t0 + dt = now - last + last = now + + pixels = renderer.render_strip(t, dt, frame) + rgbw = to_rgbw(pixels, args.white) + if sock: + sock.sendto(encode_frame(rgbw), target) + + if win: + if glfw.window_should_close(win): + break + w, h = glfw.get_framebuffer_size(win) + if w > 0 and h > 0: + renderer.render_full(t, dt, frame) + view.draw(w, h) + glfw.swap_buffers(win) + glfw.poll_events() + + frame += 1 + sleep = period - (time.time() - now) + if sleep > 0: + time.sleep(sleep) + finally: + if sock: + for _ in range(3): + sock.sendto(BLACK_FRAME, target) + print("sent black frame", file=sys.stderr) + if win: + glfw.terminate() + + +def parse_args(argv=None): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("shader", help="Shadertoy style GLSL file (mainImage)") + p.add_argument("--host", default=HOST_DEFAULT) + p.add_argument("--port", type=int, default=PORT_DEFAULT) + p.add_argument("--fps", type=float, default=60.0) + p.add_argument("--white", choices=WHITE_MODES, default="extract") + p.add_argument("--dry-run", action="store_true", + help="do not send anything over the network") + p.add_argument("--no-window", dest="window", action="store_false", + help="headless, no debug window") + return p.parse_args(argv) + + +if __name__ == "__main__": + run(parse_args())