Procedural World - Chunk Management

In this post I will be going over how chunks are managed in my procedural world project.

All chunk management occurs in the ChunkManager script. This is probably going to be the most import script in the whole project!

So what does it need to do?

Scope

The chunk manager is responsible for adding, updating and removing all the chunks around the player. Too many chunks and we lose performance, not enough and the world looks choppy and unappealing.

This would involve creating chunk gameobjects if they don’t exist, loading chunks, building chunks and destroying chunks when they are no longer needed.

This is independent of the data that is loaded into the chunks or how the chunks are built. That is handled by the currently selected FieldGenerator and MeshExtractor.

Storing Chunks in Memory

An important subject is how chunks are organized and iterated over in memory.

For now I’m going with a simple dictionary. But this could be adapted to a QuadTree or an Octree.

Finding Chunks Around the Player

Before deciding what to do with chunks we have to find them first.

My first attempt at that was to iterate over a box of chunks with the player at the center.

Something like:

for x = playerPosition.x - n; x < playerPosition.x + n; ++x:
    for z = playerPosition.z - n; z < playerPosition.z + n; ++z:
        var chunk = GetChunk(x, z);

But this is extremely inflexible.

A much better way is to perform a breath-first search on the player’s current chunk position that scans outwards into the neighboring chunks.

    private void UpdateVisibleChunks(Vector3 playerPosition)
    {
        // the player's chunk position
        Vector3Int playerChunkPosition = GetChunkPosition(playerPosition);

        // explored chunks
        HashSet<Vector3Int> explored = new HashSet<Vector3Int>();

        // queue of chunk positions
        Queue<Vector3Int> queue = new Queue<Vector3Int>();

        // load initial chunk positions to check
        queue.Enqueue(playerChunkPosition);
        foreach (var key in GetChunkNeighbors(playerChunkPosition))
        {
            queue.Enqueue(key);
        }

        // loop while items are in the queue
        while (queue.Count > 0)
        {
            // grab first position
            Vector3Int p = queue.Dequeue();

            if (explored.Contains(p)) continue;

            // explore neighbors
            Vector3Int[] neighbors = GetChunkNeighbors(p);

            foreach (var neighbor in neighbors)
            {
                // get the world position of the chunk
                Vector3 chunkPosition = GetChunkWorldCenter(neighbor);

                // check the chunk is in the render distance of the player
                var distanceFromPlayer = (playerPosition - chunkPosition).magnitude;

                if (distanceFromPlayer <= generalRenderDistance)
                {
                    queue.Enqueue(neighbor);
                }
            }

            explored.Add(p);

            // check if the current chunk is in the chunk list
            if (chunkList.ContainsKey(p))
            {
                // the chunk already exists, ensure it is enabled
                var chunk = chunkList[p];
                chunk.gameObject.SetActive(true);
            }
            else
            {
                // the chunk does not exist yet, create it
                var chunk = CreateChunk(p.x, p.z);
                chunkList.Add(p, chunk);
            }
        }
    }

I think this is pretty straight forward. One thing to note is the variable generalRenderDistance. This is the radius around the player to queue neighbor chunks.

An improvement that can be made to this is loading more chunks that are in the view on the camera. Actually we only really want the chunks in view of the camera and a minial number of chunks behind/outside of view of the player.

So we can add something like:

 private void UpdateVisibleChunks(Vector3 playerPosition)
    {
        ...
            foreach (var neighbor in neighbors)
            {
                // get the world position of the chunk
                Vector3 chunkPosition = GetChunkWorldCenter(neighbor);

                // check the chunk is in the render distance of the player
                var distanceFromPlayer = (playerPosition - chunkPosition).magnitude;

                if (distanceFromPlayer <= generalRenderDistance)
                {
                    queue.Enqueue(neighbor);
                }
                else if (IsChunkInFrustum(chunkPosition))
                {
                    // check if the chunk is in the camera's view frustum

                    // check if the chunk is in the forward render distance
                    if (distanceFromPlayer <= forwardRenderDistance)
                    {
                        queue.Enqueue(neighbor);
                    }
                }
            }
        ...
    }

