Skip to content

Drawing primitives

Everything you draw with insomni goes into a Layer. A layer exposes a pushXxx / addXxx method per primitive kind; each call packs one record into the layer’s flat instance buffer and appends (or merges into) a draw command. There is no scene graph of retained shape objects — you push records every frame, or build a layer once and retain it.

Preview of the Primitive catalogue demo
Primitive catalogue Every drawable kind once — one shape per cell, from pushRect to pushPolyline.

Each kind below is one of these packed records. Stroke styling — caps, joins, and dashes — is where polylines and curves diverge from the SDF shapes:

Preview of the Strokes — caps, joins & dashes demo
Strokes — caps, joins & dashes How pushPolyline ends, corners, and dashes a stroke.

The renderer draws SDF primitives (rect / circle / ellipse / segment / curve / arc) directly from their packed parameters, so they stay pixel-crisp at any zoom. Polygons and polylines are CPU-tessellated into triangles. Sprites are a textured sibling pass drawn after the main geometry.

Layer.pushRect(shape) / Layer.addRects(shapes, options?). A rounded, optionally rotated rectangle. (x, y) is the top-left corner; width/height are full extents.

| Field | Type | Default | Notes | | ----------------- | ------ | ----------- | ------------------------------------- | | x, y | number | — | Top-left corner. | | width, height | number | — | Full extents. | | fill | Color | transparent | Interior fill. | | stroke | Color | transparent | Border color. | | strokeWidth | number | 0 | Border thickness; straddles the edge. | | cornerRadius | number | 0 | Rounded corners. | | rotation | number | 0 | Radians, about the rect center. |

Layer.pushCircle(shape) / Layer.addCircles(shapes, options?).

| Field | Type | Default | Notes | | ------------- | ------ | ----------- | ----------------- | | cx, cy | number | — | Center. | | radius | number | — | Radius. | | fill | Color | transparent | Interior fill. | | stroke | Color | transparent | Border color. | | strokeWidth | number | 0 | Border thickness. |

Layer.pushEllipse(shape) / Layer.addEllipses(shapes, options?).

| Field | Type | Default | Notes | | ------------- | ------ | ----------- | -------------------------- | | cx, cy | number | — | Center. | | rx, ry | number | — | Radii. | | fill | Color | transparent | Interior fill. | | stroke | Color | transparent | Border color. | | strokeWidth | number | 0 | Border thickness. | | rotation | number | 0 | Radians, about the center. |

Layer.pushSegment(shape) is the dedicated straight-line primitive: a compact record with butt caps and a 1D SDF — the cheapest stroke, preferred for high-count edges (tree branches, edge bundles). Joins between segments are the caller’s responsibility.

| Field | Type | Default | Notes | | ---------- | ------ | ------- | ------------------------------- | | x1, y1 | number | — | Start point. | | x2, y2 | number | — | End point. | | color | Color | — | Stroke color (required). | | width | number | 1 | Thickness in layer-local units. |

Layer.pushLine(shape) is a back-compat alias for pushSegment — a LineShape is just a straight stroked segment, so it collapses onto the segment path. Layer.addLines(shapes, options?) bulk-appends segments.

Layer.pushCurve(shape). A cubic Bézier stroke through four control points, with optional per-end taper. The fragment solves distance-to-curve, so it stays crisp at any zoom.

| Field | Type | Default | Notes | | ------------ | ------- | --------- | ------------------------------------------------------------------------ | | p0p3 | Vec2 | — | Cubic control points (p0 start, p3 end). | | color | Color | — | Stroke color (required). | | width | number | 1 | Uniform thickness; overridden by the taper widths. | | widthStart | number | width | Thickness at p0. | | widthEnd | number | width | Thickness at p3. | | cap | LineCap | "round" | End cap. Round caps are free; butt/square discard past the endpoint. |

Layer.pushArc(shape). A thick-stroked circular arc with butt caps, rendered SDF-based. theta0 / theta1 are absolute angles in radians; the rendered geometry uses the absolute span (direction is irrelevant). For a full ring pass theta1 = theta0 + 2π.

| Field | Type | Default | Notes | | ---------- | ------ | ------- | ------------------------ | | cx, cy | number | — | Center. | | radius | number | — | Arc radius. | | theta0 | number | — | Start angle (radians). | | theta1 | number | — | End angle (radians). | | color | Color | — | Stroke color (required). | | width | number | 1 | Stroke thickness. |

Layer.pushTriangle({ p1, p2, p3, fill }) / Layer.addTriangles(...). A flat-filled triangle — the substrate every polygon and polyline tessellates into. p1/p2/p3 are Vec2; fill is a Color.

Layer.pushPolygon(shape) / Layer.addPolygons(shapes, options?) accept two forms:

  • { points, holes?, fill } — a hole-aware filled polygon. points is the outer ring; holes are inner rings. Rings are flattened and triangulated with earcut on the CPU, then each triangle is appended through the triangle kind.
  • { triangles } — a pre-triangulated polygon: the caller supplies the triangle list directly.
import { createLayer, rgba } from "insomni";
const layer = createLayer();
layer.pushPolygon({
points: [
{ x: 0, y: 0 },
{ x: 100, y: 0 },
{ x: 100, y: 80 },
{ x: 0, y: 80 },
],
holes: [
[
{ x: 30, y: 20 },
{ x: 70, y: 20 },
{ x: 50, y: 60 },
],
],
fill: rgba(0.2, 0.5, 0.9, 1),
});

Layer.pushPolyline(shape) tessellates a stroked polyline on the CPU into triangles. This is the path with full cap / join / dash control.

