Zum Inhalt

scripts/bench-headroom.ts

// scripts/bench-headroom.ts
//
// Measures sustained frame cadence (via requestAnimationFrame) for a
// preset at a given agent count. Use this to verify the engine still
// hits vsync after preset / param changes — or to find the agent ceiling
// on a given machine.
//
// Setup:
//   1. npm run dev   (in a separate terminal — dev server must be up)
//   2. npx tsx scripts/bench-headroom.ts
//
// Optional env:
//   PHYSARUM_URL=http://localhost:5173       (target URL)
//   PHYSARUM_PRESET=aurora-veil              (which built-in preset; default = aurora-veil)
//   PHYSARUM_AGENTS=500000                   (override agentCount via ?stress_agents=)
//   PHYSARUM_SETTLE_MS=4000                  (warmup before sampling)
//   PHYSARUM_SAMPLE_MS=6000                  (sample window)
//
// Note: the engine clamps agentCount to the perf tier's budget at init
// time (`budgetForTier` in src/engine/adapter.ts). On desktop tier the
// cap is currently 500_000 (matches the schema cap); mid is 100_000,
// mobile is 50_000, minimum is 10_000. Setting PHYSARUM_AGENTS higher
// than the tier cap measures the cap, not the env value.

import { chromium } from '@playwright/test';

const APP_URL = process.env.PHYSARUM_URL ?? 'http://localhost:5173';
const PRESET = process.env.PHYSARUM_PRESET ?? 'aurora-veil';
const AGENTS = Number.parseInt(process.env.PHYSARUM_AGENTS ?? '200000', 10);
const SETTLE_MS = Number.parseInt(process.env.PHYSARUM_SETTLE_MS ?? '4000', 10);
const SAMPLE_MS = Number.parseInt(process.env.PHYSARUM_SAMPLE_MS ?? '6000', 10);

interface FrameSample {
  frames: number;
  ms: number;
  fps: number;
  min: number;
  max: number;
  p50: number;
  p95: number;
}

async function main(): Promise<void> {
  const url = `${APP_URL}/?preset=${encodeURIComponent(PRESET)}&stress_agents=${AGENTS}`;
  console.log(`bench-headroom: ${url}`);
  console.log(`  settle=${SETTLE_MS}ms sample=${SAMPLE_MS}ms`);

  // Headed mode required on macOS — same reason as bake-thumbnails: the
  // headless Chrome renderer doesn't expose a working WebGPU device.
  const browser = await chromium.launch({
    headless: false,
    args: ['--enable-unsafe-webgpu', '--enable-features=Vulkan'],
  });
  const ctx = await browser.newContext({
    viewport: { width: 1440, height: 900 },
    deviceScaleFactor: 1,
  });
  const page = await ctx.newPage();
  page.on('pageerror', (err) => console.error('  pageerror:', err.message));
  try {
    await page.goto(url, { waitUntil: 'load' });
    await page.waitForTimeout(SETTLE_MS);

    // The page-side function is written as a plain Function from source so
    // tsx's transpile-time `__name(fn, "...")` instrumentation can't sneak
    // into the browser context (where `__name` doesn't exist). Type safety
    // is sacrificed inside the body — the outer `as` keeps the contract.
    const PAGE_BENCH_SRC = `return new Promise(function(resolve){
      var dts = [];
      var last = performance.now();
      var start = last;
      var tick = function(now){
        var dt = now - last;
        if (dt > 0) dts.push(dt);
        last = now;
        if (now - start >= durMs) {
          var sorted = dts.slice().sort(function(a,b){return a-b;});
          var pct = function(p){
            var i = Math.max(0, Math.min(sorted.length - 1, Math.floor(p * sorted.length)));
            return sorted[i] || 0;
          };
          var fps = function(n){ return n > 0 ? 1000 / n : 0; };
          var minDt = sorted[0] || 0;
          var maxDt = sorted[sorted.length - 1] || 0;
          resolve({
            frames: dts.length,
            ms: now - start,
            fps: (dts.length * 1000) / (now - start),
            min: fps(maxDt),
            max: fps(minDt),
            p50: fps(pct(0.5)),
            p95: fps(pct(0.05)),
          });
        } else {
          requestAnimationFrame(tick);
        }
      };
      requestAnimationFrame(tick);
    });`;
    const pageBench = new Function('durMs', PAGE_BENCH_SRC) as (
      durMs: number,
    ) => Promise<FrameSample>;
    const sample = (await page.evaluate(pageBench, SAMPLE_MS)) as FrameSample;

    console.log(`  preset=${PRESET} agents=${AGENTS}`);
    console.log(`  frames=${sample.frames} window=${sample.ms.toFixed(0)}ms`);
    console.log(`  fps   avg=${sample.fps.toFixed(1)}`);
    console.log(`        p50=${sample.p50.toFixed(1)}  p95=${sample.p95.toFixed(1)}`);
    console.log(`        min=${sample.min.toFixed(1)}  max=${sample.max.toFixed(1)}`);
  } finally {
    await ctx.close();
    await browser.close();
  }
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});