Implementing Clustered Forward Shading in GLSL

This article details the implementation of clustered forward shading using GLSL compute shaders, focusing on how a 3D spatial grid (clusters or "froxels") is allocated, culled, and sampled. Clustered forward shading solves the \(O(Lights \times Geometry)\) bottleneck of traditional forward rendering by discretizing the view frustum into a 3D grid along screen-space coordinates and view depth. By leveraging compute shaders to generate spatial bounding volumes and evaluate light-cluster intersections, the renderer allows fragment shaders to evaluate only the active lights intersecting a specific fragment's spatial cluster, retaining standard forward rendering benefits such as hardware multisampling (MSAA) and custom material models.

1. View Frustum Discretization

The camera view frustum is divided into a three-dimensional grid with dimensions \(S_x \times S_y \times S_z\) (e.g., \(16 \times 9 \times 24\)). While the \(X\) and \(Y\) coordinates represent uniform divisions of screen-space tiles, the \(Z\) coordinate is distributed exponentially across depth to ensure that clusters maintain an approximately cubic volume as frustum bounds expand with distance.

To map view-space depth \(z\) to a cluster slice index \(k\), the depth distribution is calculated as follows:

\[k = \left\lfloor \frac{\log(-z / z_{near})}{\log(z_{far} / z_{near})} \times S_z \right\rfloor\]

This exponential mapping accounts for perspective distortion, allocating higher spatial resolution near the camera and lower resolution further away.

2. Cluster AABB Generation Compute Pass

The first compute stage calculates an Axis-Aligned Bounding Box (AABB) in view space for each cluster \((i, j, k)\). This pass only needs to re-execute when the camera's projection matrix or field of view changes.

In the compute shader, each thread corresponds to a unique cluster coordinate \((i, j, k)\):

#version 450 core
layout(local_size_x = 1, local_size_y = 1, local_size_z = 1) in;

struct VolumeAABB {
    vec4 minPoint;
    vec4 maxPoint;
};

layout(std430, binding = 0) buffer ClusterAABBBuffer {
    VolumeAABB clusters[];
};

uniform mat4 inverseProjection;
uniform uvec3 clusterGridDim;
uniform vec2 screenDimensions;
uniform float zNear;
uniform float zFar;

vec4 screenToView(vec4 screenCoord) {
    vec4 ndc = vec4(
        (screenCoord.x / screenDimensions.x) * 2.0 - 1.0,
        (screenCoord.y / screenDimensions.y) * 2.0 - 1.0,
        screenCoord.z,
        1.0
    );
    vec4 view = inverseProjection * ndc;
    return view / view.w;
}

void main() {
    uvec3 clusterID = gl_GlobalInvocationID;
    if (clusterID.x >= clusterGridDim.x || clusterID.y >= clusterGridDim.y || clusterID.z >= clusterGridDim.z)
        return;

    uint index = clusterID.x +
                 clusterID.y * clusterGridDim.x +
                 clusterID.z * clusterGridDim.x * clusterGridDim.y;

    vec2 tileSize = screenDimensions / vec2(clusterGridDim.xy);

    // Calculate cluster min and max in screen coordinates
    vec2 minScreen = vec2(clusterID.xy) * tileSize;
    vec2 maxScreen = vec2(clusterID.xy + uvec2(1)) * tileSize;

    // Exponential depth slicing
    float tileNear = -zNear * pow(zFar / zNear, float(clusterID.z) / float(clusterGridDim.z));
    float tileFar  = -zNear * pow(zFar / zNear, float(clusterID.z + 1) / float(clusterGridDim.z));

    // Convert corners to view space using the near plane depth projection
    vec3 minViewNear = screenToView(vec4(minScreen, -1.0, 1.0)).xyz * (tileNear / -zNear);
    vec3 maxViewNear = screenToView(vec4(maxScreen, -1.0, 1.0)).xyz * (tileNear / -zNear);
    vec3 minViewFar  = screenToView(vec4(minScreen, -1.0, 1.0)).xyz * (tileFar / -zNear);
    vec3 maxViewFar  = screenToView(vec4(maxScreen, -1.0, 1.0)).xyz * (tileFar / -zNear);

    vec3 minPoint = min(min(minViewNear, maxViewNear), min(minViewFar, maxViewFar));
    vec3 maxPoint = max(max(minViewNear, maxViewNear), max(minViewFar, maxViewFar));

    clusters[index].minPoint = vec4(minPoint, 1.0);
    clusters[index].maxPoint = vec4(maxPoint, 1.0);
}

3. Light Culling and Allocation Compute Pass

The light culling compute shader executes every frame. It checks which lights intersect each cluster's AABB and builds an indirection grid. The data is stored in three Shader Storage Buffer Objects (SSBOs):

  1. Cluster Grid Buffer: Stores an offset and a count per cluster.
  2. Global Light Index List: A flattened dynamic array of light indices.
  3. Global Index Counter: An atomic counter keeping track of the current write index in the global light index list.
