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.
1. Why OIT
Section titled “Why OIT”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.
config.oit every few seconds: order-independent vs order-dependent blending of the same overlapping shapes. When the transparent path engages
Section titled “When the transparent path engages”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 witha < 0.5, so translucent marks must take the transparent path or they punch holes. - Emphasis dimming —
setEmphasis()multiplies tagged instances bymix(1, dimAlpha, t), which is a transparent effect. Marks that participate in emphasis (lane4key≥ 1) render through the transparent/OIT path; the opaque pass and text are intentionally never dimmed. Instances tagged with key0(the default) never dim.
Text and sprites interleave too
Section titled “Text and sprites interleave too”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).
The per-pixel A-buffer model
Section titled “The per-pixel A-buffer model”The A-buffer is two GPU storage buffers plus a small viewport uniform:
heads—array<atomic<u32>>, one entry per pixel (HEAD_BYTES = 4each). Per-pixel insert counter. The build shader doesatomicAdd(&heads[p], 1)to reserve this fragment’s slot index for pixelp; fragments whose index lands>= Kare dropped.slots—array<{ rgba: u32, depth: f32 }>(SLOT_BYTES = 8each), lengthwidth * height * K. The slot for pixelp, indexiis exactly atp * 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
oitBuildfragment. 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
Kslots, insertion-sorts them back-to-front by depth, and composites them with premultipliedover. The result is blended over the already-rendered opaque image.
Per frame, heads is cleared (clearFrame → encoder.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.
alpha = 1) hides translucent discs behind it while letting ones in front blend over it. The resolve cap (RESOLVE_MAX_K)
Section titled “The resolve cap (RESOLVE_MAX_K)”import { RESOLVE_MAX_K } from "insomni"; // 16RESOLVE_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.
K translucent fragments (here K = 4); overlaps past that are dropped. Memory and the hard error
Section titled “Memory and the hard error”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.
API reference
Section titled “API reference”ABuffer
Section titled “ABuffer”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. |
Constants
Section titled “Constants”| 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. |
createOITPipelines
Section titled “createOITPipelines”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.
Configuration
Section titled “Configuration”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. |
Inspecting the A-buffer
Section titled “Inspecting the A-buffer”Renderer2D.oitInfo returns the live A-buffer configuration (or null when
config.oit is false):
renderer.oitInfo; // { enabled: true, K, maxK, slotsBytes } | nullK 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.
Wiring it up
Section titled “Wiring it up”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 authoringWhen 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.See also
Section titled “See also”- Renderer & frame loop — config, the render path,
setEmphasis. - Damage & partial redraw — why per-pixel partitioning keeps OIT sound under scissored frames.
- Packing — how alpha and emphasis keys are written into the instance buffer.