| Field | Type | Default | Notes | | ------------- | ----------------- | --------- | ------------------------------------- | | points | readonly Vec2[] | — | Vertices. | | color | Color | — | Stroke color (required). | | width | number | 1 | Thickness. | | cap | LineCap | "butt" | "butt" | "round" | "square". | | join | LineJoin | "miter" | "miter" | "bevel" | "round". | | closed | boolean | false | Close the loop. | | miterLimit | number | 4 | Miter falls back to bevel past this. | | dashPattern | readonly number[] | — (solid) | Alternating [dash, gap, …] lengths. | | dashOffset | number | 0 | Offset into the dash pattern. |

LineCap ("butt" | "round" | "square") and LineJoin ("miter" | "bevel" | "round") are the polyline styling unions. The curve kind expresses its cap via the cap field (also a LineCap); the integer constants CURVE_CAP_ROUND (0), CURVE_CAP_BUTT (1), and CURVE_CAP_SQUARE (2) are the packed-buffer encoding of those names, exported for callers that build curve buffers by hand.

Two helpers generate ring point lists you can stroke through pushPolyline — handy for dashed/dotted SDF-shape borders (the SDF rect/circle/ellipse pipeline does not support dashes natively, but a tessellated ring does):

  • polylineEllipseRing({ cx, cy, rx, ry, rotation?, segments? })Vec2[]
  • polylineRectRing({ x, y, width, height, cornerRadius?, cornerSegments? })Vec2[]

Both return an unclosed loop — pass closed: true to the polyline:

import { polylineEllipseRing, rgba } from "insomni";
const ring = polylineEllipseRing({ cx: 50, cy: 50, rx: 40, ry: 25 });
layer.pushPolyline({
points: ring,
color: rgba(0.9, 0.3, 0.3, 1),
width: 2,
closed: true,
dashPattern: [6, 4],
});

A textured quad cannot join the shape instance buffer (it carries an external texture binding), so sprites accumulate in a per-layer SpritePack and draw in a sibling pass after the main geometry, stacked by submission order. Adjacent sprites sharing a texture coalesce into one draw command.

Layer.pushSprite(sprite) / Layer.addSprites(sprites):

| Field | Type | Default | Notes | | ---------- | ---------------------------- | -------------------- | -------------------------------------------------------------------- | | pos | Vec2 | — | Position of the sprite anchor. | | size | Vec2 | — | Size in the layer’s space. | | texture | Texture | TextureRegion | — | Source image or sub-region. | | tint | Color | opaque white | Multiplicative tint. | | rotation | number | 0 | Radians, about the anchor. | | anchor | Vec2 | { x: 0.5, y: 0.5 } | Normalized anchor within the sprite. | | opaque | boolean | false | Route to the opaque pass; set only when the texture is fully opaque. |

The SpritePack (SPRITE_FLOATS = 16, SPRITE_BYTES = 64) plus the SpriteShape and SpriteCommand types are exported for callers who pack sprite buffers directly.

loadTexture(owner, src, options?) loads an image from a URL or any ImageBitmapSource into a caller-owned Texture (premultiplied alpha, matching the renderer’s blend state). owner is the renderer / root. LoadTextureOptions: format (default "rgba8unorm") and label. The returned Texture is never destroyed for you — call texture.destroy() when done.

import { loadTexture } from "insomni";
const tex = await loadTexture(renderer, "/icons.png");
layer.pushSprite({ pos: { x: 0, y: 0 }, size: { x: 32, y: 32 }, texture: tex });

A TextureRegion is a borrowed UV sub-rectangle of a Texture:

  • texture.region(x, y, w, h) — by pixel coordinates.
  • texture.regionUV(u0, v0, u1, v1) — by normalized UV.

Two atlas helpers wrap region lookup:

  • SpriteAtlas — named regions. new SpriteAtlas(texture, { name: { x, y, w, h }, … }), then atlas.region("name"). Region definitions use the SpriteAtlasRegionDef shape.
  • GridAtlas — a uniform grid. new GridAtlas(texture, { cell: [w, h], rows?, cols? }) (GridAtlasOptions), then atlas.frame(index) or atlas.frame(row, col).
import { GridAtlas } from "insomni";
const atlas = new GridAtlas(tex, { cell: [16, 16] });
layer.pushSprite({
pos: { x: 0, y: 0 },
size: { x: 16, y: 16 },
texture: atlas.frame(3),
});

SamplerDescriptor (filter / addressMode / mipmapFilter) is exported for layers that override the default sprite sampler.

For very high counts, Layer exposes pre-packed bulk uploads that skip per-record object allocation: addSegmentsPacked(data, { count, opaque, group? }), addCurvesPacked(...), and addArcsPacked(...). Each consumes a flat buffer at the stride exported as SEGMENT_FLOATS (6), CURVE_FLOATS (12), and ARC_FLOATS (8) floats per record, and is byte-identical to looping the matching single push. See Packing for the buffer layouts.

Several per-shape scalars (size, rotation, stroke width, corner radius) pack into IEEE-754 half-precision to keep the instance record small. The packing helpers are exported for callers who build instance buffers directly:

| Function | Purpose | | -------------------------- | ------------------------------------------------------------- | | packF16(value) | Pack a float to its 16-bit half pattern (ties-to-even). | | unpackF16(bits) | Inverse of packF16. | | pack2xF16(lo, hi) | Pack two floats into one little-endian u32 (pack2x16float). | | packUnorm8x4(r, g, b, a) | Pack four [0,1] components into a unorm8x4 u32. |