All work

Voxel World

An infinite, deterministic voxel world generated entirely on the GPU with HLSL compute shaders: Simplex FBM terrain shaped by continentalness, erosion and peaks-and-valleys splines, 25 interpolated biomes, and multi-type caves modelled on Minecraft 1.18.

Year
2026
Duration
~3 months, part time
Team
3 programmers
My areas
Terrain heightmap and splines, the biome table, and moving generation onto compute shaders with async readback
Stack
Unity 6, C#, URP, HLSL compute shaders, AsyncGPUReadback, Texture2DArray, ScriptableObjects
Platform
PC / Windows
Context
Game AI coursework, third year of the HND at ESAT
Status
Finished
Links
Video

Overview

This was coursework for the Game AI module, and the angle we took on the brief was to treat procedural generation as offline AI — PCG — rather than as NPC behaviour. What we wanted to find out was whether the terrain and cave architecture Minecraft introduced in 1.18 could be reproduced in Unity and still stream without hitching.

The result is an infinite, deterministic voxel world: the same seed always produces the same world, with no pre-authored data files behind it. All the expensive work — heights, biomes, caves, ore veins — runs in HLSL compute shaders, and the result is read back to the CPU asynchronously so that meshing a chunk never stalls the frame.

Key features

Goals & constraints

Architecture

The pipeline has four stages, two of them on the GPU.

  1. HeightMapNoise.compute — computes terrain height: three Simplex FBM fields (continentalness, erosion, peaks and valleys), each passed through its own spline, with domain warping applied first. The same pass samples temperature and humidity and classifies the biome.
  2. CaveShader.compute, driven by CaveComputeManager.cs — a second pass that carves holes out of the voxels already marked solid, producing the three cave types and the ore veins.
  3. AsyncGPUReadback — returns the voxel data to the CPU without blocking. Every piece of CPU work that follows hangs off the readback callback, and that is what guarantees the ordering.
  4. Container.cs — meshes the chunk with face culling, packing UVs, colour, smoothness, metallic and the texture-array index into the vertices. TreePlacer.cs then places vegetation.

Around that: WorldManager decides which chunks load according to the view distance, a pool of pre-instantiated Containers avoids creating and destroying GameObjects, a NoiseBuffer pool reuses the GPU buffers, and every parameter lives in TerrainSettings.

Voxel terrain running from a forested coastline, across water, up to a snow-capped rocky ridge
Coast, forested lowland and a snow-capped ridge in a single view. This spread — flat regions and mountainous regions rather than uniform hills — is what the three-map split with separate splines buys.

Technical decisions

Simplex rather than classic Perlin. Lower cost as dimensions go up — O(n²) against O(n·2ⁿ) — and none of the axis-aligned artefacts Perlin leaves behind. We used the HLSL port of the Ashima Arts implementation.

FBM plus splines rather than Diamond-Square, WFC or pre-authored heightmaps. Diamond-Square is not tileable and is CPU-bound, WFC does not scale to world size in 3D, and a hand-drawn heightmap contradicts the requirement for an infinite world. FBM gives GPU parallelism, determinism and continuity.

Neural networks ruled out for the terrain: they would need curated datasets, they are not deterministic without a controlled seed, and the runtime inference cost does not pay for itself here.

Compute shaders rather than the C# Job System. Noise computation is massively parallel with no dependencies between voxels, which is exactly what suits a GPU. The Job System is the obvious next step, but for the CPU-side meshing, not for the noise.

AsyncGPUReadback rather than GetData(). GetData synchronises and stops the main thread; with the asynchronous readback the frame carries on and meshing happens when the data has actually arrived.

Caves by composing Voronoi and Simplex in one pass, rather than Perlin Worms or L-systems. Worms give only one kind of cave and are iterative, which is bad for a GPU; L-systems are CPU-bound. Composing noise fields with different offsets yields three morphologies in the same dispatch.

Hard voxels rather than Marching Cubes. Marching Cubes would give smoother cave walls, but it breaks the voxel representation and complicates the meshing considerably.

Systems in detail

Heightmap: FBM with splines

What it does. Computes terrain height for every column of the chunk, on the GPU, from world position and seed. This is what defines coastlines, plains, plateaus and mountain ranges.

How it works. Three Simplex FBM maps, each with its own scale and octave count, each passed through a different spline:

Before sampling, the coordinates are displaced by domain warping: noise is evaluated over the coordinates themselves and the result added to the sampling position. Finally the height is multiplied by the biome's height multiplier.

Why this way. With a single FBM the terrain comes out uniform — everywhere has the same "amount of mountain". Splitting it into three maps with distinct roles is what allows entire regions to be flat and others entirely mountainous, which is the feeling Minecraft has had since 1.18. The splines are cheap, a handful of operations per sample, and give far more control over the final shape than adjusting octaves and persistence blind.

// HeightMapNoise.compute — domain warping: the sampling coordinate is
// displaced, not the noise, to break the regularity of the FBM
float wx = snoise(worldXZ * warpScale);
float wz = snoise(worldXZ * warpScale + float2(5.2, 1.3));
warpedXZ += float2(wx, wz) * warpStrength;

The noise itself is untouched; what moves is the place you look at it from. Three lines and two extra samples, and the terrain loses the mathematical regularity that gives noise away. The float2(5.2, 1.3) offset on the second sample is there to keep wx and wz uncorrelated — sample the same point for both and the displacement always runs diagonally.

Biomes: a 5×5 temperature/humidity grid

