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:
- 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.
- 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).
- 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.