Concept · Rendering

Frustum Culling

At any moment, the camera in FloraForge can see only a wedge of the world — the rest is behind you, off to the side, or beyond the horizon. Frustum culling is the renderer's habit of checking, before drawing anything, whether it could possibly appear on screen, and skipping it if not. In a wide-open streamed world it is one of the largest single savings the engine makes.

The shape of what you see

A perspective camera doesn't see a box of space; it sees a pyramid, with its tip at the lens and its sides flaring out through the four edges of the screen. Two extra cuts finish the shape: a near plane just in front of the lens (you can't render things touching your eyeball) and a far plane in the distance, beyond which nothing is drawn. The result — a pyramid with its tip sliced off and its base capped — is the view frustum, and the six flat faces bounding it are the frustum's planes.

The name is a small gift from antiquity: frustum is Latin for a piece, bit, or morsel — something broken or cut off. Geometers borrowed it for the solid you get when you slice the top off a cone or pyramid with a cut parallel to its base. A camera's view is exactly that morsel: a pyramid with its point at the lens, its tip lopped off by the near plane, its base capped by the far plane. (The plural, should you ever need it, is frusta.)

Everything inside the frustum can end up on screen. Everything outside it cannot, no matter how detailed or well-lit — which makes any work spent on it pure waste. The GPU would eventually discard those triangles anyway during clipping, but only after the engine has paid to submit them and the vertex shader has paid to transform them. Culling moves the decision to the cheapest possible place: a few comparisons on the CPU, before the draw call is even recorded.

behind camera beyond the far plane camera near plane far plane side plane — a screen edge side plane — a screen edge inside the frustum — drawn (18 chunks) outside — skipped (12 chunks)
The frustum from above: a wedge of visible space between the near and far planes. Chunks that touch the wedge — even partially — are drawn; chunks behind the camera, beside it, or past the far plane never reach the GPU at all.

Six planes from one matrix

The elegant part is where the planes come from. The renderer already owns a complete description of the frustum: the view-projection matrix, the same one every vertex shader uses to put points on screen. A point is inside the frustum exactly when its projected coordinates land within the screen's bounds, and each of those six "within bounds" conditions can be rewritten as a plane equation by adding or subtracting one row of the matrix from another. This trick is known as the Gribb–Hartmann method, and FloraForge's implementation is almost a transcription of the recipe:

src/renderer_wgpu/frustum.rs — six planes out of one matrix
/// Extract 6 clip planes from a view-projection matrix (Gribb/Hartmann method).
pub fn from_view_proj(vp: Mat4) -> Self {
    let r0 = vp.row(0);
    let r1 = vp.row(1);
    let r2 = vp.row(2);
    let r3 = vp.row(3);

    let raw = [
        r3 + r0, // left
        r3 - r0, // right
        r3 + r1, // bottom
        r3 - r1, // top
        r3 + r2, // near
        r3 - r2, // far
    ];

    // …normalise each plane so distances come out in metres…
}

Each resulting plane is four numbers [a, b, c, d]: a direction the plane faces, plus an offset. For any point in the world, a·x + b·y + c·z + d is its signed distance from that plane — positive on the inside, negative on the outside. Six planes, six signed distances, and "inside the frustum" simply means all six are positive.

Testing a box against the planes

Testing every tree or vertex individually would defeat the purpose, so culling operates on bounding boxes — in FloraForge's case an axis-aligned box (AABB) around each 256-metre terrain chunk. The test uses a lovely shortcut: against each plane, you only need to check one of the box's eight corners — the one furthest in the direction the plane faces, sometimes called the p-vertex. If even that most-favourable corner is behind the plane, the entire box must be:

src/renderer_wgpu/frustum.rs — the whole test, verbatim
/// Returns true if the AABB is at least partially inside the frustum.
pub fn is_aabb_visible(&self, min: Vec3, max: Vec3) -> bool {
    for plane in &self.planes {
        let (a, b, c, d) = (plane[0], plane[1], plane[2], plane[3]);

        // Find the corner most in the direction of the plane normal (p-vertex)
        let px = if a >= 0.0 { max.x } else { min.x };
        let py = if b >= 0.0 { max.y } else { min.y };
        let pz = if c >= 0.0 { max.z } else { min.z };

        // If the p-vertex is outside, the entire AABB is outside this plane
        if a * px + b * py + c * pz + d < 0.0 {
            return false;
        }
    }
    true
}

Note the direction of the guarantee. The test is conservative: it never culls a box that's even partially visible, but it can occasionally keep a box that hugs a corner of the frustum without truly overlapping it. That asymmetry is the right one — a false "visible" costs a little wasted GPU work that the hardware clipper cleans up anyway, while a false "invisible" would make a hillside blink out of existence as you turned your head.

Culling chunks, every pass, every frame

Because FloraForge's world is organised into streamed chunks, the chunk grid is the natural unit to cull. A helper builds the box for a chunk straight from its grid coordinate, with deliberately generous vertical bounds so no mountain peak or river canyon is ever wrongly clipped away:

src/renderer_wgpu/frustum.rs — from grid coordinate to bounding box
/// Test visibility for a chunk at the given grid coordinate.
pub fn is_chunk_visible(&self, coord: IVec2) -> bool {
    let min = Vec3::new(
        coord.x as f32 * CHUNK_SIZE_METERS,
        MIN_Y,
        coord.y as f32 * CHUNK_SIZE_METERS,
    );
    let max = Vec3::new(min.x + CHUNK_SIZE_METERS, MAX_Y, min.z + CHUNK_SIZE_METERS);
    self.is_aabb_visible(min, max)
}

Every frame, the renderer extracts a fresh Frustum from the current view-projection matrix — once — and threads it through every world-drawing pass: the compute-generated terrain, the instanced trees and houses, and the river and sea surfaces. Each pass asks is_chunk_visible before submitting a chunk's draw call and silently drops the rest. The arithmetic is absurdly lopsided: six plane tests — a few dozen multiply-adds — stand in for transforming a chunk's ~16,000 terrain vertices plus every tree on it. And since the streaming system keeps chunks loaded in a full circle around the camera, while the frustum looks in only one direction, the majority of loaded chunks fail the test on any given frame. Most of the world, most of the time, costs nothing to not draw.

In the engine
The entire system is one ~70-line file, src/renderer_wgpu/frustum.rs. Chunk boxes use fixed vertical bounds of −256 m to +1024 m — comfortably enclosing any terrain the generator can produce — because a too-tight box that culled a real mountain would be a far worse bug than a slightly oversized one that occasionally draws an off-screen chunk.