← snapshot
6990 bytes
/**
* Offline probe for the orb shader.
*
* Renders the real fragment shader headlessly and reports what fraction of
* pixels the raymarch actually hits, plus mean luminance. Tuning a fractal
* by taking screenshots is guesswork; this measures it.
*
* node tour/shader-probe.mjs # current params, all subjects
* node tour/shader-probe.mjs --sweep # search scale/offset/camera
*/
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "@playwright/test";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const SRC = path.join(HERE, "..", "components", "jackie", "jackie-orb-gl.tsx");
/** Pull the shader sources out of the component so there is one source of truth. */
async function shaders() {
const file = await readFile(SRC, "utf8");
const grab = (name) => {
const m = new RegExp(`const ${name} = \`([\\s\\S]*?)\`;`).exec(file);
if (!m) throw new Error(`could not find ${name} in ${SRC}`);
return m[1];
};
const subjects = /const SUBJECTS[^=]*=\s*\{([\s\S]*?)\n\};/.exec(file);
return { vert: grab("VERT"), frag: grab("FRAG"), subjectsRaw: subjects?.[1] ?? "" };
}
const PAGE = `<canvas id="c" width="220" height="220"></canvas>`;
async function main() {
const { vert, frag, subjectsRaw } = await shaders();
const sweep = process.argv.includes("--sweep");
// Parse the SUBJECTS table out of the component.
const specs = {};
for (const m of subjectsRaw.matchAll(
/(\w+):\s*\{\s*folds:\s*([\d.]+),\s*shape:\s*(\d+),\s*scale:\s*([\d.]+),\s*offset:\s*\[([-\d., ]+)\],\s*spin:\s*([\d.]+)\s*\}/g,
)) {
specs[m[1]] = {
folds: +m[2],
shape: +m[3],
scale: +m[4],
offset: m[5].split(",").map((n) => Number.parseFloat(n)),
spin: +m[6],
};
}
const browser = await chromium.launch();
const page = await browser.newPage();
await page.setContent(PAGE);
const measure = await page.evaluate(
async ({ vert, frag }) => {
const canvas = document.getElementById("c");
const gl = canvas.getContext("webgl2", { preserveDrawingBuffer: true });
if (!gl) return { error: "no webgl2" };
const sh = (t, s) => {
const o = gl.createShader(t);
gl.shaderSource(o, s);
gl.compileShader(o);
if (!gl.getShaderParameter(o, gl.COMPILE_STATUS))
throw new Error(gl.getShaderInfoLog(o));
return o;
};
const prog = gl.createProgram();
gl.attachShader(prog, sh(gl.VERTEX_SHADER, vert));
gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, frag));
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS))
throw new Error(gl.getProgramInfoLog(prog));
gl.useProgram(prog);
const u = (n) => gl.getUniformLocation(prog, n);
// Expose a render function the outer scope can call repeatedly.
window.__render = (p) => {
gl.viewport(0, 0, 220, 220);
gl.uniform2f(u("uRes"), 220, 220);
gl.uniform1f(u("uTime"), p.time ?? 1.7);
gl.uniform1f(u("uLevel"), 0);
gl.uniform3f(u("uThread"), 0.54, 0.65, 0.88);
gl.uniform1f(u("uFolds"), p.folds);
gl.uniform1i(u("uShape"), p.shape);
gl.uniform1f(u("uScale"), p.scale);
gl.uniform3f(u("uOffset"), p.offset[0], p.offset[1], p.offset[2]);
gl.uniform1f(u("uSpin"), p.spin ?? 0.2);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, 3);
const px = new Uint8Array(220 * 220 * 4);
gl.readPixels(0, 0, 220, 220, gl.RGBA, gl.UNSIGNED_BYTE, px);
let lit = 0;
let sum = 0;
let peak = 0;
for (let i = 0; i < px.length; i += 4) {
const l = (px[i] + px[i + 1] + px[i + 2]) / 3;
sum += l;
if (l > peak) peak = l;
if (l > 26) lit++;
}
return {
litPct: +((lit / (220 * 220)) * 100).toFixed(2),
meanLum: +(sum / (220 * 220)).toFixed(2),
peak,
};
};
return { ok: true };
},
{ vert, frag },
);
if (measure.error) {
console.error(measure.error);
process.exit(1);
}
const run = (p) => page.evaluate((q) => window.__render(q), p);
console.log("current parameters:");
for (const [name, spec] of Object.entries(specs)) {
const r = await run(spec);
console.log(
` ${name.padEnd(10)} folds=${String(spec.folds).padStart(2)} scale=${spec.scale} -> lit ${String(r.litPct).padStart(5)}% mean ${String(r.meanLum).padStart(6)} peak ${r.peak}`,
);
}
if (process.argv.includes("--tune")) {
// For each subject, find the parameters closest to a good coverage —
// enough geometry to read as a figure, not so much it becomes a wall.
const TARGET = 26;
console.log(`\ntuning each subject toward ~${TARGET}% lit…`);
for (const [name, spec] of Object.entries(specs)) {
let best = null;
for (const scale of [1.7, 1.8, 1.9, 2.0, 2.1, 2.2, 2.35, 2.5]) {
for (const ox of [0.8, 0.95, 1.05, 1.2, 1.35]) {
for (const oz of [0.5, 0.7, 0.9]) {
const p = { ...spec, scale, offset: [ox, ox * 0.95, oz] };
const r = await run(p);
const score = Math.abs(r.litPct - TARGET) - r.meanLum * 0.05;
if (r.litPct > 6 && r.litPct < 52 && (!best || score < best.score)) {
best = { score, scale, offset: p.offset, ...r };
}
}
}
}
if (best) {
console.log(
` ${name.padEnd(10)} scale: ${best.scale}, offset: [${best.offset.map((n) => n.toFixed(2)).join(", ")}] -> lit ${best.litPct}% mean ${best.meanLum}`,
);
} else {
console.log(` ${name.padEnd(10)} no viable parameters found`);
}
}
await browser.close();
return;
}
if (!sweep) {
await browser.close();
return;
}
// Search for parameters that actually put geometry on screen. A good orb
// covers roughly a fifth to a half of the frame — enough to read as a
// figure, not so much that it becomes a wall.
console.log("\nsweeping (target: 15-45% lit)…");
const results = [];
for (const scale of [1.6, 1.7, 1.8, 1.9, 2.0, 2.1, 2.3, 2.5]) {
for (const off of [0.6, 0.8, 1.0, 1.2, 1.4]) {
for (const folds of [4, 8]) {
const r = await run({
folds,
shape: 0,
scale,
offset: [off, off, off * 0.7],
spin: 0.2,
});
results.push({ scale, off, folds, ...r });
}
}
}
results
.filter((r) => r.litPct > 8 && r.litPct < 60)
.sort((a, b) => b.meanLum - a.meanLum)
.slice(0, 14)
.forEach((r) =>
console.log(
` scale=${r.scale} offset=${r.off} folds=${r.folds} -> lit ${r.litPct}% mean ${r.meanLum}`,
),
);
await browser.close();
}
main().catch((e) => {
console.error(e);
process.exit(1);
});