master
AdityaAngajala Publish 0.3.0 6d39e2e 2mo ago
// Timing audit of the reveal schedule, offline. Replicates buildSchedule's math
// against the real generated tile, then simulates the release loop at a fixed
// frame clock and asks the only question that matters: how many frames does
// each route spend PARTIALLY drawn? One frame or fewer == it popped.
import { copyFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";

const here = dirname(fileURLToPath(import.meta.url));
const enginePath = join(tmpdir(), `reveal-tile-${process.pid}.mjs`);
copyFileSync(join(here, "..", "circuit-background-reveal.js"), enginePath);
const { generateCircuitTile, DEFAULTS } = await import(enginePath);

const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
function hashSeed(input) {
  let h = 2166136261;
  for (let i = 0; i < input.length; i += 1) {
    h ^= input.charCodeAt(i);
    h = Math.imul(h, 16777619);
  }
  return h >>> 0;
}
function mulberry32(seed) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

const REVEAL_JITTER = 70;
const REVEAL_CATCHUP = 40;

const cfg = { ...DEFAULTS, reveal: "flood", seed: "nitrogen" };
const tile = generateCircuitTile(cfg);
const scale = cfg.scale;
const viewWidth = 1400;
const viewHeight = 900;

// buildRevealGeometry
const geometry = tile.traces.map((trace) => {
  const points = trace.points.map(([x, y]) => [x * scale, y * scale]);
  let len = 0;
  for (let i = 1; i < points.length; i += 1) {
    len += Math.hypot(points[i][0] - points[i - 1][0], points[i][1] - points[i - 1][1]);
  }
  const anchor = trace.points[0] || trace.vias[0] || [0, 0];
  return {
    length: len,
    drawable: points.length >= 2,
    vias: trace.vias,
    anchor: [anchor[0] * scale, anchor[1] * scale],
  };
});

const tileWidth = tile.width * scale;
const tileHeight = tile.height * scale;
const tilesAcross = Math.ceil(viewWidth / tileWidth);
const tilesDown = Math.ceil(viewHeight / tileHeight);
const topOffset = -tile.height / 2;
const focusX = viewWidth / 2;
const focusY = viewHeight / 2;
const revealRandom = mulberry32(hashSeed(`${cfg.seed}:reveal:${cfg.reveal}`));

// flood depths are unavailable offline; distance-only ordering ("bloom"-ish).
// Ordering shifts which run sits where, not the size of the numbers below.
const runs = [];
for (let row = -1; row <= tilesDown; row += 1) {
  for (let column = -1; column <= tilesAcross; column += 1) {
    const x = column * tileWidth;
    const y = row * tileHeight + topOffset * scale;
    if (x > viewWidth || y > viewHeight) continue;
    if (x + tileWidth < 0 || y + tileHeight < 0) continue;
    geometry.forEach((entry) => {
      const ax = clamp(x + entry.anchor[0], 0, viewWidth);
      const ay = clamp(y + entry.anchor[1], 0, viewHeight);
      runs.push({
        geometry: entry,
        metric: Math.hypot(ax - focusX, ay - focusY),
        jitter: revealRandom(),
        nudge: revealRandom(),
      });
    });
  }
}
runs.sort((a, b) => a.metric - b.metric || a.jitter - b.jitter);

const speed = (cfg.revealSpeed * scale) / 1000;
let longest = 0;
for (const run of runs) longest = Math.max(longest, run.geometry.length / speed);
const lengths = geometry.map((e) => e.length).sort((a, b) => a - b);
const median = Math.max(1, lengths[Math.floor(lengths.length / 2)] || 1);
const padWeight = median * 0.15;
let total = 0;
for (const run of runs) total += run.geometry.length + padWeight;
const window_ = Math.max(0, cfg.revealDuration - longest);

let cursor = 0;
for (const run of runs) {
  run.duration = run.geometry.length / speed;
  run.delay = Math.max(0, (cursor / total) * window_ + (run.nudge - 0.5) * REVEAL_JITTER);
  cursor += run.geometry.length + padWeight;
}
let stretch = Infinity;
for (const run of runs) {
  if (run.delay > 0) stretch = Math.min(stretch, (cfg.revealDuration - run.duration) / run.delay);
}
if (Number.isFinite(stretch) && stretch > 0) for (const run of runs) run.delay *= stretch;

const instances = runs.filter((r) => r.geometry.drawable).sort((a, b) => a.delay - b.delay);

const pct = (arr, p) => arr[Math.min(arr.length - 1, Math.floor(arr.length * p))];
const durs = instances.map((r) => r.duration).sort((a, b) => a - b);
console.log(`runs total ${runs.length}, drawable ${instances.length}, stretch ${stretch.toFixed(3)}`);
console.log(`longest route ${longest.toFixed(0)}ms, window ${window_.toFixed(0)}ms, padWeight ${padWeight.toFixed(1)}px`);
console.log(
  `route duration ms: min ${durs[0].toFixed(1)} p10 ${pct(durs, 0.1).toFixed(1)} ` +
    `median ${pct(durs, 0.5).toFixed(1)} p90 ${pct(durs, 0.9).toFixed(1)} max ${durs[durs.length - 1].toFixed(1)}`,
);
for (const f of [16.7, 33, 50]) {
  const n = durs.filter((d) => d <= f).length;
  console.log(`  routes finishing within one ${f}ms frame: ${n} (${((100 * n) / durs.length).toFixed(1)}%)`);
}

// how far out of delay-order was the release queue BEFORE the sort?
let inversions = 0;
let worstBacklog = 0;
let peak = -Infinity;
const unsorted = runs.filter((r) => r.geometry.drawable);
for (let i = 0; i < unsorted.length; i += 1) {
  if (unsorted[i].delay < peak) {
    inversions += 1;
    worstBacklog = Math.max(worstBacklog, peak - unsorted[i].delay);
  }
  peak = Math.max(peak, unsorted[i].delay);
}
console.log(
  `delay-order before sorting: ${inversions}/${unsorted.length} out of order (${((100 * inversions) / unsorted.length).toFixed(1)}%), ` +
    `worst run held back ${worstBacklog.toFixed(0)}ms behind the queue head`,
);
const steps = [];
for (let i = 1; i < instances.length; i += 1) steps.push(instances[i].delay - instances[i - 1].delay);
steps.sort((a, b) => a - b);
console.log(`consecutive delay step: median ${pct(steps, 0.5).toFixed(2)}ms vs jitter amplitude ${(REVEAL_JITTER * stretch).toFixed(0)}ms`);

// --- simulate the real release + growth loop at a fixed frame clock ---
function simulate(frameMs, label) {
  for (const r of instances) {
    r.drawn = 0;
    r.startedAt = undefined;
    r.frames = 0;
    r.bornAt = 0;
  }
  const live = new Set();
  let next = 0;
  let time = 0;
  const bursts = [];
  const end = cfg.revealDuration + longest + 1000;
  while (time < end) {
    let released = 0;
    while (next < instances.length && instances[next].delay <= time) {
      const e = instances[next];
      e.startedAt = Math.max(e.delay, time - REVEAL_CATCHUP);
      live.add(e);
      next += 1;
      released += 1;
    }
    bursts.push(released);
    for (const run of [...live]) {
      const grown = Math.min(run.geometry.length, (time - run.startedAt) * speed);
      if (run.drawn === 0) run.bornAt = grown;
      if (grown > 0 && grown < run.geometry.length) run.frames += 1;
      run.drawn = grown;
      if (grown >= run.geometry.length) live.delete(run);
    }
    time += frameMs;
  }
  const popped = instances.filter((r) => r.frames <= 1);
  const bornWhole = instances.filter((r) => r.bornAt >= r.geometry.length - 0.01);
  bursts.sort((a, b) => b - a);
  const fr = instances.map((r) => r.frames).sort((a, b) => a - b);
  console.log(
    `\n@${frameMs}ms frames (${label}): partially-drawn frames per route — ` +
      `min ${fr[0]} p10 ${pct(fr, 0.1)} median ${pct(fr, 0.5)} p90 ${pct(fr, 0.9)}`,
  );
  console.log(
    `  routes animated in <=1 frame: ${popped.length}/${instances.length} ` +
      `(${((100 * popped.length) / instances.length).toFixed(1)}%)   born fully grown: ${bornWhole.length}`,
  );
  console.log(`  biggest single-frame release bursts: ${bursts.slice(0, 6).join(", ")}`);
}
simulate(16.7, "60fps");
simulate(33, "30fps");
simulate(60, "hitching");