Files
Cloudnyco e8ba091298 feedback7 G-TOOLS: add the battle performance bench under public/dev (PR #198 by @Cloudnyco)
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).
2026-10-08 18:48:42 +08:00

173 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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; });
}