Home Games Shader Sandbox

Game Dev Mechanics: Frustum Culling — How It Works

When you load up a modern open-world game like The Witcher 3 or Horizon Zero Dawn, the engine isn't drawing every tree, building, enemy, and blade of grass in the entire world simultaneously. Such naïveté would crush any GPU within milliseconds. Instead, these engines employ a range of visibility-determination techniques, the most fundamental of which is frustum culling.

Frustum culling is the process of determining which objects in your scene fall within the camera's visible region, called the view frustum, and skipping the draw calls for everything outside it. On a dense scene with thousands of objects, this can reduce draw calls by 80–90%, sharply improving frame rate. It appears in virtually every real-time 3D renderer ever shipped.

In this article we'll dig into the mathematics of the view frustum, derive the six clip planes, explore bounding-volume intersection tests, and look at how modern game engines handle hierarchical culling. The interactive demo above lets you observe frustum culling in real-time as a virtual camera sweeps through a scene. Objects inside the frustum glow green while those outside fade to near-invisibility, and a live counter shows how many draw calls are saved each frame.

The View Frustum

The word "frustum" comes from the Latin for a slice cut from a cone or pyramid. In 3D graphics, the view frustum is the truncated pyramid shape that defines the region of 3D space a perspective camera can see. Think of it as the volume swept out by the camera's rays from the nearest visible surface to the farthest.

A perspective camera is characterised by four parameters:

  • Field of View (FoV): the vertical angle (in radians) that controls how wide or narrow the camera sees.
  • Aspect Ratio: width divided by height, which determines horizontal extent.
  • Near clip distance $z_n$: the closest depth at which geometry is rendered.
  • Far clip distance $z_f$: the farthest depth at which geometry is rendered.

Together these four values define six planes that form the frustum's boundary: a near plane, a far plane, and left, right, top, and bottom planes. The near and far planes are parallel rectangles; the four side planes each pass through the camera's position (the apex of the full pyramid before truncation).

The core insight is simple: if an object is entirely on the wrong side of any one of the six planes, it cannot possibly be visible. Finding one such plane is enough to skip the object entirely. This gives us an early-exit loop that is extremely cache-friendly and branch-predictable on modern CPUs.

Extracting Frustum Planes from the Projection Matrix

The most direct way to extract frustum planes is from the camera's combined view-projection matrix. This technique, popularised by Gil Gribb and Klaus Hartmann's 2001 paper, avoids computing plane corners and normals individually.

Let $M = P \cdot V$ be the combined matrix where $P$ is the projection matrix and $V$ is the view matrix (world-to-camera transform). The six frustum planes can be read directly from the matrix rows. If we denote the four rows as $\vec{r}_1, \vec{r}_2, \vec{r}_3, \vec{r}_4$, then:

$$\text{Left:} \quad \vec{r}_4 + \vec{r}_1$$ $$\text{Right:} \quad \vec{r}_4 - \vec{r}_1$$ $$\text{Bottom:} \quad \vec{r}_4 + \vec{r}_2$$ $$\text{Top:} \quad \vec{r}_4 - \vec{r}_2$$ $$\text{Near:} \quad \vec{r}_4 + \vec{r}_3$$ $$\text{Far:} \quad \vec{r}_4 - \vec{r}_3$$

Each resulting four-component vector $(a, b, c, d)$ defines a plane equation $ax + by + cz + d = 0$ where the normal $(a, b, c)$ points inward toward the frustum interior. To use these planes for accurate distance calculations, normalise by dividing all four components by $\sqrt{a^2 + b^2 + c^2}$.

In Three.js this extraction is handled internally by the Frustum class:

const frustum = new THREE.Frustum();
const viewProjectionMatrix = new THREE.Matrix4();

function updateFrustum(camera) {
    camera.updateMatrixWorld();
    viewProjectionMatrix.multiplyMatrices(
        camera.projectionMatrix,
        camera.matrixWorldInverse
    );
    frustum.setFromProjectionMatrix(viewProjectionMatrix);
}

After this call, frustum.planes contains all six normalised THREE.Plane objects ready for intersection testing.

