pixi-crawler
v0.1.0
Published
Drop-in performance crawler for PixiJS 8: per-frame phase timing, workload counters, true GPU time, Spine profiling with live pose metrics, telemetry, and a HUD + frame inspector drawn with pixi-silk.
Maintainers
Readme
pixi-crawler
A drop-in performance profiler for PixiJS 8. It breaks every frame down
into where the milliseconds go (CPU pre-work, pixi build / transform /
execute, Spine, true GPU time via EXT_disjoint_timer_query), keeps
device-invariant workload counters, samples live Spine poses through the
Spine Benchmark's own metrics,
and ships a telemetry sink for collecting the same numbers from a live game.
The HUD, a minimal gauge card and the frame inspector are drawn with pixi-silk - conic-gradient load rings, glows, gradient area sparklines, squircle panels - on their own small WebGL2 canvas, so the profiler's UI never shows up in the numbers it reports.
Install
npm install pixi-crawlerPeer dependencies (npm 7+ installs them for you; with pnpm or yarn add them explicitly):
| package | why |
| --- | --- |
| pixi.js ^8.6 | The crawler patches pixi internals, so it must be the same pixi instance your app uses. |
| @spine-benchmark/metrics-impact-formula | Canonical Spine pose walker + impact formulas (live Spine metrics). |
| @spine-benchmark/metrics-analyzers | Device classification + fitted cost-model predictions. |
pixi-silk (the HUD renderer) is a regular dependency.
Quick start
import { Application } from "pixi.js";
import { mountCrawler } from "pixi-crawler";
const app = new Application();
await app.init({ background: "#101317", resizeTo: window });
// HUD visible, auto-dispose on pagehide
const crawler = mountCrawler(app);
// just the minimal gauge card
const quiet = mountCrawler(app, { hud: "mini" });
// headless (no HUD) + telemetry
const headless = mountCrawler(app, { hud: false, telemetry: { sink } });mountCrawler takes a pixi Application or any { renderer, ticker } pair;
the second argument is a config object plus autoDispose (default true).
For full lifecycle control:
import { Crawler } from "pixi-crawler";
const crawler = new Crawler({
targetFrameMs: 1000 / 60,
hud: !import.meta.env.PROD,
spineProfile: { enabled: true },
telemetry: { sink, sampling: { windowMs: 5000 }, rawFrames: "on-overrun" },
});
crawler.attach(app.renderer, app.ticker);
window.addEventListener("pagehide", () => void crawler.dispose());The HUD
Minimal card (hud: "mini", or the – button of the full panel): a
load ring around the FPS - how much of the frame budget the CPU (app ticker +
pixi + Ticker.shared) or the GPU actually used, on a conic gradient that runs
green -> amber -> red and glows once headroom is gone - plus frame ms vs
budget, dropped frames, a frame-time sparkline whose area fades in the load
color, and CPU / GPU meters. It repaints in ~0.1 ms. Click it for the full
panel; crawler.setHud("mini" | "full" | false) switches at runtime.
Full panel:
- Header: FPS with the same load ring, frame total vs budget, CPU p95, a live frame-time sparkline, the average frame's composition (pre / pixi / spine / Ticker.shared / idle) as a stacked strip, and the open workload / GPU cost measures with their bottleneck.
- Frame breakdown: the additive partition (prePixi + pixi + postPixi = rafDelta) with drill-downs into runner phases, render split, per-pipe execute, Spine CPU phases, then parallel rows (GPU, async loads, long tasks, audio, LoAF) that are not part of the sum. Click a parent row to fold it, hover any row for what it measures.
- Counters, Cost, Devices, Details, Spine · live pose, Hot functions: collapsible sections (the device tier matrix draws a cpu / gpu ring per device).
- Capture: record frames, save them as JSON, load a recording back.
- Frames: opens the inspector - a timeline over the ring buffer; click a
bar (or ◀ ▶, arrow keys) to inspect any frame,
★ worstjumps to the worst one,● livefollows the newest; Esc closes.
How it stays out of the measurement:
- It renders on its own canvas with its own pixi renderer (WebGL2, low-power, no ticker), on demand: the 250 ms data tick, hover, scroll and short transitions. A full repaint costs about 0.25 ms.
- Texture work of the overlay is excluded from the crawler's global texture
hook; the pixi globals a second renderer would overwrite (
EventsTicker, the default event mode) are restored, andFilter.defaultOptionsis never touched. - The canvas takes no pointer events; an invisible box exactly over the panel does, so the game stays clickable around it.
Themes: hudTheme: "slate" | "warm" | "contrast". hudMotion: false turns
off transitions. On narrow or touch screens the HUD docks to the bottom edge
with larger text and touch-size buttons. The HUD needs WebGL2; the crawler
itself (headless, telemetry) does not.
Spine metrics (Spine Benchmark)
Enable spineProfile and every Spine the renderer draws is registered
automatically. On top of the per-phase CPU timing, getSpineMetrics() walks
the live poses through the benchmark's canonical walker - the same code its
fitted cost model is trained on, so a crawler reading and a benchmark
prediction share one vocabulary:
const crawler = mountCrawler(app, {
spineProfile: { enabled: true },
spineMetrics: {
// optional: the Spine Benchmark's fitted model table (its /api/model JSON)
costModel: await (await fetch(MODEL_URL)).json(),
},
});
const s = crawler.getSpineMetrics();
s?.total.vertices; // active renderable vertices, scene total
s?.total.coveredKpx; // screen coverage (device kilopixels), s.total.overdrawFactor
s?.ri; // Rendering / Computational Impact
s?.device.gpuFamily; // "Apple GPU", "Adreno 7xx", ... (the model's cluster key)
s?.prediction?.totalMs; // predicted Spine ms on this GPU family
s?.prediction?.trusted; // real fit with a held-out error of at most 35%Sampling is lazy (only when something reads) and throttled to
spineMetrics.intervalMs (default 1000 ms). Set spineMetrics.coverage:
false to skip the coverage estimate, spineMetrics.enabled: false to turn
it off.
Configuration
Every CrawlerConfig field is optional; each gates one subsystem.
| Field | Default | What it does |
| --- | --- | --- |
| hud | off | true / "full": the HUD; "mini": the minimal gauge card. On (full) via mountCrawler. |
| hudTheme | "slate" | HUD palette: "slate", "warm" or "contrast". |
| hudMotion | on | Damped bar transitions and the expand/collapse fade. |
| targetFrameMs | 1000/60 | Frame budget in ms; bars and status are normalized against it. |
| bufferSize | 600 | Frame ring-buffer size. |
| enableGpuTiming | on | True per-frame GPU time via EXT_disjoint_timer_query (gpuMs). |
| deepRenderSplit | on | CPU render split by phase (build / transform / execute / ...). |
| filterProfile | on | Filter timing (push / apply / pop) and pass count. |
| textureTracking | on | Texture uploads / unloads, bytes, active GPU count. |
| spineProfile | off | Per-phase Spine breakdown. Patches Spine's methods. |
| spineMetrics | on with spineProfile | Live pose metrics through the Spine Benchmark packages (see above). |
| pipeProfile | off | Per-render-pipe timing. The heaviest overhead of any flag. |
| workloadCost | on | Open device-independent workload measure (+ device tier via deviceKey / ceiling). |
| gpuCost | on | Open GPU-heaviness measure (fill footprint + filters). |
| selfProfile | off | Per-function self-time via the JS Self-Profiling API (needs Document-Policy: js-profiling, Chromium). |
| memoryProfile | off | Process memory via measureUserAgentSpecificMemory() (needs cross-origin isolation). |
| audioProfile | off | WebAudio decode time, sources, automation, context state. |
| telemetry | off | Periodic aggregate flush to your sink. |
Reading measurements
The HUD is optional - read frames directly and build your own panel:
const f = crawler.getLastFrame(); // the latest FrameRecord
f.gpuMs; // true GPU time, ms (undefined without a timer)
f.counters.drawCalls;
f.renderSplit?.updateRenderablesMs;
crawler.getFrames(); // the whole ring buffer
crawler.getWorkloadCost(); // open workload measure + bottleneck
crawler.getGpuCost(); // open GPU-heaviness measure
crawler.getWorstFrame(); // worst frame + a scene dump
crawler.getSpineMetrics(); // live Spine pose metrics
crawler.deviceIdentity; // GPU family / form factor / family
// GPU queries land 1-3 frames late; drain them at the end of a window:
await crawler.flushPendingGpu();Telemetry
const sink = {
send(batch) {
navigator.sendBeacon("/telemetry", JSON.stringify(batch));
},
};
const crawler = new Crawler({
telemetry: { sink, sampling: { windowMs: 5000 }, rawFrames: "on-overrun" },
});
crawler.attach(app.renderer, app.ticker);
crawler.setTelemetryLabel("bonus-game"); // tag the current sceneEach TelemetryBatch (schema 2) carries frame-time quantiles, phase
quantiles, summed counters, Spine and GPU summaries, the workload and GPU
cost measures, and - with Spine metrics - device (GPU family, form factor,
family; never the raw renderer string) and aggregate.spine_pose (canonical
features, RI/CI, prediction), ready to feed the benchmark's model fit.
Recording a session
crawler.startRecording();
// ...reproduce the problem...
crawler.stopRecording();
const recording = crawler.getRecording(); // JSON-serialisable
crawler.openRecording(recording); // replay in the inspectorContracts
- The app's ticker must not be
Ticker.shared- Spine collides with the crawler there. This is the one hard incompatibility. - One pixi instance: the crawler patches pixi prototypes, so your app and
the crawler must resolve the same
pixi.js. gpuMsis missing on Safari / iOS (noEXT_disjoint_timer_query_webgl2); CPU measurements still work.dispose()/detach()restore every patched method; call one on unmount.
If your app drives its own loop (ticker stopped, own requestAnimationFrame),
bracket each frame instead of relying on attach()'s ticker hooks:
app.ticker.stop();
function frame() {
crawler.frameStart();
// ...advance the scene, then render...
app.render();
crawler.frameEnd(); // flushes one FrameRecord
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);Development
npm install
npm run dev # demo scene with the HUD (sprites, filters, masks, Spine)
npm test # unit tests
npm run typecheck
npm run build # tsdown + publint + attwThe demo takes ?spines=N, ?sprites=N, ?theme= and ?model=<url>.
Credits
Extracted from @spine-benchmark/pixi-crawler in
spine-benchmark. The demo's
Spine symbols come from pixi-reels.
