Damage & partial redraw
A static chart that only re-highlights one point should not re-rasterize the
whole canvas. insomni’s damage-tracked partial redraw repaints only the rects
that changed, leaving everything else untouched in a persistent backbuffer.
This is on by default (config.partialRedraw, config.persistent).
render(layers, { regions }). The persistent-backbuffer + scissor model
Section titled “The persistent-backbuffer + scissor model”WebGPU does not guarantee that the swap-chain texture retains its contents
between frames. So insomni (when config.persistent) renders into a
renderer-owned single-sample color texture — the backbuffer — that does
retain its pixels across frames. That retention is what makes a scissored
loadOp: "load" partial frame correct: untouched pixels survive in the
backbuffer.
Each frame, after the render pass ends, the whole backbuffer is copied onto the swap-chain texture so the browser presents the complete image:
import { blitBackbuffer } from "insomni/internal";
// Recorded after pass.end(), before queue.submit(). Always full-canvas.blitBackbuffer(encoder, ctx.backbuffer, swapChainTexture, ctx.width, ctx.height);The blit is always full-canvas, never per-rect. A partial copy is unsound: the swap chain exposes no buffer-age signal and the physical buffer rotated in may be any of the swap chain’s N buffers, so a partial copy would leave pixels outside the damaged rect cleared on whichever buffer is presented — ghosting/flicker. The partial-redraw win lives on the render side (untouched geometry isn’t re-rasterized), not on the blit side; the blit is a cheap fixed-cost copy.
Choosing what to repaint
Section titled “Choosing what to repaint”render(layers, opts) decides full vs partial by this precedence (see
Renderer):
fullFrame: true > explicit regions > damage: "auto" > default full.
// 1. Caller-supplied regions (CSS px, Bounds2D = { minX, minY, maxX, maxY }).renderer.render(layers, { regions: [changedRect] });
// 2. Let core derive the damage by diffing instance AABBs frame-over-frame.renderer.render(layers, { damage: "auto" });
// 3. Force a whole-canvas repaint.renderer.render(layers, { fullFrame: true });A persistent frame with an unchanged composite view and a non-empty
regions list (or damage: "auto") takes the partial path; otherwise a full
repaint runs. The “view” is a fingerprint over camera, size, dpr, background,
z-order, visibility, emphasis, and the caller’s viewKey (group transforms / data
domain). Any change to it forces a full frame automatically.
damage: "auto"
Section titled “damage: "auto"”With damage: "auto", core retains a per-layer snapshot of the previous frame
(pack version, instance count, a copy of the per-instance world AABBs) and diffs
it against the current pack, projecting the small old ∪ new rects of whatever
moved. Notable promotions to full:
- The first auto frame (or any layer missing a snapshot) →
"auto-damage-seed". - Union damage exceeding 60% of the canvas →
"auto-damage-area". - A layer whose glyph version changed →
"auto-damage-glyphs"(glyph packs carry no per-glyph AABBs to diff).
Projecting changed instances to repaint regions
Section titled “Projecting changed instances to repaint regions”When you know which instances changed (a hover halo, a bring-to-front, an
emphasis dim/undim), project their AABBs into a CSS-px region and pass it as
regions.
The renderer convenience entry:
// Project layer instances at `indices` to one padded CSS-px Bounds2D, or null.const region = renderer.regionsFromLayerAabbs(layer, [hoveredIndex]);if (region) renderer.render(layers, { regions: [region] });The pure helpers behind it (for callers that already hold a layer-space view-projection):
| Export | Description |
| ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| projectAabbToBounds(minX, minY, maxX, maxY, viewProjection, cssW, cssH, pad?) | Project one world AABB through a layer-space→NDC matrix into a padded CSS-px Bounds2D. All four corners are transformed, so the result is correct under camera rotation. |
| unionRegionFromAabbs(aabbs, indices, viewProjection, cssW, cssH, pad?) | Union the projected regions of aabbs[i*4..i*4+3] for each index into one padded Bounds2D, or null if none contribute. Degenerate zero AABBs (the cull sentinel) are skipped. |
| DEFAULT_REGION_PAD | 2 — CSS-px padding added on every side, covering SDF anti-alias + a thin halo. |
For several far-apart clusters, call unionRegionFromAabbs once per cluster to
get disjoint regions; a single call always returns one enclosing rect.
The frame spine
Section titled “The frame spine”The frame loop is driven by a FrameSpine: it wires a dirty-region channel to a
scheduler, coalescing many mark() calls before a flush into one callback (via
rect union) and suppressing empty flushes.
import { FrameSpine, createTestSpine, type DirtyRegion } from "insomni/internal";
const spine = new FrameSpine({ onFlush: (regions: DirtyRegion) => renderFrame(regions), // scheduler defaults to a RAF scheduler in production});spine.mark([{ rect: { x: 0, y: 0, w: 10, h: 10 }, kind: "paint" }]);| Export | Description |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| FrameSpine | mark(regions) accumulates + schedules one flush; dispose() removes the subscriber + cancels pending flush. |
| FrameSpineOptions | { scheduler?, onFlush }. |
| DirtyRegion | An array of { rect: { x, y, w, h }, kind } entries. |
| createTestSpine(onFlush) | Returns { spine, pump } backed by a manual scheduler for deterministic tests; call pump() to drain synchronously. |
The GPU scene root
Section titled “The GPU scene root”GpuSceneRoot is a full scene-graph wrapper that owns a FrameSpine, a
recursive damage cull, and a PointerRouter, mapping each damage rect 1:1 to a
GPU scissor region. Nodes extend GpuSceneNode and emit geometry in drawSelf.
import { GpuSceneRoot, GpuSceneNode, rectToBounds, type PaintCtx } from "insomni/internal";
class DotNode extends GpuSceneNode { drawSelf(ctx: PaintCtx): void { // push shapes into ctx.layer; read ctx.projectOpts for screen-pixel math }}
const root = new GpuSceneRoot(renderer, layer, { projectOpts: () => ({ camera, group: null, dpr, viewportWidth, viewportHeight }), groups: () => activeGroups, // folded into viewKey; a group move forces full});node.invalidatePaint(); // schedules a coalesced partial frameThe recursive cull prunes any subtree whose AABB misses every damage rect — a leaf deep in a large container repaints only the nodes whose bounds actually touch a damage rect, so cost scales with damaged area, not scene size.
| Export | Description |
| --------------------- | --------------------------------------------------------------------------------------------------------------------- |
| GpuSceneRoot | Scene root: beginFrame/endFrame, requestPaint(), requestPaintRegion(rect), dispatchPointer(e), dispose(). |
| GpuSceneRootOptions | { scheduler?, fullFrame?, onFrameTiming?, projectOpts, groups?, dataKey? }. |
| GpuSceneNode | Abstract node: drawSelf(ctx), invalidatePaint/Layout/Data(rect?), projectBounds(ctx, space?). |
| PaintCtx | { layer, projectOpts } threaded down the paint walk (no per-node re-walk). |
| rectToBounds(r) | Convert a spatial Rect ({x,y,w,h}, CSS px) to the renderer’s Bounds2D. |
Bakes: caching static and z-banded content
Section titled “Bakes: caching static and z-banded content”A layer whose geometry doesn’t change every frame can be baked to an
offscreen texture and composited instead of re-rasterized. cacheLayer(layer)
on the renderer does this; the cache policy is driven by a layer’s cache
CacheHint ("auto" | "always" | "never") and the
config.layerCacheBudgetBytes budget.
The bake is z-aware: it lands under all live geometry when its z-band sits below the first live layer, over when it sits above the last, and is demoted back to a live draw for any frame in which it is sandwiched between two live layers.
Building a bake
Section titled “Building a bake”A bake runs at config.msaaBakes× MSAA and resolves into a canvas-sized
single-sample texture. Crucially, it draws the layer’s MSDF glyphs inside the
same bake pass, so a cached layer’s text is part of the snapshot (no live-text
desync during pan/zoom).
| Export | Description |
| ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | --- |
| createBakeTexture(device, format, width, height) | Allocate the single-sample resolve target (also TEXTURE_BINDING so it can be sampled back). Caller destroys it. |
| createBakePipelines(device, format, shader, sampleCount) | MSAA shape (opaque + transparent) pipelines + an MSDF glyph pipeline + shared camera/instance layouts. sampleCount = config.msaaBakes (1 | 4). |
| runBakePass(device, config, input, pipelines, opts) | Single-submit RTT bake: shapes (opaque reverse, transparent forward) then glyphs, into one pass. Creates and destroys all scratch per call. |
| bakeViewProjection(space, ctx, camera, dpr, cacheSize?) | Bake-time view-projection. world freezes the camera at bake time; ui / device bake in their own coordinate space. |
| packCamera(vp, out, viewportDevW?, viewportDevH?, dpr?) | Write a Mat3 view-projection (+ viewport/dpr tail) into the 10-float camera uniform. |
| BakeInput | { shapes: UberPack, glyphs?, atlas? }. |
| BakeOptions | { target, width, height, viewProjection, clearColor, glyphSampler?, clip? }. |
| BakeAtlas | { texture: { view }, pxRange, atlasSize } — a GlyphAtlas satisfies it. |
| BakeSizeSource | { width, height } (device px). |
| BakePipelineSet | The pipelines returned by createBakePipelines. |
Compositing a bake
Section titled “Compositing a bake”The bake texture is drawn back with the composite pipeline — a textured quad that samples the (premultiplied) bake and blends it premultiplied src-over.
import { createCompositePipeline, packFullScreenComposite } from "insomni/internal";
const composite = createCompositePipeline(device, format);const uniform = new Float32Array(8); // COMPOSITE_UNIFORM_FLOATSpackFullScreenComposite(uniform); // dest = whole NDC viewport, full UV| Export | Description |
| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| createCompositePipeline(device, format) | Returns { pipeline, pipelineInPass, layout }. pipeline is the post-main composite (no depth, bakes that stack above live geometry); pipelineInPass runs inside the main pass with inert depth (bakes that land under live geometry). |
| packFullScreenComposite(out) | Fill the uniform for a full-screen bake: dest = whole NDC viewport, UVs flipped so the texture reads upright. |
| COMPOSITE_UNIFORM_FLOATS / COMPOSITE_UNIFORM_BYTES | 8 / 32 — the composite uniform size (dest NDC rect + source UV rect). |
| CompositePipelineSet | The set returned by createCompositePipeline. |
Configuration
Section titled “Configuration”| Knob | Default | Effect |
| ----------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------- |
| partialRedraw | true | Honor regions / damage: "auto" in render(). Read live each frame. |
| persistent | true | Render into the persistent backbuffer (required for partial redraw to be sound). |
| msaaBakes | 4 | MSAA sample count for bakes (1 or 4). |
| layerCacheBudgetBytes | 128 MiB | Resident memory cap for auto-managed bakes. 0 (or negative) = unlimited. "always" / manual cacheLayer() bakes are pinned. |
See also
Section titled “See also”- Renderer & frame loop —
render()options,cacheLayer,setEmphasis. - Layers & groups — the
cacheCacheHintper layer. - Spaces & cameras — the
world/ui/deviceprojections the bake freezes. - Transparency (OIT) — why per-pixel partitioning stays sound under scissored frames.
- Debugging & profiling — inspect full vs partial frames and the cache state per layer.