Profiling & Diagnostics
Estella ships its diagnostics as ordinary SDK surface: a Stats resource your
systems can read, an in-game overlay, a structured logger with pluggable
sinks, a frame capture that records every draw call (and can replay the frame
up to any one of them), GL error checking, GPU resource residency stats,
and a per-app subsystem health registry. This guide covers each piece.
Frame stats — statsPlugin
Section titled “Frame stats — statsPlugin”Add statsPlugin and the engine collects per-frame performance data into the
Stats resource every frame (in the Last schedule, after everything else ran):
import { statsPlugin } from 'esengine';
app.addPlugin(statsPlugin);statsPlugin is a ready-made instance with defaults. For options, construct
StatsPlugin yourself:
import { StatsPlugin } from 'esengine';
app.addPlugin(new StatsPlugin({ overlay: true, position: 'top-right' }));StatsPluginOptions |
Type | Default | Description |
|---|---|---|---|
overlay |
boolean | true |
Show the DOM overlay (skipped automatically where there’s no document). |
position |
StatsPosition |
'bottom-left' |
Overlay corner: 'top-left' / 'top-right' / 'bottom-left' / 'bottom-right'. |
container |
HTMLElement | document.body |
Element the overlay panel is appended to. |
The plugin also calls app.enableStats(), which turns on per-system and
per-phase timing collection (off otherwise, so shipped games don’t pay for it).
Reading Stats from a system
Section titled “Reading Stats from a system”Stats is a normal resource — inject it with Res(Stats):
import { defineSystem, addSystem, Res, Stats } from 'esengine';
const watchPerf = defineSystem([Res(Stats)], (stats) => { if (stats.fps > 0 && stats.fps < 30) { console.warn(`slow frame: ${stats.frameTimeMs.toFixed(1)}ms, ${stats.drawCalls} draw calls`); }});addSystem(watchPerf);FrameStats fields (FPS and frame time are a 60-frame sliding-window average;
the render counters are the current frame’s):
FrameStats field |
Type | Description |
|---|---|---|
fps |
number | Frames per second, averaged over the last 60 frames. |
frameTimeMs |
number | Average frame time in milliseconds over the same window. |
entityCount |
number | Live entities in the world. |
systemTimings |
Map<string, number> |
CPU ms per system this frame, keyed by system name. |
phaseTimings |
Map<string, number> |
CPU ms per schedule phase this frame. |
drawCalls |
number | GPU draw calls issued this frame. |
triangles |
number | Triangles submitted this frame. |
sprites |
number | Sprites rendered this frame. |
text |
number | Text entities rendered this frame. |
spine |
number | Spine skeletons rendered this frame. |
meshes |
number | Meshes rendered this frame. |
culled |
number | Entities culled (not submitted) this frame. |
The stats overlay
Section titled “The stats overlay”With overlay: true (the default) the plugin renders a small fixed-position
monospace panel: FPS + frame time, draw calls / triangles / sprites / culled,
entity count, and the top 5 systems ranked by their worst frame — each as
avg / max ms. The panel re-renders at most every 500 ms, accumulating
system timings in between, so it costs next to nothing and a one-frame spike
still shows up in the max column.
You can also drive a StatsOverlay yourself — for example to bind it to a
debug hotkey:
import { StatsOverlay, defineSystem, addSystem, Res, Stats } from 'esengine';
const overlay = new StatsOverlay(document.body, 'top-right');
const feedOverlay = defineSystem([Res(Stats)], (stats) => { overlay.update(stats);});addSystem(feedOverlay);
// overlay.hide() / overlay.show() to toggle, overlay.dispose() to remove it.Renderer counters — Renderer.getStats()
Section titled “Renderer counters — Renderer.getStats()”The render counters in FrameStats come from Renderer.getStats(), which you
can also call directly (no stats plugin needed) — it returns a RenderStats
with the same seven fields: drawCalls, triangles, sprites, text,
spine, meshes, culled.
import { Renderer } from 'esengine';
const rs = Renderer.getStats();console.log(`${rs.drawCalls} draw calls, ${rs.triangles} triangles`);Building your own tooling
Section titled “Building your own tooling”Two small utilities back the plugin and are exported for custom tooling:
StatsCollector— the 60-frame sliding window behindfps/frameTimeMs. Feed it delta times withpushFrame(deltaSeconds)and readgetFps()/getFrameTimeMs();reset()clears the window.FrameHistory— a ring buffer ofFrameSnapshots (default capacity 300, ~5 seconds at 60 fps) for building frame-time graphs.push(frameTimeMs, phaseTimings, systemTimings?)deep-copies the maps so snapshots stay valid;getLatest()returns the newest snapshot,getAll()the whole window oldest-first, andcount/reset()do what they say.
FrameSnapshot field |
Description |
|---|---|
frameTimeMs |
The frame’s total CPU time. |
phaseTimings |
Per-phase ms (copied at push time). |
systemTimings |
Per-system ms (copied at push time). |
Recording a capture from a shipped game — ProfileRecorder
Section titled “Recording a capture from a shipped game — ProfileRecorder”The editor’s profiler only exists where the editor is attached, and what drops
to 40 fps usually drops on a player’s device. ProfileRecorder records
frames from a running game into a .esprof capture the editor’s Profiler
panel opens — the same tree, the same rows, the same totals.
import { ProfileRecorder } from 'esengine';
const recorder = new ProfileRecorder(app, { maxFrames: 1800, // ~30s at 60Hz; oldest dropped after source: { platform: 'wechat', label: 'Redmi Note 12 · boss fight' },});
recorder.start();// … play the part that stutters …recorder.stop();
const capture = recorder.take(); // a plain objectProfileRecorderOptions |
Type | Default | Description |
|---|---|---|---|
maxFrames |
number | 1800 |
Frames kept before the oldest is dropped. |
budgetMs |
number | 1000 / 60 |
The budget a reader judges the capture against. |
source |
CaptureSource |
{} |
Device, build, scene — whatever identifies this capture later. |
start() turns on the instrumentation it reads — both the JS-side stats and the
engine’s C++ profiling, since a capture missing the second looks like an engine
that costs nothing rather than one that was not measured — and stop() puts them
back down. Nothing is recorded, and nothing is measured, until you start it.
Saving it from a web build is ordinary DOM:
const blob = new Blob([JSON.stringify(recorder.take())], { type: 'application/json' });const a = document.createElement('a');a.href = URL.createObjectURL(blob);a.download = 'boss-fight.esprof';a.click();Then open it with Profiler ▸ Open… in the editor. parseProfileCapture
refuses a file that is not a capture with the reason (not JSON, no version,
frames that are not frames, a version newer than the editor reads) rather than
throwing, and summarizeCapture is the same function the live view uses — so a
file recorded on a phone and the editor’s own last second cannot report a
different frame rate from the same frames.
Watching frames yourself — app.onFrameEnd
Section titled “Watching frames yourself — app.onFrameEnd”The recorder is an ordinary consumer of app.onFrameEnd(fn), which fires once
per frame after its systems have run and its timings are final. It is a
broadcast, so your own frame-budget alarm can watch alongside the recorder:
const off = app.onFrameEnd((dtMs) => { if (dtMs > 33) console.warn(`long frame: ${dtMs.toFixed(1)}ms`);});It returns a disposer. Pair it with app.getFrameCosts() for the per-system and
per-scope costs of the frame that just ended.
Logging
Section titled “Logging”The SDK routes its own diagnostics through a structured logger instead of raw
console.*, and your game code can use the same channel. Every message has a
level, a free-form category string ('physics', 'net', your own
'gameplay'…), a message, and optional structured data:
import { log, setLogLevel, LogLevel } from 'esengine';
setLogLevel(LogLevel.Debug); // default is LogLevel.Info
log.debug('gameplay', 'Wave spawned', { wave: 3, enemies: 12 });log.info('save', 'Game saved');log.warn('net', 'High latency', { rttMs: 240 });log.error('boot', 'Asset manifest failed', err); // Error keeps its stackLogLevel is Debug < Info < Warn < Error; setLogLevel sets the minimum
level — anything below it is dropped before reaching any handler. The
standalone debug / info / warn / error functions are also exported and
forward to the same default logger; getLogger() returns it if you need the
Logger instance itself.
By default one console handler is installed: it formats
[time] [LEVEL] [category] message and picks the matching console method
per level, passing Error data through as a real argument so the browser
renders the stack trace.
Custom log sinks
Section titled “Custom log sinks”A LogHandler receives every accepted message as a structured LogEntry
— the hook for an in-game console, a file writer, or crash-report
breadcrumbs. A handler that throws is caught and reported, never breaking the
app:
import { getLogger, LogLevel, type LogEntry, type LogHandler } from 'esengine';
class Breadcrumbs implements LogHandler { entries: LogEntry[] = []; handle(entry: LogEntry): void { if (entry.level >= LogLevel.Warn) { this.entries.push(entry); if (this.entries.length > 100) this.entries.shift(); } }}
const crumbs = new Breadcrumbs();getLogger().addHandler(crumbs);// getLogger().removeHandler(crumbs) to detach,// getLogger().clearHandlers() to remove every handler (console one included).LogEntry field |
Type | Description |
|---|---|---|
timestamp |
number | Date.now() at log time (epoch ms). |
level |
LogLevel |
Debug / Info / Warn / Error. |
category |
string | The channel string the caller passed. |
message |
string | The message. |
data |
unknown | Optional payload (object, Error, …). |
Error reporting from a shipped game
Section titled “Error reporting from a shipped game”Everything above assumes you’re watching. After a build ships you aren’t: the console
that would show the error is on a player’s phone. The Diagnostics resource
collects what went wrong and hands it to a destination you choose.
The engine never picks that destination. There is no default endpoint, no bundled vendor, and nothing opens a socket on its own. Stack traces carry file paths and messages carry whatever was interpolated into them, so where that goes is your decision, not ours. With no sink installed the plugin still does its whole job locally — events aggregate and you can read them — and nothing leaves the device.
The plugin is installed by default, so this is available without any setup:
import { Diagnostics, type DiagnosticEvent } from 'esengine';
const diagnostics = app.getResource(Diagnostics);
diagnostics.setSink((events: readonly DiagnosticEvent[]) => { // Batched, distinct problems with counts — not one call per occurrence. void fetch('https://your-service.example/errors', { method: 'POST', body: JSON.stringify({ build: '1.4.2', events }), });});Your sink must not throw (one that does is caught and dropped) and should not block — awaiting a network round-trip inside it trades a bug report for a stutter. Batch it and send it yourself.
What it collects
Section titled “What it collects”| Kind | Where it comes from |
|---|---|
engine |
Everything the engine logs as an error — a system that threw during a schedule, an asset that would not load, a subsystem that refused to start. |
unhandled |
Reached the host with nobody catching it: window.onerror / unhandledrejection, wx.onError / wx.onUnhandledRejection. Usually game code outside a system — a callback, a promise, a timer. |
context-lost |
The GPU took the rendering context away. The frames after it draw nothing, and no error is thrown — this is the only way to find out it happened. |
memory |
The OS says memory is running out: the warning that arrives before the process is killed, which is the crash that never gets a report of its own. |
game |
Whatever you called report about. |
The engine half needs no platform support — it listens to the same log broadcast the
section above describes, which is why installing a Diagnostics sink takes nothing
away from app.onError, app.onSystemError or your own LogHandler. They all keep
working. The other kinds need the host to have a signal for them:
| web | WeChat / mini-game | native (Android / iOS) | |
|---|---|---|---|
unhandled |
yes | yes | if the shell wires it |
memory |
— | yes | if the shell wires it |
context-lost |
yes | no — a mini-game canvas is not a DOM element and no vendor API reports it | if the shell wires it |
Warnings are not collected by default; pass captureLevel: LogLevel.Warn to the
plugin when chasing a specific complaint.
Distinct problems, not occurrences
Section titled “Distinct problems, not occurrences”A system that throws does it every frame — sixty identical reports a second, from every player at once. Sent one by one that isn’t telemetry, it’s an outage your game caused. So the unit is the distinct problem with a count:
diagnostics.setSink((events) => { for (const e of events) { console.log(`${e.kind} ${e.source ?? ''}: ${e.message} ×${e.count}`); // engine physics: the world could not step ×1842 }});Two occurrences are the same problem when their kind, source, message and throw
site match. The whole stack would split one bug across its callers; the message
alone would merge two unrelated bugs into one. Numbers in messages are normalized, so
Entity 41 has no Transform files one problem rather than one per entity.
DiagnosticEvent field |
Type | Description |
|---|---|---|
kind |
DiagnosticKind |
One of the five above. |
id |
string | Stable identity across repeats. |
message |
string | The message, as reported. |
source |
string? | Log category, or the system’s name. |
stack |
string? | Present when something with a stack was thrown. |
count |
number | Occurrences since it was first seen. |
firstAt / lastAt |
number | Epoch ms, first and most recent. |
context |
object? | Whatever the reporter attached; the newest wins. |
Reporting from your own game
Section titled “Reporting from your own game”diagnostics.report({ kind: 'game', message: 'Cloud save rejected the payload', context: { slot: 3, bytes: 41_233 },});
try { risky(); } catch (err) { diagnostics.reportError('game', err, 'shop');}Keep player-identifying data out of context for the same reason the engine picks no
endpoint — it ends up wherever your sink sends it.
Tuning
Section titled “Tuning”import { DiagnosticsPlugin } from 'esengine';
app.addPlugin(new DiagnosticsPlugin({ maxDistinct: 64, // distinct problems tracked at once flushIntervalSec: 10, // seconds between handing a batch to the sink}));At maxDistinct new problems are dropped while known ones keep counting, and
diagnostics.dropped says how many — a non-zero value means the batch is incomplete,
and a sink should say so rather than let it read as the whole picture. The earliest
distinct problems are the ones kept: when a game comes apart it comes apart in
cascade, and the first failure is what explains the next fifty.
Flushing runs on the engine clock, not a timer, so a backgrounded tab or a suspended
mini-game doesn’t report from a game that isn’t running. flush() sends immediately —
useful right before a level transition — and shutdown flushes once on its own.
Frame capture — every draw call, explained
Section titled “Frame capture — every draw call, explained”When “why is this 40 draw calls?” needs an answer, capture a frame. Arm the capture, let one frame render, then read back a record for every draw call — what it drew, with which texture/material/shader and state, and crucially why the previous batch broke:
import { Renderer, RenderType, BatchBreak } from 'esengine';
Renderer.captureNextFrame(); // arm: the NEXT rendered frame records
// …one frame later:if (Renderer.hasCapturedData()) { const capture = Renderer.getCapturedData(); // FrameCaptureData | null for (const dc of capture!.drawCalls) { console.log( `#${dc.index} ${RenderType[dc.type]} tex=${dc.textureId} ` + `tris=${dc.triangleCount} entities=${dc.entityCount} ` + `break=${BatchBreak[dc.breakReason]}` ); } console.log(`${capture!.cameraCount} camera pass(es)`);}FrameCaptureData is { drawCalls: DrawCallInfo[], cameraCount }. Each
DrawCallInfo:
DrawCallInfo field |
Description |
|---|---|
index |
Draw call index within the frame (submission order). |
cameraIndex |
Which camera pass issued it. |
stage |
Render stage (RenderStage: Background / Opaque / Transparent / Overlay). |
type |
What kind of content (RenderType, below). |
blendMode |
Blend mode id in effect. |
textureId / materialId / shaderId |
GPU resources bound for the call. |
vertexCount / triangleCount |
Geometry submitted. |
entityCount / entityOffset / entities |
The entity ids batched into this call. |
layer |
Render layer of the batch. |
breakReason |
Why this call started instead of joining the one before it (BatchBreak, below). |
scissorX/Y/W/H, scissorEnabled |
Scissor rect state (UI masks). |
stencilWrite / stencilTest / stencilRef |
Stencil state (mask writers/readers). |
textureSlotUsage |
Texture slots the batch consumed. |
RenderType tells you what a call drew: Sprite, Spine, Mesh,
ExternalMesh, Text, Particle, Shape, UIElement.
BatchBreak is the batching story — each value names the state change that kept
this call from joining the one before it, i.e. what to fix to batch better. Every
member is a branch of the merge predicate itself, so the list cannot drift from
the rule that produced it:
BatchBreak |
Meaning |
|---|---|
RunStart |
Nothing to join — the first call of a run. |
Instanced |
An instanced draw: one command per emitter, never coalesced. |
Shader |
Shader switched — the state change nothing batches across. |
Blend |
Blend mode switched — interleaved additive/normal content. |
Layout |
Vertex layout switched (sprite quads vs mesh vertices). |
Material |
Material switched — group entities by material. |
Depth |
Depth test/write differ — opaque and blended content interleaved. |
Cull |
Cull state differs. |
State |
Some other render state flag differs. |
Scissor |
Scissor rect changed (a UIMask boundary). |
Stencil |
Stencil ref changed (mask write/test boundary). |
IndexGap |
Indices are not contiguous — the sort put something between them. |
TextureSlots |
Mergeable, but the combined set overflowed the 8 texture slots — atlas more textures together. |
None is the merge’s own answer and never appears on a captured call: a command
that reaches it was folded into the one before it instead of starting a new one.
The same reasons are published per frame as batch.break.* counters alongside
batch.draws and batch.merged, so “6 draw calls” reads as “1 run start and
5 index gaps, with 41 commands merged away” without capturing anything.
Replaying to a draw call
Section titled “Replaying to a draw call”A capture can be replayed up to any draw call to see the frame being built
— this is what the editor’s frame inspector does. replayToDrawCall(i)
re-renders draw calls 0…i into a snapshot; getSnapshotImageData() resolves
with its pixels once the GPU readback lands (immediate on WebGL, a later tick
on WebGPU):
import { Renderer } from 'esengine';
Renderer.replayToDrawCall(5); // draw calls 0..5 onlyconst img = await Renderer.getSnapshotImageData(); // ImageData | nullif (img) ctx2d.putImageData(img, 0, 0); // e.g. into a debug canvasGL debugging — GLDebug
Section titled “GL debugging — GLDebug”GLDebug toggles GL error checking inside the wasm renderer — off by default
because checking has a cost:
import { GLDebug } from 'esengine';
GLDebug.enable(); // check GL errors at key pointsconst errors = GLDebug.check('after-spawn'); // explicit check; returns error countGLDebug.diagnose(); // dump renderer diagnostics to the consoleGLDebug.disable();check(context) runs an immediate error check and returns the number of GL
errors found, tagging any log output with your context string so you can
bisect where in the frame an error appears.
GPU resource budgets & residency
Section titled “GPU resource budgets & residency”Released textures stay resident in a byte-budgeted warm cache (the full model is in the assets guide). The diagnostics side of it:
import { getResourceStats, setTextureBudget, trimTextureCache } from 'esengine';
setTextureBudget(256 * 1024 * 1024); // resize the budget (0 = no warm cache)
const stats = getResourceStats(); // ResourceStats | null before engine initif (stats && stats.textureBytes > stats.textureBudget * 0.9) { const freed = trimTextureCache(); // drop every evictable texture now console.log(`freed ${freed} cached textures`);}ResourceStats field |
Description |
|---|---|
shaderCount |
Live compiled shaders. |
textureCount |
Live GPU textures. |
vertexBufferCount / indexBufferCount |
Live GPU buffers. |
cacheHits / cacheMisses |
Resource-cache hit/miss counters. |
textureBytes |
Resident texture bytes (RGBA8 estimate) — held + evictable. |
textureBudget |
The current resident-byte budget (0 = eviction off). |
textureEvictableCount |
Cached refcount-0 textures awaiting revive or eviction. |
trimTextureCache() frees every evictable entry immediately and returns how
many textures it freed — the engine calls it on OS memory warnings; call it
yourself before a known memory spike. Held (referenced) textures and the
budget are untouched.
evictTextureDimensions(handle) drops the SDK-side cached width/height for
one texture handle so the next getTextureDimensions query re-reads it from
the engine — only relevant to tooling that replaces texture contents under an
existing handle.
Subsystem health
Section titled “Subsystem health”Every App carries a SubsystemRegistry at app.subsystems that tracks each
engine subsystem’s lifecycle phase (registered → initializing → ready,
error terminal) and derived liveness (stepping / idle / inactive) — the
“is physics actually running?” question:
for (const s of app.subsystems.getStatuses()) { console.log(`${s.displayName}: ${s.phase} (${s.activity})`, s.lastError ?? '');}recentEvents() returns the recent lifecycle transitions and subscribe(fn)
notifies on phase changes. The full lifecycle model is covered in
App & Lifecycle.
In the editor
Section titled “In the editor”The desktop editor builds its profiling UI on these same surfaces: the
Profiler panel shows a live frame-time graph with per-phase and per-system
breakdowns (click a frame to inspect it), and the viewport has a small perf
overlay for an at-a-glance FPS readout. For in-game builds, statsPlugin above
is the equivalent.
Best practices
Section titled “Best practices”- Ship without
statsPlugin(or gate it behind a debug flag) — timing collection is off by default for a reason; add it while profiling. - Blame before optimizing — read
systemTimings/phaseTimingsto find which system is slow before touching code; the overlay’smaxcolumn catches one-frame spikes an average hides. - Capture a frame to fix draw calls —
breakReasonnames the exact state change that broke each batch (TextureSlots→ atlas;Material→ group by material). - Log with categories, not
console.log— structured entries let a handler filter by channel and level, andsetLogLevel(LogLevel.Debug)turns verbosity on without code changes. - Leave
GLDebugoff in production — per-call GL error checking is expensive; enable it only while hunting a rendering bug. - Watch
textureBytesagainsttextureBudgeton memory-constrained targets, andtrimTextureCache()before known spikes.
See also
Section titled “See also”- Assets — the texture warm cache and budget the resource stats observe.
- App & Lifecycle — plugins, schedules, and the subsystem registry.
- Systems — schedules and system names (the keys in
systemTimings). - Editor — the desktop editor, including its Profiler panel.