Per-Pixel Linked Lists for OIT in GLSL

Order-independent transparency (OIT) using per-pixel linked lists solves the long-standing graphical challenge of rendering overlapping transparent surfaces without presorting geometry on the CPU. By leveraging OpenGL Shading Language (GLSL) atomic operations and Shader Storage Buffer Objects (SSBOs), this technique captures every transparent fragment generated during rasterization, stores its color and depth into a dynamically allocated per-pixel linked list, and sorts these fragments in a subsequent pass. This article explains the data structures, the list creation pass, and the final resolution pass required to implement per-pixel linked list OIT.

Required Data Structures and Buffers

Implementing per-pixel linked lists requires three primary GPU memory resources:

  1. Head Pointer Texture (or Buffer): A 2D single-channel integer texture (R32UI) matching the screen resolution. Each texel represents a pixel on the screen and stores the index of the first node (head) in that pixel's linked list. It is initialized to an invalid index (such as 0xFFFFFFFF) at the beginning of each frame.
  2. Atomic Counter Buffer: An atomic counter or atomic unsigned integer used as a global memory allocator. It tracks the next available index in the node storage buffer.
  3. Node Storage Buffer (SSBO): A large linear buffer of structs. Each struct represents a fragment node containing:
    • vec4 color: The fragment's color and alpha.
    • float depth: The fragment's depth value.
    • uint next: The index of the next node in the linked list.
struct Node {
    vec4 color;
    float depth;
    uint next;
};

layout(std430, binding = 0) buffer NodeBuffer {
    Node nodes[];
};

layout(binding = 0, r32ui) uniform uimage2D headPointerImage;
layout(binding = 1) uniform atomic_uint nodeCounter;

Pass 1: Building the Linked Lists

During the geometry pass, all transparent objects are drawn with depth writes disabled, while depth testing remains active against opaque geometry. The fragment shader captures incoming fragments without writing directly to the framebuffer.

  1. Allocate a Node: The fragment shader increments the global atomic counter using atomicCounterIncrement(nodeCounter). This returns a unique index inside the SSBO. A bounds check ensures the index does not exceed the maximum capacity of the node buffer.
  2. Link the Fragment: The shader inserts the new node at the beginning of the pixel's list using imageAtomicExchange. This atomic exchange replaces the current head pointer for coordinate ivec2(gl_FragCoord.xy) with the newly allocated node index and returns the old head pointer.
  3. Store Fragment Data: The returned old head pointer becomes the next pointer of the new node. The shader then writes the fragment’s color, depth (gl_FragCoord.z), and next index into the node buffer at the allocated index.
uint nodeIndex = atomicCounterIncrement(nodeCounter);

if (nodeIndex < MAX_NODES) {
    uint prevHead = imageAtomicExchange(headPointerImage, ivec2(gl_FragCoord.xy), nodeIndex);
    
    nodes[nodeIndex].color = fragmentColor;
    nodes[nodeIndex].depth = gl_FragCoord.z;
    nodes[nodeIndex].next = prevHead;
}

Memory Synchronization

Between the building pass and the resolution pass, an execution and memory barrier must be dispatched on the host application side. In OpenGL, calling glMemoryBarrier(GL_SHADER_STORAGE_BARRIER_BIT | GL_SHADER_IMAGE_ACCESS_BARRIER_BIT) guarantees that all writes to the atomic counter, the head pointer image, and the SSBO are completed and visible to subsequent shader invocations.

Pass 2: Sorting and Blending Fragments

The final phase runs a full-screen pass—via a full-screen triangle or a compute shader—to resolve the lists into the final color buffer:

  1. Traverse the List: For each pixel, the shader fetches the head index from the head pointer texture. It iteratively follows the next pointers through the node buffer, loading each node's color and depth into a local, fixed-size temporary array.
  2. Sort by Depth: Once the nodes for the current pixel are collected, they are sorted by their depth values. Simple in-register sorting algorithms, such as insertion sort, are ideal because the number of overlapping transparent fragments per pixel is typically low.
  3. Composite: The sorted fragments are blended in order (either back-to-front using standard over-blending, or front-to-back using premultiplied alpha) over the existing opaque scene color. The final blended color is written to the screen output.

Performance and Memory Considerations

Per-pixel linked list OIT guarantees pixel-perfect transparency regardless of geometric complexity or intersecting primitives. However, memory consumption scales with screen resolution and fragment density; if transparent overdraw is high, the node buffer can run out of space, resulting in dropped fragments. Developers typically cap the maximum nodes per pixel during traversal to limit register pressure and prevent divergence across warp threads during the sorting stage.