Skip to content

Stats overlay

<Scene stats />

stats mounts the overlay. It is its own chunk, imported the first time the prop is true; a literal false strips it from the build. It reads the engine’s counters ten times a second and renders through phaze bindings — it does nothing per frame.

The shape is the stats.js panel that drei wraps — frames per second, a millisecond figure, memory — extended with what a WebGPU engine can know about itself: GPU time from the device’s clock, draw and pass counts, the shape partition, the adapter, and whether the engine is idle or paused.

FPS — how many frames per second the engine is actually rendering, taken from the last frame alone: 1 / delta. It is deliberately not averaged (it was a 90/10 moving average; switched to raw) so a stall shows the instant it happens rather than as a dip in a trend. Green from 55, yellow from 30, red below.

CPU — how long fabric’s JavaScript took to produce one frame, in milliseconds: from the start of the frame’s work to the moment the commands are handed to the GPU. It is a duration, not the gap between frames. Green under 8 ms, yellow under 16, red above.

GPU — how long the GPU spent drawing the frame, by its own clock. n/a when the adapter has no timestamp-query. Same colours.

Read them together: CPU low and GPU high means fabric’s JavaScript is cheap and the shader is the cost. Both low with a red FPS means the time is going somewhere outside fabric.

This is one tick of the engine’s loop. CPU is the bracketed part only.

browser fires requestAnimationFrame
│
├─ schedule the next rAF
├─ canvas off-screen? ─ yes ─▶ return PAUSED — nothing measured
├─ fps cap: too soon? ─ yes ─▶ return skipped frame — nothing measured
├─ delta = now − last → FPS = 1 / delta
├─ resize-settle bookkeeping
├─ speed 0 and no dirty frames? ─ yes ─▶ return idle — CPU keeps its last value
├─ dirtyFrames−−, camPos ← camPosRef, internalTime += delta × speed
│
├─ ▶ start = performance.now() ──────────────────────────────────────────┐
│ uploadUniforms matrices, scene + camera → one writeBuffer │
│ _sdfUpdate the record write: walk every shape, resolve every │
│ reactive value, fill the staging array, writeBuffer │ CPU
│ _textUpdate text uniforms │
│ submitFrame encode: compute (sim, grid clear + assign) │ the JS time of
│ → pass 1 (particles, layers, axes, text) │ producing the
│ → [glass: copy, shape pre-pass, mips, │ frame's commands
│ back-face pass] │
│ → pass 2 (fast, mesh, glass, fast halo, │
│ effect, gizmo) │
│ → blit → resolve timestamps │
│ queue.submit() │
│ (with <View>s: uploadUniforms + submitFrame once per view) │
│ ◀ latency = performance.now() − start ─────────────────────────────────┘
│ frameTimes[i] = latency ← the sparkline
│
└─ return to the browser
├─ GPU executes the submitted passes ← GPU: the device's clock,
│ read back a frame or two later
├─ compositor presents the swapchain
└─ next rAF

Three things follow from where the bracket sits:

  • It only exists on rendered frames. The three early returns happen before start, so a paused, capped or idle frame leaves CPU and the sparkline at the last rendered value. With speed at 0 and nothing dirty the number simply stops changing — the engine idling, not a stall.
  • It ends at queue.submit(). Submit hands the commands over and returns. What the GPU then does is the GPU number, on a different clock, arriving later.
  • What it measures. Fabric’s per-frame render work and nothing else — the record write, the uniforms, the encoding, the submit. Camera damping, pointer interaction and springs run in their own loops and are not the frame. So the number is the CPU time fabric spends preparing and submitting one frame, measured the way stats.js measures its millisecond panel.

It is not a CPU-side estimate. WebGPU’s timestamp-query lets the GPU write its own clock into a query set at the beginning and end of a pass; the engine reads the difference back.

  1. At device creation the engine requests timestamp-query only if adapter.features.has('timestamp-query') — requesting it blindly rejects the device. That detect is authoritative: recent iOS Safari exposes it, so the overlay shows real numbers on an iPhone; where it is absent the row reads n/a. Do not infer support from the browser or OS.
  2. On the first frame with stats on, the renderer allocates a two-slot timestamp query set, a resolve buffer and a mappable readback buffer. With stats off nothing is allocated and nothing is done per frame.
  3. The first pass encoded — the particle sim, else the grid rebuild, else pass 1 — writes slot 0 at its beginning. The last — the resize blit, else pass 2 — writes slot 1 at its end. Whatever runs between them is inside the span.
  4. After the frame is encoded, resolveQuerySet writes the timestamps into the resolve buffer and a copy moves them into the readback buffer, which is then mapAsynced. Nothing waits: the loop continues, the value lands a frame or two later, and while a read is in flight the next frame skips its resolve. The GPU is never stalled for a number.
  5. GPU = slot 1 − slot 0, nanoseconds to milliseconds.

