Add treppe-gl shader renderer for treppe2000

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
April John 2026-10-16 14:34:05 +02:00
commit 5e6bc54d08
7 changed files with 550 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
__pycache__/
.pytest_cache/

29
README.md Normal file
View file

@ -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"`.

10
shaders/flow.glsl Normal file
View file

@ -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);
}

7
shaders/pulse.glsl Normal file
View file

@ -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);
}

16
shell.nix Normal file
View file

@ -0,0 +1,16 @@
let
pkgs = import <nixpkgs> {};
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 ];
}

61
tests/test_treppe_gl.py Normal file
View file

@ -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

425
treppe_gl.py Normal file
View file

@ -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())