Skip to content

Renderer & frame loop

Renderer2D is the serial encode spine: it owns the camera uniform, the single instance storage buffer, and the per-frame encode. One call — render(layers) — draws a whole frame.

Preview of the Frame loop demo
Frame loop A breathing circle and a turning square driven entirely by frame(now, dt).

The renderer constructor takes pre-built GPU pipelines so it compiles no WGSL itself and stays unit-testable. In an app you almost always use the batteries-included factory instead, which assembles the shader, pipelines, and (when OIT is on) the A-buffer for you.

import { initGPU, createRenderer, rgba } from "insomni";
const gpu = await initGPU();
const renderer = createRenderer(gpu, canvas, {
background: rgba(0.02, 0.04, 0.07, 1),
camera: { x: 0, y: 0, zoom: 1 },
});

Returns a fully wired Renderer2D. It runs the assembly the constructor deliberately skips:

  1. assembleShader(ORDERED_KINDS, ...) — one uber-shader from every primitive kind, in typeFlag order.
  2. createPipelines(root, format, shader) — opaque / transparent / clear-quad pipelines plus shared camera/instance bind-group layouts.
  3. When config.oit is on (the default): an ABuffer sized to the canvas plus createOITPipelines(...).

RendererOptions (every field optional):

| Field | Type | Default | Notes | | ------------ | ------------------------- | ------------------------------------------ | ------------------------------ | | config | Partial<RendererConfig> | DEFAULT_CONFIG | Declarative knobs (see below). | | camera | CameraState | { x: 0, y: 0, zoom: 1, rotation: 0 } | Initial camera. | | background | Color | transparent | Initial clear color. | | dpr | number | 1 | CSS→device pixel ratio. | | format | GPUTextureFormat | navigator.gpu.getPreferredCanvasFormat() | Color-target format. |

createTestRenderer(owner, canvas, pipelines, config?)"insomni/internal"

Section titled “createTestRenderer(owner, canvas, pipelines, config?) — "insomni/internal"”

A thin wrapper over new Renderer2D(owner, canvas, { pipelines, config }) exported from "insomni/internal". It is a test helper for callers that supply their own Pipelines without a real GPU. Application code should use createRenderer.

The DI constructor. RendererConstructorOptions requires pipelines; oit is required whenever config.oit is true. Use it only when sharing pipelines across renderers or supplying your own A-buffer.

| Field | Type | Notes | | ------------ | ------------------------- | ----------------------------------------------------------------- | | pipelines | Pipelines | Required. Render pipelines + shared layouts. | | oit | { abuffer, pipelines } | Required when config.oit is true (a missing bundle throws). | | config | Partial<RendererConfig> | Declarative overrides. | | camera | CameraState | Initial camera. | | background | Color | Initial clear color (folds into config.initialBackground). | | dpr | number | Initial DPR (folds into config.initialDpr). | | shader | AssembledShader | Optional; required to use cacheLayer. | | format | GPUTextureFormat | Color-target format (folds into config.colorFormat). |

  • rendererForCanvas(canvas) — the Renderer2D most recently constructed against canvas, or undefined. Debug/tooling lookup only.
  • onRendererCreated(cb) — subscribe to renderer construction. The callback fires immediately for every live renderer, then for each future one. Returns an unsubscribe function. Renderers are tracked via WeakRef, so subscribing does not keep one alive.
render(
layers: readonly (Layer | BufferLayer | CustomDrawable)[],
opts?: RenderOptions,
): void

Each layer’s pack is uploaded in submission order into one storage buffer, and the draw commands run in a single render pass: opaque first, then transparent, with the explicit per-instance order as the sole depth source. BufferLayers and CustomDrawables are split out of the hot path and drawn as distinct post-main draws, stacking above the dynamic geometry by submission order.

The renderer reads each layer’s space to pick its view-projection and reads layer.pack for the geometry. Layers are sorted into flat z-bands, invisible layers are filtered out, and each layer’s cache hint is applied before the frame is drawn.

RenderOptions:

| Option | Type | Effect | | ----------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | regions | readonly Bounds2D[] | Damage rects (CSS px) for the partial redraw path. Empty/absent ⇒ full frame. See Damage. | | fullFrame | boolean | Force a full repaint even if regions are supplied. | | damage | "auto" | Let core derive the damage rects from a per-layer snapshot diff instead of passing regions. | | viewKey | string | Fold caller-side group transforms / data domain into the composite fingerprint so any change forces a full frame automatically. | | debug | { reason?, data? } | Caller annotation surfaced in the debug probe; no effect on rendering. |

The renderer seeds its live state from config once at construction, then owns it behind setters (config is never re-read at runtime):

| Method | Notes | | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | | setCamera(state: CameraState) | Replace the camera; missing fields default to { x: 0, y: 0, zoom: 1, rotation: 0 }. | | setBackground(color: Color) | Live clear color. | | setDpr(dpr: number) | CSS→device pixel ratio for scissor mapping. | | setEmphasis({ focusedKey?, dimAlpha?, t? }) | Global emphasis dim. Writes one 16-byte uniform — no repack. | | resize(w, h) | Resize the canvas/backbuffer; forces a full repaint next frame. | | forceFullFrame() | Force the next render() to a full repaint regardless of regions. | | Renderer2D.detectDpr() | static; returns window.devicePixelRatio (the renderer never reads it itself — pass the result to setDpr). |

