Applied as-is. Captured real battles live in public/dev/perf (including the round-13 联防 on the wide round battlefield), with a browser speed page and a headless benchmark. public/dev stays out of the package. The Chrome benchmark was not run. The numbers in the original pull request are from 0.1.4 and are not quoted here. Tests: node --test test/perfbench.test.js test/render/battle-perf.browser.test.js — 13 pass, 0 fail; the Chrome suite skipped (RENDER_E2E unset).
173 lines
10 KiB
JavaScript
173 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
||
// tools/perfbench.mjs — frame-time benchmark of real battles in headless Chrome (public/dev/battle-perf.html: the battle
|
||
// runner + sim + field view, as in a match) for every spec × quality × CPU slowdown. Chrome DevTools CPU throttling
|
||
// stands in for slower phones (4× ≈ mid-range, 6× ≈ low-end — an approximation: no phone GPU, thermals or Safari).
|
||
// With --profile, a CPU profile of each sample is summarised by source area, by function (self time) and by pipeline
|
||
// stage (inclusive time: Spine pose, impostor redraw, Pixi render, three.js board, battle runner, sim, …).
|
||
//
|
||
// Usage: CHROME_PATH=<chrome> node tools/perfbench.mjs [--specs a,b] [--quality high,low] [--cpu 1,4,6] [--warm 20]
|
||
// [--sample 6] [--width 844] [--height 390] [--dpr 2] [--profile] [--out test/e2e/out/perf] [--json]
|
||
// --specs names of public/dev/perf/index.json (default: all) --warm seconds before sampling (default 20)
|
||
// --sample seconds per sample (default 6) --profile also record and summarise a CPU profile
|
||
// Needs the downloaded assets (public/assets: real Spine models; without them every unit is a diamond and the numbers
|
||
// mean little). Writes <out>/results.json (+ <spec>-<quality>-<cpu>x.cpuprofile / .summary.json with --profile).
|
||
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||
const DEFAULT_CHROME = {
|
||
win32: 'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
||
darwin: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||
linux: '/usr/bin/google-chrome',
|
||
};
|
||
|
||
/** GPU flags for headless Chrome per platform (ANGLE backend; Metal exists on macOS only, D3D11 on Windows only). */
|
||
export function gpuArgs(platform = process.platform) {
|
||
const angle = platform === 'win32' ? ['--use-angle=d3d11'] : platform === 'darwin' ? ['--use-angle=metal'] : [];
|
||
return ['--no-first-run', '--enable-gpu', '--ignore-gpu-blocklist', ...angle];
|
||
}
|
||
|
||
/** Source area of a profile call frame (vendor libraries, the game's own modules, the browser's own work). */
|
||
export function areaOf(cf) {
|
||
const url = cf.url || '';
|
||
if (!url) {
|
||
if (cf.functionName === '(garbage collector)') return 'gc';
|
||
if (cf.functionName === '(idle)') return 'idle';
|
||
if (cf.functionName === '(program)') return 'program';
|
||
return 'native';
|
||
}
|
||
if (/\/sim\//.test(url)) return 'sim';
|
||
if (/\/js\/battle\//.test(url)) return 'runner';
|
||
if (/\/js\/render\//.test(url)) return 'render';
|
||
if (/\/js\/(ui|screens)\//.test(url)) return 'ui';
|
||
if (/pixi-spine/.test(url)) return 'spine';
|
||
if (/pixi(\.min)?\.js/.test(url)) return 'pixi';
|
||
if (/three\./.test(url)) return 'three';
|
||
return 'other';
|
||
}
|
||
|
||
/** Pipeline stages measured inclusively (a sample counts once per stage on its stack). */
|
||
export const STAGES = [
|
||
['spinePose', 'Spine pose (actor.update)', (cf) => cf.functionName === 'update' && /render\/spine\.js/.test(cf.url)],
|
||
['impostorFlush', 'impostor redraw (skeletons → atlas)', (cf) => cf.functionName === 'flush' && /render\/impostor\.js/.test(cf.url)],
|
||
['unitUpdate', 'unit views (units.js update)', (cf) => cf.functionName === 'update' && /render\/units\.js/.test(cf.url)],
|
||
['frameBody', 'view frame (all JS before the Pixi render)', (cf) => cf.functionName === 'frameBody' && /render\/app\.js/.test(cf.url)],
|
||
['pixiRender', 'Pixi render (main + render textures)', (cf) => cf.functionName === 'render' && /pixi(\.min)?\.js/.test(cf.url)],
|
||
['board3d', 'three.js board render', (cf) => cf.functionName === 'render' && /board3d\/scene\.js/.test(cf.url)],
|
||
['runner', 'battle runner frame', (cf) => cf.functionName === 'frame' && /js\/battle\/runner\.js/.test(cf.url)],
|
||
['sim', 'sim (any /sim/ module)', (cf) => /\/sim\//.test(cf.url || '')],
|
||
['gc', 'garbage collector', (cf) => cf.functionName === '(garbage collector)'],
|
||
['program', 'browser-native (program)', (cf) => cf.functionName === '(program)'],
|
||
];
|
||
|
||
/**
|
||
* Summary of a CDP CPU profile ({ nodes, samples, timeDeltas }): busy time (idle excluded), the share of each source
|
||
* area (self time), the top functions by self time, and each STAGES entry's inclusive share.
|
||
*/
|
||
export function aggregateProfile(profile, top = 30) {
|
||
const nodes = new Map(profile.nodes.map((n) => [n.id, n]));
|
||
const parent = new Map();
|
||
for (const n of profile.nodes) for (const c of n.children || []) parent.set(c, n.id);
|
||
const area = new Map(), fn = new Map(), stage = new Map(STAGES.map(([k]) => [k, 0]));
|
||
let busy = 0;
|
||
for (let i = 0; i < profile.samples.length; i++) {
|
||
const dt = profile.timeDeltas[i] || 0;
|
||
const leaf = nodes.get(profile.samples[i]);
|
||
if (!leaf) continue;
|
||
const a = areaOf(leaf.callFrame);
|
||
if (a === 'idle') continue;
|
||
busy += dt;
|
||
area.set(a, (area.get(a) || 0) + dt);
|
||
const cf = leaf.callFrame;
|
||
const key = `${cf.functionName || '(anonymous)'} ${cf.url ? `${cf.url.replace(/^[a-z]+:\/\/[^/]+/, '')}:${cf.lineNumber + 1}` : ''}`.trim();
|
||
fn.set(key, (fn.get(key) || 0) + dt);
|
||
const hit = new Set();
|
||
for (let id = profile.samples[i]; id != null; id = parent.get(id)) {
|
||
const f = nodes.get(id).callFrame;
|
||
for (const [k, , test] of STAGES) if (!hit.has(k) && test(f)) hit.add(k);
|
||
}
|
||
for (const k of hit) stage.set(k, stage.get(k) + dt);
|
||
}
|
||
const pct = (us) => (busy > 0 ? Math.round((us / busy) * 1000) / 10 : 0);
|
||
const ms = (us) => Math.round(us / 100) / 10;
|
||
return {
|
||
busyMs: ms(busy),
|
||
areas: [...area].sort((x, y) => y[1] - x[1]).map(([name, us]) => ({ area: name, ms: ms(us), pct: pct(us) })),
|
||
stages: STAGES.map(([k, label]) => ({ stage: k, label, ms: ms(stage.get(k)), pct: pct(stage.get(k)) })),
|
||
top: [...fn].sort((x, y) => y[1] - x[1]).slice(0, top).map(([name, us]) => ({ fn: name, ms: ms(us), pct: pct(us) })),
|
||
};
|
||
}
|
||
|
||
async function main() {
|
||
const argv = process.argv.slice(2);
|
||
const opt = (k, d) => { const i = argv.indexOf(`--${k}`); return i >= 0 ? argv[i + 1] : d; };
|
||
const list = (k, d) => String(opt(k, d)).split(',').map((s) => s.trim()).filter(Boolean);
|
||
const index = JSON.parse(fs.readFileSync(path.join(ROOT, 'public/dev/perf/index.json'), 'utf8'));
|
||
const specs = argv.includes('--specs') ? list('specs', '') : index.map((s) => s.name);
|
||
const qualities = list('quality', 'high');
|
||
const cpus = list('cpu', '1,4,6').map(Number);
|
||
const warm = Number(opt('warm', 20)), sample = Number(opt('sample', 6));
|
||
const width = Number(opt('width', 844)), height = Number(opt('height', 390)), dpr = Number(opt('dpr', 2));
|
||
const profileOn = argv.includes('--profile');
|
||
const out = path.resolve(ROOT, opt('out', 'test/e2e/out/perf'));
|
||
const chrome = process.env.CHROME_PATH || DEFAULT_CHROME[process.platform];
|
||
if (!chrome || !fs.existsSync(chrome)) { console.error(`Chrome not found (${chrome}); set CHROME_PATH`); return 2; }
|
||
if (!fs.existsSync(path.join(ROOT, 'public/assets/spine'))) console.warn('warning: public/assets/spine is missing (npm run setup): units draw as diamonds');
|
||
fs.mkdirSync(out, { recursive: true });
|
||
|
||
const puppeteer = (await import('puppeteer-core')).default;
|
||
const { startServer } = await import('../server/index.js');
|
||
const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||
const browser = await puppeteer.launch({ executablePath: chrome, headless: true, args: gpuArgs() });
|
||
const results = [];
|
||
try {
|
||
for (const spec of specs) for (const quality of qualities) for (const cpu of cpus) {
|
||
const page = await browser.newPage();
|
||
const errors = [];
|
||
page.on('pageerror', (e) => errors.push(e.message));
|
||
await page.setViewport({ width, height, deviceScaleFactor: dpr });
|
||
await page.goto(`http://127.0.0.1:${srv.port}/dev/battle-perf.html?spec=${encodeURIComponent(spec)}&quality=${quality}&panel=0`);
|
||
await page.waitForFunction('window.__perf && (window.__perf.ready || window.__perf.error)', { timeout: 60000 });
|
||
const err = await page.evaluate(() => globalThis.__perf.error);
|
||
if (err) { console.log(`${spec}: ${err.split('\n')[0]}`); await page.close(); continue; }
|
||
const cdp = await page.createCDPSession();
|
||
await cdp.send('Emulation.setCPUThrottlingRate', { rate: cpu });
|
||
await new Promise((r) => setTimeout(r, warm * 1000));
|
||
if (profileOn) {
|
||
await cdp.send('Profiler.enable');
|
||
await cdp.send('Profiler.setSamplingInterval', { interval: 200 });
|
||
await cdp.send('Profiler.start');
|
||
}
|
||
const s = await page.evaluate((ms) => globalThis.__perf.sample(ms), sample * 1000);
|
||
let summary = null;
|
||
if (profileOn) {
|
||
const { profile } = await cdp.send('Profiler.stop');
|
||
summary = aggregateProfile(profile);
|
||
const base = path.join(out, `${spec}-${quality}-${cpu}x`);
|
||
fs.writeFileSync(`${base}.cpuprofile`, JSON.stringify(profile));
|
||
fs.writeFileSync(`${base}.summary.json`, JSON.stringify(summary, null, 1));
|
||
}
|
||
const rec = { spec, quality, cpu, ...s, profiled: profileOn, errors: errors.slice(0, 3) };
|
||
results.push(rec);
|
||
if (!argv.includes('--json')) {
|
||
console.log(`${spec.padEnd(18)} ${quality.padEnd(6)} ${String(cpu).padStart(2)}x fps ${String(s.fps).padStart(6)} p95 ${String(s.p95).padStart(6)} ms >33ms ${String(s.over33).padStart(5)}%`
|
||
+ ` sim ${s.simMsPerTick} ms/tick view CPU ${s.viewCpuMs} ms units ${s.units} load ${s.lod}${errors.length ? ` ERRORS ${errors.length}` : ''}`);
|
||
if (summary) console.log(` ${summary.stages.filter((x) => x.pct > 0).map((x) => `${x.stage} ${x.pct}%`).join(' · ')}`);
|
||
}
|
||
await page.close();
|
||
}
|
||
} finally {
|
||
fs.writeFileSync(path.join(out, 'results.json'), JSON.stringify(results, null, 1));
|
||
await browser.close();
|
||
await srv.close();
|
||
}
|
||
if (argv.includes('--json')) console.log(JSON.stringify(results));
|
||
return results.some((r) => r.errors.length) ? 1 : 0;
|
||
}
|
||
|
||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||
main().then((code) => { process.exitCode = code; }, (err) => { console.error(err); process.exitCode = 1; });
|
||
}
|