Casper van Battum

Projects

Roots

2024-2025 | Unity 6.1 • C# • Jobs/Burst • Shaders • Mesh generation

Optimizing, generating, and rendering real-time procedural terrain, built from scratch for my graduation project.

Terrain generation is typically a resource-intensive process. There are also challenges when it comes to real-time interaction with procedural terrain, with terrain data often being inaccessible and/or static during runtime.

Real-time chunk loading

Dating all the way back to the days I played early Minecraft (I remember beta 1.8 being a big deal for us), I have been very interested in procedural terrains. As I developed my technical skills and started to understand the implementations of such systems, I only grew more fascinated. Computing and moving such amounts of data as unnoticeably as possible was a challenge I always wanted to try my hand at. What better time to attempt it than during my graduation?

Beginnings

The foundation of my chunk loader is simple: track the location of the player and toggle chunks on or off based on the distance to the player. The terrain in question was simply a large, chunk-sized plane mesh that I would instantiate and destroy on demand. It would all happen in linear time, because simple instantiation of a prefab in Unity is not that slow.

That changed when I wanted to add interesting features to my terrain. My first experiment involved placing trees on the ground. On every loaded chunk I would also spawn a whole bunch of trees. Because it was still just an experiment, I quickly ran into the limitations of what I could do with simple functions in linear time.

Generating terrain meshes

I ended up dropping the infinite forest of trees in favor of terrain heightmaps (more on that later). This is where the amount of vertex data became impossible to calculate in real-time without serious optimization work. Listed below are the optimizations I applied, in no particular order:

  • Fast noise library: The standard Unity noise functions in Mathf are fairly slow. I opted for the FastNoiseLite library for its ease of integration and open license. At first this did not work cleanly with the Burst compiler, but after some minor modifications it did.
  • Object reuse: The small brother of object pooling. Object pooling assumes a variable number of objects and tracks object usage dynamically. In a finite grid, you will always have a constant number of loaded chunks. The number will not change, only the positions will change. By simply moving the chunks outside the range into the new range, you get a zero-overhead system where no object needs to be instantiated after load.
  • Unity Jobs & Burst: By far the most important optimization, and solely responsible for at least a ~20x speed increase, if not more. I use jobs to generate a noise sample for each chunk on load, then to convert these noise samples into an array of fully qualified vertices (position, normals, UVs), and then to generate a new mesh based on the list of vertices. I can leverage the Burst compiler really well for these jobs, as most of it is plain math that is easily vectorized into SIMD-instructions.
  • Non-blocking chunk loading: New chunks are always loaded in the distance, not right in front of you. It doesn’t matter that these chunks aren’t there in the exact frame you crossed a chunk boundary; it’s fine if they arrive a few frames later. Because a Unity Job is multithreaded by design, one simply has to query whether the job is done each frame, and when it is, assign the data and re-enable the chunk with its new data.
  • Disabling Unity physics: This might seem like an odd one out. During profiling, I was consistently being confronted with the time it took to generate physics colliders for the terrain meshes. My first optimization was to generate the collision mesh from a lower resolution sample of the height function, but this was still exceedingly slow. Ditching Unity physics entirely proved to be a massive improvement, but this meant I had to create custom solutions to replace certain features, like gravity and ray intersection. Read more about it in the section about the terrain API.

Jobs profiler

Caching

There are more optimizations possible for generating the terrain. Caching is conspicuously missing from the above list. It should be fairly simple to implement based on the existing infrastructure, but it was simply never necessary as the generation is fast enough as it stands. The same is true for saving the world, or pre-generating it, a feature which would come hand-in-hand with caching.

Terrain interaction (API)

Using a custom terrain required me to implement some custom interaction features. The basis for most of the interaction is actually knowing the terrain height at any given point. For this, I assumed the terrain will always be convex. I implemented two methods of obtaining the height at any given point: fast and slow.

The fast method (ChunkLoader.GetInterpolatedGroundHeightAt(Vector3 position)) uses the stored heights of an existing chunk and interpolates the height at the exact world position. It is less exact, but much faster. The major limitation is that this can only be run for chunks that are currently loaded.

The slow method (ChunkLoader.GetExactGroundHeightAt(Vector3 position)) runs the original noise job for any given world position. It is exact (but not necessarily confined to the existing ground mesh, which can be both advantage or disadvantage), and can be run at any world position even where chunks are not currently loaded.

Additionally, I implemented a method that always returns a valid height for any given position, which switches between the interpolated and exact methods based on whether the chunk that position sits in is loaded or not.

Using this API, I implemented the following features:

  • Collision: The player needs to be able to walk on the terrain. Originally I was using physics-based colliders generated from the terrain, but due to the cost of generating these in real time, I favored the idea of a height-based collision. It is less collision than it is simply copying the interpolated height of the terrain to the player’s own height. This does not allow for jumping, but I did not need a jumping feature. In theory, it should be fairly easy to re-implement jumping based on the current API.
  • Ray intersection: Again, I couldn’t rely on the collider-based raycast in Unity (Physics.Raycast) when I wanted to implement a feature where the player would aim at the terrain. I implemented a simple raymarching algorithm, which samples the (interpolated) height of the terrain along a ray and returns an intersection point.
  • Spawning objects: I wanted to place objects at various positions on the map. These usually use the exact height, because they might spawn in places that are not currently loaded. I also implemented features to find the lowest position around chunks, which simply finds a least-worse location around an input position.