Read-only getters: width, height, canvas, and debug (the lazily-built render-debug probe).

A retained layer keeps a stable GPU buffer; an unchanged retained layer issues zero re-upload and still draws.

renderer.retainLayer(layer, "static-axes");
// ... later, only if its content changed:
layer.clear();
rebuildAxes(layer); // bumps layer.pack.version
renderer.retainLayer(layer, "static-axes"); // re-uploads once
renderer.unretainLayer(layer); // evict the stable buffer

| Method | Signature | Notes | | --------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | retainLayer | (layer, key: string, opts?: { spatialIndex?: boolean }) | Re-upload happens only when key changes or pack.version advances under the same key. Pass { spatialIndex: true } to build a cell-binned grid for world-space overzoom culling. | | unretainLayer | (layer) | Evict the stable buffer (required to avoid a GPU leak). |

Retained content stacks above the dynamic geometry by submission order; its internal painter order is frozen at build time, so it never desyncs as the live set grows or shrinks.

cacheLayer rasterizes a layer’s shapes + glyphs into a single-sample texture and composites the snapshot on later frames instead of redrawing it.

| Method | Signature | Notes | | -------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------- | | cacheLayer | (layer, managed = false) | Requires the renderer to hold an assembled shader (use createRenderer, which supplies it). Throws otherwise. | | uncacheLayer | (layer) | Drop the bake; the layer rasterizes live again. |

A bake is a translucent image, so it can only composite under all live geometry (a leading z-run) or over it (a trailing z-run) — a bake sandwiched between two live layers is demoted to live for that frame. Prefer the declarative cache hint over manual calls.

RendererConfig is the single declarative source of truth. The constructor spread-merges Partial<RendererConfig> over DEFAULT_CONFIG, reads it once to seed state, then never re-reads it (except for the few “live per frame” knobs noted below).

| Field | Type | Default | Notes | | ----------------------- | -------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------- | | oit | boolean | true | Order-independent transparency. See Transparency. | | oitFragmentsPerPixel | number | 8 | Bounded-K A-buffer depth. | | partialRedraw | boolean | true | Master switch for the damage-tracked path. | | cull | boolean | true | Frustum-cull world-space draws. Read live per frame. | | msaaBakes | 1 \| 4 | 4 | MSAA sample count for RTT bakes. | | persistent | boolean | true | Persistent backbuffer (required for partial redraw). | | layerCacheBudgetBytes | number | 128 * 1024 * 1024 | Resident bake-texture budget; 0 = unlimited. Read live per frame. | | initialBackground | Color | transparent | Seeds _background (mutate via setBackground). | | initialDpr | number | 1 | Seeds _dpr (mutate via setDpr). | | colorFormat | GPUTextureFormat? | undefined | Bake/composite format; must match the pipelines + swap chain. undefined ⇒ preferred canvas format. | | onFrameTiming | (t: FrameTiming) => void | undefined | Per-frame diagnostics callback. |

FrameTiming is the record handed to onFrameTiming each frame.

| Field | Type | Meaning | | ---------------------------------- | --------- | ----------------------------------------------------------------------------------- | | totalMs | number | Wall-clock ms for the full render call. | | uploadMs | number | ms in GPU upload (writeBuffer). | | encodeMs | number | ms encoding + submitting. | | drawCalls | number | Draw calls issued this frame. | | damageRects | number | Damage rects processed (0 = full frame). | | cpuMs / renderNs / computeNs | read-only | Back-compat aliases derived from the canonical fields (computeNs is always 0n). |

const renderer = createRenderer(gpu, canvas, {
config: {
onFrameTiming(t) {
console.log(`${t.drawCalls} draws in ${t.totalMs.toFixed(2)}ms`);
},
},
});

FrameSpine — the dirty-region frame loop

Section titled “FrameSpine — the dirty-region frame loop”

FrameSpine is a separate frame-loop primitive that coalesces damage marks and schedules a single flush. It does not call render() itself — you wire its onFlush to your draw call. It is what a scene root uses to turn many mark() calls into one batched repaint.

import { FrameSpine } from "insomni/internal";
const spine = new FrameSpine({
onFlush(regions) {
renderer.render(layers, { regions: regions.map((r) => r.rect) });
},
});
spine.mark([{ rect: { x: 0, y: 0, w: 100, h: 40 }, kind: "paint" }]);
// ...later
spine.dispose();

| Member | Notes | | ----------------------------------------- | --------------------------------------------------------------------- | | new FrameSpine({ scheduler?, onFlush }) | scheduler defaults to a RAF scheduler; pass a manual one for tests. | | mark(regions: DirtyRegion) | Accumulate damage; empty array is a no-op. | | dispose() | Unsubscribe + cancel any pending flush. | | createTestSpine(onFlush) | Factory returning { spine, pump } backed by a manual scheduler. |