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.
The instance schema
Section titled “The instance schema”A single instance is 16 floats / 64 bytes, declared once in INSTANCE_FIELDS:
| Field | Float offset | Byte offset | Kind | Purpose |
|---|---|---|---|---|
geom0 | 0 | 0 | vec4f | Geometry slot 0 (kind-specific) |
geom1 | 4 | 16 | vec4f | Geometry slot 1 (kind-specific) |
colorBits | 8 | 32 | u32 | Packed unorm8x4 color |
typeFlag | 9 | 36 | u32 | Shape-kind discriminant |
order | 10 | 40 | f32 | Explicit depth (never index-derived) |
lane0–lane3 | 11–14 | 44–56 | u32 | Kind-specific payload |
lane4 | 15 | 60 | u32 | Per-instance emphasis key (shapes) |
The exported constants:
| Export | Value | Meaning |
|---|---|---|
INSTANCE_FIELDS | the array above | The single source of truth. |
INSTANCE_FLOATS | 16 | Floats per instance. |
INSTANCE_BYTES | 64 | Bytes 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; // 64INSTANCE_LAYOUT.floatStride; // 16INSTANCE_LAYOUT.offsets.order; // { floatOffset: 10, byteOffset: 40 }InstanceLayout member | Type | Notes |
|---|---|---|
byteStride | number | INSTANCE_BYTES. |
floatStride | number | INSTANCE_FLOATS. |
offsets | Record<string, { floatOffset, byteOffset }> | Per-field offsets keyed by name. |
tgpuStruct | TypeGPU struct | GPU buffer layout. |
wgslAccessor | string | Currently an empty string (kinds read inst.* directly); kept for API compatibility. |
Primitive kinds & strides
Section titled “Primitive kinds & strides”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_* | Value | In ORDERED_KINDS? |
|---|---|---|
TYPE_RECT | 0 | yes |
TYPE_CIRCLE | 1 | yes |
TYPE_ELLIPSE | 2 | yes |
TYPE_SEGMENT | 3 | yes |
TYPE_CURVE | 4 | yes |
TYPE_SPRITE | 5 | no (textured-quad path) |
TYPE_TRIANGLE | 6 | yes |
TYPE_ARC | 7 | yes |
TYPE_GLYPH | 8 | no (dedicated MSDF pipeline) |
TYPE_STROKE_PATH | 9 | yes (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 constant | Floats | Layout |
|---|---|---|
SEGMENT_FLOATS | 6 | [x1, y1, x2, y2, colorBits, width] |
CURVE_FLOATS | 12 | four control points, colorBits, widthPacked, flagsBits, pad |
ARC_FLOATS | 8 | [cx, cy, radius, theta0, theta1, width, colorBits, _pad] |
TRIANGLE_FLOATS | 12 | three vertices + color |
SHAPE_BYTES | 32 | legacy interop shape stride |
UberPack
Section titled “UberPack”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| Member | Notes |
|---|---|
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. |
buffer | The underlying ArrayBuffer (first byteLength bytes valid). |
byteLength | Occupied bytes. |
commands | Read-only DrawCommand[]. |
aabbs | Parallel per-instance world AABBs (4 floats each), consumed by the cull stage. |
version | Monotonic mutation counter (bumped by append and clear); consumers diff it to detect rebuilds. |
DrawCommand & command merging
Section titled “DrawCommand & command merging”A DrawCommand is a contiguous span of instances that share a kind, opacity, and
group — suitable for one GPU draw call.
| Field | Type | Notes |
|---|---|---|
kind | number | The PrimitiveKind.typeFlag. |
opaque | boolean | True when every instance in the span is fully opaque. |
byteOffset | number | Byte start within the pack buffer (multiple of 64). |
byteCount | number | Byte length (multiple of 64). |
clipRect | FrameRect | null | Optional per-command CSS-px clip. |
group | Group | null | Optional 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.
Buffer growth
Section titled “Buffer growth”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
RangeErrorifrequiredexceedsNumber.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.
Retained and dynamic buffers
Section titled “Retained and dynamic buffers”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 & dirty ranges
Section titled “RegionIndex & dirty ranges”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();| Method | Notes |
|---|---|
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 }.
v1-to-v3 repack helpers
Section titled “v1-to-v3 repack helpers”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:
| Helper | Input stride | Produces |
|---|---|---|
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.