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.
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?
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.
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:
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.
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.
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:
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.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.
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.

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.

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.

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

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.

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:

The image also displays how the peaks and the valleys get more flattened by the smoothstep, whereas the transitional regions become more craggy.
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:
3 * 72 = 216 units in front of you, and the loaded terrain stretches 7 * 72 = 504 units across in total at any given time.361 * 361 = 130'321 vertices. The below animation shows the same terrain, generated at four different resolutions from low to high.
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).
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.