Building a High-Performance Voxel Engine in Unity: A Step-by-Step Guide Part 4: Infinite Terrain

CodeComplex
10 min readNov 27, 2023

--

Imagine a world without an end, as we move around the map we should generate new chunks with new voxels and sampled noise. We will require some optimizations to our system and new ways of thinking regarding our voxels.

To spawn terrain around our player, we need a player! Let’s add one now…

Just a capsule will do, add the capsule GameObject & call it Player, and attach a script to it call it PlayerController.cs…

using UnityEngine;

public class PlayerController : MonoBehaviour
{

void Start()
{
SetPlayerCenter();
}

void SetPlayerCenter() {
// Calculate the center position of the world
int worldCenterIndex = World.Instance.worldSize / 2;
float worldCenterX = worldCenterIndex * World.Instance.chunkSize;
float worldCenterZ = worldCenterIndex * World.Instance.chunkSize;

float noiseValue = GlobalNoise.GetGlobalNoiseValue(worldCenterX, worldCenterZ, World.Instance.noiseArray);

// Normalize noise value to [0, 1]
float normalizedNoiseValue = (noiseValue + 1) / 2;

// Calculate maxHeight
float maxHeight = normalizedNoiseValue * World.Instance.maxHeight;

// Adjust the height for the player's position (assuming the player's capsule collider has a height of 2 units)
maxHeight += 1.5f; // This ensures that the base of the player is at the terrain height

// Set the player's position to be on top of the terrain
transform.position = new Vector3(worldCenterX, maxHeight, worldCenterZ);
}

}

So upon the Start() method we call the SetPlayerCenter() function and it will calculate the center of the world based on the worldSize and chunkSize defined in the World instance. It then fetches the noise value at this central position, which is used to determine the terrain height at that point.

The noise value is normalized to a range between 0 and 1, which is then scaled by the maxHeight, as we do in the DetermineVoxelType() function and then get a height value that corresponds to the terrain’s elevation in the game world.

The player’s height is then adjusted by adding 1.5 units to ensure that the player is positioned above the terrain surface. This offset accounts for the player’s capsule collider height so that the player does not spawn inside the terrain.

Finally, the player’s position is set to this calculated x, y (height), and z coordinate, effectively placing the player on top of the terrain at the world’s centre when the game starts.

For an FPS style experience while we build the voxel engine drag the main camera to the player, reset the position and pull it up a bit so it is eye level with the capsule. Since we move and look with the camera let us split the logic between the camera and Player.

Remove these lines in the FlyThroughCamera script…

public float moveSpeed = 5.0f;

...

// Move the camera based on arrow keys/WASD input
float x = Input.GetAxis("Horizontal") * moveSpeed * Time.deltaTime; // A/D or Left Arrow/Right Arrow
float z = Input.GetAxis("Vertical") * moveSpeed * Time.deltaTime; // W/S or Up Arrow/Down Arrow

// Apply the movement
transform.Translate(x, 0, z);

In our PlayerController script add these vars…

public CharacterController characterController;
public Transform cameraTransform;

public float speed = 6.0f;
public float gravity = -9.81f;
public float jumpHeight = 2.0f;

private Vector3 playerVelocity;
private bool groundedPlayer;

We will be using the Character Controller, a built-in unity feature to handle movement in a somewhat easy manner. So go to add component on the Player and add the Character Controller component.

Add this function to the PlayerController class…

void PlayerMove() {
groundedPlayer = characterController.isGrounded;
if (groundedPlayer && playerVelocity.y < 0)
{
playerVelocity.y = 0f;
}

Vector3 move = new Vector3(Input.GetAxis("Horizontal"), 0, Input.GetAxis("Vertical"));
move = cameraTransform.forward * move.z + cameraTransform.right * move.x;
move.y = 0; // We do not want to move up/down by the camera's forward vector

characterController.Move(move * Time.deltaTime * speed);

// Changes the height position of the player..
if (Input.GetButtonDown("Jump") && groundedPlayer)
{
playerVelocity.y += Mathf.Sqrt(jumpHeight * -3.0f * gravity);
}

playerVelocity.y += gravity * Time.deltaTime;
characterController.Move(playerVelocity * Time.deltaTime);
}

Call it from the Update()…

void Update() 
{
PlayerMove();
}

This script provides basic movement and jumping functionality, don’t forget to drag the references into the inspector…

Press Play and jump around…

Why walk? When you can fly! Let’s add a feature so when we press F we fly instead of walking. Add some vars…

public float flySpeed = 12.0f; // Speed when flying
private bool isFlying = false; // Whether the player is currently flying

Change the Update()…

void Update() 
{
// Toggle fly mode
if (Input.GetKeyDown(KeyCode.F)) {
isFlying = !isFlying;
characterController.enabled = !isFlying; // Disable the character controller when flying
}

if (isFlying) {
Fly();
} else {
PlayerMove();
}
}

So when we press F we toggle our isFlying bool and disable the characterController, if the flying bool is true call Fly(), add our Fly() function…

