Frustum Culling in Voxel Engines Explained

Frustum Culling in Voxel Engines Explained

Table of contents

Note, the LOD source code example below is a non-dynamic LOD implementation, and it's missing some of the other code, but it serves as an example of the technique. I'll update it with more clear examples as I develop a more advanced dynamic LOD system for my voxel engine.

Frustum culling is a crucial optimization technique for voxel engines, which involves determining which objects fall within the camera’s viewable area and only rendering those. By not rendering objects outside of this frustum, computational resources and rendering time are significantly reduced, enhancing overall performance.

The need for frustum culling becomes apparent when you consider the immense size of voxel worlds, where thousands of chunks can exist but only a fraction are visible to the player at any given time.

Understanding the View Frustum

The view frustum in a voxel engine is a pyramidal shape that represents the field of vision of the camera. It consists of six planes: top, bottom, left, right, near, and far. Each chunk or voxel that intersects with these planes is considered for rendering.

Implementing effective frustum culling requires understanding the mathematics of plane equations and intersections. Chunks are typically checked against these planes using bounding boxes or spheres, which simplifies the intersection tests.

Implementing Frustum Culling

Implementing frustum culling starts with calculating the planes of the frustum based on the camera’s position and orientation. Once these planes are determined, each chunk’s bounding volume is tested for intersection against the frustum. If a chunk’s bounding volume does not intersect with the frustum, it is culled and not rendered.

Most voxel engines will integrate frustum culling into the rendering pipeline, where it serves as the first pass in the visibility determination process. This can dramatically reduce the number of draw calls and the amount of data sent to the GPU.

Frustrum Culling C++ Source Code

If you're writing your engine in OpenGL, it is recommended to use the GLM library which conains definitions of vectors, matrices and planes. The GLM library works well with OpenGL coordinate system and will rarely cause any discrepancies between OpenGL implementation.

Note, this code assumes that you have already initialized all chunk data within world object. The dimensions of the world object are defined by its properties: width, length and height. This is how many chunks the world consists of.

This code loops through all chunks in the world, and uses the intersect function to determine whether the chunk should fall within the frustrum. The frustrum definition is not shown here, but it should consist of a view frustrum matrix. I guess I'll have to add the class for that in this example at a later time... (I've temporarily lost access to my voxel engine code.)

I recommend asking Chat GPT to write this code for you, and generally when working with something as complex as voxel engines, using AI to write core algorithms will save a lot of time. However, don't use AI just to generate the code without first understanding what it is actually doing.

    // -------------------------------
    //      .....Draw world.....
    // -------------------------------

    for (int x = 0; x < world.width; ++x) {
        for (int y = 0; y < world.height; ++y) {
            for (int z = 0; z < world.length; ++z) {

                glm::vec3 chunkCenter = glm::vec3(x * spacing, y * spacing, z * spacing);

                if (frustum.intersects(chunkCenter, CHUNK_SIZE)) {

                    // What is the distance from player to chunk's center?
                    float distance = glm::distance(playerPos, chunkCenter);

                    // Default, use the original 64x64x64 geometry
                    int LOD = -1;

                    /* This is where you determine where each LOD kicks in,
                       you can change these to your own values: */

                    if (distance > 256.0f)
                        LOD = 0;

                    if (distance > 512.0f)
                        LOD = 1;

                    if (distance > 512.0f + 512.0f)
                        LOD = 2;
                    
                    if (distance > 512.0f + 512.0f + 512.0f)
                        LOD = 3;

                    // Check if the chunk is within the frustum
                    if (frustum.intersects(chunkCenter, CHUNK_SIZE / 2)) {
                            Bits &cellBits = world.getBitsAt(x, y, z);

                            glUniform3fv(chunkPosUniformLoc, 1, &chunkCenter[0]);
                            glUniform1i(lookingAtLocation, 0);
                            glUniform1i(sunlightAmbientPos, sunlightAmbientLight);
                            glUniform3f(playerPositionUniform, player.x, player.y, player.z);
                            glUniform1f(farDistanceUniform, farDistance);
                            glUniform1f(evenFartherDistanceUniform, evenFartherDistance);
                            glUniform1i(glGetUniformLocation(shaderVoxel.shaderProgram, "LOD"), LOD);

                            GLuint vaoToUse = cellBits.vao;

                            int numIndices = 36;
                            int numInstances = 0;

                            if (LOD == 0) {
                                vaoToUse = cellBits.lodVao;
                                numInstances = cellBits.cubePositionsLOD0.size();
                            } else if (LOD == 1) {
                                vaoToUse = cellBits.lodVao1;
                                numInstances = cellBits.cubePositionsLOD1.size();
                            } else if (LOD == 2) {
                                vaoToUse = cellBits.lodVao2;
                                numInstances = cellBits.cubePositionsLOD2.size();
                            } else if (LOD == 3) {
                                vaoToUse = cellBits.lodVao3;
                                numInstances = cellBits.cubePositionsLOD3.size();
                            } else { // LOD = -1
                                numInstances = cellBits.cubePositions.size();
                            }

                            if (numInstances > 0) {
                                glBindVertexArray(vaoToUse);
                                glDrawElementsInstanced(GL_TRIANGLES, numIndices, GL_UNSIGNED_INT, 0, numInstances);
                            }

                            glBindVertexArray(0);
                    }
                }
            }
        }
    }
  
And here is the source code for the frustrum's intersect method. This is the function that identifies whether a chunk of voxels intersects with the frustrum, and therefore either falls inside or outside the frustrum volume.
bool intersects(const glm::vec3& center, float halfSize) const {
    int insideCount;
    for (const auto& plane : planes) {
        float distance = glm::dot(glm::vec3(plane), center) + plane.w;
        if (distance < -halfSize) {
            return false; // Fully outside a single plane
        }
        if (distance > halfSize) {
            insideCount++;
        }
    }
    return insideCount > 0; // Change this to check for partial inclusion if needed
}

Note that while culling chunks with this function will significanty improve performance of your voxel engine, it isn't always enough and you will sometimes want to additionally check whether each individual voxel within the chunk that intersects with frustrum falls inside or outside the frustrum volume.

Dynamic Frustum Culling

Dynamic frustum culling adjusts the frustum in real-time based on player movement and camera orientation. This requires constant recalculation of the frustum planes and reevaluation of the chunks’ visibility status. It’s a crucial aspect for maintaining high performance in dynamic environments, such as when the player is moving quickly or when there are a lot of moving objects in the scene.

The challenge with dynamic frustum culling is ensuring that the calculations are efficient and do not themselves become a bottleneck. Optimizations may involve spatial data structures like octrees or bounding volume hierarchies, which can accelerate the process of determining which chunks need to be tested against the frustum.

© 2024 Voxel Engine Tutorial. All Rights Reserved. | Privacy Policy | Terms of Service