GPU Bounding Sphere Frustum Culling in GLSL

This article explains how to implement bounding sphere view frustum culling using GLSL compute shaders in modern GPU-driven rendering pipelines. By offloading visibility determination from the CPU to compute threads running in parallel, applications can cull hundreds of thousands of individual object instances per frame. The process involves defining the camera's six frustum planes, evaluating the signed distance from each plane to the sphere's center, and conditionally updating visibility buffers or indirect draw arguments directly on the GPU.

Mathematical Foundation of the Plane-Sphere Test

A viewing frustum consists of six bounding planes: near, far, left, right, top, and bottom. Each plane can be represented mathematically in Hessian normal form:

\[Ax + By + Cz + D = 0\]

Where \(\vec{n} = (A, B, C)\) is the normalized normal vector (\(|\vec{n}| = 1\)) pointing inward toward the interior of the frustum, and \(D\) is the signed distance from the world-space origin to the plane.

For a bounding sphere defined by a center position \(\vec{C}\) and a positive radius \(r\), the signed distance \(d\) from the plane to the center of the sphere is computed using the dot product:

\[d = \vec{n} \cdot \vec{C} + D\]

The evaluation yields three possible spatial relationships per plane:

  1. \(d > r\): The sphere is completely inside the plane (on the positive side).
  2. \(-r \le d \le r\): The sphere intersects the plane.
  3. \(d < -r\): The sphere is completely outside the plane (on the negative side).

If a sphere is located completely outside of even a single frustum plane (\(d < -r\)), the entire object lies outside the frustum and can be culled. To be considered visible, the sphere must satisfy \(d \ge -r\) for all six planes simultaneously.

Extracting and Preparing Frustum Planes

To minimize compute shader overhead, frustum planes should be extracted on the CPU once per frame from the combined view-projection matrix and passed to the shader via a Uniform Buffer Object (UBO). Using the standard Gribb-Hartmann method, the coefficients are extracted directly from the row/column combinations of the matrix:

layout(std140, binding = 0) uniform CameraData {
    mat4 viewMatrix;
    mat4 projectionMatrix;
    vec4 frustumPlanes[6]; // xyz = normalized normal, w = distance (D)
};

Normalizing each plane's normal \((\vec{n} = \text{vec3}(A, B, C))\) and dividing \(D\) by the length of \(\vec{n}\) before uploading to the GPU is critical. If the planes are not unit length, the signed distance calculation will produce distorted results unless an extra division is performed within the compute shader.

Compute Shader Implementation

The compute shader operates on a flat array of instance data stored inside a Shader Storage Buffer Object (SSBO). Each thread handles one bounding sphere using gl_GlobalInvocationID.x.

#version 460 core

layout(local_size_x = 64, local_size_y = 1, local_size_z = 1) in;

struct InstanceData {
    mat4 modelMatrix;
    vec4 boundingSphere; // xyz = center, w = radius
};

layout(std430, binding = 1) readonly buffer InstanceBuffer {
    InstanceData instances[];
};

layout(std430, binding = 2) writeonly buffer VisibleIndicesBuffer {
    uint visibleIndices[];
};

layout(std430, binding = 3) buffer IndirectDrawCommand {
    uint count;
    uint instanceCount;
    uint first;
    uint baseInstance;
} drawCommand;

uniform uint totalInstances;

bool isSphereInsideFrustum(vec3 center, float radius) {
    for (int i = 0; i < 6; ++i) {
        // Dot product with plane normal + plane distance D
        float distance = dot(frustumPlanes[i].xyz, center) + frustumPlanes[i].w;
        if (distance < -radius) {
            return false;
        }
    }
    return true;
}

void main() {
    uint index = gl_GlobalInvocationID.x;
    if (index >= totalInstances) {
        return;
    }

    vec4 sphere = instances[index].boundingSphere;
    vec3 center = sphere.xyz;
    float radius = sphere.w;

    if (isSphereInsideFrustum(center, radius)) {
        // Atomically append the visible instance index
        uint outputIndex = atomicAdd(drawCommand.instanceCount, 1);
        visibleIndices[outputIndex] = index;
    }
}

Transforming Local Bounds to World Space

If bounding spheres are defined in local object space rather than world space, they must be transformed before the intersection test.

Transform the sphere center by the instance model matrix:

vec3 worldCenter = (instances[index].modelMatrix * vec4(sphere.xyz, 1.0)).xyz;

For the radius, non-uniform scaling requires selecting the maximum scale component among the matrix's basis vectors:

mat4 model = instances[index].modelMatrix;
float scaleX = length(model[0].xyz);
float scaleY = length(model[1].xyz);
float scaleZ = length(model[2].xyz);
float maxScale = max(scaleX, max(scaleY, scaleZ));
float worldRadius = sphere.w * maxScale;

Integration with GPU-Driven Rendering

Once visibility is determined, the result is combined with an indirect draw buffer (such as DrawArraysIndirectCommand or DrawElementsIndirectCommand). By issuing atomicAdd on instanceCount, the compute shader generates an exact count and a contiguous index array of visible geometry without any CPU readback. The host application subsequently issues a glDrawElementsIndirect or glDrawArraysIndirect call, executing the draw pass using only the primitives that passed the compute shader's intersection test.