void Fly()
{
// Get input for flying
float x = Input.GetAxis("Horizontal");
float y = Input.GetAxis("Jump") - Input.GetAxis("Crouch"); // Space to go up, Crouch (Ctrl) to go down
float z = Input.GetAxis("Vertical");

Vector3 flyDirection = cameraTransform.right * x + cameraTransform.up * y + cameraTransform.forward * z;
transform.position += flyDirection * flySpeed * Time.deltaTime;
}

In the Fly() function, we’re taking input for the x, y, and z axes. The ‘Jump’ key (usually Spacebar) makes the player ascend, while another key (assigned to “Crouch” in the Input Manager, commonly Left Ctrl) makes the player descend.

The player can move in the direction the camera is facing, including upwards or downwards based on the camera’s orientation.

For the script to work correctly, ensure you have input axes set up for “Crouch” in your Input Manager, or replace Input.GetAxis(“Crouch”) with your desired key detection logic.

If you need help setting up Crouch here are the steps…

  1. Open the Input Manager:
  2. Go to Edit -> Project Settings in the Unity Editor.
  3. In the Project Settings window, select the Input Manager from the list on the left side.
  4. Add a New Input Axis: In the Input Manager, you’ll see a list of input axes already set up (like Horizontal, Vertical, Jump, etc.). Find the Size field under the Axes and increase it by 1. This will create a new entry at the bottom of the list.
  5. Configure the New Axis for Crouch: Scroll to the bottom of the list to find the new axis you just added. Expand the axis and set up the following properties:
  • Name: Give it a descriptive name like Crouch.
  • Negative Button: Leave this blank (not needed for crouch).
  • Positive Button: Enter the key you want to use for crouching, such as left ctrl.
  • Alt Negative Button and Alt Positive Button: Leave these blank or set alternative keys if needed.
  • Gravity, Dead, and Sensitivity: Set these to the default values used by other buttons, like 1000 for Gravity and Sensitivity, and 0.001 for Dead.
  • Snap: Unchecked.
  • Type: Key or Mouse Button.
  • Axis: Leave at default (usually the X axis).
  • Joy Num: Leave this as Get Motion from All Joysticks.
  • Save Your Changes: Close the Project Settings window. Your new Crouch input is now set up and ready to use in scripts.

Since we now use Ctrl in our Fly() function you can change the FlyThroughCamera class, in this class we use ctrl to lock the cursor. I have changed the keycode to alt here, you can choose any key combos you require.

if (Input.GetKeyDown(KeyCode.AltGr) || Input.GetKeyDown(KeyCode.LeftAlt))
{
isCursorLocked = !isCursorLocked;
ToggleCursorState();
}

Having dealt with our basic player system we can move on to the Infinite Terrain system. For implementing infinite terrain in our voxel engine let’s focus on several key components:

Dynamic Chunk Loading and Unloading

  • Develop a system to generate and display chunks around the player’s position. As the player moves, new chunks should be generated in their vicinity while distant chunks are unloaded to free up resources.

Seamless Terrain Generation

  • Noise Algorithm: Use noise algorithms to produce seamless results when transitioning between chunks.
  • Biome Integration: For more varied landscapes, implement different biomes with unique characteristics.

Performance Optimization

  • Level of Detail (LOD): Implement LOD to render distant chunks with less detail.
  • Multithreading: Utilize Unity’s Job System or threads to generate chunks without hindering the game’s performance.

Memory and Resource Management

  • Object Pooling: Reuse chunk objects instead of constantly creating and destroying them.
  • Efficient Data Structures: Use data structures that minimize memory usage and support quick access and modifications.

Testing and Refinement

  • Bug Testing: Continuously test for bugs, especially at chunk borders.
  • Performance Tuning: Regularly profile the game to identify and optimize performance bottlenecks.

This framework sets the foundation for creating an infinite world. The key is to balance dynamic world generation with performance and resource management to provide a smooth and engaging player experience.

Finding the Player position is going to be important, in the PlayerController class add this var…

private Transform playerTransform;

In the Start(), caching the transform is always good practice, this is the Players transform, from it we will get the real-time position…

void Start()
{
SetPlayerCenter();
playerTransform = transform;
}

Adding a new function to get the Players position will come next so add this function to the PlayerController class also…

public Vector3 getPlayerPosition()
{
return playerTransform.position; // Return the current position of the player.
}

In the World class add these vars…

PlayerController playerController;
Vector3 playerPosition;

Also add a new reference in the Start()…

void Start()
{
playerController = FindObjectOfType<PlayerController>(); // Add this
chunks = new Dictionary<Vector3, Chunk>();
GenerateWorld();
}

We are finding the PlayerController at Start() and caching it, add these lines in the World class Update() function…

playerPosition = playerController.getPlayerPosition();
UpdateChunks(playerPosition);

Using our recently added GetplayerPosition() in the PlayerController we can get our Players position then sending it to the UpdateChunks() function which we will look at now…

void UpdateChunks(Vector3 playerPosition)
{
// Determine the chunk coordinates for the player's position
Vector3Int playerChunkCoordinates = new Vector3Int(
Mathf.FloorToInt(playerPosition.x / chunkSize),
Mathf.FloorToInt(playerPosition.y / chunkSize),
Mathf.FloorToInt(playerPosition.z / chunkSize));

// Load and unload chunks based on the player's position
LoadChunksAround(playerChunkCoordinates);
UnloadDistantChunks(playerChunkCoordinates);
}

