Skip to content

Core renderer overview

insomni is an opinionated 2D WebGPU renderer built on TypeGPU. It is tuned for data-heavy scenes: every primitive is packed into one flat typed array, uploaded in a single batch per frame, and drawn with SDF-style pipelines. The public surface is one barrel import:

import { initGPU, createRenderer, createLayer, rgba } from "insomni";
Preview of the Hello insomni demo
Hello insomni A rounded card and a row of accent dots — a hello-world that traces the full path from GPU device to pixels.

A frame moves through three stages.

  1. Pack (CPU). You author shapes into a Layer. Each pushRect / pushCircle / pushSegment call appends one instance — a fixed 16-float (64-byte) record — into the layer’s UberPack. All shape kinds share one ArrayBuffer; adjacent instances of the same kind, opacity, and group merge into a single DrawCommand.
  2. Upload (GPU). Renderer2D.render(layers) concatenates every layer’s pack into one storage buffer and uploads it once. The buffer grows power-of-2 / 1.25× and is reused frame to frame.
  3. Draw (GPU). The renderer issues the merged draw commands in a single render pass: opaque geometry first (depth write on), then transparent geometry. Depth is the per-instance order field, stamped in global submission order, so painter stacking is explicit — later-submitted draws land on top.
const gpu = await initGPU();
const renderer = createRenderer(gpu, canvas);
const layer = createLayer();
layer.pushRect({ x: 16, y: 16, width: 180, height: 72, fill: rgba(0.06, 0.09, 0.13, 1) });
layer.pushCircle({ cx: 240, cy: 52, radius: 18, fill: rgba(0.2, 0.7, 1, 0.9) });
function frame() {
renderer.render([layer]);
requestAnimationFrame(frame);
}
frame();

There is no separate “background” or “overlay” type — every drawable is a Layer (or a specialized layer the renderer treats like one). A Layer wraps one UberPack, a coordinate space, an optional default Group, and a handful of metadata fields (zIndex, cache, visible, label). Text and sprites ride on the same layer through dedicated sibling pipelines.

render([a, b]) draws a first, then b. Layers are flat z-bands: layer b is entirely above layer a (painter’s order), with no per-shape interleave across layers. To interleave shapes, put them in the same layer (intra- layer overlap resolves via transparency and depth). To override insertion order without reordering the array, set Layer.zIndex.

By default a layer is dynamic: its pack is re-concatenated and re-uploaded every frame. For content that rarely changes, two retention tiers skip that work:

  • Retained buffersrenderer.retainLayer(layer, key) hands the layer’s pack a stable GPU buffer. While key (and pack.version) are unchanged the layer draws with zero re-upload. See Packing and Renderer.
  • Cached RTT bakesrenderer.cacheLayer(layer) (or the cache: "always" / "auto" hint) rasterizes the layer once into a texture and composites the snapshot instead of redrawing it.

Combined with damage-tracked partial redraw, this lets a busy scene re-rasterize only what actually moved.