#!/usr/bin/env node import { access, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { analyzeGlbFile } from "./glb-performance.mjs"; const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const baselineFile = path.join( projectRoot, "scripts/asset-pipeline/environment-performance-baseline.json", ); const defaultRoots = [ path.join(projectRoot, "public/assets/game/manastorm"), path.join(projectRoot, "src/assets/game/dungeons"), ]; const defaultThresholds = { maxEstimatedDrawCalls: 1_500, maxUnbatchedDrawCallSavings: 250, }; async function exists(file) { try { await access(file); return true; } catch { return false; } } async function collectVisualGlbs(root, files = []) { if (!await exists(root)) return files; const entry = await stat(root); if (entry.isFile()) { if (/(?:^|[-_])visual\.glb$/i.test(path.basename(root))) files.push(root); return files; } for (const child of await readdir(root, { withFileTypes: true })) { const target = path.join(root, child.name); if (child.isDirectory()) await collectVisualGlbs(target, files); else if (/(?:^|[-_])visual\.glb$/i.test(child.name)) files.push(target); } return files; } function projectPath(file) { return path.relative(projectRoot, file).split(path.sep).join("/"); } function exceeds(report, limits) { return report.estimatedDrawCalls > limits.maxEstimatedDrawCalls || report.unbatchedDrawCallSavings > limits.maxUnbatchedDrawCallSavings; } const args = process.argv.slice(2); const writeBaseline = args.includes("--write-baseline"); const roots = args.filter((argument) => argument !== "--write-baseline") .map((argument) => path.resolve(projectRoot, argument)); const files = [...new Set((await Promise.all( (roots.length ? roots : defaultRoots).map((root) => collectVisualGlbs(root)), )).flat())].sort(); const reports = await Promise.all(files.map(async (file) => ({ file: projectPath(file), byteLength: (await stat(file)).size, ...(await analyzeGlbFile(file)), }))); if (writeBaseline) { const baseline = { schemaVersion: 1, thresholds: defaultThresholds, exceptions: Object.fromEntries( reports .filter((report) => exceeds(report, defaultThresholds)) .map((report) => [report.file, { maxEstimatedDrawCalls: Math.max(defaultThresholds.maxEstimatedDrawCalls, report.estimatedDrawCalls), maxUnbatchedDrawCallSavings: Math.max( defaultThresholds.maxUnbatchedDrawCallSavings, report.unbatchedDrawCallSavings, ), }]), ), }; await mkdir(path.dirname(baselineFile), { recursive: true }); await writeFile(baselineFile, `${JSON.stringify(baseline, null, 2)}\n`, "utf8"); console.log(`Wrote ${Object.keys(baseline.exceptions).length} performance exceptions to ${projectPath(baselineFile)}.`); process.exit(0); } if (!await exists(baselineFile)) { throw new Error(`Missing ${projectPath(baselineFile)}. Run this script with --write-baseline.`); } const baseline = JSON.parse(await readFile(baselineFile, "utf8")); const thresholds = { ...defaultThresholds, ...baseline.thresholds }; const regressions = reports.filter((report) => { const limits = { ...thresholds, ...baseline.exceptions?.[report.file] }; return exceeds(report, limits); }); const debt = reports.filter((report) => exceeds(report, thresholds)); const top = [...reports] .sort((left, right) => right.estimatedDrawCalls - left.estimatedDrawCalls) .slice(0, 12) .map((report) => ({ asset: report.file, calls: report.estimatedDrawCalls, avoidable: report.unbatchedDrawCallSavings, gpuBatches: report.gpuInstanceBatches, nodes: report.nodes, })); console.table(top); console.log(JSON.stringify({ status: regressions.length ? "blocked" : "green", assets: reports.length, knownBudgetExceptions: debt.length, regressions: regressions.map((report) => ({ file: report.file, estimatedDrawCalls: report.estimatedDrawCalls, unbatchedDrawCallSavings: report.unbatchedDrawCallSavings, })), }, null, 2)); if (regressions.length) process.exitCode = 1;