aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-15 00:02:51 +0200
committeruser <user@clank>2026-08-15 00:02:51 +0200
commit599da705d93f27efb2a4c62b0747356d97e8f9b3 (patch)
tree34dca7208797dba5088c52abdf328095b33fde90
parentcontrib: draw the shadertoy preview at the size it means (diff)
downloadslosh-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.md20
-rw-r--r--contrib/shadertoy.html112
-rw-r--r--tests/test_shadertoy.py51
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"]