What it does. Classifies every point in the world into one of 25 biomes, from tundra (cold and dry) to jungle (warm and humid), and from that derives the surface block, the subsurface block, the depth of the surface layer and the height multiplier.

How it works. Two independent FBM fields, temperature and humidity, normalised to [0,1]. Those two values index a 5×5 matrix. The point is that the two lookups are done differently: the block ID uses nearest neighbour, a hard integer selection, while the height multiplier is interpolated bilinearly between the four adjacent cells. The multipliers are clamped to [0.85, 1.15], and the maximum cave height scales with the same multiplier, so an ocean-floor biome gets proportionally shallower caves.

Why this way. The first version used nearest neighbour for both, and sudden cliffs appeared right on the biome boundaries: the multiplier jumped and the terrain stepped. Interpolating the block ID makes no sense — there is no "half sand, half snow" block — but height genuinely is a continuous field. Decoupling the two lookups killed the seam without blurring the visual identity of the biomes.

A snow-capped orange rock biome meeting a green forested biome, with the terrain flowing continuously across the boundary
The boundary between two biomes, and the fix from the section above in one frame: the surface blocks change abruptly — which is correct, they are categories — while the terrain height runs continuously across the join. The snow line comes from the height multiplier, not from the biome lookup.

Development process

1. A voxel world on the CPU

A single-octave heightmap, everything computed on the CPU, no biomes. This is where the data structures that survived the rest of the project were built: IndexArray<T>, the NoiseBuffer pool, Container and WorldManager. What it taught us: one octave gives you soft hills and nothing else, and the CPU chokes the moment you raise the view distance.

2. Moving to the GPU

The noise moved into HLSL compute shaders and the data came back through AsyncGPUReadback so as not to stop the main thread. Multi-octave FBM arrived, along with the three-map spline system. This is the point where the project stopped being an exercise in noise and became a CPU–GPU synchronisation problem.

3. Biomes and caves

The 5×5 table, the temperature and humidity fields, bilinear interpolation of height, and the second compute pass (CaveShader.compute) with the three cave morphologies. This was the milestone with the most parameter iteration: tuning 25 biomes so they stay distinct from each other while still blending took a lot of trial and error.

4. Polish and performance

Domain warping, tree placement, the snow line, ore veins, the dynamic collision radius, the Container pool, and grass decoration with cross-quads.

Problems & solutions

Sudden height steps on biome boundaries

Symptom. Clean vertical cuts in the terrain, cliffs a couple of blocks high, following lines that corresponded to no geographic feature — you could see it was a grid rather than relief. They always appeared where the surface block type changed.

Diagnosis. The first suspect was the noise: we assumed the FBM had discontinuities, or that the problem was in sampling across chunk boundaries, so we went through the world coordinates and the chunk edges. It was neither — the artefact also showed up in the middle of a chunk. Painting the height multiplier as a debug colour made it obvious: the multiplier was constant per biome cell and jumped in one step. The noise was continuous; what was not was the table lookup.

Fix. Separate the two lookups into the biome table. The block ID stays on nearest neighbour — it is a category, it does not interpolate — while the height multiplier moved to bilinear interpolation between the four adjacent cells.

Result. The seams disappeared completely, without blurring the surface blocks or losing the visual identity of the biomes. We did not measure the cost, but bilinear interpolation is four reads and three lerps per column: noise next to the FBM itself.

Corrupt meshes and misplaced trees after moving to the GPU

Symptom. Chunks with loose faces and holes, and trees floating or buried inside the terrain. It was not deterministic — the same chunk came out right or wrong depending on load.

Diagnosis. An ordering problem, not a data problem. TreePlacer and GenerateMesh were being called before the GPU readback had finished, so they were reading a half-populated buffer. With synchronous GetData() it did not happen — but GetData() stops the main thread, which was precisely what we were trying to avoid.

Fix. Chain every CPU operation inside the AsyncGPUReadback callback, in the closure, instead of firing them after the dispatch. The ordering is then guaranteed by construction rather than by luck.

Result. No more corrupt meshes, and the asynchronous readback is kept, with no main-thread stall.

Hitching while streaming chunks, and physics stalls

Symptom. Frame-time spikes when moving through the world, particularly on entering new areas.

Diagnosis. Two separate causes stacked on each other. A GameObject was being instantiated and destroyed per chunk, with all the GC pressure that generates; and having a MeshCollider active on every loaded chunk triggered constant physics rebuilds.

Fix. A pre-warmed pool of Containers with a reset step instead of Instantiate/Destroy, and collision enabled only on the chunks within collisionRadius of the player.

Result. The allocation spikes and the physics rebuild stalls both went away. Not yet measured with numbers.

A carved cave system seen in cross-section, with chambers and tunnels running beneath the forested surface
The cave system in cross-section. The wide chambers are the cheese caves, from inverted Voronoi; the long passages running between them are the spaghetti tunnels. All of it is carved in a single second compute pass over voxels already marked solid.

Results

An infinite, deterministic world generated entirely on the GPU, streaming chunks with no latency you notice while playing normally. 25 biomes, three cave types, ore veins, trees configurable from a ScriptableObject, and a custom shader driving a Texture2DArray. It was submitted as the Game AI practical alongside a technical postmortem.

One honest gap: we never took measurements. The optimisations are correct by construction — pooling, collision by radius, asynchronous readback — but there are no before-and-after numbers for milliseconds per chunk or frame rate at a given view distance, and that is the first thing I would add.

What I would do differently

What I learned

Video

Credits & references

Built with Guillermo Boscá Ballester and Alessandro J. Acevedo Peña.

Third-party code.

Technical references.