From d917860201ed78051e0dde3b93adfbfa5b5acf52 Mon Sep 17 00:00:00 2001 From: April John Date: Fri, 16 Oct 2026 15:28:41 +0200 Subject: [PATCH] Add 2D canvas mode compacted to the steps, ocean-chaos and reef-2d shaders Co-Authored-By: Claude Sonnet 5.5 --- README.md | 8 +++ shaders/ocean-chaos.glsl | 51 ++++++++++++++++++ shaders/reef-2d.glsl | 46 ++++++++++++++++ tests/test_treppe_gl.py | 37 +++++++++++++ treppe-dry.sh | 4 +- treppe-run.sh | 4 +- treppe_gl.py | 114 ++++++++++++++++++++++++++++++++++++--- 7 files changed, 252 insertions(+), 12 deletions(-) create mode 100644 shaders/ocean-chaos.glsl create mode 100644 shaders/reef-2d.glsl diff --git a/README.md b/README.md index f11f053..67beeb7 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,14 @@ The same shader also runs at full size in the debug window, so derive the step from the resolution (`floor(fragCoord.x / iResolution.x * 16.0)`) or use `fragCoord.x / iResolution.x`, never raw `fragCoord.x`. +2D shaders: put `// canvas: 256x48 compact: mean` at the top of a shader (or use +`--canvas 256x48 --compact mean`) and it is rendered as a real 2D picture of +that size, then compacted to the 16 steps, each step being a 16 pixel wide +(canvas width / 16) group of columns. The width must be a multiple of 16. +`compact`: `mean` averages the group (default), `max` takes the brightest value +per channel, `row` averages only the middle row. The debug window then shows +the actual canvas on top. See `shaders/reef-2d.glsl`. + 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. diff --git a/shaders/ocean-chaos.glsl b/shaders/ocean-chaos.glsl new file mode 100644 index 0000000..5c6a2d1 --- /dev/null +++ b/shaders/ocean-chaos.glsl @@ -0,0 +1,51 @@ +// Slow colourful ocean for the chaos event: the palette drifts through the +// whole colour wheel over a few minutes, jellyfish glide along the stairs, +// plankton fades in and out. Nothing here moves fast. + +float hash(float n) { return fract(sin(n * 12.9898) * 43758.5453); } + +vec3 hsv(float h, float s, float v) { + vec3 rgb = clamp(abs(mod(h * 6.0 + vec3(0.0, 4.0, 2.0), 6.0) - 3.0) - 1.0, + 0.0, 1.0); + return v * mix(vec3(1.0), rgb, s); +} + +// A jellyfish: soft blob that glides and breathes +vec3 jelly(float i, float t, float seed) { + float pos = 6.0 + 5.5 * sin(t * (0.07 + 0.03 * seed) + seed * 4.0); + float d = i - pos; + float breath = 0.55 + 0.45 * sin(t * 0.5 + seed * 3.0); + float hue = 0.85 + 0.35 * seed + t / 180.0; // contrasts with the water + return hsv(hue, 0.7, 1.0) * exp(-d * d * 0.55) * breath * 0.75; +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + float i = floor(fragCoord.x / iResolution.x * 16.0); + float t = iTime; + + // waves, all slow + float w1 = 0.5 + 0.5 * sin(i * 0.55 - t * 0.45); + float w2 = 0.5 + 0.5 * sin(i * 0.90 + t * 0.30 + 1.0); + float w3 = 0.5 + 0.5 * sin(i * 0.25 - t * 0.17 + 2.5); + float h = 0.45 * w1 + 0.30 * w2 + 0.25 * w3; + + // water colour: blue to magenta to teal, wheel drifts over 3 minutes + float hue = 0.58 + 0.22 * sin(i * 0.22 + t * 0.06) + 0.10 * h + t / 180.0; + vec3 col = hsv(hue, 0.95, 0.10 + 0.75 * h); + + // foam on the crests + float foam = smoothstep(0.80, 0.97, h); + col = mix(col, vec3(0.9), foam * 0.7); + + // jellyfish + col += jelly(i, t, 0.0) + jelly(i, t, 1.0); + + // plankton, each step lights up now and then for ~12 s + float cyc = t * 0.08 + hash(i); + float cell = floor(cyc); + float life = sin(3.14159265 * fract(cyc)); + float on = step(0.72, hash(i * 13.0 + cell)); + col += hsv(hue + 0.5, 0.4, 1.0) * pow(life, 4.0) * on * 0.55; + + fragColor = vec4(col, 1.0); +} diff --git a/shaders/reef-2d.glsl b/shaders/reef-2d.glsl new file mode 100644 index 0000000..179d7bb --- /dev/null +++ b/shaders/reef-2d.glsl @@ -0,0 +1,46 @@ +// canvas: 256x48 compact: mean +// +// A real 2D picture, compacted to the stairs by treppe-gl: every step is the +// average of a 16 pixel wide column of the canvas. Fish swim through slowly, +// caustics ripple near the surface, the colours drift around the wheel. + +float hash(float n) { return fract(sin(n * 12.9898) * 43758.5453); } + +vec3 hsv(float h, float s, float v) { + vec3 rgb = clamp(abs(mod(h * 6.0 + vec3(0.0, 4.0, 2.0), 6.0) - 3.0) - 1.0, + 0.0, 1.0); + return v * mix(vec3(1.0), rgb, s); +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = fragCoord / iResolution.xy; // 0..1, y up + float t = iTime; + float drift = t / 180.0; + + // water, darker towards the bottom + vec3 col = hsv(0.55 + drift + 0.08 * sin(uv.x * 3.0 + t * 0.05), + 0.95, 0.12 + 0.55 * uv.y); + + // caustics near the surface + float c = sin(uv.x * 60.0 + t * 0.4 + 2.0 * sin(uv.y * 8.0 + t * 0.3)) + * sin(uv.x * 41.0 - t * 0.3 + uv.y * 6.0); + col += hsv(0.50 + drift, 0.4, 1.0) * pow(max(c, 0.0), 3.0) * uv.y * 0.5; + + // fish + for (int k = 0; k < 5; k++) { + float fk = float(k); + float speed = 0.02 + 0.015 * hash(fk + 1.0); // screen widths / s + float dir = mod(fk, 2.0) < 1.0 ? 1.0 : -1.0; + float fx = fract(t * speed * dir + fk * 0.23) * 1.4 - 0.2; + float fy = 0.25 + 0.5 * hash(fk + 7.0) + 0.05 * sin(t * 0.5 + fk); + vec2 d = vec2((uv.x - fx) / 0.10, (uv.y - fy) / 0.30); + float body = 1.0 - smoothstep(0.7, 1.0, length(d)); + // tail behind the fish + vec2 dt = vec2((uv.x - (fx - dir * 0.09)) / 0.04, (uv.y - fy) / 0.22); + float tail = 1.0 - smoothstep(0.6, 1.0, length(dt)); + vec3 fc = hsv(hash(fk + 3.0) + 0.5 + drift, 0.85, 1.0); + col = mix(col, fc, clamp(body + 0.8 * tail, 0.0, 1.0) * 0.9); + } + + fragColor = vec4(col, 1.0); +} diff --git a/tests/test_treppe_gl.py b/tests/test_treppe_gl.py index cae6c2e..892f3f6 100644 --- a/tests/test_treppe_gl.py +++ b/tests/test_treppe_gl.py @@ -68,3 +68,40 @@ def test_brightness_scales_and_clamps(): assert t.to_rgbw(px, "extract", 0).max() == 0 np.testing.assert_allclose(t.to_rgbw(px, "extract", 500), t.to_rgbw(px, "extract", 100)) + + +CANVAS_SHADER = """ +void mainImage(out vec4 c, in vec2 p) { + // red in the two lowest rows, and in the two leftmost columns (step 0) + c = vec4(p.y < 2.0 ? 1.0 : 0.0, p.x < 2.0 ? 1.0 : 0.0, 0.0, 1.0); +}""" + + +def test_canvas_compaction_modes(): + ctx, _ = t.create_context(window=False) + r = t.Renderer(ctx) + r.load(CANVAS_SHADER) + r.configure((32, 4), "mean") # 2 columns per step, 4 rows + px = r.render_strip(0.0) + assert px.shape == (16, 4) + np.testing.assert_allclose(px[:, 0], 0.5) # 2 of 4 rows + np.testing.assert_allclose(px[0, 1], 1.0) # step 0 green + np.testing.assert_allclose(px[1:, 1], 0.0) + r.configure((32, 4), "max") + np.testing.assert_allclose(r.render_strip(0.0)[:, 0], 1.0) + r.configure((32, 4), "row") # row 2 is dark + np.testing.assert_allclose(r.render_strip(0.0)[:, 0], 0.0) + r.configure(None) # back to 1D + assert r.render_strip(0.0).shape == (16, 4) + + +def test_canvas_parsing(): + assert t.parse_canvas("256x32") == (256, 32) + for bad in ("100x10", "abc", "8x8", "256x0"): + with pytest.raises(ValueError): + t.parse_canvas(bad) + assert t.directive("// x\n// canvas: 128x16 compact: max\nvoid f(){}") == ( + (128, 16), "max") + assert t.directive("void mainImage() {}") == (None, None) + with pytest.raises(ValueError): + t.directive("// canvas: 128x16 compact: nope") diff --git a/treppe-dry.sh b/treppe-dry.sh index ef57b40..1538afe 100755 --- a/treppe-dry.sh +++ b/treppe-dry.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash -# Usage: treppe-dry.sh [brightness 0-100, default 100] +# Usage: treppe-dry.sh [brightness 0-100, default 100] [extra treppe_gl.py args] set -euo pipefail here=$(dirname "$(realpath "$0")") [ $# -ge 1 ] || { echo "usage: $0 [brightness 0-100]" >&2; exit 2; } shader=$(realpath "$1") cd "$here" -exec nix-shell --run "python treppe_gl.py '$shader' --brightness ${2:-100} --dry-run" +exec nix-shell --run "python treppe_gl.py '$shader' --brightness ${2:-100} ${*:3} --dry-run" diff --git a/treppe-run.sh b/treppe-run.sh index a17f896..b7db725 100755 --- a/treppe-run.sh +++ b/treppe-run.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash -# Usage: treppe-run.sh [brightness 0-100, default 100] +# Usage: treppe-run.sh [brightness 0-100, default 100] [extra treppe_gl.py args] set -euo pipefail here=$(dirname "$(realpath "$0")") [ $# -ge 1 ] || { echo "usage: $0 [brightness 0-100]" >&2; exit 2; } shader=$(realpath "$1") cd "$here" -exec nix-shell --run "python treppe_gl.py '$shader' --brightness ${2:-100}" +exec nix-shell --run "python treppe_gl.py '$shader' --brightness ${2:-100} ${*:3}" diff --git a/treppe_gl.py b/treppe_gl.py index 6e9db2d..a71cb54 100644 --- a/treppe_gl.py +++ b/treppe_gl.py @@ -11,6 +11,7 @@ An optional debug window shows the shader at full resolution and the import argparse import os +import re import signal import socket import sys @@ -31,6 +32,7 @@ FLAG_RGBA = 0x02 FLAG_BITS_16 = 0x08 WHITE_MODES = ("extract", "alpha", "off") +COMPACT_MODES = ("mean", "max", "row") # --- Protocol -------------------------------------------------------------- @@ -68,6 +70,58 @@ def encode_frame(rgbw): BLACK_FRAME = encode_frame(np.zeros((STEPS, 4), dtype=np.float32)) +# --- 2D canvas ------------------------------------------------------------ + +CANVAS_RE = re.compile( + r"^\s*//\s*canvas:\s*(\d+)x(\d+)(?:\s+compact:\s*(\w+))?", re.M) + + +def parse_canvas(text): + """Parse '256x32' into (256, 32); the width must fit the 16 steps.""" + m = re.fullmatch(r"(\d+)x(\d+)", text.strip()) + if not m: + raise ValueError("canvas must look like 256x32, got {!r}".format(text)) + w, h = int(m.group(1)), int(m.group(2)) + if w < STEPS or h < 1 or w % STEPS != 0: + raise ValueError( + "canvas width must be a multiple of {}, got {}x{}".format( + STEPS, w, h)) + return (w, h) + + +def directive(source): + """ + Read '// canvas: 256x32 compact: mean' from the top of a shader. + Returns (canvas or None, compact or None). + """ + m = CANVAS_RE.search("\n".join(source.splitlines()[:15])) + if not m: + return None, None + compact = m.group(3) + if compact is not None and compact not in COMPACT_MODES: + raise ValueError("unknown compact mode: {}".format(compact)) + return parse_canvas("{}x{}".format(m.group(1), m.group(2))), compact + + +def compact_canvas(pixels, mode="mean"): + """ + Compact a 2D canvas (H x W x 4 floats) to STEPS x 4, one step per + W/STEPS wide group of columns. + + mean: average of the whole column group + max: brightest value per channel in the group + row: average of the middle row only + """ + h, w, _ = pixels.shape + cols = w // STEPS + if mode == "row": + return pixels[h // 2].reshape(STEPS, cols, 4).mean(axis=1) + groups = pixels.reshape(h, STEPS, cols, 4) + if mode == "max": + return groups.max(axis=(0, 2)) + return groups.mean(axis=(0, 2)) + + # --- Shaders --------------------------------------------------------------- VERTEX = """ @@ -193,30 +247,53 @@ class Renderer: self.quad = ctx.buffer( np.array([-1, -1, 3, -1, -1, 3], dtype="f4").tobytes()) self.size = size + self.canvas = None + self.compact = "mean" 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._make_full(size, "f1") 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 + def _make_full(self, size, dtype): 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.size = size + self.full_tex = self.ctx.texture(size, 4, dtype=dtype) + self.full_tex.filter = (moderngl.NEAREST if self.canvas + else moderngl.LINEAR,) * 2 self.full_fbo = self.ctx.framebuffer([self.full_tex]) + def resize(self, size): + """Resize the preview. In 2D canvas mode the canvas size is fixed.""" + if self.canvas: + return + size = (max(1, int(size[0])), max(1, int(size[1]))) + if size != self.size: + self._make_full(size, "f1") + + def configure(self, canvas=None, compact="mean"): + """Use a 2D canvas (W, H) compacted to the steps, or None for 1D.""" + if canvas is not None: + canvas = parse_canvas("{}x{}".format(*canvas)) + if compact not in COMPACT_MODES: + raise ValueError("unknown compact mode: {}".format(compact)) + self.compact = compact + if canvas == self.canvas: + return + self.canvas = canvas + if canvas: + self._make_full(canvas, "f4") + else: + self._make_full((512, 288), "f1") + def load(self, source): """Compile a shader. Raises on error and keeps the old one.""" program = self.ctx.program( @@ -243,11 +320,22 @@ class Renderer: def render_strip(self, t, dt=0.0, frame=0): """Render the 16x1 strip, returns STEPS x 4 float pixels.""" + if self.canvas: + # Render the 2D canvas and compact it down to the steps. + self._draw(self.full_fbo, self.canvas, t, dt, frame) + raw = self.full_fbo.read(components=4, dtype="f4") + w, h = self.canvas + pixels = np.frombuffer(raw, dtype="f4").reshape(h, w, 4) + strip = compact_canvas(pixels, self.compact).astype("f4") + self.strip_tex.write(strip.tobytes()) + return strip 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): + if self.canvas: + return # already rendered by render_strip self._draw(self.full_fbo, self.size, t, dt, frame) @@ -371,7 +459,12 @@ def run(args): source = shader.poll() if source is not None: try: + canvas, compact = directive(source) + if args.canvas: + canvas = parse_canvas(args.canvas) renderer.load(source) + renderer.configure( + canvas, args.compact or compact or "mean") print("loaded {}".format(args.shader), file=sys.stderr) except Exception as err: print("shader error:\n{}".format(err), file=sys.stderr) @@ -417,6 +510,11 @@ def parse_args(argv=None): 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("--canvas", metavar="WxH", + help="render a 2D canvas and compact it to the steps " + "(overrides '// canvas: WxH' in the shader)") + p.add_argument("--compact", choices=COMPACT_MODES, + help="how the canvas is compacted (default mean)") p.add_argument("--brightness", type=float, default=100.0, help="0..100 percent, scales what is sent (default 100)") p.add_argument("--dry-run", action="store_true",