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:

  1. Coverage Testing: The rasterizer calculates pixel coverage for the primitive using edge functions.
  2. Register Allocation: For every covered fragment (or \(2 \times 2\) pixel quad), the rasterizer initializes the fragment's input attributes.
  3. 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.