Skip to content

Performance & diagnostics

Beyond heatmaps, Uptimizr captures how the scene performs on real devices and where its rendering cost and attention actually land.

The perf channel samples on a slow timer (default ≈0.5 Hz; tune via sampling.perf). Beyond fps / frameTimeMs / drawCalls, each sample reports percentiles and resolution over the window:

Field Meaning
frameTimeP95Ms, frameTimeP99Ms 95th/99th-percentile frame time over the window (jank tail).
longFrames Count of frames slower than jankFrameMs (default 50) in the window.
dpr Device pixel ratio.
renderScale Engine hardware-scaling factor (1 = native, <1 = downscaled).
position Optional [x, y, z] camera world-position at the sample. Powers the spatial FPS heatmap below.

A steady FPS is meaningful telemetry, so the perf channel reports continuously by default. To dedupe a stable frame rate, set suppressIdlePerfSamples: true (and tune perfFpsThreshold). Read the summary from GET /api/v1/perf. asset_load events additionally carry an optional ttiMs alongside loadMs / ttffMs.

frame_perf samples optionally carry the camera position at the moment they’re taken, which lets the collector answer where your scene runs slow — not just when. The Babylon connector fills position automatically from the tracked camera; other connectors can set it on the event they emit. It’s fully backward-compatible: it reuses the same promoted position column as the other spatial events (no migration), and older SDKs that never send it simply don’t appear in the map.

Read the binned result from GET /api/v1/heatmaps/perf — it reuses the world heatmap’s voxel grid and cellSize params and returns vx,vy,vz,samples,avg_fps,min_fps per cell, ordered avg_fps ASC so the jankiest voxels survive truncation. The dashboard renders it as the Performance heatmap (3D) panel: hot = slow (each voxel’s colour/size scales with slowness so your worst spots stand out), and hovering a cell shows its average/min FPS and sample count.

GET /api/v1/perf/by-device segments per-session median FPS by the session_start.device block — graphics engine, isMobile, and GPU renderer — plus a coarse browser and os pair. The browser/OS families are derived server-side from the request User-Agent at ingestion (e.g. Chrome / Safari on Windows / iOS); the raw User-Agent is never stored and no version or device model is retained (derived, non-PII — ADR 0003 / ADR 0041). This needs no SDK or client change — it reuses a header the collector already receives. The dashboard renders it as the “FPS by device” panel with Backend / Browser / OS / Mobile / GPU columns.

GPU / memory footprint (resource_sample) — opt-in

Section titled “GPU / memory footprint (resource_sample) — opt-in”

Off by default. When capture.resourceSample is enabled, the connector samples the actual cost the scene asks of the device on a slow timer (default every 15 s), separate from per-frame frame_perf:

trackScene(scene, {
// ...
capture: { resourceSample: true },
resourceSample: { intervalMs: 15000 }, // one footprint sample per window (default)
});

Each resource_sample carries whatever the engine can cheaply report, all optional: textureBytes, geometryBytes (resident GPU memory), triangles, vertices (submitted last frame), and jsHeapBytes (JS heap). Pair it with the device caps on session_start to spot scenes that overspend their target hardware.

Coverage differs by engine (the SDK never mutates the engine): Babylon reports triangles (active indices ÷ 3) and vertices; three.js reports triangles (renderer.info.render.triangles). jsHeapBytes comes from performance.memoryChromium-only, omitted elsewhere rather than zeroed. Resident textureBytes / geometryBytes aren’t on either engine’s public surface, so they’re left unset; the read API’s averages ignore unreported metrics (an absent metric never reads as 0).

Babylon-only, on by default via capture.compileStall. Times Babylon’s main-thread shader/pipeline compilation span — the #1 source of first-interaction hitches — reporting durationMs and phase. three.js has no equivalent engine hook.

Perf-driven churn (GET /api/v1/perf/churn)

Section titled “Perf-driven churn (GET /api/v1/perf/churn)”

Perf distribution tells you how the scene performs; this tells you whether a stutter actually cost you the session. It correlates perf dips against early session end (#144): of the sessions that ended in range (sessions), it reports how many ended within a window of an FPS dip or a compile stall (churn_sessions), and attributes the cause into fps_churn_sessions / stall_churn_sessions. A session hit by both a dip and a stall is counted under each cause but only once in the headline total, so the cause counts can sum to more than churn_sessions.

It’s buildable from existing telemetry — derived from frame_perf, compile_stall and session_end, with no schema change and aggregate counts only (no per-session identifiers leave the query, ADR 0003). Three query params tune the correlation (all optional):

Param Default Meaning
windowMs 30000 How long before a session’s end a perf dip still counts as correlated.
fpsThreshold 30 A frame_perf sample below this FPS counts as an FPS dip.
stallMs 100 A compile_stall of at least this many ms counts as a stall.

The dashboard renders it as the “Perf-driven churn” panel next to the performance-distribution panel: a headline perf-correlated churn rate plus the FPS-dip vs. compile-stall cause split, with the window and thresholds exposed as viewer-tunable sliders.

Fallbacks (WebGPU→WebGL2), quality/LOD auto-downgrades, and device recovery are app-reported via client.reportCapabilityChange(...) — see custom events & input. They explain perf and visual-fidelity variance across your user base; read the rollup from GET /api/v1/capabilities.

Off by default (privacy + cost). When capture.gaze is enabled, the connector raycasts the camera-forward ray into the scene on each frame that already emits a camera_sample, and attaches the surface hit to that sample as hitPoint (world-space point) + hitMesh (hit object’s name). This answers “where did the audience’s gaze rest on the actual geometry” for every camera style (orbit, first-person, XR) — distinct from the click-only world heatmap and the abstract view-direction sphere.

trackScene(scene, {
// ...
capture: { gaze: true },
gaze: {
maxDistance: 1000, // ignore hits farther than this along the ray (default)
meshes: ["product-hero"], // allowlist; omit to hit any mesh
predicate: (mesh) => mesh.name !== "ground", // exclude skybox/helpers (sync connectors)
},
});

Gaze is cheap by design: one pick per emitted pose. It rides the existing, idle-suppressed camera cadence — it never adds a timer or picks at frame rate, and a static (pose-deduped) frame costs nothing. The hit is normalized to the canonical coordinate frame at the emission boundary, so the gaze heatmap aligns with the pointer world heatmap across engines.

Connector parity (same capture.gaze flag + GazeOptions):

  • @uptimizr/babylonscene.pickWithRay() from camera.getForwardRay() (sync); predicate supported.
  • @uptimizr/three — single reused THREE.Raycaster from NDC centre (sync); predicate over Object3D.
  • @uptimizr/playcanvas — single reused pc.Ray vs mesh-instance AABBs (sync, physics-free); predicate over GraphNode.
  • @uptimizr/babylon-lite — async GPU picker at the centre pixel; the hit rides the next sample (≤ 1 sample latency); no predicate (name allowlist + maxDistance only).
  • @uptimizr/r3f — inherits three’s options; pass capture.gaze + gaze through useUptimizr / <Uptimizr>.
  • @uptimizr/aframe — flat HTML schema exposes a boolean toggle only: <a-scene uptimizr="gaze: true">.

Read the result via GET /api/v1/heatmaps/gaze — it reuses the world heatmap’s voxel grid, params, and 3D renderer. You can also paint it into your own scene.