diff options
| author | user <user@clank> | 2026-08-15 00:02:51 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-15 00:02:51 +0200 |
| commit | 599da705d93f27efb2a4c62b0747356d97e8f9b3 (patch) | |
| tree | 34dca7208797dba5088c52abdf328095b33fde90 | |
| parent | contrib: draw the shadertoy preview at the size it means (diff) | |
| download | slosh-599da705d93f27efb2a4c62b0747356d97e8f9b3.tar.gz slosh-599da705d93f27efb2a4c62b0747356d97e8f9b3.tar.xz slosh-599da705d93f27efb2a4c62b0747356d97e8f9b3.zip | |
contrib: presets worth stealing, and a test that they do anything
Eleven examples, grouped, and most of them are less "effect" than
*instrument*: a cursor line, a crosshair, indent guides, a right margin at
three quarters, a torch that dims everything more than eight cells from where
you are looking. Things that tell you where you are, inside a program that
has never heard of any of them -- which is the argument for shading contents
rather than chrome, made in examples instead of prose.
Then the fun: phosphor and amber CRTs (grayscale, tint, scanlines, vignette
as a four-pass chain), film grain, a rolling bar, an interlace flicker; sonar
pings expanding from the cursor, ripples, a barber pole, plasma from two
triangle waves, matrix rain with a fading tail; and every built-in written as
an expression, because `vignette` being `dim` with an argument is the whole
design and is better shown than argued.
Two rules the presets follow, both about staying identical to the C: never
multiply `t` (it is a millisecond clock and gets large -- divide it down
first and the arithmetic stays in an int32 forever), and keep hash
coefficients small for the same reason. `(x * 11) % 23` is fine; the usual
Knuth constant is a different number in C than in JavaScript.
The test now lifts the *presets* out of the page as well as the evaluator, so
one added later is conformance-checked without anyone remembering to add it.
And it evaluates every preset over a whole pane at four points in time and
fails any that is zero everywhere -- an example that does nothing reads as a
broken shader system rather than a bad example.
That check caught its own first draft: sampling every other row made three
`y % 2` effects look dead when they were fine. It samples every cell now,
which costs six hundredths of a second for four hundred thousand
evaluations, because the thing being tested is quick.
| -rw-r--r-- | contrib/README.md | 20 | ||||
| -rw-r--r-- | contrib/shadertoy.html | 112 | ||||
| -rw-r--r-- | tests/test_shadertoy.py | 51 |
3 files changed, 147 insertions, 36 deletions
diff --git a/contrib/README.md b/contrib/README.md index 3a4b1c2..5aba78f 100644 --- a/contrib/README.md +++ b/contrib/README.md @@ -57,9 +57,21 @@ what it will do in a pane. Anything reading `t` animates. Presets cover every built-in written as an expression, which is the equivalence the design rests on, and the config line to paste is printed underneath. +The presets are grouped, and most of them are less "effect" than +*instrument*: a cursor line, a crosshair, indent guides, a right margin, a +torch that dims everything more than eight cells from where you are looking +-- things that tell you where you are inside a program that has never heard +of any of them. Then a CRT section (phosphor, amber, film grain, a rolling +bar), a motion section (sonar pings from the cursor, ripples, matrix rain, +plasma), and every built-in written as an expression, which is the +equivalence the design rests on: `vignette` is `dim` with an argument. + It reimplements `src/expr.c` in JavaScript, which is the obvious hazard: a preview that disagrees with the compiler is worse than no preview, and it -would disagree quietly. So `tests/test_shadertoy.py` lifts the evaluator out -of the page, runs a few hundred expression/environment pairs through both it -and the real compiler, and fails if a single number differs. It found its -first bug on its first run. +would disagree quietly. So `tests/test_shadertoy.py` lifts the evaluator *and +the presets* out of the page and runs them through both implementations -- +new presets are checked without anyone remembering to add them. It also +evaluates every preset over a whole pane at four points in time and fails any +that computes zero everywhere, because an example that does nothing reads as +a broken shader system rather than a bad example. It found its first bug on +its first run. diff --git a/contrib/shadertoy.html b/contrib/shadertoy.html index cea9b2f..809cdd4 100644 --- a/contrib/shadertoy.html +++ b/contrib/shadertoy.html @@ -73,8 +73,10 @@ .hint { color: var(--dim); font-size: 11px; } .ref { color: var(--dim); font-size: 11px; line-height: 1.7; } .ref code { color: var(--fg); } - .presets { display: flex; flex-wrap: wrap; gap: 5px; } + .presets { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 8px; } .presets button { font-size: 11px; padding: 4px 7px; } + .group { color: var(--dim); font-size: 10px; letter-spacing: .08em; + text-transform: uppercase; margin: 8px 0 4px; } </style> </head> <body> @@ -84,7 +86,7 @@ <p class="sub">A shader's strength is an expression, evaluated for every cell.</p> <h2>presets</h2> - <div class="presets" id="presets"></div> + <div id="presets"></div> <h2>chain</h2> <div id="chain"></div> @@ -327,25 +329,92 @@ const hex2rgb = (h) => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16) parseInt(h.slice(5, 7), 16)]; const rgb2css = (c) => `rgb(${c[0]},${c[1]},${c[2]})`; -/* ---- the chain UI ------------------------------------------------------- */ +/* ---- presets ------------------------------------------------------------ + * + * Grouped, because most of these are not effects so much as *instruments*: + * something that tells you where the cursor is, or where column 64 is, in a + * program that has never heard of either. + * + * Two rules learned the hard way, both about staying identical to the C: + * - never multiply `t`. It is a millisecond clock and gets large; divide it + * down first (`t / 40`) and the arithmetic stays inside an int32 forever. + * - keep coefficients small for the same reason. `(x * 11) % 23` is a fine + * hash here; `x * 2654435761` is a different number in C than in JS. + */ const PRESETS = { - "scanlines": [{ op: "dim", amount: "(y % 2) * 40", color: "#ff5fd7" }], - "right margin":[{ op: "dim", amount: "(x > cols - 10) * 120", color: "#ff5fd7" }], - "spotlight": [{ op: "dim", amount: "clamp(dist(x, y, curx, cury) * 9, 0, 200)", color: "#ff5fd7" }], - "vignette": [{ op: "dim", amount: "clamp(dist(x, y, cols / 2, rows / 2) * 4, 0, 190)", color: "#ff5fd7" }], - "ruler at 40": [{ op: "tint", amount: "(x == 40) * 220", color: "#ff5fd7" }], - "zebra": [{ op: "dim", amount: "((y / 3) % 2) * 60", color: "#ff5fd7" }], - "checker": [{ op: "dim", amount: "(((x / 8) + (y / 4)) % 2) * 45", color: "#ff5fd7" }], - "unfocused": [{ op: "grayscale", amount: "180", color: "#ff5fd7" }, + "guides": { + "cursor line": [{ op: "tint", amount: "(y == cury) * 45", color: "#ff5fd7" }], + "crosshair": [{ op: "dim", amount: "((y == cury) || (x == curx)) * 55", color: "#ff5fd7" }], + "focus mode": [{ op: "dim", amount: "clamp(abs(y - cury) * 26, 0, 190)", color: "#ff5fd7" }], + "torch": [{ op: "dim", amount: "clamp((dist(x, y, curx, cury) - 8) * 22, 0, 215)", color: "#ff5fd7" }], + "indent guides":[{ op: "tint", amount: "(x % 4 == 0) * 38", color: "#7aa2f7" }], + "thirds": [{ op: "tint", amount: "(x == cols / 3 || x == cols * 2 / 3) * 140", color: "#ff5fd7" }], + "right margin": [{ op: "dim", amount: "clamp((x - cols * 3 / 4) * 30, 0, 170)", color: "#ff5fd7" }], + "edge falloff": [{ op: "dim", amount: "clamp((5 - min(x, cols - 1 - x)) * 34, 0, 170)", color: "#ff5fd7" }], + }, + + "crt": { + "phosphor": [ + { op: "grayscale", amount: "255", color: "#33ff66" }, + { op: "tint", amount: "80", color: "#33ff66" }, + { op: "dim", amount: "(y % 3 == 0) * 55", color: "#33ff66" }, + { op: "dim", amount: "clamp(dist(x, y, cols / 2, rows / 2) * 4, 0, 150)", color: "#33ff66" }, + ], + "amber": [ + { op: "grayscale", amount: "255", color: "#ffb000" }, + { op: "tint", amount: "95", color: "#ffb000" }, + { op: "dim", amount: "(y % 2) * 40", color: "#ffb000" }, + ], + "rolling bar": [{ op: "dim", amount: "(abs(y - (t / 90) % rows) < 2) * 70", color: "#ff5fd7" }], + "interlace": [{ op: "dim", amount: "((y + t / 80) % 2) * 45", color: "#ff5fd7" }], + "film": [ + { op: "grayscale", amount: "255", color: "#ffffff" }, + { op: "dim", amount: "((x * 7 + y * 13 + t / 40) % 17 < 2) * 70", color: "#ffffff" }, + { op: "dim", amount: "clamp(dist(x, y, cols / 2, rows / 2) * 5, 0, 165)", color: "#ffffff" }, + ], + }, + + "motion": { + "sonar": [{ op: "tint", amount: "(abs(dist(x, y, curx, cury) - (t / 22) % 80) < 3) * 220", color: "#33ff66" }], + "ripples": [{ op: "tint", amount: "((dist(x, y, curx, cury) + t / 30) % 10 < 5) * 50", color: "#7aa2f7" }], + "matrix rain": [ + { op: "grayscale", amount: "220", color: "#33ff66" }, + { op: "tint", color: "#33ff66", + amount: "clamp(240 - (((t / 70 + (x * 11) % 23 * 3) % rows - y + rows) % rows) * 45, 0, 240)" }, + ], + "barber pole": [{ op: "dim", amount: "((x + y + t / 50) / 3 % 2) * 50", color: "#ff5fd7" }], + "plasma": [{ op: "dim", + amount: "clamp(abs((x * 2 + t / 40) % 34 - 17) * 7 + abs((y * 3 - t / 55) % 26 - 13) * 6 - 40, 0, 170)", + color: "#ff5fd7" }], + "sweep": [{ op: "dim", amount: "clamp(abs(x - (t / 24) % cols) * 22, 0, 200)", color: "#ff5fd7" }], + "pulse": [{ op: "tint", amount: "clamp((t / 6) % 300 - 45, 0, 160)", color: "#ff5fd7" }], + "typewriter": [{ op: "dim", amount: "(y > (t / 400) % rows) * 200", color: "#ff5fd7" }], + }, + + "the built-ins, as expressions": { + "scanlines": [{ op: "dim", amount: "(y % 2) * 40", color: "#ff5fd7" }], + "vignette": [{ op: "dim", amount: "clamp(dist(x, y, cols / 2, rows / 2) * 4, 0, 190)", color: "#ff5fd7" }], + "spotlight": [{ op: "dim", amount: "clamp(dist(x, y, curx, cury) * 9, 0, 200)", color: "#ff5fd7" }], + "zebra": [{ op: "dim", amount: "(y / 3 % 2) * 60", color: "#ff5fd7" }], + "ruler": [{ op: "tint", amount: "(x == 40) * 220", color: "#ff5fd7" }], + "gradient": [{ op: "dim", amount: "y * 200 / rows", color: "#ff5fd7" }], + "checker": [{ op: "dim", amount: "((x / 8 + y / 4) % 2) * 45", color: "#ff5fd7" }], + }, + + "pane states": { + "unfocused": [{ op: "dim", amount: "90", color: "#ff5fd7" }], + "dead": [{ op: "grayscale", amount: "200", color: "#ff5fd7" }, { op: "dim", amount: "90", color: "#ff5fd7" }], - "pulse (t)": [{ op: "tint", amount: "clamp((t / 6) % 300 - 45, 0, 160)", color: "#ff5fd7" }], - "sweep (t)": [{ op: "dim", amount: "clamp(abs(x - (t / 24) % cols) * 22, 0, 200)", color: "#ff5fd7" }], - "dead pane": [{ op: "grayscale", amount: "200", color: "#ff5fd7" }, + "scrolled": [{ op: "tint", amount: "30", color: "#ffcc88" }], + "suspended": [{ op: "grayscale", amount: "180", color: "#ff5fd7" }, { op: "dim", amount: "90", color: "#ff5fd7" }], + }, }; -let chain = structuredClone(PRESETS["scanlines"]); +/* ---- the chain UI ------------------------------------------------------- */ + +let chain = structuredClone(PRESETS["guides"]["focus mode"]); let cursor = { x: 44, y: 23 }; const el = (tag, props = {}, kids = []) => { @@ -387,10 +456,15 @@ function renderChain() { function renderPresets() { const box = document.getElementById("presets"); - for (const [name, passes] of Object.entries(PRESETS)) { - const b = el("button", { textContent: name }); - b.onclick = () => { chain = structuredClone(passes); renderChain(); refresh(); }; - box.append(b); + for (const [group, entries] of Object.entries(PRESETS)) { + box.append(el("div", { className: "group", textContent: group })); + const row = el("div", { className: "presets" }); + for (const [name, passes] of Object.entries(entries)) { + const b = el("button", { textContent: name }); + b.onclick = () => { chain = structuredClone(passes); renderChain(); refresh(); }; + row.append(b); + } + box.append(row); } } diff --git a/tests/test_shadertoy.py b/tests/test_shadertoy.py index ce508b1..0b1dd49 100644 --- a/tests/test_shadertoy.py +++ b/tests/test_shadertoy.py @@ -25,6 +25,7 @@ from harness import check, report HERE = os.path.dirname(os.path.abspath(__file__)) HTML = os.path.join(HERE, "..", "contrib", "shadertoy.html") EVAL = os.path.join(HERE, "..", "build", "expr_eval") +SCRIPT = open(HTML).read().split("<script>")[1].split("</script>")[0] # Environments to evaluate at: corners, the middle, and a moved cursor. ENVS = [ @@ -34,19 +35,23 @@ ENVS = [ dict(x=40, y=12, cols=100, rows=40, curx=99, cury=39, cursor=1, focused=1, t=7), ] +def page_presets(): + """Every preset the page ships, lifted out of the page. + + Read rather than listed, so a preset added later is checked without anyone + remembering to add it here -- which nobody would.""" + prefix = SCRIPT[:SCRIPT.index("/* ---- the chain UI")] + out = subprocess.run(["node", "-e", prefix + "\nconsole.log(JSON.stringify(PRESETS));"], + capture_output=True, text=True) + groups = json.loads(out.stdout) + return [(f"{group}/{name}", i, p["amount"]) + for group, entries in groups.items() + for name, chain in entries.items() + for i, p in enumerate(chain)] + + EXPRS = [ - # the presets the page ships, which are the ones people will actually copy - "(y % 2) * 40", - "(x > cols - 10) * 120", - "clamp(dist(x, y, curx, cury) * 9, 0, 200)", - "clamp(dist(x, y, cols / 2, rows / 2) * 4, 0, 190)", - "(x == 40) * 220", - "((y / 3) % 2) * 60", - "(((x / 8) + (y / 4)) % 2) * 45", - "clamp((t / 6) % 300 - 45, 0, 160)", - "clamp(abs(x - (t / 24) % cols) * 22, 0, 200)", - "180", - # and the corners of the language + # the corners of the language "1 + 2 * 3", "(1 + 2) * 3", "10 - 3 - 2", @@ -146,7 +151,9 @@ def main(): check("the expression evaluator is built", False, EVAL) return report() - exprs = EXPRS + rand_exprs(40) + presets = page_presets() + check("the page ships presets to check", len(presets) >= 20, str(len(presets))) + exprs = EXPRS + [e for _, _, e in presets] + rand_exprs(40) pairs = [(env, e) for e in exprs for env in ENVS] import tempfile @@ -177,6 +184,24 @@ def main(): not bad, "\n".join(f" {e!r} at {env} -> C {w}, JS {g}" for e, env, w, g in bad[:8])) + # A preset that computes 0 everywhere is a preset that does nothing, which + # is a broken example rather than a subtle one: it looks like the shader + # system is not working. Checked over a whole pane and four points in time, + # cell by cell -- sampling every other row made three `y % 2` effects look + # dead when they were fine. + grid = [dict(x=x, y=y, cols=64, rows=24, curx=30, cury=12, cursor=1, + focused=1, t=t) + for t in (0, 500, 1500, 4000) for y in range(24) for x in range(64)] + flat = [(env, e) for _, _, e in presets for env in grid] + vals = c_values(flat) + per = len(grid) + for idx, (label, i, expr) in enumerate(presets): + window = vals[idx * per:(idx + 1) * per] + nums = [int(v) for v in window if not v.startswith("error")] + check(f"{label} pass {i} does something", + nums and max(0, min(255, max(nums))) > 0, + f"{expr!r} is 0 everywhere") + # And that a refusal is a refusal on both sides, since a page that quietly # accepts what the config rejects teaches the wrong language. refused = ["nosuchvar + 1", "wobble(1, 2)", "(1 + 2", "1 +", "1 + 2 )", "1 ? 2"] |
