Skip to content

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).

Preview of the Partial redraw demo
Partial redraw Hover and click a grid where only the touched cells repaint via render(layers, { regions }).

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.

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.

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 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. |

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 frame

The 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.

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. |

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_FLOATS
packFullScreenComposite(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. |

| 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. |