devuiGuideDemosAPIGitHub

Frame scope

The frame scope shows how each frame is built while it runs: which passes it makes, what they draw into, what they read, and where the time goes. Attach it to whatever renders:

import { frames } from '@haxiomic/devui/frames';

frames(gui, gl);         // a WebGL2 context
frames(gui, device);     // a GPUDevice
frames(gui, renderer);   // a three.js renderer (a WebGPURenderer after its init())

Attach it at start-up, before you make textures, so their memory is counted. Nothing else changes in your code. Or leave it to gui.perf(): given a context (or with the three.js extension), its Performance folder has a frame scope folder, attached the first time it's opened and recording only while it's on screen (performance.md). The palette's Frame scope command opens it there. On WebGPU a scope attached later still knows what was made before it (devui keeps that from its import): its passes' targets, pipelines and bind groups. Those textures are counted once they're used, and a capture pictures them where their own usage lets it (COPY_SRC; three.js gives every texture that).

A Frame folder shows the scope, the frame's metrics, and a time plot:

What usually means trouble is marked: queries that stall, mid-frame compiles, readbacks, and resources made and deleted every frame.

From code:

const scope = frames(gui, gl, { folder: 'Frame', timing: true, history: 64 });   // frames kept: 64 by default
scope.name(bloomTexture, 'bloom A');   // a lane's name
const release = scope.hold();          // keep recording with the folder closed; release() lets go
scope.stats;                           // { gpu, cpu, counts, memory }, a few times a second
scope.timing.restart();                // where GPU times come over frames: every pass timed afresh
await scope.capture();                 // the next frame, kept (below); captureAndShow() opens it too
scope.dispose();

To show the scope later and still count everything, watch the context as it's made. A scope attached later knows what it made meanwhile: its textures and buffers and, on WebGPU, the copy usage a capture needs. onDraw is told of its first frame that draws, which a context made only to test for support never has:

watchFrames(gl, { onDraw: () => frames(gui, gl) });   // nothing shows until it draws

Lanes take their names from what's there:

A render target resized (three, and most apps, delete its textures and make new ones) keeps its lane and its row in Targets, named or not, and an inspector open on it shows the new texture. The new texture is taken for the old one's replacement when the old one is deleted and the new one is first drawn into at about the same place in the frame, with the same kind and format. A cube's faces, an array's layers and a texture's mips are passes in its one lane.

Each context gets a scope of its own, in a folder of its own: a second frames() shows beside the first, as Frame 2 (or name it: frames(gui, minimapGL, { folder: 'Minimap' })), and each records while it's on screen. Scopes attached with { group: true } share their folder instead, one shown at a time: where there are several, a select at the start of the scope's bar picks the context (by its API and canvas), the choice is kept for the page, and the palette's capture commands take the context shown. devui's Chrome extension groups the scopes it attaches.

three's draws are labelled object · material.

CPU time is the JavaScript each pass was submitted in, read with performance.now():

GPU time is measured per pass:

Cost:

Limits:

Frame capture

The scope shows what a frame does while it runs. A capture keeps one frame, so you can step through it draw by draw and see what each draw did. capture in the scope's bar (or the palette's Capture frame, or await scope.capture()) captures the next frame and opens it in a window, titled with the capture it shows (Frame capture #2).

An app that draws on demand draws an idle frame when nothing changes, so the next frame is rarely the one you want. Two palette commands wait for the right one:

Each waits up to 30 seconds (within, in ms).

A draw is named by what it draws, wherever it's mentioned: three's object · material, an integration's label, else its program's or pipeline's (a WebGPU label), then its number in its pass: knot · knot · draw 1 of 2 · hdr scene · pass 2. One with no label is draw 3.

The window, top to bottom: its bar, the timeline, the chosen pass's draws, the chosen draw's title; then the picture, what the draw read, and the draw. From 980 px wide, the picture is on the left, kept in view as the draw's details scroll on the right, under what it read. It opens that wide where the screen has room beside the panel.

A pass is the draws into one target, one after another (a WebGPU render pass; on WebGL2, the draws between binding a framebuffer and binding another). It's a stretch of its target's lane on the timeline, and its draws are the strip's cards. A draw is always what's chosen: choosing a pass (Shift+←/→, a link to one) chooses its last draw, the pass as it ended. Only a pass of no draws (a clear, a blit) is chosen as itself. A blit's pass shows its call and what it copied: which buffers (colour, depth, stencil), from and to which targets with their sizes and samples, and whether it resolves samples (an MSAA resolve, a resolve in its title), scales or flips. Resolving stencil can cost far more than colour and depth, so the buffers are worth reading.

what's wrong with its data, each a link to where (3 NaNs in normal, indices reach 1,240: position's buffer holds 1,234, an attribute read past its buffer's end, attributes that overlap), and, dimmer, what may be meant (uv is the same for every vertex, degenerate triangles); each buffer's layout, a bar its stride wide with each attribute's bytes along it (gaps hatched, an overlap or a byte past the stride red); and its attributes, each with its values' range over the draw;

Capture files

A .devui-capture file is a zip: the frame as JSON, its pictures (PNG for 8-bit, raw floats, halves and integers otherwise), and what each draw read, as it read it:

Raw data is deflated. On three.js's examples a file was 34–122 MB, about what a file held before it kept everything (mips and cube faces cost what deflating saves), saved in 5–11 s and opened in 2–6 s.

Opened, its frame shows as it did, and each draw is drawn again on this page: as you step through it, a draw's picture (and a pass's, its last draw's) is made here, and its note says how it compares with the frame's own picture of it in the file, texel by texel: the same as the frame's own picture, to the bit, or where and by how much it differs (another GPU can filter or round differently). Its texel history, compare and the agent tools work on it.

Captures are kept, numbered (#1, #2…), and the oldest go past room for two at the memory cap. One capture keeps at most 1 GB of pictures on the GPU; the bar's memory chooses (256 MB to 4 GB, kept for next time), as does scope.capture({ budget }). Past it, the per-draw pictures are spread through the frame, and replay fills in the rest; the capture's notes say so. The textures kept whole for a file take at most a quarter of it, so they never cost the pictures more than that; past it, a texture is kept as its picture alone, and the notes say so.

What it needs: on WebGPU, the scope makes what the device makes copyable, so a capture can picture it:

Something made before the scope attached may not be copyable, and the capture says so.

Cost:

Reading GL state back is a round trip to the GPU process for each value. So the capture reads what a draw runs with once, then follows the app's calls: the fixed state, each program's uniforms, vertex arrays and uniform blocks.

Limits: