Skip to content

Transparency (OIT)

insomni renders transparent geometry with order-independent transparency (OIT) via a per-pixel bounded-K A-buffer. This is on by default — you do not sort your draws, and overlapping translucent shapes blend correctly regardless of submission order. The cost is a storage buffer sized to the canvas, and a hard cap on how many transparent fragments stack at one pixel.

Preview of the Transparency (OIT) demo
Transparency (OIT) Overlapping translucent shapes blend correctly no matter their draw order — just give a fill an alpha below 1.

The single render pass draws opaque geometry first (depth-write on), then has to deal with transparent fragments. Classic alpha blending requires sorting back-to-front per pixel — impossible to do correctly for interpenetrating or arbitrarily-ordered translucent marks on the GPU. The A-buffer sidesteps sorting at draw time: every transparent fragment is captured into a per-pixel list, then a final full-screen pass sorts and composites each pixel’s list.

The demo below renders the same overlapping translucent shapes with OIT on and with it off (the sorted alpha-blend fallback). With OIT off, the composite depends on submission order, so reversing it changes the blend; with OIT on the per-pixel depth sort keeps the result stable.

Preview of the OIT on vs off demo
OIT on vs off Toggling config.oit every few seconds: order-independent vs order-dependent blending of the same overlapping shapes.

A draw command flows through the OIT path (rather than the opaque pass) when its fragments are not fully opaque:

  • Alpha — any instance whose fill/stroke alpha is < 1. The opaque pass discards fragments with a < 0.5, so translucent marks must take the transparent path or they punch holes.
  • Emphasis dimmingsetEmphasis() multiplies tagged instances by mix(1, dimAlpha, t), which is a transparent effect. Marks that participate in emphasis (lane4 key ≥ 1) render through the transparent/OIT path; the opaque pass and text are intentionally never dimmed. Instances tagged with key 0 (the default) never dim.

Glyphs and transparent sprites are not drawn in a separate “always on top” pass. When OIT is on they append into the same per-pixel A-buffer as shapes — text through the glyph OIT emit pipeline and transparent sprites through the sprite OIT emit pipeline, both binding the A-buffer at @group(3) during the build sweep. Each glyph/sprite fragment is stamped with its submission order as depth, so a label pushed between two translucent panels composites between them in the resolve pass — behind the nearer panel, in front of the farther one. There is no text-floats-above-everything hack.

When OIT is off, glyphs and sprites fall back to a depth-stamped painter order: they still carry the order they were submitted with, so opaque geometry occludes them correctly — text no longer floats unconditionally.

Because z-order is submission order within a layer, interleaving text with shapes means pushing them into the same Layer in the desired depth order (the flat-z-band contract — see Layers & groups).

Preview of the Text interleaves demo
Text interleaves MSDF glyphs z-interleave with translucent shapes — a label can sit behind one panel and in front of another.

The A-buffer is two GPU storage buffers plus a small viewport uniform:

  • headsarray<atomic<u32>>, one entry per pixel (HEAD_BYTES = 4 each). Per-pixel insert counter. The build shader does atomicAdd(&heads[p], 1) to reserve this fragment’s slot index for pixel p; fragments whose index lands >= K are dropped.
  • slotsarray<{ rgba: u32, depth: f32 }> (SLOT_BYTES = 8 each), length width * height * K. The slot for pixel p, index i is exactly at p * K + i — a per-pixel partition, never a shared pool. There is no global atomic counter and no freelist; each slot is written exactly once, so slot stores never race. (This per-pixel partitioning is also what makes the A-buffer sound under partial redraw.)

Two render pipelines, built by createOITPipelines, drive it:

  • Build — runs the same vertex stage as the main passes plus the assembled oitBuild fragment. Color writes are masked off (writeMask: 0); depth is tested (less) but not written. So opaque geometry still occludes transparent fragments, but transparent fragments never occlude each other. Each surviving fragment stores its premultiplied RGBA + depth into a reserved slot.
  • Resolve — a full-screen triangle that, per pixel, reads up to K slots, insertion-sorts them back-to-front by depth, and composites them with premultiplied over. The result is blended over the already-rendered opaque image.

Per frame, heads is cleared (clearFrameencoder.clearBuffer); slots need no pre-clear because they are write-once within the build pass and the resolve pass reads only the first min(count, K) of them.

Because the build pass tests depth against the opaque image, opaque geometry occludes transparent fragments behind it while transparent fragments in front still blend over. In the demo below, translucent discs submitted before the solid card pass behind it (occluded); discs submitted after it blend over its face.

Preview of the Opaque occlusion demo
Opaque occlusion An opaque card (alpha = 1) hides translucent discs behind it while letting ones in front blend over it.
import { RESOLVE_MAX_K } from "insomni"; // 16

RESOLVE_MAX_K is the compile-time maximum number of fragments the resolve shader sorts and blends per pixel (WGSL local arrays must be compile-time sized). The runtime K (config.oitFragmentsPerPixel, default 8) may be smaller; the resolve loop clamps to min(count, K, RESOLVE_MAX_K). If more than K transparent fragments overlap a single pixel, the surplus is dropped at build time. For most 2D scenes 8 is ample; raise oitFragmentsPerPixel (up to RESOLVE_MAX_K) only if you see transparency artifacts in dense stacks.

The demo below stacks concentric translucent discs over a single point with oitFragmentsPerPixel: 4. Once more than K = 4 translucent fragments overlap the center, the surplus is dropped at build time, so the center stops getting more saturated even as more discs pile on.

Preview of the Fragment budget (K) demo
Fragment budget (K) Each pixel keeps only K translucent fragments (here K = 4); overlaps past that are dropped.

The slots buffer is the binding-limit gate: width * height * K * SLOT_BYTES. At high resolutions or large K this can exceed the device’s maxStorageBufferBindingSize. Unlike the predecessor, which silently clamped K, ABuffer.resize hard-errors — silently rendering at a smaller K than requested produces wrong transparency with no signal. The message tells you to reduce oitFragmentsPerPixel or render at a lower resolution.

import { ABuffer } from "insomni/internal";
import type { ABufferOptions } from "insomni";
const abuffer = new ABuffer(device, { fragmentsPerPixel: 8 });
abuffer.resize(canvas.width, canvas.height);

| Member | Description | | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | new ABuffer(device, { fragmentsPerPixel }) | Allocate the bind-group layouts + viewport uniform. K = max(1, fragmentsPerPixel), fixed for the buffer’s lifetime. | | K | The per-pixel fragment budget (readonly). | | heads / slots / viewportUniform | The GPU buffers (allocated by resize). | | buildLayout / resolveLayout | Bind-group layouts for the build and resolve passes. | | buildBindGroup / resolveBindGroup | Bind groups, rebuilt on each resize. | | resize(width, height) | (Re)allocate heads + slots for a surface and rebuild bind groups. No-op when unchanged. Throws when slots would exceed the device storage-binding limit. | | clearFrame(encoder) | Zero the heads insert counters at frame start. | | destroy() | Release all GPU buffers. |

| Constant | Value | Meaning | | --------------- | ----- | ------------------------------------------------------------------- | | SLOT_BYTES | 8 | Bytes per slot (rgba: u32 + depth: f32). | | HEAD_BYTES | 4 | Bytes per head entry (atomic<u32>). | | RESOLVE_MAX_K | 16 | Compile-time cap on per-pixel fragments the resolve shader handles. |

import { createOITPipelines, type OITPipelines, type OITPipelineDeps } from "insomni/internal";
const oit = createOITPipelines(device, format, abuffer, shader, {
cameraLayout, // @group(0) — from createPipelines
instanceLayout, // @group(1) — from createPipelines
});

Returns OITPipelines:

| Field | Description | | ----------------- | ---------------------------------------------------------------------------------------------------------- | | buildLayout | Explicit pipeline layout [cameraLayout, instanceLayout, abuffer.buildLayout]. | | buildPipeline | Build pass: assembled vertex + oitBuild fragment, color masked, depth tested not written, single-sample. | | resolvePipeline | Resolve pass: full-screen triangle, premultiplied over-blend, no depth. |

OITPipelineDeps is { cameraLayout, instanceLayout } — the bind-group layouts shared with the main passes so one camera + one instances bind group serve the build pass too.

Two RendererConfig knobs control OIT (see Renderer):

| Knob | Default | Effect | | ---------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------ | | oit | true | Enable the A-buffer transparency path. When false, transparent commands route through the plain sorted alpha-blend pass instead. | | oitFragmentsPerPixel | 8 | K — per-pixel fragment budget. Capped by RESOLVE_MAX_K in the resolve shader and by maxStorageBufferBindingSize at allocation. |

Renderer2D.oitInfo returns the live A-buffer configuration (or null when config.oit is false):

renderer.oitInfo; // { enabled: true, K, maxK, slotsBytes } | null

K is the per-pixel budget, maxK is RESOLVE_MAX_K, and slotsBytes is the current slots allocation (width * height * K * SLOT_BYTES). The insomni-devtools panel surfaces these as an OIT (A-buffer) readout — handy for spotting when K is near the cap or the slots buffer is large at high resolution.

In an app you rarely touch these directly — createRenderer assembles the shader, pipelines, and the A-buffer when config.oit is on:

import { initGPU, createRenderer } from "insomni";
const gpu = await initGPU();
// OIT is default-on; nothing extra to enable.
const renderer = createRenderer(gpu, canvas, {
config: { oitFragmentsPerPixel: 12 }, // raise K for dense translucent stacks
});
// Translucent marks blend correctly with no manual sorting:
const layer = renderer.createLayer?.(); // see Layers & groups for authoring

When you construct a Renderer2D by hand with oit: true, you must inject the A-buffer and pipelines yourself (the renderer compiles no WGSL):

import { ABuffer, createOITPipelines } from "insomni/internal";
const abuffer = new ABuffer(device, { fragmentsPerPixel: config.oitFragmentsPerPixel });
abuffer.resize(canvas.width, canvas.height);
const oit = createOITPipelines(device, format, abuffer, shader, {
cameraLayout: pipelines.cameraLayout,
instanceLayout: pipelines.instanceLayout,
});
// pass { abuffer, pipelines: oit } as the renderer's `oit` option.