#version 450 core
layout(local_size_x = 16, local_size_y = 9, local_size_z = 1) in;

struct PointLight {
    vec4 positionView; // xyz: position in view space, w: radius
    vec4 color;
};

struct LightGrid {
    uint offset;
    uint count;
};

layout(std430, binding = 0) readonly buffer ClusterAABBBuffer { VolumeAABB clusters[]; };
layout(std430, binding = 1) readonly buffer LightDataBuffer    { PointLight pointLights[]; };
layout(std430, binding = 2) writeonly buffer LightGridBuffer   { LightGrid clusterGrid[]; };
layout(std430, binding = 3) writeonly buffer GlobalIndexBuffer { uint globalLightIndices[]; };
layout(binding = 4) uniform atomic_uint globalIndexCounter;

uniform uint totalLights;
uniform uvec3 clusterGridDim;

bool testSphereAABB(vec3 center, float radius, vec3 aabbMin, vec3 aabbMax) {
    vec3 closestPoint = clamp(center, aabbMin, aabbMax);
    vec3 delta = center - closestPoint;
    return dot(delta, delta) <= (radius * radius);
}

void main() {
    uint clusterIndex = gl_GlobalInvocationID.x +
                        gl_GlobalInvocationID.y * clusterGridDim.x +
                        gl_GlobalInvocationID.z * clusterGridDim.x * clusterGridDim.y;

    VolumeAABB aabb = clusters[clusterIndex];
    uint localLightIndices[256];
    uint localCount = 0;

    for (uint i = 0; i < totalLights; ++i) {
        PointLight light = pointLights[i];
        if (testSphereAABB(light.positionView.xyz, light.positionView.w, aabb.minPoint.xyz, aabb.maxPoint.xyz)) {
            if (localCount < 256) {
                localLightIndices[localCount] = i;
                localCount++;
            }
        }
    }

    uint globalOffset = atomicCounterAdd(globalIndexCounter, localCount);

    for (uint i = 0; i < localCount; ++i) {
        globalLightIndices[globalOffset + i] = localLightIndices[i];
    }

    clusterGrid[clusterIndex].offset = globalOffset;
    clusterGrid[clusterIndex].count = localCount;
}

4. Fragment Shading Pass Integration

In the final rendering pass, standard forward rendering shaders read from the spatial grid. Given a fragment's gl_FragCoord and view-space depth, the shader calculates the cluster index and iterates through the assigned light sublist.

// Fragment Shader Integration
layout(std430, binding = 1) readonly buffer LightDataBuffer    { PointLight pointLights[]; };
layout(std430, binding = 2) readonly buffer LightGridBuffer   { LightGrid clusterGrid[]; };
layout(std430, binding = 3) readonly buffer GlobalIndexBuffer { uint globalLightIndices[]; };

uniform uvec3 clusterGridDim;
uniform vec2 tileSize;
uniform float zNear;
uniform float zFar;

uint getClusterIndex(vec4 fragCoord, float viewZ) {
    uint clusterX = uint(fragCoord.x / tileSize.x);
    uint clusterY = uint(fragCoord.y / tileSize.y);
    uint clusterZ = uint((log(-viewZ / zNear) / log(zFar / zNear)) * float(clusterGridDim.z));

    clusterX = clamp(clusterX, 0u, clusterGridDim.x - 1u);
    clusterY = clamp(clusterY, 0u, clusterGridDim.y - 1u);
    clusterZ = clamp(clusterZ, 0u, clusterGridDim.z - 1u);

    return clusterX + clusterY * clusterGridDim.x + clusterZ * clusterGridDim.x * clusterGridDim.y;
}

vec3 calculateLighting(vec3 fragPosView, vec3 normalView, float viewZ) {
    uint clusterIndex = getClusterIndex(gl_FragCoord, viewZ);
    LightGrid grid = clusterGrid[clusterIndex];

    vec3 directLighting = vec3(0.0);

    for (uint i = 0; i < grid.count; ++i) {
        uint lightIndex = globalLightIndices[grid.offset + i];
        PointLight light = pointLights[lightIndex];

        vec3 L = light.positionView.xyz - fragPosView;
        float dist = length(L);
        if (dist < light.positionView.w) {
            L = normalize(L);
            float nDotL = max(dot(normalView, L), 0.0);
            float attenuation = 1.0 - (dist / light.positionView.w);
            directLighting += light.color.rgb * nDotL * attenuation;
        }
    }
    return directLighting;
}

This decoupled pipeline isolates geometry processing from scene lighting complexity, bounding compute complexity strictly to the number of light-cluster overlaps occurring within the active camera view volume.