Indexing

I am involved in a long-standing battle with grid indexing methods. Earlier, in other projects, I attempted using dictionaries or multi-dimensional arrays. They both have their inefficiencies, with C# Dictionary having very significant overhead during lookup. For this project I implemented a single-dimensional array. The code for this turned out to be fairly complex, but it is also very fast. Alongside the world-space grid, I overlay a virtual local grid to convert world positions or world-space chunk positions into indices for the chunk array. I implemented several methods on ChunkLoader to convert any input position into any other input position, making indexing extremely cheap computationally.

Modularity

The chunk loader acts as a central control point, but most of the other features are completely modular. The terrain generator acts as a module to the chunk loader, and even the noise generator is a swappable module for the terrain generator. Most gameplay components will at some point need to interface with the terrain, but can do so easily using ChunkLoader’s public API. Most gameplay components can function independently based on a provider-consumer pattern; they might need some other component, but that other component will not need them.

Different modular components in the inspector

Point cloud

Early in the project I wanted to be able to switch between a physical world and a point cloud visualization of the terrain. I kept this feature around for a very long time, and kept it compatible with most of the features I added later in the project. I had to implement callbacks and events on the ChunkLoader to be able to update the GPU buffers required for instanced rendering. I would later use these callbacks for other features as well, further contributing to the modularity of the terrain system.

Point cloud visualization of the terrain

Terrain noise functions

Cellular density map

One aspect of this project was designing and experimenting with noise maps. My first idea was to create a density map for my tree world that would act as an unbroken network of paths for the player to walk through. I achieved this by combining a voronoi (worley) distance noise input map with a simple perlin (gradient) noise map. The perlin noise offsets the edges of the voronoi cells to make them look more natural and organic.

You can see this setup, prototyped in the Unity shader editor, in the image below. The white areas indicate where trees would grow, the black is where paths emerge.

Shader graph to generate a density map with paths

Below you can see the map overlaid on the terrain directly: Density map overlaid on terrain

Terrain map

The next step was implementing terrain height. I wanted to keep the paths from the tree density map, but interpret them as valleys in the terrain instead. Using the FastNoise library, I used its complementary noise tool, a visual node-based graph editor, to prototype the look I wanted to have. I ended up using a combination of fractal simplex noise (fractal brownian motion), fractal ridged noise, and a cellular distance map.

Four noise maps that I combined into my terrain

On top of the fractal modifier, I apply domain warp for a more natural look to the terrain. I first combine the ridge map and the voronoi map, then I calculate a FBm sample. I use the voronoi to set what is essentially the strength of the FBm map. A “low” value means that there is a flat valley, a higher sample results in a hilly peak. I chose to apply this effect with smoothstep rather than linearly, to get steeper (and more natural-looking) transitions in the landscape.

In the screenshot below, you can clearly see the lines traced by the valleys, where the distance to the center of the cells of the voronoi map is the greatest:

Overview that shows the valleys in the terrain

The image also displays how the peaks and the valleys get more flattened by the smoothstep, whereas the transitional regions become more craggy.

Rendering

Rendering large terrain is not trivial either. I had to resort to several optimizations in order to achieve a reasonable performance. Here they are listed in no particular order:

  • GPU instancing compatible: The Unity URP renderer can auto-batch dynamic objects into a single draw call using the SRP batcher. Unity 6 introduces the GPU resident drawer, further improving performance by moving more work to the GPU. Making sure the terrain was compatible with these features saved on much drawing overhead.
  • Chunk size: The chunk size is fully dynamic in my renderer. Large meshes are expensive to generate, but conversely, drawing fewer of them actually improves rendering performance pretty significantly. After some experimenting, I settled on a chunk size of 72 world units, in a (square) radius of 3 chunks. In other words, the world stretches 3 * 72 = 216 units in front of you, and the loaded terrain stretches 7 * 72 = 504 units across in total at any given time.
  • Terrain resolution: A major factor in performance is the resolution of the terrain, i.e. how many vertices each chunk contains. A low resolution meant less defined features, but good performance. A detail I also noticed was how prominent self-shadows became in the low-resolution terrain. I settled for a resolution of 4 samples per unit, which results in each 72-unit chunk containing 361 * 361 = 130'321 vertices. The below animation shows the same terrain, generated at four different resolutions from low to high.

Terrain at different resolutions from low to high.

Further optimizations

Due to time constraints and assignment requirements, I did not have the time to implement further optimizations. Things that would help real-time performance even more (though usually at the cost of generation performance) are mesh quantization (low resolution in low detail areas, higher resolution in higher detail areas), generating lower resolution meshes for further away (LODs), and drawing more terrain in front of the player than behind them (culling, sort of).

Vegetation rendering

Later in the project I experimented with rendering vegetation meshes. I did not invest much time and resources into this, but one observation was that my custom-rolled implementation of instancing was not that much faster and much harder to work with than relying on the GPU resident drawer for instancing.