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.

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

FieldTypeDefaultNotes
configPartial<RendererConfig>DEFAULT_CONFIGDeclarative knobs (see below).
cameraCameraState{ x: 0, y: 0, zoom: 1, rotation: 0 }Initial camera.
backgroundColortransparentInitial clear color.
dprnumber1CSS→device pixel ratio.
formatGPUTextureFormatnavigator.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.

FieldTypeNotes
pipelinesPipelinesRequired. Render pipelines + shared layouts.
oit{ abuffer, pipelines }Required when config.oit is true (a missing bundle throws).
configPartial<RendererConfig>Declarative overrides.
cameraCameraStateInitial camera.
backgroundColorInitial clear color (folds into config.initialBackground).
dprnumberInitial DPR (folds into config.initialDpr).
shaderAssembledShaderOptional; required to use cacheLayer.
formatGPUTextureFormatColor-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 use specialized phases rather than the ordinary layer painter order. Custom drawables select an underlay or overlay phase and preserve relative order within that phase.

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:

OptionTypeEffect
regionsreadonly Bounds2D[]Damage rects (CSS px) for the partial redraw path. Empty/absent ⇒ full frame. See Damage.
fullFramebooleanForce 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.
viewKeystringFold 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):

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

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

FieldTypeDefaultNotes
oitbooleantrueOrder-independent transparency. See Transparency.
oitFragmentsPerPixelnumber8Bounded-K A-buffer depth.
partialRedrawbooleantrueMaster switch for the damage-tracked path.
cullbooleantrueFrustum-cull world-space draws. Read live per frame.
msaaBakes1 | 44MSAA sample count for RTT bakes.
persistentbooleantruePersistent backbuffer (required for partial redraw).
layerCacheBudgetBytesnumber128 * 1024 * 1024Resident bake-texture budget; 0 = unlimited. Read live per frame.
initialBackgroundColortransparentSeeds _background (mutate via setBackground).
initialDprnumber1Seeds _dpr (mutate via setDpr).
colorFormatGPUTextureFormat?undefinedBake/composite format; must match the pipelines + swap chain. undefined ⇒ preferred canvas format.
onFrameTiming(t: FrameTiming) => voidundefinedPer-frame diagnostics callback.

FrameTiming is the record handed to onFrameTiming each frame.

FieldTypeMeaning
totalMsnumberWall-clock ms for the full render call.
uploadMsnumberms in GPU upload (writeBuffer).
encodeMsnumberms encoding + submitting.
drawCallsnumberDraw calls issued this frame.
damageRectsnumberDamage rects processed (0 = full frame).
cpuMs / renderNs / computeNsread-onlyBack-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();
MemberNotes
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.