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
- Infinite, deterministic world generation resolved entirely on the GPU through HLSL compute shaders, with
AsyncGPUReadbackand no CPU stalls. - Terrain built from Simplex FBM, combining three maps through their own splines: continentalness, erosion, and peaks and valleys.
- 25 biomes on a 5×5 temperature/humidity grid, with the height multiplier interpolated bilinearly so the boundaries do not step.
- Multi-type caves carved in a second compute pass: cheese (large chambers from inverted Voronoi), spaghetti (long tunnels) and noodle (micro-tunnels from Voronoi raised to the fourth power), plus iron and gold veins.
- Domain warping applied to the sampling coordinates, to break the regularity of the noise and make the terrain read as geology.
- Meshing with face culling — only faces adjacent to air or water are emitted — and a Texture2DArray driven by a custom shader (VoxelLit) so materials vary without extra draw calls.
- Pre-warmed chunk pool, with MeshCollider enabled only within a radius around the player.
- Vegetation placed by deterministic hash on the highest grass block, configurable from ScriptableObjects without touching code.
Goals & constraints
- An infinite world streamed in chunks with no perceptible hitching: a chunk has to be ready before the player reaches it.
- Total determinism — same seed, same world — with no dependency on external data files or pre-authored assets.
- Every generation parameter exposed in a ScriptableObject (
TerrainSettings), so the team could iterate without recompiling. - Constraints: Unity was imposed by the module, roughly three months part time, three of us.
- Deliberately out of scope: world persistence, LOD for distant chunks, and greedy meshing.
Architecture
The pipeline has four stages, two of them on the GPU.
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.CaveShader.compute, driven byCaveComputeManager.cs— a second pass that carves holes out of the voxels already marked solid, producing the three cave types and the ore veins.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.Container.cs— meshes the chunk with face culling, packing UVs, colour, smoothness, metallic and the texture-array index into the vertices.TreePlacer.csthen 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.
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:
- Continentalness — a spline curving upward that amplifies values above 0.50, so the coast-to-mountain transition is pronounced rather than a gentle ramp.
- Erosion — multiplies the continental height, flattening peaks where erosion is high (the spline returns
1 - e * 0.6). - Peaks and valleys — adds local vertical variation through an inverted absolute-value spline,
1 - |2p - 1|, which produces sharp ridges exactly at the mid-range of the noise.
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.
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.
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
- Greedy meshing. One face per visible voxel is emitted today. Merging coplanar faces of the same type into large quads would cut the vertex count enormously.
- LOD. Distant chunks are meshed at full resolution. Macro-blocks of 2×2 or 4×4 would be invisible at range and save a great deal.
- Persistence. If the player modifies the world, it is lost when the chunk unloads. Modified chunks would need serialising.
- Meshing is still on the main thread. When several chunks finish their readback at once, you feel it. That belongs in the Job System with Burst.
- Measure from the start. We made optimisations we know are right without numbers on either side of them — and the absence shows.
- Cave walls are hard voxels. A hybrid using Marching Cubes for the caves alone would look better.
What I learned
- Writing compute shaders in HLSL and assembling the whole pipeline — dispatch, buffer,
AsyncGPUReadback, CPU work inside the callback — without blocking the main thread. - Telling apart which fields can be interpolated and which cannot: a continuous scalar (height) interpolates, a category (block ID) is chosen. Almost every seam artefact comes from confusing the two.
- Composing noise deliberately: multi-octave FBM, post-process splines to shape the curve, and domain warping to break regularity. I no longer adjust parameters blind.
- Designing deterministic generation — same seed, same world — including vegetation placed by integer hash rather than
Random. - Debugging on the GPU by painting intermediate values as colour, which often gets there before the profiler does.
- Non-obvious HLSL details: the components of
uint3 SV_DispatchThreadIDhave to be cast explicitly tointfor bounds checks, because unsigned arithmetic wraps rather than saturating. - Reducing GC pressure in Unity with pre-warmed pools instead of per-chunk Instantiate/Destroy.
Video
Credits & references
Built with Guillermo Boscá Ballester and Alessandro J. Acevedo Peña.
Third-party code.
- McEwan, I. / Ashima Arts (2011). Simplex noise GLSL 2D/3D/4D, MIT licence (ashima/webgl-noise).
- Lex-DRL (2013). HLSL port of Ashima's Simplex for Unity, used in
SimplexNoise.compute.
Technical references.
- Gustavson, S. (2005). Simplex Noise Demystified. Linköping University.
- Quilez, I. (2002–2024). Articles on noise functions and domain warping, iquilezles.org — the main reference for the domain warping.
- Shaker, N., Togelius, J., Nelson, M. J. (2016). Procedural Content Generation in Games. Springer.
- Millington, I. (2019). Artificial Intelligence for Games, 3rd ed. CRC Press.
- Mojang Studios (2021). Caves & Cliffs Part II: The Features — the cheese / spaghetti / noodle cave architecture that
CaveShader.computeis modelled on. - Minecraft Wiki (2024). Java Edition terrain generation — reference for the continentalness / erosion / peaks scheme.
- Unity Technologies (2024).
AsyncGPUReadbackdocumentation and URP shader reference.