treppe-gl/treppe_gl.py
April John 5e6bc54d08 Add treppe-gl shader renderer for treppe2000
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 14:34:05 +02:00

425 lines
13 KiB
Python

#!/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())