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:
- The scope:
- Each render target is a lane, and the canvas comes last.
- A frame's passes are blocks along its GPU time, or along CPU time where the GPU can't be timed. The pipeline lays them out in sequence, sized by their draws: the structure alone (in a capture, each pass as its picture on its lane). CPU and pipeline are for a look: the scope and the capture open along GPU time each time.
- Each pass's draws are thinner blocks inside it, coloured by program.
- A curve joins the pass that wrote a texture to the passes that read it. A dashed curve means the texture was written in an earlier frame, as in a ping-pong.
- A frame that changes (real frames jitter, every frame), read steadily:
- The time axis steps between round spans (8, 16.7, 33.3 ms…). It goes up when the frame outgrows it for a few frames, and down only after two seconds well inside a smaller one. A one-off spike runs off the edge, marked there.
- Each block's size is taken over a span the bar's ~3 s chooses. frame: each frame's own readings, the GPU's waits and the CPU clock's steps in them (see below). ¼ s: as each pass typically takes now, over its last 15 frames. ~1 s, ~3 s (the default) or ~10 s: a soft moving average of that. The blocks are laid end to end in those sizes: they hold still, and read as what each pass costs a frame. The hover tip has this frame's own times beside them. Behind each block, a faint band runs out to its longest lately, like an oscilloscope's persistence. The times above the scope are averaged the same way.
- Lanes keep their places: each takes its place where it first comes (just before the lane of the next pass, so passes late in the frame stay late, as a capture orders them) and keeps it, however the passes move about from frame to frame. They fade when unused, and go after a while.
- Reading it:
- Hover a block for its pass: target, time, size, what it reads, and every target it writes when there are several (a G-buffer). You also get the draw under the pointer: its label, vertices, instances, program and the textures it samples. Pointing at the plot pauses it, so what's under the pointer holds still. The tip sits beside the scope, at a fixed width.
- Hold (or Space) freezes the frame shown.
- Zoom in on part of the frame as every graph in the panel does (see gestures): Ctrl or ⌘ + scroll (or a pinch) over the lanes, a plain scroll over the axis, or + and −. Zoomed, drag the lanes (or Shift + scroll, the middle button, two fingers or the arrow keys) to pan; a bar under the axis shows where you are in the frame, and draws too thin to see come apart. Double-click (or 0, or fit over its corner) shows the whole frame again. A plain scroll over the lanes still scrolls the panel.
- Click a lane's name to open its target in the texture inspector. Targets lists them all, live.
- devui's own GPU work (its texture views and readbacks) is left out of the lanes and the counts; the times above the scope say how much it is (devui +…).
- window opens the scope in a window, with room for its lanes.
- Metrics (a folder): a dense grid of the frame's numbers, four times a second. A zero is dimmed, and what most apps never use (multi-draw, bundles, samplers…) shows once it's been seen. Hover a cell for what it counts and its range over the last frames.
- frame: GPU and CPU time, passes, draws, clears (and blits, copies, compute passes, dispatches and submits where there are any);
- draws: indexed and not, instanced, instances, multi-draw, indirect, bundles;
- modes: draws by primitive mode (triangles, strips, fans, lines, line strips, loops, points) as a bar and its key;
- prims: triangles, line segments, points and vertices;
- state: program (pipeline) switches, texture, buffer, VAO, framebuffer and sampler binds (bind groups on WebGPU), uniform calls, fixed-function state changes, attribute setup;
- traffic: texture and buffer bytes uploaded, readbacks, GL queries that wait for the GPU process, compiles, resources made and deleted;
- memory: textures, buffers, renderbuffers, the canvas, and the total.
What usually means trouble is marked: queries that stall, mid-frame compiles, readbacks, and resources made and deleted every frame.
- Time (with the metrics): a plot of each frame's GPU and CPU time.
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:
- three's render target and texture names, and shadow maps after their lights;
- WebGPU labels;
- the label of a
gui.texture()row showing that texture; scope.name(texture, 'bloom A');- otherwise, the target's size.
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():
- Browsers coarsen that clock against timing attacks. Chrome's steps by 0.1 ms, so in any one frame a quick pass reads 0 or 0.1 ms. Chrome rounds at random points, so the mean over frames comes out right: a pass's CPU time is the mean of its last 15 readings, interruptions left out (more than twice the middle, and a millisecond over it: the thread descheduled, a collection). The scope's blocks (from ¼ s), CPU ms and its plot use it.
- On a cross-origin-isolated page Chrome's clock reads to 5 µs, and each frame's own CPU times are good too (frame shows them). Serve the page with two headers:
Cross-Origin-Opener-Policy: same-originandCross-Origin-Embedder-Policy: credentialless(orrequire-corp, for Safari). Cross-origin images and scripts then need CORS headers to load (withcredentialless, they load without cookies instead). With Vite:server: { headers: { 'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Embedder-Policy': 'credentialless' } }. - The CPU button's tip says which clock the page has.
GPU time is measured per pass:
- On WebGL2 it needs
EXT_disjoint_timer_query_webgl2(desktop Chrome and Edge). Your own timer queries keep working, andgui.perf()takes its GPU time from the scope while it's open. - On WebGPU it needs the device requested with the
'timestamp-query'feature. - A pass has one timer: while your app times its own (a WebGPU pass with its own
timestampWrites, or a WebGL timer query open, as three'strackTimestampdoes), the scope can't. Its GPU axis and GPU ms say so, and it shows the frame along CPU time. - Times arrive a few frames late.
- A pass's time is as it typically takes now: the low quarter of its last 15 readings (the 4th lowest). A reading is time on the GPU's clock, and that counts the GPU waiting for the browser to send it the rest of the pass. On Chrome on Windows a pass reads milliseconds more, a whole frame if the wait runs past a present, whoever times it; the last pass before a present can wait in a third of frames or more. A wait only adds, so the low quarter reads through waits in as many as 11 frames of 15; a drop shows within 4 frames, a rise within 12. The scope's GPU axis, GPU ms, the plot and
gui.perf()all use it (gui.perf()times passes the same way with the scope closed); a block's tip has the frame's own reading too, where a wait made it more. - On WebGL2, a timer query may not time a pass on its own. On Chrome on macOS (ANGLE's Metal backend) a query reads its command buffers'
GPUEndTime - GPUStartTime, and a buffer queued behind others starts at about when it was committed, not when its work runs: a 2×2 pass after a 13 ms pass reads 13 ms, and a frame's passes add up to many frames' worth. So devui tests the timers on the context, once, as it first times passes: a tiny pass timed right after a heavy one, a few ms of the GPU, a few times. Times are held back until the test is in (a fraction of a second). Where it fails, and wherever readings come in faster than time passes, the scope works the same, and the times come another way:- Each frame times one run of passes with one query, and a pass's time is the difference between two runs from the same pass, one ending at it and one at the pass before: both read their end from the same unknown start. Nothing stalls, and one query a frame leaves the frame running much as it would.
- So times come over frames: about one frame for each pass and a few more for a sweep (a second or two at 60 fps; a frame of 40 passes at 10 fps, about seven seconds). Passes show as they're read; the frame's total shows once every pass has a time. Until the first times are in the GPU axis is off and says so, and an app at rest, drawing a frame now and then, gets its times as slowly as it draws.
- After a change. Each run's time is taken low among its last five readings, as a reading only goes wrong upward, so a pass that got cheaper shows within two sweeps and one that got dearer only within four. So a change finished in the panel (yours, an agent's, a preset, an undo, an A/B) times every pass afresh. From the next frame, a pass's time comes only from readings taken since, within a sweep or two. Until a pass has been read again it keeps its time from before, hatched. Its tip says when it will be read, and the numbers above the lanes count those left (re-timing, 12 left). For a change devui can't see (the camera, the app's own state), press re-time in the bar (the stopwatch beside GPU · CPU · pipeline), run Re-time the frame's passes from the palette, or call
scope.timing.restart(). - They're the frame's times, not this frame's: every frame shows the latest for its passes, and the plot's GPU line moves with them. Large passes and the frame's total read well. A small pass's time can jump by a ms or more, or move to a neighbouring pass, so read it over a few seconds; frames that vary a lot read less steadily. A very small pass can read 0: the GPU runs it alongside the one before.
- The first pass of a frame is read against a 1×1 draw of devui's, in the frames that time it.
- A reading can be far out for reasons not all understood. Where a frame's passes add up to well over its whole time, measured apart with a fence, the pass that accounts for the difference gives it up. So totals hold up better than any one pass. To size a saving, turn the feature off.
- The app's own timer query takes the timer, as ever: devui times nothing while it's open.
Cost:
- It records only while its rows are on screen (not scrolled away, the panel not hidden), in the panel or a window. Otherwise the draw-path wrappers come off the context or the WebGPU prototypes. Texture views, plots and rows likewise do nothing while they're off screen.
- Recording adds about 0.3 µs of JavaScript to a draw with a texture bind and a uniform call (each counted call adds a few tens of nanoseconds), measured on a stand-in context. In Chrome, on a frame of 10,000 draws with two uniform calls each, recording took about 6.6 ms a frame (0.65 µs a draw), and drawing the scope 1.8 ms.
- In steady state neither it nor devui's texture views ask GL anything that waits for the GPU. The state they need to save, they follow as it's set.
@haxiomic/devui/frames/noopattaches nothing, for production.
Limits:
- A WebGL1 context is recorded, but without GPU time.
- An indirect draw's counts are on the GPU, so its size is unknown.
- A WebGPU compute pass's writes to storage textures aren't linked to their readers.
- A target swapped for another without deleting it (a pool, say) shows as a new lane: only a deleted one is followed to its replacement.
- A frame is the work of one task. Work in a timer or a message is part of the animation frame's when it runs before that frame closes, and a frame of its own when it runs after: which, the browser decides.
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:
- Capture the next frame that does work (
scope.capture({ when: 'work' })) passes over frames that do what the frames before them typically did, and takes the first that does more (more passes or draws). Run it, then interact with the app. On an app that does its work every frame, in one pass or many, that's the next frame; frames that only show the last result (a draw or two of a render target) are the idle ones. - Capture after the next input (
{ when: 'input' }) waits for a pointer or key on the page (not on devui's panel), then takes the next frame that does work. A hover's highlight often comes a frame or two after the event that caused it.
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.
- The bar, grouped by what each acts on: ← back; the captures (‹ #2 ›, capture again); vs (compare), save, open and the memory cap; the timeline's GPU, CPU, pipeline and zoom. The picture's own (changes, wire, overdraw, cost, full size) are by the draw's title, with ◂ and ▸.
- The timeline: the frame's lanes and passes, frozen, with each draw a tick. ←/→ steps a draw (a draw found, while some are), Shift a pass, Home/End to the ends. The grip under it shows more lanes or fewer, and dragged past all of them makes them taller.
- Along time, as the scope shows the frame: GPU (the default, where the GPU is timed) or CPU (the JavaScript each pass was submitted in). Each pass is as long as it typically takes (from the frames recorded just before the capture: the captured frame itself is slowed by capturing), shared among its draws by what they process (vertices × instances). The bar has the typical total.
- A ruler over the lanes has the frame's time and the playhead's handle: drag it (or anywhere along the ruler) to scrub through the draws. Zoomed in, the view follows past its ends.
- Scroll the wheel to zoom about the pointer, or pinch (on a trackpad or a touch screen). A sideways scroll (two fingers on a trackpad, or Shift + scroll) pans; a trackpad's scroll up or down scrolls the lanes. A drag (one finger, or the middle button) pans both ways: along the frame, and the lanes. A click (or a tap) picks the draw under it.
- The pipeline (pipeline): the passes in the order they ran, each draw the same width, and pictures on their lanes, dimmed where nothing reads the pass. Where each of a pass's draws has room (zoom in for more), each shows the target after it; else the pass shows its picture at its end: what later passes read of it (a shadow map's depth). Along time, each lane instead has its target in a slot beside its name, as the playhead finds it. Click, and drag to scrub through the draws; the wheel and a pinch zoom, and a sideways scroll pans, as along time.
- Either way, − and + in the bar (or the keys) zoom about the chosen draw, and ×N (or 0, or a double-click) shows the whole frame again; a bar along the top shows where you are. A draw chosen out of view brings the view to it.
- Along time, as the scope shows the frame: GPU (the default, where the GPU is timed) or CPU (the JavaScript each pass was submitted in). Each pass is as long as it typically takes (from the frames recorded just before the capture: the captured frame itself is slowed by capturing), shared among its draws by what they process (vertices × instances). The bar has the typical total.
- The draws: under the timeline, the frame's draws in order, grouped by pass under its name, a card each: the shape its vertex data makes (solid, from three-quarters above), its name, and what it drew (
14k triangles,2 triangles ×40). One with no shape says why: compute (its workgroups), no attributes (a full-screen triangle fromgl_VertexID), no positions, its vertex data not kept. The chosen card is outlined and kept in view; a click chooses another, in any pass. Hovering a card marks its tick on the timeline, and hovering a tick marks its card. A long frame scrolls sideways (a wheel scrolls it along), only the cards in view drawn, and draws of one mesh share one picture. - Finding draws: type in find draws over the strip (or press / in the window) and the draws whose name, material, program or target has every word stay lit; the rest dim, on the timeline too, where each found has a notch over it. Enter or ↓ chooses the next found (round from the end), Shift+Enter or ↑ the one before, and ←/→ in the window step through those found alone while the field has words in it. Esc clears it, and again goes back to the window.
- A peek: rest the pointer on a card, or on any draw's tick on the timeline (any pass's), and the draw's picture shows over it: its target after it, with its name and its target. Move along and the next draw's picture follows at once. A draw with no picture of its own (WebGPU's, or one over the memory cap) has its pass drawn again up to it, as when it's chosen. Nothing is chosen until you click.
- Links (an input's pass, a draw the numbers point at, the same draw in a compared capture) choose what they point at. Rest on one first and its picture peeks over it, as a card's does. The window stays where it is while the picture's in view (beside the details, from 980 px wide); scrolled past it, the window goes back up to the timeline. The title's marked either way, and ← back (or Alt+←) returns to where you were.
- The picture: the target after the chosen draw (or pass), in the texture inspector, with its zoom, probes, channels and numbers. How it's shown belongs to its target: its colour space, range, channels, exposure and framing, changed in the inspector, are kept for that target (by its name and format) in later captures and after a reload. They start from a first guess: the canvas's pictures as the screen showed them (sRGB: an app writes it encoded); a render target's as Linear light, encoded for the screen, so an HDR target's highlights clip unless a tone map is chosen; Non-colour (as stored, fitted to its range) for depth, integers and what three's normal, depth and distance materials write. The picture's header says how it's shown where that isn't plain light.
- Depth (a shadow map, a depth pre-pass) is fitted to what its pass drew, leaving out the value it cleared to, and framed on where its draws landed: a small scene in a large shadow map fills the picture. drawn and framed in the inspector turn either off, kept for its target.
- What the draw read, beside it: each texture it samples as it was then, small (a click opens it at full size in the inspector).
- changes shows, in the picture, what the draw changed: |after − before| × 8, black where it changed nothing.
- overdraw shows the pass's overdraw in the picture's place: how many of its draws, up to the chosen one, drew on each spot. White is the most on a spot in the whole pass, so it builds as you step through the draws. It's on for a pass reached from the frame's numbers' overdraw; changes, wire and full size wait while it shows.
- cost shows what the chosen draw costs where, in the picture's place, as heat: each tile's cost a pixel, in ns (hover a tile for it), from dark blue at nothing through green and yellow to dark red at the dearest few. It's timed on the GPU when asked: the pass replayed up to the draw, then the draw drawn again and again over each tile of where it costs, against the same with nothing in the tile (its vertices cost either way). Each tile's time is its share of the time the draw's fragments take. Where it costs is found from coarse tiles over the whole target; the rest of the target keeps those, and its share.
- It shows what a draw's numbers can't: a shader that branches, loops or samples more in one place. A raymarched cloud's thin edges can cost more than its dense middle, where rays stop early.
- A small tile drawn alone runs on the part of the GPU its pixels go to, and some parts are slower: a pattern of the GPU's own, not the draw's. Each tile is also timed with a fixed draw of devui's, the same work on every pixel, and the draw's time is divided by how slow that one is there. Where devui's draw doesn't time steadily, the tooltip says the pattern stays.
- The detail beside it sets how fine: up to 16, 32 or 64 tiles across where it costs (a tile's side is a power of two, so it's often fewer). Finer takes longer: a second or two at 16, a minute or so at 64. It shows as it's timed: the coarse tiles first, then the finer ones as they come. Only the draw shown is timed: stepping to another draw, changing the detail or closing the window stops it.
- Each tile's time is the least of its readings that agree: other work on the GPU only adds to a reading. A GPU's clocks change as it runs, so a tile timed in every batch sets each batch's pace, and that's divided out.
- A draw whose vertices take most of each timing leaves each tile only a little of it, near the timer's wobble; one that costs very little (a full-screen copy) times its tiles in a few of the timer's steps, which Chrome rounds WebGPU's to (65 µs). Either way the tooltip says to read the map roughly.
- The app pauses a moment for each timing. It needs GPU timers: WebGL2's timer queries (desktop Chrome and Edge), or WebGPU's
timestamp-query, which the Chrome extension asks for with a page's device. - A draw that writes depth is timed with its depth writes off, so each repeat meets the depth it found: a part of it hidden behind another part of itself is timed as if it weren't. On WebGPU that needs how its pipeline was made: one made before devui was there is timed as it is, and the tooltip says so.
- On WebGL2 each draw's picture is a reduced copy (512 px), taken as the frame runs. full size draws it again at full size: its pass replayed up to it, from the pass's pictures before it.
- On WebGPU a render pass can't be copied part way through. Passes are pictured whole, and a draw's picture is always its pass replayed up to it.
- The pass, a section of the draw's details: what it is (draws into one target, a blit's copy), its target, and its pictures, a line each for its colour and its depth: as the pass found its target (for a pass that clears, its clear, or cleared to 0, 0, 0, 1 where that wasn't pictured) → as it left it, each opening in the inspector, and compare opens the two in the inspector with a wipe between them. Then how much of its target its draws drew on, what this draw changed, its overdraw, and the draw that changed it most.
- The draw, its values coloured by kind (numbers, enums, types, the frame's objects, what's on and off):
- its call and its state, one card a group, with what differs from the draw before marked and groups that are all off dimmed;
- its uniforms, with vectors and matrices as such and colours as swatches. On WebGPU they're read from its bind groups' buffers and named by the shaders' WGSL (
u.offset: vec2f); - its inputs as they were, each linked to the pass that wrote it;
- its geometry, open from the start: what it drew (
24 vertices · 12 triangles · indexed (uint16, 36 indices) · 1 instance · 2 buffers, 1.2 KB); its attributes as pictures, each drawn as what it is, and each opening the Vertices window showing it so:- the position: its shape;
- another vertex attribute (a normal, a colour, a scalar): the shape coloured by it, a value not a number magenta;
- a 2-vector of floats (a UV): its primitives laid out in its own plane, with the unit square;
- a vector of the instances' (an offset, a matrix's last column): where it puts each instance, a point each;
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;
- the calls before it, and its program's or pipeline's code: coloured, numbered, with the draw's own uniforms, inputs and attributes marked;
- its pass (
Pass 2 · 12 draws into hdr scene · up to 3 on a spot): what a pass is and which of its draws this is, its target, its pictures, how much of it its draws drew on and this one changed, its overdraw (show it), and the draws that changed it most.
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:
- every texture a draw samples, whole: each level, cube face and layer, compressed ones too (on WebGPU as their blocks; on WebGL2, which can't read one back, decoded as the GPU reads it);
- the vertex, index, indirect, uniform and storage bytes it read;
- its program (WebGL2: the sources, its attributes' locations and blocks' bindings) or pipeline (its shaders, layouts, bind groups, samplers, views), and the state it ran with;
- the device's features and limits (WebGPU), the canvas's samples (WebGL2).
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.
- WebGL2 captures draw in the opening scope's context, with the extensions its page enabled: devui enables none there. A draw that needs one the page hasn't (a float target needs
EXT_color_buffer_float; a multi-draw,WEBGL_multi_draw; a shader's#extension) says so, and draws in the viewer, whose context has every extension. A compressed texture is drawn from its decoded texels: a colour its format interpolated may differ by a step, and the note says so. - WebGPU captures draw on devui's own device (never your page's), which has every feature your GPU has: one opens on any page with WebGPU, a WebGL2 app's too. A draw that needs a feature this GPU hasn't (BC textures on most phones,
shader-f16) says so. - A draw whose vertex data wasn't kept (past the memory cap, a buffer made before the scope attached) can't be drawn again, nor can one reading a video (an external texture). Attach the scope at start-up for a complete file: what was made before it may not be kept.
- A file holds your app's shaders, textures and geometry: share it as you would those. Opening one runs what's in it: a WebGL2 file's shaders in the opening scope's context (on an app's page, the app's own, so a file that hangs the GPU or loses the context takes the app with it), a WebGPU file's on devui's own device. Open files from others in the viewer, whose context is its own. Its
?file=<url>fetches as the page would: cookies go only to the viewer's own origin.
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:
- textures, and uniform, storage, vertex, index and indirect buffers get
COPY_SRC; - depth targets get
TEXTURE_BINDING: they're copied by a draw; - the canvas gets
COPY_SRC.
Something made before the scope attached may not be copyable, and the capture says so.
Cost:
- Nothing until used: the capture is its own chunks, loaded on the first capture: 29 KB gzip to capture, and 43 KB for its window (with the shapes' renderers, which its draws, its geometry's pictures and the Vertices window share). The Vertices window adds 9.6 KB when it first opens.
- The shapes of draws (the strip's, the geometry's pictures) are drawn as they come into view, a few a frame, on the CPU: no GPU device is made for them, nothing touches your context, and nothing's done once they're drawn. A mesh of a few thousand triangles takes a millisecond or two, one of a million about 130 ms, once.
- The captured frame is slow, the rest aren't. Measured in Chrome on software rendering:
- the stress frame (1,038 draws, 39 passes) took 1.4 s to capture instead of 44–68 ms;
- at 10,118 draws it took 2.1 s instead of 305 ms, a picture every 9 draws past the cap;
- a 19-draw WebGPU frame took 12 ms instead of 1.2.
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.
- Vertex data is copied on the GPU, read back after the frame. WebGL2 copies what the draws read of a buffer just before the app writes it, or as the frame ends: a static buffer once, however many draws read it. An indexed draw's indices aren't known until they're read back, so it keeps what its index type can reach from its base (65,535 vertices for 16-bit indices). WebGPU copies as each pass ends, on the app's own encoder. On the stress frame with real meshes (
?mesh=), 1,118 draws of one 2,500-vertex mesh kept 106 KB; spread over 100 meshes, 10.4 MB; 10,118 draws over 1,000 meshes, 42 MB. The captured frame took as long as it does without them. The capture comes back a frame later: the copies are read back once the GPU has made them, so your next frame isn't held up. It counts towards half the memory cap, so the pictures always have the other half; past it, a draw's data that didn't fit says so. - The frame's numbers are worked out once, on the GPU: on WebGL2 from the pictures, on WebGPU from a replay of each pass. Saving reads every picture back at once.
- Your app is left as it was: whatever devui does on your context, your GL state, vertex attribute values and pending errors are as you left them (your next
getError()still sees yours). On WebGPU its work runs in error scopes of its own, so nothing reaches your error scopes oruncapturederrorhandler, and nothing it records on your encoders can make them invalid. If devui itself fails during a capture, the capture stops with a note; your calls go on.- devui never enables an extension in your WebGL context, nor makes a WebGL context of its own: a float texture's values, numbers and HDR need none (values travel as their bits in integer targets). What does need one you haven't enabled (a float target's picture in a capture) says so. The one exception is GPU timing, which enables
EXT_disjoint_timer_query_webgl2: it only adds queries. - While your WebGL context has a query open or transform feedback active, devui draws nothing in it (they'd count its work): replay and after VS say why, and texture views keep their last picture.
- On WebGPU, the copy usage devui adds to your textures and buffers so it can picture them doesn't show in their
usage. A canvas configured before devui attached isn't configured again (that would blank it), so it isn't pictured.
- devui never enables an extension in your WebGL context, nor makes a WebGL context of its own: a float texture's values, numbers and HDR need none (values travel as their bits in integer targets). What does need one you haven't enabled (a float target's picture in a capture) says so. The one exception is GPU timing, which enables
Limits:
- Replay uses the app's own programs and pipelines. Vertex and index buffers are as each draw read them (made again from the bytes kept, at the same addresses), as are uniforms, uniform blocks and bind groups' buffers. So are the textures a draw samples: a pass's picture, or a copy of the texture taken as the frame read it. Where a draw's vertex data wasn't kept (past the memory cap, or a buffer made before the scope attached), its replay uses the app's buffers as they are now (not one since deleted), and says so.
- A mipmapped texture the frame reads is copied with its mips (on WebGL2 a 2D one; on WebGPU any but a compressed or depth one): the inspector steps through them, and replay samples through them. One the frame writes and then reads through its mips is sampled as it is now (a pass's picture is one level); a file keeps it whole, as the draw read it. A WebGPU indirect draw's counts are read as its buffer held them when it drew; on WebGL2 there are no indirect draws.
- On WebGL2, a texture a framebuffer can't hold (compressed, WebGL's unsized ALPHA and LUMINANCE, RGB9_E5, SRGB8, RGB16F and RGB32F, the SNORM formats) is copied by drawing it: its picture is what sampling it gives (an sRGB one as sRGB, HDR as floats, an ALPHA one's colour black).
- Stencil isn't pictured, nor kept in files: a file's pass that loads it starts from 0.
- On WebGL2 the numbers compare the reduced pictures: a change too small to show at their size is missed.
- after VS runs the vertices from the least the draw fetched to the greatest (at most 2 million records of vertices × instances; past it, the first instances), and captures as many outputs as transform feedback takes at once (64 components,
gl_Positionfirst; the rest are named). - A WebGPU compute pass's dispatches are shown (their uniforms and inputs) but not pictured.
- A WebGL1 context is captured without pictures or vertex data.
- A
WEBGL_multi_drawcall (three'sBatchedMesh) is captured as one draw and replayed. Its vertex data is each of its draws' in turn (a restart between them), so its shape, geometry pictures and Vertices show what it drew. Each draw's instance count isn't kept apart: all show the most. - A WebGPU command buffer that failed (an error of yours) wasn't run: its draws say their vertex data wasn't read, and its pictures are left out. A compatibility-mode device doesn't picture depth or compressed textures.