Diagnostics
The built-in frame-time performance metrics, the console FPS overlay, the pluggable logging system, and the device-failure signals that tell you when there is no GPU left to render on.
Meep ships three diagnostic tools: a set of ring-buffer performance metrics that the engine populates every frame and prints to the console on an interval, a pluggable logging system that routes structured messages to one or more backends, and a small set of signals that tell you the GPU device could not be acquired or has gone away.
Performance metrics
What the engine tracks
Every Engine instance owns a MetricCollection at engine.performance. The
engine pre-creates three named metrics and records into them on every frame:
| Name | What is recorded |
|---|---|
"frame_delay" | Wall-clock time between animation frames (seconds) |
"render_time" | Time spent inside GraphicsEngine.render() (seconds) |
"simulation_time" | Time spent inside EntityManager.update(dt) (seconds) |
simulation_time is recorded from the engine’s own ticker subscription, so it
covers one whole simulation step across every registered system - and it stays
empty if you handed the engine an external entity manager, because then nothing
subscribes.
render_time is only recorded on frames that actually draw
(engine.renderingEnabled true and graphics.needDraw set) and it measures
CPU time to build and submit the frame, not GPU time. Submitted is not
finished: the GPU is still working when graphics.on.postRender fires. For
where the time went on the device, see
GPU profiling.
Reading metrics
Each metric is a RingBufferMetric holding the last 128 samples. Call
computeStats (zero-allocation) or access the .stats getter (allocates a
new MetricStatistics):
import { MetricStatistics }
from "@woosh/meep-engine/src/engine/development/performance/MetricStatistics.js";
const stats = new MetricStatistics();
engine.performance.get("render_time").computeStats(stats);
console.log(`render mean: ${(stats.mean * 1000).toFixed(2)} ms`);
console.log(`render max: ${(stats.max * 1000).toFixed(2)} ms`);
// stats also has .min and .median
getLastRecord() returns the most recent value without computing the full
statistics.
Automatic console output
On engine.start() the engine begins a PeriodicConsolePrinter that fires
every 15 seconds. It computes stats for all three metrics, clears each buffer,
and logs a line like:
FPS: 59.97, RENDER: 2.34ms, SIMULATION: 1.12ms
This runs without any setup. To stop it - for instance in a production build -
call engine.__performacne_monitor.stop() after start() resolves. (The
field is private by convention, typo included; there is no public API for this
yet.)
Adding custom metrics
import { MetricCollection }
from "@woosh/meep-engine/src/engine/development/performance/MetricCollection.js";
// engine.performance is already a MetricCollection.
const metric = engine.performance.create({ name: "pathfinding_ms", buffer_size: 64 });
// in your update loop:
const t0 = performance.now();
runPathfinding();
metric.record(performance.now() - t0);
create({ name, buffer_size }) adds a RingBufferMetric and returns it.
buffer_size defaults to 128; larger values give more accurate statistics at
the cost of memory. metric.clear() flushes all samples without removing the
metric from the collection.
Streaming to the console
MetricCollectionConsoleMonitor wraps any MetricCollection and prints all
of its metrics on a configurable interval:
import { MetricCollectionConsoleMonitor }
from "@woosh/meep-engine/src/engine/development/performance/monitor/MetricCollectionConsoleMonitor.js";
MetricCollectionConsoleMonitor.from(engine.performance, 5).start();
// prints all metric stats every 5 seconds
from(metrics, timeout_seconds) returns a PeriodicConsolePrinter. Call
.start() to begin and .stop() to end.
FPS overlay widget
EngineHarness.addFpsCounter(engine) adds a FrameRateView
(src/view/elements/FrameRateView.js) to the engine’s view stack: one number,
refreshed twice a second, counting engine.graphics.on.postRender dispatches
over a 500 ms window. It returns the view, so a settings screen can take it
down again:
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";
// after bootstrap:
const fps = EngineHarness.addFpsCounter(engine);
// later, from a graphics menu:
engine.viewStack.removeChild(fps);
FrameRateView binds itself: new FrameRateView({ frame_signal }) counts any
signal that fires once per frame, links the binding when the view is linked and
drops it when the view leaves the stack, so removing it stops the work. No
package is involved. EngineHarness.buildBasics also accepts showFps and
calls this for you, but it defaults to false.
Because the view counts postRender dispatches, it reports how fast frames are
being submitted, which is the same caveat render_time carries above.
Graphics device failure
Meep 3 renders on WebGPU and only WebGPU. There is no WebGL fallback and no degradation tier, so hardware that cannot run the renderer produces a clean failure with a message written for a player rather than a lesser picture. See Rendering overview for what the renderer does with a device once it has one.
The device floor
Renderer.initialize() checks a fixed floor before it requests a device:
| Requirement | Value | How it is enforced |
|---|---|---|
indirect-first-instance | adapter feature; indirect draw is used throughout | checked on the adapter, BelowFloor naming the feature |
float32-blendable | adapter feature; order-independent transparency | same |
maxStorageBuffersPerShaderStage | at least 10 | checked on the adapter before the device is requested, so the failure names the number it offered |
maxColorAttachmentBytesPerSample | 32 - the G-buffer is wide | asked for in requiredLimits, so an adapter that cannot supply it fails inside requestDevice |
maxBufferSize and maxStorageBufferBindingSize are requested at whatever the
adapter offers, because the spec defaults are far too small.
timestamp-query, subgroups and texture-formats-tier1 are requested when
the adapter offers them and never required. That matters for diagnostics: some
browsers withhold timestamp-query on hardware that is otherwise fine, and when
it is absent the GPU timers go quiet rather than startup failing.
One caveat the floor does not cover: in practice the engine also needs the WGSL
immediate_address_space extension, which shipped in Chrome 149/150. A browser
below that passes the floor, acquires a device, and then fails when it tries to
dispatch shaders.
The signals
engine.graphics.on carries five signals. Two are frame hooks (preRender,
postRender); three are about the device:
| Signal | Payload | Fires when |
|---|---|---|
contextFailed | the error Renderer.initialize() threw | graphics.start() could not get a usable device |
contextLost | ShadeDeviceFailure with reason DeviceLost | a working device went away mid-session |
contextRestored | - | never |
contextRestored exists because the renderer contract has it and subscribers
expect the shape, but Shade does not recover a lost device: everything it built
lived on that device and went with it. A subscriber there is waiting for
something that will not happen. Treat a loss as terminal.
contextFailed usually carries a ShadeDeviceFailure, but not always - a
browser with no navigator.gpu at all, or a canvas that will not hand over a
webgpu context, raises a plain Error. Test with isShadeDeviceFailure
before reading reason.
The failure object
import { ShadeDeviceFailureReason }
from "@woosh/meep-engine/src/shade/device/ShadeDeviceFailureReason.js";
engine.graphics.on.contextFailed.add((failure) => {
// failure.message is written to be shown to a person
// failure.detail is the specific missing thing, for the log
if (failure.isShadeDeviceFailure !== true) {
reportStartupFailure(String(failure));
return;
}
if (failure.reason === ShadeDeviceFailureReason.BelowFloor) {
reportUnsupportedHardware(failure.detail);
}
});
ShadeDeviceFailure (named export of
src/shade/device/ShadeDeviceFailure.js) extends Error and adds three fields:
| Field | Meaning |
|---|---|
reason | a ShadeDeviceFailureReason value |
detail | the specific feature, limit and value, or lost-device reason - for the log, not the player. Empty when there is nothing more specific to say |
isShadeDeviceFailure | always true; the safe way to tell it from a plain Error |
ShadeDeviceFailureReason (named export of the sibling module) is a numeric
enum:
| Value | Number | Meaning |
|---|---|---|
WebGPUUnavailable | 0 | no WebGPU at all - old browser, or behind a flag |
AdapterUnavailable | 1 | WebGPU is present but would not hand over an adapter |
BelowFloor | 2 | an adapter that does not meet the floor above; detail names which requirement |
DeviceRequestFailed | 3 | the adapter was acceptable but the device request itself failed |
DeviceLost | 4 | a working device went away - driver reset, GPU removed, browser reclaiming resources |
Two of those five are not raised by 3.21.0 in practice, which is worth knowing
before you branch on them. Renderer.initialize() checks 'gpu' in navigator
first and throws a plain Error there, so a browser without WebGPU reaches
you as that rather than as WebGPUUnavailable. And nothing constructs
DeviceRequestFailed: requestDevice is called unguarded, so a refused device
arrives as the browser’s own DOMException. The reasons you will actually see
are AdapterUnavailable, BelowFloor and DeviceLost; handle the rest by
falling back on failure.message.
The static constructors (ShadeDeviceFailure.below_floor(detail),
.device_lost(detail) and so on) are how the renderer builds these; you catch
them rather than construct them.
What the engine already does
Engine subscribes to all three signals itself. On contextFailed it logs the
error, makes sure the simulation is not left running (nothing can be drawn, so
there is no point burning cycles invisibly), and adds a
GraphicsContextFailureView to engine.viewStack - a full-screen overlay with
a title, an explanation and a reload button. Do not add one yourself in a
contextFailed handler; you will get two stacked overlays.
GraphicsContextFailureView (named export of
src/view/graphics/GraphicsContextFailureView.js) takes
{ title, text, action_label, action }, all optional; action defaults to
reloading the page. Its strings default to English on purpose - this view exists
for the case where the renderer is dead, which is a bad moment to depend on the
localization pipeline having the right keys. The engine constructs it with no
arguments and there is no flag to suppress that, so localizing the wording means
styling or replacing the view yourself. Its CSS class is
webgl-context-failure-view; the name is historical.
Two consequences worth planning for:
graphics.start()re-throws after dispatchingcontextFailed, andEngine.start()awaits it first, soawait engine.start()rejects with the same error.- The engine never mounts
engine.viewStack.elinto the document; the host does.EngineHarnessmounts it afterengine.start()resolves, so underEngineHarness.bootstrapa startup device failure leaves the overlay parented to a view stack that never reaches the page, and the promisebootstrap()returned never settles. If you need a device-failure screen on that path, mountengine.viewStack.elyourself, or render your own message from acontextFailedhandler.
Handling a loss mid-session
contextLost is the asymmetric one. Engine handles it by starting a
two-second timer and pausing the simulation if the outage outlasts it - it does
not add the failure view, because that path was written for an outage that
might end. Since restoration never comes, a shipped game should say something
itself:
import { GraphicsContextFailureView }
from "@woosh/meep-engine/src/view/graphics/GraphicsContextFailureView.js";
engine.graphics.on.contextLost.add((failure) => {
// nothing will render after this; the simulation pauses two seconds from now
engine.viewStack.addChild(new GraphicsContextFailureView({
text: failure.message
}));
});
Application state is left intact when the simulation pauses, which keeps a save-on-failure handler viable.
Running without a device at all
new Engine(platform, { enableGraphics: false }) skips the graphics engine
entirely and logs that it did. engine.graphics is then null - there is no
on to subscribe to, render() returns immediately, and only frame_delay and
simulation_time are ever recorded. That is enough for headless simulation.
Tests that need the renderer’s own code paths without a GPU use the software
device instead - see
Testing without a GPU.
Logging
Global logger
logger is a process-wide Logger instance exported from
engine/logging/GlobalLogger.js. It starts with no backends - messages are
silently dropped until you add one.
import { logger }
from "@woosh/meep-engine/src/engine/logging/GlobalLogger.js";
import { ConsoleLoggerBackend }
from "@woosh/meep-engine/src/engine/logging/ConsoleLoggerBackend.js";
logger.addBackend(ConsoleLoggerBackend.INSTANCE);
EngineHarness calls this automatically during construction.
Log levels
LogLevel is a numeric enum - lower numbers are more severe:
| Name | Value | console method used by ConsoleLoggerBackend |
|---|---|---|
Severe | 0 | console.error |
Error | 1 | console.error |
Warning | 2 | console.warn |
Info | 3 | console.log |
Debug | 4 | console.log |
Each backend has a level threshold. A message is only forwarded if its
level is at or below the backend’s threshold. The default threshold is Info
(3), so Debug messages are suppressed by default.
import { LogLevel }
from "@woosh/meep-engine/src/engine/logging/LogLevel.js";
backend.setLevel(LogLevel.Debug); // see everything
backend.setLevel(LogLevel.Warning); // suppress Info and Debug
Logging from application code
logger.info("scene loaded");
logger.warn("asset missing, using fallback");
logger.error("physics body escaped the world");
// or with explicit level:
logger.log(LogLevel.Debug, "collision narrowphase iteration 42");
Backends
| Class | Behavior |
|---|---|
ConsoleLoggerBackend | Forwards to console.log / .warn / .error. Singleton at .INSTANCE. |
VoidLoggerBackend | Discards all messages. Useful in tests or production builds. Singleton at .INSTANCE. |
ElasticSearchLogger | Buffers records in a deque and bulk-posts to an Elasticsearch index via XMLHttpRequest. |
Elasticsearch backend
import { ElasticSearchLogger }
from "@woosh/meep-engine/src/engine/logging/elastic/ElasticSearchLogger.js";
const es = new ElasticSearchLogger({
url: "https://logs.example.com",
target: `game-log-${new Date().toISOString()}`
});
logger.addBackend(es);
The backend buffers records locally and flushes when either 500 records
accumulate or 2 000 ms pass since the last flush, whichever comes first.
Each flushed batch is a POST to <url>/<target>/_bulk. Timestamps come from
performance.now().
Registering multiple backends
logger.addBackend and logger.removeBackend work on the same list. A
message is dispatched to every backend whose threshold allows it:
logger.addBackend(ConsoleLoggerBackend.INSTANCE); // development
logger.addBackend(es); // production telemetry
// later:
logger.removeBackend(ConsoleLoggerBackend.INSTANCE);
Writing a custom backend
Extend LoggerBackend and implement log(level, message):
import { LoggerBackend }
from "@woosh/meep-engine/src/engine/logging/LoggerBackend.js";
import { LogLevel }
from "@woosh/meep-engine/src/engine/logging/LogLevel.js";
class RemoteBackend extends LoggerBackend {
constructor() {
super();
this.setLevel(LogLevel.Warning); // only warnings and above
}
log(level, message) {
fetch("/api/logs", {
method: "POST",
body: JSON.stringify({ level, message, ts: Date.now() }),
headers: { "Content-Type": "application/json" }
});
}
}
logger.addBackend(new RemoteBackend());