Skip to content

Packing & buffers

Every shape in insomni packs into one universal per-instance record. The CPU layout, the GPU buffer layout, and the WGSL accessor are all derived from a single field declaration, so they cannot drift relative to one another.

A single instance is 16 floats / 64 bytes, declared once in INSTANCE_FIELDS:

FieldFloat offsetByte offsetKindPurpose
geom000vec4fGeometry slot 0 (kind-specific)
geom1416vec4fGeometry slot 1 (kind-specific)
colorBits832u32Packed unorm8x4 color
typeFlag936u32Shape-kind discriminant
order1040f32Explicit depth (never index-derived)
lane0lane311–1444–56u32Kind-specific payload
lane41560u32Per-instance emphasis key (shapes)

The exported constants:

ExportValueMeaning
INSTANCE_FIELDSthe array aboveThe single source of truth.
INSTANCE_FLOATS16Floats per instance.
INSTANCE_BYTES64Bytes per instance.

Depth is carried by the explicit order field rather than a draw-count-derived baseZ + zStep * index, which removes the per-draw uniform and decouples depth precision from draw count. The renderer stamps order in global submission order so later-submitted instances draw on top.

Derived layout: INSTANCE_LAYOUT / deriveLayout

Section titled “Derived layout: INSTANCE_LAYOUT / deriveLayout”

deriveLayout() walks INSTANCE_FIELDS once and emits three artefacts that must agree byte-for-byte: the CPU offsets, the TypeGPU tgpuStruct, and a WGSL accessor string. INSTANCE_LAYOUT is the eagerly-derived canonical instance:

import { INSTANCE_LAYOUT } from "insomni/advanced";
INSTANCE_LAYOUT.byteStride; // 64
INSTANCE_LAYOUT.floatStride; // 16
INSTANCE_LAYOUT.offsets.order; // { floatOffset: 10, byteOffset: 40 }
InstanceLayout memberTypeNotes
byteStridenumberINSTANCE_BYTES.
floatStridenumberINSTANCE_FLOATS.
offsetsRecord<string, { floatOffset, byteOffset }>Per-field offsets keyed by name.
tgpuStructTypeGPU structGPU buffer layout.
wgslAccessorstringCurrently an empty string (kinds read inst.* directly); kept for API compatibility.

Each shape kind has a fixed typeFlag discriminant (the TYPE_* exports), and the renderer dispatches one shader branch per kind. ORDERED_KINDS is the canonical list, sorted ascending by typeFlag.

TYPE_*ValueIn ORDERED_KINDS?
TYPE_RECT0yes
TYPE_CIRCLE1yes
TYPE_ELLIPSE2yes
TYPE_SEGMENT3yes
TYPE_CURVE4yes
TYPE_SPRITE5no (textured-quad path)
TYPE_TRIANGLE6yes
TYPE_ARC7yes
TYPE_GLYPH8no (dedicated MSDF pipeline)
TYPE_STROKE_PATH9yes (analytic SDF stroke; opt-in via Layer.pushStroke, round-join only)

While packed instances are a uniform 64 bytes, the legacy/CPU-side per-kind strides used by hot-path producers (e.g. phylo’s tile baker) are exported so a caller sizes its own flat buffers to match the repack helpers below:

Stride constantFloatsLayout
SEGMENT_FLOATS6[x1, y1, x2, y2, colorBits, width]
CURVE_FLOATS12four control points, colorBits, widthPacked, flagsBits, pad
ARC_FLOATS8[cx, cy, radius, theta0, theta1, width, colorBits, _pad]
TRIANGLE_FLOATS12three vertices + color
SHAPE_BYTES32legacy interop shape stride

UberPack is the CPU-side flat instance buffer + draw-command list backing every Layer. All shape kinds share one ArrayBuffer (a Float32Array and a Uint32Array alias).

