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:
- \(d > r\): The sphere is completely inside the plane (on the positive side).
- \(-r \le d \le r\): The sphere intersects the plane.
- \(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.