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(now, dt). Construction
Section titled “Construction”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 },});createRenderer(owner, canvas, options?)
Section titled “createRenderer(owner, canvas, options?)”Returns a fully wired Renderer2D. It runs the assembly the constructor
deliberately skips:
assembleShader(ORDERED_KINDS, ...)— one uber-shader from every primitive kind, intypeFlagorder.createPipelines(root, format, shader)— opaque / transparent / clear-quad pipelines plus shared camera/instance bind-group layouts.- When
config.oitis on (the default): anABuffersized to the canvas pluscreateOITPipelines(...).
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.
new Renderer2D(owner, canvas, options)
Section titled “new Renderer2D(owner, canvas, options)”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). |
Lookup helpers
Section titled “Lookup helpers”rendererForCanvas(canvas)— theRenderer2Dmost recently constructed againstcanvas, orundefined. 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 viaWeakRef, so subscribing does not keep one alive.
The frame: render(layers, opts?)
Section titled “The frame: render(layers, opts?)”render( layers: readonly (Layer | BufferLayer | CustomDrawable)[], opts?: RenderOptions,): voidEach 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. |
Live state mutators
Section titled “Live state mutators”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).
Retained layers
Section titled “Retained layers”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.versionrenderer.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.
Cached RTT bakes
Section titled “Cached RTT bakes”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 & DEFAULT_CONFIG
Section titled “RendererConfig & DEFAULT_CONFIG”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. |
Frame timing
Section titled “Frame timing”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" }]);// ...laterspine.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. |