import { UberPack } from "insomni/internal";
import { rect } from "insomni";
const pack = new UberPack();
pack.clear();
pack.append(rect, { x: 0, y: 0, width: 10, height: 10, fill }, /* opaque */ true);
// Upload pack.buffer[0 .. pack.byteLength) to the GPU, then iterate pack.commands.
pack.tickShrink(); // once per frame end
MemberNotes
append(kind, rec, opaque, group?, emphasisKey?)Pack one instance via kind.pack, stamp lane4, capture its world AABB, merge/push a command.
appendRaw(src, typeFlag, opaque, count, group?, clipRect?)Copy a pre-packed count × INSTANCE_BYTES block verbatim (AABBs are degenerate ⇒ always submitted by cull).
clear()Reset for the next frame; capacity is reused.
tickShrink()Track the low-water mark and shrink after sustained underuse.
bufferThe underlying ArrayBuffer (first byteLength bytes valid).
byteLengthOccupied bytes.
commandsRead-only DrawCommand[].
aabbsParallel per-instance world AABBs (4 floats each), consumed by the cull stage.
versionMonotonic mutation counter (bumped by append and clear); consumers diff it to detect rebuilds.

A DrawCommand is a contiguous span of instances that share a kind, opacity, and group — suitable for one GPU draw call.

FieldTypeNotes
kindnumberThe PrimitiveKind.typeFlag.
opaquebooleanTrue when every instance in the span is fully opaque.
byteOffsetnumberByte start within the pack buffer (multiple of 64).
byteCountnumberByte length (multiple of 64).
clipRectFrameRect | nullOptional per-command CSS-px clip.
groupGroup | nullOptional per-command group.

append merges into the previous command when kind + opacity + group + clip match and the spans are byte-contiguous; otherwise it pushes a new command. A different Group (by reference; null = “no group”) or a different clip is a merge boundary — two shapes in different groups never coalesce, because each group routes to its own camera slot.

UberPack grows on demand via nextByteCapacity and never re-uploads pipelines on growth — only the storage buffer + bind group are replaced.

import { nextByteCapacity } from "insomni/internal";
nextByteCapacity(currentBytes, requiredBytes); // next capacity
  • Below 64 MiB: power-of-2 doubling from the current capacity until ≥ required.
  • At or above 64 MiB: ceil(current × 1.25), clamped up to required.
  • Shrink: after 300 consecutive frames with occupancy under 50% of capacity, reallocate to ceil(peak × 0.5) (never below the live bytes).
  • Throws a RangeError if required exceeds Number.MAX_SAFE_INTEGER.

The renderer’s GPU instance buffer uses the same nextByteCapacity policy, so the CPU pack and the GPU buffer grow in lock-step.

By default a layer is dynamic: its pack is re-concatenated into the shared GPU buffer and re-uploaded every frame. A layer marked renderer.retainLayer(layer, key) instead gets a stable GPU buffer that is re-uploaded only when key changes or the pack’s version advances under the same key — an unchanged retained layer issues zero writeBuffer.

RegionIndex answers “which bytes must be re-uploaded when group G is mutated?” It tracks each Group-identity’s byte span and coalesces dirty ranges for GPUQueue.writeBuffer.

import { RegionIndex } from "insomni/internal";
const regions = new RegionIndex();
regions.reset();
regions.beginRegion(group, pack.byteLength);
// ... pack the group's shapes ...
regions.endRegion(group, pack.byteLength);
regions.markDirty(group);
const ranges = regions.coalesced(); // sorted, non-overlapping DirtyRange[]
regions.clearDirty();
MethodNotes
beginRegion(groupId, byteOffset) / endRegion(groupId, byteOffset)Bracket a group’s span during packing.
reset()Clear span records for a new frame (does not clear dirty ranges).
markDirty(groupId)Mark a group’s span dirty (no-op for an unrecorded/empty span).
coalesced()Minimal, sorted, non-overlapping DirtyRange[] (non-destructive).
clearDirty()Clear accumulated dirty ranges.
spanOf(groupId)Recorded span (testing/debug).

DirtyRange is { byteOffset: number; byteLength: number }.

For hot paths that already hold flat v1-stride buffers, three helpers bulk- translate them into the 16-float instance layout for one appendRaw submission — byte-identical to looping the matching single push:

HelperInput strideProduces
repackSegments(src, count)SEGMENT_FLOATS (6)Float32Array of count × 16 floats
repackCurves(src, count)CURVE_FLOATS (12)Float32Array of count × 16 floats
repackArcs(src, count)ARC_FLOATS (8)Float32Array of count × 16 floats
import { repackSegments } from "insomni/internal";
const instances = repackSegments(v1SegmentBuffer, count);
pack.appendRaw(new Uint8Array(instances.buffer), TYPE_SEGMENT, /* opaque */ true, count);

The layer-level wrappers addSegmentsPacked / addCurvesPacked / addArcsPacked do this for you.