DirectX Append Consume Buffers for Dynamic Workloads

This article provides an overview of append and consume buffers in DirectX, detailing how these specialized GPU resources operate and why they are critical for dynamic rendering workloads. Append and consume buffers allow Compute Shaders to build and consume data streams on the fly without CPU intervention. By leveraging internal atomic counters, they enable efficient GPU-driven pipelines such as dynamic level-of-detail, particle simulation, and visibility culling, eliminating memory overhead and minimizing CPU-GPU synchronization bottlenecks.

What Are Append and Consume Buffers?

In DirectX (Direct3D 11 and Direct3D 12), append and consume buffers are specialized variants of Structured Buffers bound as Unordered Access Views (UAVs). In High-Level Shading Language (HLSL), they are exposed via the AppendStructuredBuffer<T> and ConsumeStructuredBuffer<T> template types.

Functionally, these buffers behave like concurrent, GPU-side stacks or dynamic collections:

  • AppendStructuredBuffer<T>: Allows shader threads to write items to the end of the buffer using the .Append() intrinsic.
  • ConsumeStructuredBuffer<T>: Allows shader threads to retrieve and remove items from the end of the buffer using the .Consume() intrinsic.

Unlike standard structured buffers that require explicit array indexing managed by thread IDs (SV_DispatchThreadID), append and consume buffers manage indexing automatically using an internal, hardware-accelerated atomic counter.

How the Internal Counter Operates

Every append and consume UAV contains an internal 32-bit counter maintained by the GPU hardware:

  • When a thread invokes .Append(), the hardware atomically increments the counter and assigns the previous counter value as the write offset for the new item.
  • When a thread invokes .Consume(), the hardware atomically decrements the counter and reads the data located at the new offset.

Because thread execution order across warps or wavefronts is non-deterministic, the order of elements pushed to or popped from the buffer is not strictly sequential. It does not guarantee First-In-First-Out (FIFO) or Last-In-First-Out (LIFO) order relative to thread invocation, but it guarantees that every valid write receives a unique, sequential memory slot without race conditions.

Host applications can initialize this counter to a specific value when binding the UAV or read the counter back using DirectX API functions such as CopyStructureCount in Direct3D 11 or CopyBufferRegion combined with indirect arguments in Direct3D 12.

Managing Dynamic Workloads on the GPU

In modern real-time rendering, workload sizes are often variable and unpredictable. For example, a scene containing 100,000 objects might only have 12,410 objects visible after frustum and occlusion culling.

Without append buffers, the GPU would either:

  1. Write visibility flags to a fixed-size buffer, requiring subsequent passes to process all 100,000 entries (most of which do no work).
  2. Read the results back to the CPU to compute the active draw calls, introducing significant latency and pipeline stalls.

Append and consume buffers solve this problem by enabling fully dynamic, GPU-driven execution:

1. Work-Compaction (Culling)

A compute pass performs bounding-box visibility tests against the camera view frustum. If an object is visible, the shader calls Append() with the object's instance ID. The output buffer now contains a tightly packed list of only the visible objects, with no empty slots or inactive entries.

2. Producer-Consumer Pipelines

One compute shader acts as a producer (e.g., simulating a fluid or breaking down geometry via dynamic tessellation) and appends newly generated primitives to an AppendStructuredBuffer. A subsequent compute shader binds the same underlying memory as a ConsumeStructuredBuffer, popping items to perform secondary processing, shading, or collision checks.

3. Indirect Drawing and Dispatching

Through Direct3D’s DrawInstancedIndirect or DispatchIndirect commands, the value stored in the append buffer's counter can be copied directly into an indirect arguments buffer. This allows the GPU to launch the exact number of vertex draws or compute threads required to process the compacted data without round-tripping to the CPU.

HLSL Usage Example

struct Particle
{
    float3 Position;
    float3 Velocity;
    float  LifeTime;
};

// Producer shader: updates active particles
ConsumeStructuredBuffer<Particle> CurrentParticles : register(u0);
AppendStructuredBuffer<Particle>  NextParticles    : register(u1);

[numthreads(64, 1, 1)]
void CSMain(uint3 id : SV_DispatchThreadID)
{
    Particle p = CurrentParticles.Consume();
    
    p.LifeTime -= 0.016f; // Update lifetime
    p.Position += p.Velocity * 0.016f;

    // Keep particle alive only if lifetime remains
    if (p.LifeTime > 0.0f)
    {
        NextParticles.Append(p);
    }
}

Practical Considerations and Limitations

  • Memory Allocation: Append buffers do not resize automatically. Developers must allocate a buffer large enough to accommodate the worst-case scenario. If the append counter exceeds the buffer capacity, writes are discarded or risk out-of-bounds undefined behavior.
  • Atomic Contention: Because every thread writing or reading from the buffer must access the same underlying counter, high levels of thread concurrency can cause memory contention. Modern GPU architectures optimize these atomic increments at the sub-group/wavefront level, but excessive atomic traffic can impact throughput.
  • Underflow and Overflow Safety: Shaders calling .Consume() on an empty buffer produce undefined values. Shaders must ensure that the thread dispatch count matches the exact element count stored in the buffer's counter via indirect execution.