In the World class add the UpdateChunks() function, we get the Chunk coordinates based on the player position, to begin we will just use the player position to load and unload chunks in real-time, for optimization we can manage chunks, when we leave a chunk, or when we pass 2 chunks.

Add one more var to the World script…

public int loadRadius = 5; // Define how many chunks to load around the player

Put the LoadChunksAround() function in the World script…

void LoadChunksAround(Vector3Int centerChunkCoordinates)
{
for (int x = -loadRadius; x <= loadRadius; x++)
{
for (int z = -loadRadius; z <= loadRadius; z++)
{
Vector3Int chunkCoordinates = new Vector3Int(centerChunkCoordinates.x + x, 0, centerChunkCoordinates.z + z);
Vector3 chunkPosition = new Vector3(chunkCoordinates.x * chunkSize, 0, chunkCoordinates.z * chunkSize);
if (!chunks.ContainsKey(chunkPosition))
{
GameObject chunkObject = new GameObject($"Chunk_{chunkCoordinates.x}_{chunkCoordinates.z}");
chunkObject.transform.position = chunkPosition;
chunkObject.transform.parent = this.transform; // Optional, for organizational purposes

Chunk newChunk = chunkObject.AddComponent<Chunk>();
newChunk.Initialize(chunkSize); // Initialize the chunk with its size

chunks.Add(chunkPosition, newChunk); // Add the chunk to the dictionary
}
}
}
}

Since we passed the chunk coordinates the player is currently on, we loop through our load distance set the position for these new chunks if a chunk does not already exist on the position then Create the new GameObject chunk, set its position, add out chunk component to it, do the initilization which creates the mesh, and add it to the chunks array.

In a similar manner we need to unload the distant chunks since we can not see them anymore according to our new unloadRadius var…

public int unloadRadius = 7; // Chunks outside this radius will be unloaded

Separating the unload and load radius distance will give us more control over optimizations and future endeavours. Add the public var to the top of the World script and now we insert our UnloadDistantChunks() function…

void UnloadDistantChunks(Vector3Int centerChunkCoordinates)
{
List<Vector3> chunksToUnload = new List<Vector3>();
foreach (var chunk in chunks)
{
Vector3Int chunkCoord = new Vector3Int(
Mathf.FloorToInt(chunk.Key.x / chunkSize),
Mathf.FloorToInt(chunk.Key.y / chunkSize),
Mathf.FloorToInt(chunk.Key.z / chunkSize));

if (Vector3Int.Distance(chunkCoord, centerChunkCoordinates) > unloadRadius)
{
chunksToUnload.Add(chunk.Key);
}
}

foreach (var chunkPos in chunksToUnload)
{
Destroy(chunks[chunkPos].gameObject);
chunks.Remove(chunkPos);
}
}

We also need to change the way we do noise. Currently our noise generation creates a static array of noise points, however with infinite terrain we need a dynamic noise solution. Luckily our library has the exact function we need. In our GlobalNoise class add a new function…

public static float GetNoisePoint(int x, int z) 
{
float scale = World.Instance.noiseScale;
float noise = Noise.CalcPixel2D(x, z, scale);

return noise;
}

Replacing the old noise generation is this GetNoisePoint() function that takes the coordinates along the x and z, also our noise scale variable and we feed that into the CalcPixel2D() function, this function can return continuous seamless points of noise.

While in the Noise class add one more function…

public static void SetSeed() 
{
Noise.Seed = World.Instance.noiseSeed;
}

Just setting the noise seed to allow reproducible terrain.

In the World class Start() remove the GenerateWorld() call and add the SetSeed() call…

void Start()
{
GlobalNoise.SetSeed();
playerController = FindObjectOfType<PlayerController>();
chunks = new Dictionary<Vector3, Chunk>();
}

Visit the Chunk class, this time we are modifying the DertermineVoxelType() method…

private Voxel.VoxelType DetermineVoxelType(float x, float y, float z)
{
// Now we use the new GetNoisePoint() function
float noiseValue = GlobalNoise.GetNoisePoint((int)x, (int)z);

// Normalize noise value to [0, 1]
float normalizedNoiseValue = (noiseValue + 1) / 2;

// Calculate maxHeight
float maxHeight = normalizedNoiseValue * World.Instance.maxHeight;


if (y <= maxHeight)
return Voxel.VoxelType.Grass; // Solid voxel
else
return Voxel.VoxelType.Air; // Air voxel
}

Using our GetNoisePoint() function we get the new noise values from our modified noise code. Press Play…

Our next step is to optimize the delivery and removal of chunks, currently we create and destroy chunks leading to resource overheads. Rather we should pool chunks and use already created chunks and even delve into the Unity job system to offload some calculations.

--

--

CodeComplex

Exploring the digital depths of coding and tech. Innovator at heart, sharing insights on voxel engine development and Unity. #CodeComplex 🌐💻🚀