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

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

ExportDescription
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_PAD2 — 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" }]);
ExportDescription
FrameSpinemark(regions) accumulates + schedules one flush; dispose() removes the subscriber + cancels pending flush.
FrameSpineOptions{ scheduler?, onFlush }.
DirtyRegionAn 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.

ExportDescription
GpuSceneRootScene root: beginFrame/endFrame, requestPaint(), requestPaintRegion(rect), dispatchPointer(e), dispose().
GpuSceneRootOptions{ scheduler?, fullFrame?, onFrameTiming?, projectOpts, groups?, dataKey? }.
GpuSceneNodeAbstract 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).

ExportDescription
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).
BakePipelineSetThe 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
ExportDescription
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_BYTES8 / 32 — the composite uniform size (dest NDC rect + source UV rect).
CompositePipelineSetThe set returned by createCompositePipeline.
KnobDefaultEffect
partialRedrawtrueHonor regions / damage: "auto" in render(). Read live each frame.
persistenttrueRender into the persistent backbuffer (required for partial redraw to be sound).
msaaBakes4MSAA sample count for bakes (1 or 4).
layerCacheBudgetBytes128 MiBResident memory cap for auto-managed bakes. 0 (or negative) = unlimited. "always" / manual cacheLayer() bakes are pinned.