What it covers: the whole frame as the GPU runs it — both render passes and everything between them: the background copy, the shape pre-pass, both mip chains, the mesh back-face pass — and the compute passes before pass 1. It is wall time from the first command to the last, so with enableReflection or enableRefraction on you see the second march of every visible non-glass shape that the pre-pass costs.

What it leaves out: nothing the frame submits. Idle gaps between passes, if the GPU has any, count too.

All are reset at the start of each frame and read once it is submitted.

DRAW — how many times fabric told the GPU “draw this” in one frame. Each draw / drawIndexed call is one. Particles are one; all the fast shapes are one (a single instanced draw over the whole range); the mesh is one; glass shapes one; the <Fast> halo one; effect shapes one; the axes, the gizmo, each text block, each mip step and the blit one each. It counts things being drawn, not pixels, and it climbs with features, not with shape count — a hundred fast spheres are still one draw. A plain scene sits around 3–5.

PASS — how many render passes the frame was split into. A pass is one “start drawing into this target … stop” bracket on the GPU. With no glass the whole frame is one pass. Glass forces a split, because the background has to be copied out between them — two. The shape pre-pass for reflection or refraction adds one, a glass mesh’s back-face pass one, a drag-resize’s blit one. Each pass has a fixed cost — on iOS, starting a pass is what the tile-based GPU pays for — which is why it turns yellow above 2 and red above 7. Compute passes and mip passes are not counted here.

MIP — how many mip-generation passes ran this frame. When a glass material asks for blur (roughness, anisotropy, anisotropyBlur) or a blurry reflection (reflectionRoughness), the renderer builds a chain of half-size copies — five small passes, up to ten when both chains run — so the blur is a cheaper texture read at a lower level. 0 means no glass is asking for blur. The background chain follows the blur options alone. The shape chain runs only on a frame the shape pre-pass ran — a scene flag on and a non-glass shape visible — for reflectionRoughness, or for the blur options with enableRefraction on. So MIP can move between 5 and 10 as shapes enter and leave the view. They are cheap on Apple GPUs, which is why they got their own counter instead of inflating PASS.

OBJS — how many SDF shape components are mounted: every <Sphere>, <Box>, <Torus> … in the shape map, including ones hidden by <Visible> or culled by autoCull. Not the mesh — the mesh is not counted anywhere on the panel.

SHAPES n[fast].m[fx] — [fast] is the shapes inside <Fast> that are visible this frame. [fx] is everything else: shapes inside <Effect>, bare shapes with no wrapper (they go down the effect pipeline too), and glass shapes. The split is fast versus not fast, not Fast versus Effect. And because OBJS counts all mounted shapes while [fast] counts only visible ones, [fx] overstates when fast shapes are hidden or culled.

PTS — particles.

DIRTY — dirty frames remaining. 0 with speed at 0 means the engine is idle and drawing nothing.

MEM — the JavaScript heap, from performance.memory. Chromium only; n/a elsewhere.

RES — the canvas size in CSS pixels, and the pixel ratio (capped at 2).

DEBUG — HEATMAP when debugMode is on: the shader returns its step count as colour instead of the shaded surface.

GPU (the footer line) — which adapter you are on: its description, device, or vendor · architecture, from adapter.info with a fallback to requestAdapterInfo(). Blank when the browser withholds it.

PAUSED — the canvas is off-screen; the loop returns before drawing anything, and every number holds its last value.

Sixty samples of the CPU number against a dashed line at 16.7 ms. It plots the frame’s CPU time, not the interval between frames — so a line flat under the dash beside a red FPS means the time is going somewhere else: GPU first, then the rest of the page.

With stats on: the query set and two 32-byte buffers, one resolve and one copy per frame, one async map in flight, and a 10 Hz poll. The chunk is 1.6 KB compressed and leaves the build entirely on a literal false — see Lazy subsystems.