Wishlist on Steam
No.15

Frustum Culling

Frustum culling is the foundational visibility technique that stops your GPU from drawing objects the camera can’t see. Learn the math behind view frustums, plane extraction, bounding volume tests, and hierarchical culling used in every modern 3D game engine.

Published
Reading time
8 min
Demos
One to play with
Author
Kieren Hovasapian
Interactive 3D · Drag to orbit · scroll to zoomOpen full screen

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 : the closest depth at which geometry is rendered.
  • Far clip distance : 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 be the combined matrix where is the projection matrix and 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 , then:

Each resulting four-component vector defines a plane equation where the normal points inward toward the frustum interior. To use these planes for accurate distance calculations, normalise by dividing all four components by .

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

JavaScript
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 and radius . To test it against a single frustum plane with inward-pointing unit normal and offset , we compute the signed distance from the sphere center to the plane:

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

  • If : the sphere is entirely outside this plane, cull the object
  • If : the sphere is entirely inside, safe for this plane, check the next
  • If : 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:

JavaScript
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 and maximum corner , the test uses the concept of the positive vertex , the corner of the box farthest in the direction of the plane normal :

(Similarly for the and components.) If , 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:

JavaScript
// 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:

JavaScript
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:

JavaScript
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.

Mechanics Library

The live demo from Octrees.

No.38Spatial structures & culling

Octrees

Octrees partition 3D space into eight recursive subdivisions, enabling lightning-fast spatial queries for collision detection, frustum culling, and more. Learn the algorithm, the math, and explore an interactive 3D visualization.

1 demo8 min read

The live demo from Occlusion Culling.

No.18Spatial structures & culling

Occlusion Culling

Occlusion culling identifies and discards geometry the camera cannot possibly see — hidden behind walls, buildings, or terrain — before it ever reaches the GPU, slashing draw calls and unlocking dramatically higher scene complexity.

1 demo9 min read

The live demo from Level of Detail (LOD).

No.16Spatial structures & culling

Level of Detail (LOD)

Learn how Level of Detail (LOD) systems automatically swap between high- and low-complexity meshes as objects move closer or farther from the camera, and why this technique is the backbone of performance in every large-scale 3D game.

1 demo9 min read

The live demo from Spatial Hashing.

No.11Spatial structures & culling

Spatial Hashing

Spatial hashing transforms O(n²) collision detection into an O(n) operation by dividing the world into a uniform grid and using a hash map to find only nearby object pairs — cutting tens of thousands of checks down to dozens every frame.

1 demo9 min read

Esc