Cubeless

Back

Cubeless is a procedural voxel terrain generator capable of generating mutable and infinate terrain (at least within the range of the 32bit chunk coordinates on each axis).

Cubeless is developed as a generic implementation so that it can be used across a number of game projects.

Key Features

Parallel Generation

Each step of the generation process is implemented as a parallel process with burst compiled jobs in Unitys DOTS. This ensures voxel terrain generation can take place at runtime without a significant performance cost.

Terrain Model Generation

Terrain model generation is the initial placement of blocks in the voxel terrain. It is achieved by generating fractal noise maps for different aspects of the terrain.

The following is a simplified explanation of the process.

  1. A 2D Perlin noise map is generated for terrain height. The value of noise at each position is mapped to the min and max block height of the world. Several octaves of noise help to make the relief look more natural.
  2. A 3D perlin noise map is generated for ore deposits. Values with a Y position greater than the terrain height are discarded so that ore does not appear above the surface.
  3. A 2D perlin noise map is generated for forested areas. Trees are then generated at random positions within these areas taking local constrainsts into account such as terrain relief, block types, and proximety to other trees.
Cubeless main 0

Mesh Generation

Terrain meshes are generated in chunks of 16x32x16 blocks. This has a number of advantages for performance:

  1. The number of meshes and therefore draw calls is significantly reduced compared to having a mesh for each block.
  2. Mesh regeneration as a result of terrain modifications is limited to the chunks where the changes occurred (and potentially their neighbors).
  3. Chunks that are not needed can be unloaded from memory.

Removing unnecessary faces

To reduce the vertex and face count of a chunks mesh, only block faces that are exposed to air are appended to the mesh during generation.

This means each chunk must be aware of its neighboring chunks to determine if its outer layer of blocks is exposed to air. For this purpose a bitmask is generated for a chunk's faces indicating the presence or absence of blocks. This can be referenced by a neighboring chunk when determining which blocks to include in its mesh.

Combining faces

Even after removing block faces that are not exposed to air, a chunk mesh could still include many unnecessary vertices in flat areas blocks.

One option to improve this is the Greedy Meshing algorithm, which combines faces into larger quads where possible. The issue with Greedy meshing is that it requires many block comparisons, and could impact the performance of chunk regeneration. Without profiling I can't be sure this would have been a problem, however as its implementation would have interfered with the existing parallelised generation pipeline I decided to look for alternatives.

The solution I went with was inspired by this article, which suggested combining block faces together in runs of rows and columns. This fits much better with my existing parallel architecture as block lookups are limited to that row or column. Although this approach does not reduce the number of vertices and faces as much as greedy meshing would, it still reduces the count by a significant amount and had minimal impact on chunk mesh generation performance.

Goblin Mode ARD0
Before
Goblin Mode ARD1
After

UV Mapping

UV mapping is an import aspect of the chunk mesh generation process, so that the correct texture is applied to each block face. To achieve this each vertex was assigned 3 dimensional texture coordinates.

The U and V values of the texture coordinates were assigned based on the size of the quad so that the texture would repeat for each block face the quad represents.

The final component of the texture coordinates was assigned the index of the block type represented by the face, which mapped to a layer in a 2D texture array containing all block textures.

A minor drawback with this approach was that the previously mentioned technique for combining faces into runs had to be adjusted. Now runs can only continue while the block type is the same. If a different type is found, a new run is started.

Vertex attributes and custom Shader

This project was the first time I have worked with Unity's advanced mesh API. The advanced API gives more control when reading and writing mesh data, and allows for snapshots of mesh data to be used in burst compiled code and jobs.

In the advanced mesh API vertex attributes are explicitly defined when assigning vertices to the mesh. Default attributes can carry much more data than is required for a voxel mesh, so I thought it would be an interesting challenge to see how much I could compress this data.

The default vertex attributes used a float3 for position, normals and uvs. This totaled to 36 bytes per vertex.

I managed to reduce this down to two uint32 attributes for a total of 8 bytes per vertex.

Its worth noting that a custom shader was required to ingest the compressed data. Heres how it was allocated:

Attribute 1 (Position)

Attribute 1 (Texture Coordinates)

Cubeless main 1

raycasting

Raycasting within the voxel terrain was a feature I wanted to implement to find a voxel or chunk within the terrain.

Using Unity's build in ray casting system would require chunks to have colliders. I wanted to avoid this, as the terrain could be regenerated at a high frequency, and generating colliders can be an expensive process.

As an alternative I implemented a DDA (Digital Differential Analyzer), with the help of this video. DDA excells at performantly rayycasting through 2D or 3D grid partitioned space. It works by stepping through the grid space, with each step ending at the boundary between two voxels. After each step the voxel face the ray is entering can be checked, and if it exists that would be the collision point.

Photo

The current implementation is simple, always stepping though each voxel within the ray path. It could be further improved by leveraging chunk metadata to skip entire chunks in one step when empty.

© Daniel John Miller