To check if the chunks is in the camera view frustum we check if the top four corners of the chunk are in the frustum. To check if the point is in the frustum:

I use a margin value to apply a buffer for loading chunks on the edge of the view frustum.

    // ChunkManager.cs
    // check if the corners of the chunk are in the camera frustum
    private bool IsChunkInFrustum(Vector3 chunkCenter)
    {
        float offsetX = (float)chunkPrefab.chunkSizeX / 2.0f;
        float offsetY = (float)chunkPrefab.chunkSizeY / 2.0f;
        float offsetZ = (float)chunkPrefab.chunkSizeZ / 2.0f;

        Vector3[] corners =
        {
            new Vector3(chunkCenter.x - offsetX, chunkCenter.y + offsetY, chunkCenter.z + offsetZ),
            new Vector3(chunkCenter.x + offsetX, chunkCenter.y + offsetY, chunkCenter.z + offsetZ),
            new Vector3(chunkCenter.x - offsetX, chunkCenter.y + offsetY, chunkCenter.z - offsetZ),
            new Vector3(chunkCenter.x + offsetX, chunkCenter.y + offsetY, chunkCenter.z - offsetZ)
        };

        foreach (var corner in corners)
        {
            if (playerCamera.IsPointInFrustum(corner, viewportMargin))
            {
                return true;
            }
        }

        return false;
    }

    // CameraExtension.cs
    public static class CameraExtension
    {
        public static bool IsPointInFrustum(this Camera camera, Vector3 point, float margin = 0.0f)
        {
            float min = 0 - margin;
            float max = 1 + margin;

            Vector3 viewportPoint = camera.WorldToViewportPoint(point);

            return viewportPoint.x >= min && viewportPoint.x <= max && viewportPoint.y >= min && viewportPoint.y <= max && viewportPoint.z > 0;
        }
    }

Loading Chunks

Chunks are loaded using the ChunkLoader script. Its purpose is to queue chunks using ThreadPool. We queue chunks for loading when they are created.

    private void UpdateVisibleChunks(Vector3 playerPosition)
    {
        ...
            // check if the current chunk is in the chunk list
            if (chunkList.ContainsKey(p))
            {
                // the chunk already exists, ensure it is enabled
                var chunk = chunkList[p];
                chunk.gameObject.SetActive(true);
            }
            else
            {
                // the chunk does not exist yet, create it
                var chunk = CreateChunk(p.x, p.z);
                chunkList.Add(p, chunk);

                chunkLoader.Load(chunk);
            }
        ...
    }

Removing Chunks

Only the chunks infront and immediately around the player need to be active. We can check the chunks distance to the player and use a few parameters to determine if the chunk should be set inactive or destroyed.

private void RemoveFarChunks(Vector3 playerPosition)
{
    List<Vector3Int> toRemove = new List<Vector3Int>();

    // iterate over chunks in the dictionary
    foreach (var pair in chunkList)
    {
        var chunk = pair.Value;
        var chunkPosition = GetWorldPositionFromChunkPosition(pair.Key);

        // calculate distance between player and chunk
        var distanceToChunk = (playerPosition - chunkPosition).magnitude;

        // check if the chunk is not in the view frustum
        if (!IsChunkInFrustum(chunkPosition))
        {
            // if the distance is greater than the distance to which the chunk should be inactive, but not removed
            if (distanceToChunk >= distanceToInactive)
            {
                // set the chunk to inactive
                chunk.gameObject.SetActive(false);
            }

            // if the distance is grater tan the distance to which the chunk shoould be destroyed
            if (distanceToChunk >= distanceToDestroy)
            {
                // set the chunk for removal from the list
                toRemove.Add(pair.Key);
                // and destroy the gamebobject
                Destroy(chunk);
            }
        }
    }

    // remove destroyed chunks from the list
    foreach (var key in toRemove)
    {
        chunkList.Remove(key);
    }
}

Parameters

Image not found!