Add 2D canvas mode compacted to the steps, ocean-chaos and reef-2d shaders

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
April John 2026-10-16 15:28:41 +02:00
commit d917860201
7 changed files with 252 additions and 12 deletions

View file

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

51
shaders/ocean-chaos.glsl Normal file
View file

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

46
shaders/reef-2d.glsl Normal file
View file

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

View file

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

View file

@ -1,8 +1,8 @@
#!/usr/bin/env bash
# Usage: treppe-dry.sh <shader.glsl> [brightness 0-100, default 100]
# Usage: treppe-dry.sh <shader.glsl> [brightness 0-100, default 100] [extra treppe_gl.py args]
set -euo pipefail
here=$(dirname "$(realpath "$0")")
[ $# -ge 1 ] || { echo "usage: $0 <shader.glsl> [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"

View file

@ -1,8 +1,8 @@
#!/usr/bin/env bash
# Usage: treppe-run.sh <shader.glsl> [brightness 0-100, default 100]
# Usage: treppe-run.sh <shader.glsl> [brightness 0-100, default 100] [extra treppe_gl.py args]
set -euo pipefail
here=$(dirname "$(realpath "$0")")
[ $# -ge 1 ] || { echo "usage: $0 <shader.glsl> [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}"

View file

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