Unpacking Meshlet Indices Using GLSL Shared Memory

Modern graphics pipelines utilize GLSL mesh shaders (GL_EXT_mesh_shader) to replace the fixed-function primitive assembler with a compute-like programming model. To maximize memory bandwidth and cache efficiency, meshlet data—specifically vertex and triangle index buffers—is heavily compressed into packed byte or bit streams rather than stored as raw 16-bit or 32-bit integers. Mesh shaders leverage workgroup shared memory as a fast, on-chip staging area, allowing invocation threads to cooperatively load, unpack, and route these compressed index streams to output primitives with minimal latency.

The Need for Meshlet Index Compression

A standard meshlet typically bounds geometry to 64 vertices and 128 triangles. Within this localized scope, primitive connectivity only requires references to local vertex indices (0 to 63) rather than global vertex buffer IDs.

Because a 6-bit or 8-bit value can represent any local vertex within this boundary, storing indices as standard 32-bit integers wastes significant memory bandwidth. Compilers and asset pipelines typically pack three 8-bit vertex indices into a single 32-bit word, or use advanced variable-bit packing (e.g., 5-bit or 6-bit fields). The GPU must decompress these compact streams into the target topology format before emitting primitives.

The Role of Workgroup Shared Memory

Accessing global VRAM individually per thread to read sub-byte values creates memory divergence and unaligned reads. Workgroup shared memory (shared in GLSL) resolves this by acting as an ultra-fast L1-adjacent scratchpad:

  1. Cooperative Coalesced Loads: Threads in the workgroup execute aligned 32-bit or 128-bit loads from global storage buffers (SSBOs) containing the compressed bitstream, populating the shared array in parallel.
  2. Scatter/Gather Decoupling: Shared memory allows the loading phase (aligned to memory boundaries) to be decoupled from the unpacking and primitive generation phase (aligned to triangle topology).
  3. Bandwidth Preservation: Reading compressed packets once into shared memory ensures that multi-pass operations or cross-thread data dependencies do not incur repeated VRAM fetches.

Cooperative Unpacking Workflow

The unpacking process in a GLSL mesh shader follows a structured execution model:

1. Collaborative Buffering

The workgroup sets up a shared memory array sized to the meshlet's maximum index requirement. Threads collectively fetch the packed chunks from the SSBO:

shared uint sharedPackedIndices[32]; // Holds 128 8-bit indices packed into 32-bit words

void loadPackedData(uint threadId) {
    if (threadId < 32) {
        sharedPackedIndices[threadId] = meshletBuffer.data[meshletOffset + threadId];
    }
    barrier();
    memoryBarrierShared();
}

2. Bitwise Extraction

Once the shared memory is populated and synchronized via barrier(), threads extract local indices. For a standard 8-bit packed stream (three indices per triangle plus one byte of padding, or tightly packed triplets across 32-bit boundaries), bit-shift and bit-mask operations isolate each index:

uvec3 unpackTriangleIndices(uint triangleIndex) {
    // Example: 3 contiguous bytes per triangle
    uint byteOffset = triangleIndex * 3;
    uint wordIndex = byteOffset / 4;
    uint byteInWord = byteOffset % 4;

    uint packedWord = sharedPackedIndices[wordIndex];
    
    // Handle indices that cross 32-bit word boundaries
    uint nextWord = (byteInWord > 1) ? sharedPackedIndices[wordIndex + 1] : 0;
    uint64_t combined = (uint64_t(nextWord) << 32) | packedWord;
    
    uint i0 = uint((combined >> (byteInWord * 8)) & 0xFF);
    uint i1 = uint((combined >> ((byteInWord + 1) * 8)) & 0xFF);
    uint i2 = uint((combined >> ((byteInWord + 2) * 8)) & 0xFF);

    return uvec3(i0, i1, i2);
}

For variable bit-length streams (such as 5-bit indices), GLSL intrinsic functions like bitfieldExtract provide hardware-accelerated extraction directly from the values staged in shared memory.

3. Assigning Output Topology

Finally, each thread assigns its unpacked indices to the built-in primitive index output array, gl_PrimitiveTriangleIndicesEXT[]:

layout(local_size_x = 32) in;
layout(triangles, max_vertices = 64, max_primitives = 128) out;

void main() {
    uint tid = gl_LocalInvocationIndex;
    
    // Allocate outputs for the meshlet
    SetMeshOutputsEXT(vertexCount, triangleCount);

    // Step 1: Load to shared memory
    loadPackedData(tid);

    // Step 2 & 3: Unpack and emit
    for (uint i = tid; i < triangleCount; i += gl_WorkGroupSize.x) {
        gl_PrimitiveTriangleIndicesEXT[i] = unpackTriangleIndices(i);
    }
}

By decoupling memory loads from topology assembly through workgroup shared memory, GLSL mesh shaders achieve high throughput, maximize memory bus utilization, and maintain low latency during real-time decompression.