Bounding Volume Tests

Now that we have our six planes, how do we test whether an object is inside, outside, or straddling the frustum? Testing exact triangle geometry against six planes would be prohibitively expensive for complex meshes with thousands of triangles. Instead, we use bounding volumes, simplified shapes that completely enclose an object and can be tested cheaply.

Bounding Sphere Test

The simplest bounding volume is a sphere, defined by a center point $\vec{c}$ and radius $r$. To test it against a single frustum plane with inward-pointing unit normal $\hat{n}$ and offset $d$, we compute the signed distance from the sphere center to the plane:

$$\delta = \hat{n} \cdot \vec{c} + d$$

The sign of $\delta$ tells us which side of the plane the center is on. Given the radius:

  • If $\delta < -r$: the sphere is entirely outside this plane, cull the object
  • If $\delta > r$: the sphere is entirely inside, safe for this plane, check the next
  • If $-r \leq \delta \leq r$: the sphere straddles the plane, check remaining planes

For complete frustum culling, test against all six planes. The moment one test fails, exit early and cull the object:

function isSphereInFrustum(frustum, center, radius) {
    const planes = frustum.planes;
    for (let i = 0; i < 6; i++) {
        if (planes[i].distanceToPoint(center) < -radius) {
            return false; // entirely outside this plane, cull
        }
    }
    return true; // inside or straddling all planes
}

This test has a known failure mode: a large object might be reported as intersecting the frustum even though no part of it is actually visible. Bounding spheres overestimate volume, especially for long thin objects like swords or bridges. False positives are conservative, meaning a few extra objects get rendered. False negatives would cull visible geometry, causing pop-in artifacts. Always err on the side of caution.

Axis-Aligned Bounding Box (AABB) Test

AABBs provide tighter fits for many objects. For a box with minimum corner $\vec{b}_{min}$ and maximum corner $\vec{b}_{max}$, the test uses the concept of the positive vertex $\vec{p}^+$, the corner of the box farthest in the direction of the plane normal $\hat{n} = (n_x, n_y, n_z)$:

$$p^+_x = \begin{cases} b^{max}_x & \text{if } n_x \geq 0 \\ b^{min}_x & \text{otherwise} \end{cases}$$

(Similarly for the $y$ and $z$ components.) If $\hat{n} \cdot \vec{p}^+ + d < 0$, the entire box is outside the plane. This produces fewer false positives than the sphere test while remaining very fast. Three.js exposes this as:

// Three.js handles AABB frustum tests natively:
const isVisible = frustum.intersectsBox(mesh.geometry.boundingBox);

// Or with a pre-transformed world-space AABB:
mesh.geometry.computeBoundingBox();
const worldBox = mesh.geometry.boundingBox.clone()
    .applyMatrix4(mesh.matrixWorld);
const isVisible = frustum.intersectsBox(worldBox);

In practice, most engines use sphere tests for dynamic objects (cheap to update when the object moves) and AABB or OBB tests for static geometry where tighter fits justify the slightly higher test cost.

Hierarchical Culling

For scenes with thousands of objects, testing every object individually, even with cheap sphere tests, still adds up. Hierarchical culling improves on this by using spatial data structures to reject entire groups of objects at once.

A Bounding Volume Hierarchy (BVH) organises objects into a binary tree where each internal node holds a bounding volume enclosing all its children. When traversing the tree during a frustum cull pass, if a node's bounding volume fails the frustum test, every object under that node is culled in a single test:

function cullBVH(node, frustum, visibleList) {
    // Test this node's bounding volume
    if (!frustum.intersectsSphere(node.boundingSphere)) {
        return; // entire subtree culled in one test
    }
    if (node.isLeaf) {
        visibleList.push(node.object);
        return;
    }
    // Recurse into children
    cullBVH(node.left,  frustum, visibleList);
    cullBVH(node.right, frustum, visibleList);
}

For static open-world geometry, octrees partition 3D space into nested cubes. If a frustum doesn't intersect an octree node's cube, all geometry within that cube is culled instantly. Minecraft's chunk-based rendering is essentially a specialised octree: entire 16×16×16-block sections are culled against the view frustum before any individual block is considered.

