Primitive ID Generation in GLSL Without Geometry Shaders
Modern graphics pipelines allow fragment shaders to consume the
built-in variable gl_PrimitiveID directly without binding
an explicit geometry shader. This article explains how the underlying
Graphics Processing Unit (GPU) hardware tracks, generates, and
propagates primitive IDs natively from the primitive assembly stage
through rasterization and into fragment shader registers.
The Input Assembly and Primitive Counter
Hardware primitive ID generation begins at the fixed-function Input
Assembler (IA) and Primitive Assembler (PA). As the GPU processes
vertices, the hardware evaluates the bound primitive topology (such as
GL_TRIANGLES, GL_TRIANGLE_STRIP, or
GL_LINES).
Rather than relying on shader code to count primitives, the primitive assembly unit maintains a hardware counter for the current draw call:
- List Topologies: For non-indexed or indexed lists
(e.g.,
GL_TRIANGLES), the hardware increments the primitive index after every \(N\) vertices (e.g., every 3 vertices for triangles). - Strip and Fan Topologies: The hardware tracks winding order, adjacency, and primitive restarts. Once the initial primitive is formed, every subsequent valid vertex trigger automatically increments the internal primitive counter by one.
- Instanced Draws: When instancing is used, the
primitive counter automatically resets to zero at the start of each new
instance, ensuring that primitive indices match across instances unless
combined with
gl_InstanceID.
Because this logic is implemented in fixed-function silicon, counting primitives introduces no compute or pipeline bubble overhead.
Passing the ID to Triangle Setup
Before the introduction of modern core OpenGL profiles, passing a primitive ID down the pipeline frequently required a geometry shader to capture the primitive index and pass it down as an output attribute. Modern GPUs bypass this entirely through direct primitive setup routing.
Once the primitive assembler constructs a complete geometric primitive, it generates a primitive setup packet. This packet contains vertex positions, calculated edge equations, depth slopes, and primitive-level metadata. The primitive assembler injects the generated primitive counter directly into this primitive-level metadata packet as a System-Generated Value (SGV).
Rasterization and Fragment Routing
The rasterizer consumes the primitive setup packets to determine which pixel fragments are covered. Because the primitive ID applies uniformly across the entire face of a primitive, the rasterizer treats it as a flat, non-interpolated integer attribute:
- Coverage Testing: The rasterizer calculates pixel coverage for the primitive using edge functions.
- Register Allocation: For every covered fragment (or \(2 \times 2\) pixel quad), the rasterizer initializes the fragment's input attributes.
- Hardware Mapping to
gl_PrimitiveID: Instead of performing barycentric interpolation—as it does for smooth attributes like texture coordinates—the hardware assigns the flat primitive ID stored in the setup packet directly to the fragment's allocated hardware input register.
When the fragment shader executes, GLSL exposes this hardware
register directly as the read-only built-in variable
gl_PrimitiveID. The shader reads this value with zero
interpolation cost, providing constant-time access to the index of the
primitive that generated the fragment.