jacquardSnapshot

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