Unreal Engine's world uses a combination of BSP trees for static architecture and a dynamic octree for movable actors. Unity's integration with the Umbra occlusion system adds a second pass on top of frustum culling, skipping objects that are geometrically inside the frustum but hidden behind opaque occluders, a technique called occlusion culling.

Implementation in Practice

A typical game engine's culling pipeline runs every frame and looks roughly like this:

function buildRenderList(scene, camera) {
    // 1. Recompute frustum from current camera transform
    updateFrustum(camera);

    // 2. Broad phase: spatial structure narrows candidates
    const candidates = scene.octree.query(frustumAABB);

    // 3. Narrow phase: precise frustum test per candidate
    const visible = candidates.filter(obj =>
        frustum.intersectsSphere(obj.boundingSphere)
    );

    // 4. Sort: front-to-back for opaque (early-Z rejection),
    //          back-to-front for transparent (correct blending)
    visible.sort((a, b) => a.distToCamera - b.distToCamera);

    // 5. Submit draw calls to the GPU
    visible.forEach(obj => renderer.submitDrawCall(obj));
}

A few real-world caveats worth knowing:

Shadow casters. An object might be outside the view frustum but still cast a shadow into it. A separate, extended frustum test covering the light's shadow projection volume is required for shadow casting, which is why shadow map generation is one of the most expensive passes in modern renderers.

Dynamic objects. For moving objects, bounding volumes need updating each frame. Most engines compute bounding volumes in local space once at load time and transform them to world space at runtime using only the object's matrix, far cheaper than recomputing from scratch.

LOD integration. Frustum culling pairs naturally with Level of Detail (LOD). Once we know an object is visible, its distance to camera selects the appropriate detail level. Objects very small on screen can be culled entirely using a screen-size threshold even though they're technically inside the frustum.

GPU-driven culling. Modern APIs like DirectX 12 and Vulkan support compute shaders that perform frustum culling entirely on the GPU, feeding results into indirect draw call buffers. This approach, used in engines like id Tech 7 (Doom Eternal) and Ubisoft's Snowdrop engine, can cull tens of thousands of objects in a single dispatch, removing the CPU bottleneck entirely.

Real-World Examples

Frustum culling has been a staple of 3D games since the beginning:

  • Quake (1996) combined BSP-based visibility sets with frustum culling for indoor environments, enabling smooth 60fps on mid-1990s hardware by typically culling over 90% of the level geometry per frame.
  • Minecraft (Java Edition) tests entire 16×16×16 chunk sections against the camera frustum. If a section is invisible, all ≈16,000 potential block faces in it are skipped instantly, a critical optimisation in a world potentially millions of blocks wide.
  • GTA V uses a multi-tier streaming and culling system that manages hundreds of thousands of objects across a 256 km² map, with hierarchical culling reducing the visible set to typically a few thousand draw calls at any moment.
  • The Legend of Zelda: Breath of the Wild pairs aggressive frustum culling with visibility flags set during world design to maintain performance on the Switch's modest GPU across a massive open world with virtually no loading screens.
  • Unity & Unreal Engine both expose per-layer culling distances, allowing designers to set aggressive culling radii on props and vegetation while keeping large structures always-tested, giving fine-grained control over the performance-fidelity tradeoff.

Closing Thoughts

A handful of dot products per object can eliminate thousands of draw calls per frame. The math, six planes and one signed distance test each, takes an afternoon to implement and fits into any rendering pipeline. Paired with a spatial structure for hierarchical rejection and compute shaders for GPU-driven culling, the technique handles everything from small indie scenes to open worlds with hundreds of thousands of objects. For any 3D renderer, it's the right first optimization to reach for.

Comments

Like this article? Consider supporting us

Your support helps us keep creating free game dev content, tutorials, and tools.

Free

$0 /month

Newsletter and public posts

  • Newsletter access
  • Public posts & updates
  • Community access

Studio Backer

$25 /month

Direct impact on development with your name in the credits

  • Everything in Supporter
  • Your name in game credits
  • Priority feature requests
  • Direct developer access
  • Monthly asset downloads