Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Terrain

Verified against Minecraft 26.3 · Part XII · One chunk’s rock: seven hundred and sixty-eight cells filled from their corners, a water table decided before the caves are cut, and the cave that fills with water because of it.

You dig into a cave and it is flooded. The water is not a fluid that flowed in and settled; nothing flowed anywhere. Before the cave existed, while the chunk was still solid stone, something decided that a point at that depth in that column belongs to water rather than to air — and when the hole the carver marked out came to be filled, the aquifer gave the same answer. A carver does not choose the block it carves. It chooses the shape; the Aquifer chooses the material, and it chose it for the stone as well.

This page is one chunk status, ChunkStatus.TERRAIN, and the three steps inside it that turn a scalar field into blocks — the noise fill, the surface pass and the carvers — which share one workspace for as long as the status’s one task runs. The conveyor that runs the statuses, the dependency pyramid and the threading are the chunk generation pipeline in Part IV; this is the cargo. The scalar field itself is density functions, and the labels that steer the surface pass are biomes.

The cast

classwhat it decideswhen
ChunkGeneratorthe API the status calls — ChunkGenerator.buildTerrain, one call for all three steps. ChunkGenerators.bootstrap registers exactly three implementationsworldgen executor
NoiseGeneratorSettingsthe per-dimension recipe: the NoiseSettings floor and height, the default block and fluid, the NoiseRouter, the material rule, the sea level, and the aquifer’s own functions where there is onedata, loaded with the world
NoiseChunkthe per-chunk workspace — the chunk’s DensityVolume, the samplers that carry its caches, the Aquiferbuilt when the terrain task starts, closed when it ends; a height query builds a one-column one of its own
InterpolatedFunctionthe cell: its input sampled at the corners, every block between them interpolatedwhenever the graph is sampled
Aquiferwhat liquid, if any, belongs at a point — and therefore what a carved hole is filled withfill and carving
OreVeinRulecopper or iron, from the sign of one functionthe surface step: in the overworld’s tree, after the bedrock floor and before any surface rule
MaterialSystemthe column re-skin: grass over dirt over stone, sand, the badlands bands, the ore veinsthe surface step and the carvers’ re-skin, one instance per level
WorldCarverthe shape of caves and canyons, and nothing about their contentsthe carver step

Everything here starts on the worldgen executor, and for this generator the whole status fans out from it: NoiseBasedChunkGenerator.buildTerrain hands the fill, the surface pass and the carvers to the worker pool as one task (which steps fork, and why the parallelism is smaller than the thread names). What is worth carrying from that into this page is one object. RandomState — the per-level seed root — is built once in ChunkMap and shared by every generating chunk, and it owns the MaterialSystem, which is therefore per level, not per chunk.

Three steps, and what each hands on

flowchart LR
    BIO["BIOMES"]
    subgraph TER["TERRAIN, one task"]
    FIL["fill"] -- "rock and water" --> SUR["surface"]
    SUR -- "a skin, the veins" --> CAR["carvers"]
    end
    FEA["FEATURES, a later page"]
    BIO -- "biomes, no workspace" --> FIL
    CAR -- "holes, workspace closed" --> FEA

The terrain status is one task: the three steps inside it share one NoiseChunk, built when the task starts and closed when it ends, and the last box belongs to a later page.

The frame is the point. The three steps are one job. ChunkStatusTasks.buildTerrain builds a Blender for the chunk’s neighbourhood and hands it, with the region, to NoiseBasedChunkGenerator.buildTerrain, which runs the fill, the surface pass and the carvers back to back as one task. The workspace is born at the start of that task, when NoiseBasedChunkGenerator.createNoiseChunk gives it the chunk’s DensityVolume, samplers that carry the chunk’s caches, and an Aquifer, and it dies at the end, when NoiseChunk.close hands its density buffers back to RandomState. The biome step before it builds none of this: it samples the climate through a caching sampler of its own (biomes). Two passengers ride in on the workspace, and both are things this page’s terrain is bent by rather than things it decides.

The beardifier is one of them: a beardifier node in the final density’s own data, which reads zero unless the sampling context holds a Beardifier — and NoiseBasedChunkGenerator.createNoiseChunk puts one there for this chunk, built by Beardifier.forStructuresInChunk (wrap: once per chunk). Because it is built with the workspace, a structure has bent the density field before the field is ever sampled — the flat shelf under a village is a number added to a scalar field, not a block edit made afterwards. What that term’s shape is — Beardifier.Rigid boxes, junctions at half weight, and the five TerrainAdjustment modes — belongs to structure placement. A structure asking how high the ground is gets the answer from before it bent anything: NoiseBasedChunkGenerator.iterateNoiseColumn, behind ChunkGenerator.getBaseHeight and ChunkGenerator.getBaseColumn, builds a throwaway NoiseChunk one block wide with an empty Blender and no Beardifier at all, samples a column, and closes it.

The blender is the other, and it is why an empty one appears in that sentence. Blender reads measurements harvested from chunks an older version generated and enters the density graph as three nodes. The one that reaches the graph is the one ChunkStatusTasks.buildTerrain builds with Blender.of for this task, which NoiseChunk puts in the sampling context beside the beardifier; the one the biome step builds bends only the biome resolver (blending at the old-chunk border). BelowZeroRetrogen, the world-deepening path, rides the same hooks: it wraps the biome resolver, patches bedrock after the terrain step, and gates the spawn step (blending’s other passenger).

That one workspace then serves all three steps, so the carving step reuses what the aquifer has already worked out: the water tables it computes on its grid, and the surface levels it caches, are still there when the carved holes are filled — and since each is fixed by the seed, the position and, beside an old chunk, the blender, they would come out the same if they were not. The surface pass samples the preliminary surface and the ore veins through the same samplers and their caches (the one cache, and what a single point may use), and nothing is pinned to a thread: the three steps run back to back inside one task on one worker, so program order is all that serialises them.

Filling the noise: six loops, one number at the bottom

The fill asks for the whole chunk in one call. NoiseBasedChunkGenerator.doFill has the workspace’s samplers sample the router’s final density over the chunk’s DensityVolume — sixteen by 384 by sixteen blocks in the overworld — and gets back one buffer holding a number for every block. The cells are inside that call. NoiseSettings holds only a floor and a height; the cell size belongs to the interpolated node in the final density, and the overworld’s asks for four blocks across and eight up. So the unit of overworld terrain is a cell four blocks wide, four deep and eight tall, and a chunk is four by four by forty-eight of them.

768 cells make one overworld chunk, each holding 128 blocks (InterpolatedFunction).

An interpolated node samples its input at cell corners only. Everything inside a cell is three linear interpolations away from the eight corners around it, and the walk that does this is six loops deep:

flowchart TD
    subgraph CZ["4 cell rows, in Z"]
    subgraph CX["4 cell columns, in X"]
    subgraph CY["48 cells, upward"]
    subgraph BZ["4 block rows, in Z"]
    subgraph BX["4 blocks in X"]
    BY["8 blocks upward, one number written at each"]
    end
    end
    end
    end
    end

Six loops, and none of them samples the graph: every corner was sampled in one call before the first, and everything nested inside is interpolation.

looprunswhat it does
cell row, in Z4nothing but advance
cell column, in X4reads the four corners at the column’s foot
cell, Y upward48reads the four corners above, which become the next cell’s floor
block row, in Z4four interpolations in Z
block, in X4two more in X, and the step from block to block in Y
block, Y upward8one addition, one number written

Read the nesting as the cost model. Every corner is sampled once, in the one volume call made before the first loop, and nothing below it evaluates the graph again: each cell reads four new corners and inherits four, and every block inside it is arithmetic on eight numbers. The counts go up by one in each direction when you move from cells to their corners: four by four by forty-eight cells have five by five by forty-nine corners between them:

1,225 corner samples per chunk for each four-by-eight interpolated term the fill reads (InterpolatedFunction, one volume of five by forty-nine by five).

Six interpolated terms sit in the overworld router, resolved through every reference: one round the bulk of the final density, four inside the noodle-cave graph, and one in chunk_surface_level, whose cell is a whole chunk wide and one block tall. The fill reads the first five; the sixth is the surface pass’s, and the ore veins bring three more of their own. Neither the veins’ gap nor the aquifer’s barrier is interpolated: the vein rule and the Aquifer sample each block by block, and only where they need it. Everything else is sampled at the resolution of the volume it is asked for, less any axis it does not depend on — a term that ignores Y is sampled once per column (the one cache, and what a single point may use).

Then NoiseBasedChunkGenerator.doFill walks the finished buffer block by block — Z, then X, then Y downward — and hands each number to the aquifer. The two worldgen heightmaps are updated as those blocks are written, so walking down, a column’s height for each map is settled by the first block that counts for it.

The write at the bottom of that walk skips air entirely — a chunk starts empty, so only non-air is ever written — and updates Heightmap.Types.OCEAN_FLOOR_WG and Heightmap.Types.WORLD_SURFACE_WG by hand rather than through the chunk’s ordinary write path. It can, because it holds every section across its noise range for the whole fill (what placing a block actually does).

Those two stop being maintained once this status completes, and not because anything stops writing them: which maps are live is a property of the status, not of the step — the two _WG maps up to ChunkStatus.BIOMES, the status the chunk still holds while all three steps run, and the four final ones from ChunkStatus.TERRAIN on, primed by Heightmap.primeHeightmaps as the terrain task ends (the six heightmaps). A chunk saved mid-generation really does persist the pair the fill was keeping.

The aquifer: what the number becomes

NoiseBasedChunkGenerator.doFill does not read the density and compare it to zero. It hands the number to Aquifer.computeSubstance and writes what comes back, and the aquifer is the only thing the number meets.

The aquifer. Aquifer.computeSubstance receives the final density and decides, from four noises of its own — a barrier it samples block by block, and floodedness, spread and lava, which it samples on coarse grids — whether this point is stone, air, or a fluid. None of them is read from the router: the noise settings carry an Aquifer.Config of six functions, those four, an exclusion that forbids a local water table in any aquifer cell where it is positive, and surface_level — which the overworld points at its preliminary_surface_level function — which it samples once per four-by-four column to know roughly where the ground is before any ground exists. Aquifer.FluidPicker is the global fallback underneath its local water tables — the sea, and the lava — and Aquifer.FluidStatus is the record of any one table, local or global. Aquifer.NoiseBasedAquifer is the real implementation; a dimension with aquifers switched off gets a trivial one.

The ore veins are not decided here at all. OreVeinRule is a material rule, and the overworld’s rule tree tries its two veins, copper then iron, after its bedrock floor and ahead of every surface rule, so the surface pass writes them into the stone the fill left. That is why copper and iron veins are terrain rather than decoration: they exist before the carvers, and no feature places them. Every other ore in the game is an AbstractOreFeature placed at ChunkStatus.FEATURES (features and placement). Which of the two veins you get is the sign of one function, ore_vein/toggle — copper’s density reads it as it is and iron’s reads it negated — and there is no separate “which ore” noise.

If the aquifer returns nothing, the block becomes the settings’ default block.

The surface pass, and the two places it breaks its own rule

MaterialSystem.buildSurface compiles the dimension’s NoiseGeneratorSettings.materialRule tree once for the whole chunk — one rule tree per dimension, which then branches on biome inside itself, then walks each of the 256 columns downward from the worldgen surface heightmap, tracking depth below stone and water height in a MaterialRuleContext that carries its own caches. Every block that is neither air nor fluid is offered to the tree, and the first rule that answers is written: fluid is measured, never offered, and the overworld’s tree tries the ore veins, after its bedrock floor, before any surface rule, which is what makes aquifer water and ore veins immune to being turned into grass.

That rule tree is itself two registries of data-driven types, with files of their own that the noise settings name by id — MaterialRule for what to write and MaterialCondition for when, each dispatched on a type id like any other (the data-driven type pattern) — so a pack composes a surface out of the shipped condition types and cannot write a new kind of condition. The biome the tree branches on is the jittered read, BiomeManager.getBiome, not the palette’s exact one (the two borders), which is why a surface rule can change block for block along the same ragged line the grass colour does.

Almost none of this runs in a superflat world. FlatLevelSource lays its layers at the terrain status and DebugLevelSource writes nothing there — neither has a surface pass or carvers, both make spawning a no-op, and DebugLevelSource writes its state grid at the decoration step instead — so of the three ChunkGenerators.bootstrap registers, two skip most of this page. Debug flags can switch off much more: SharedConstants carries flags, set from JVM system properties in any build, that disable the surface pass, the carvers, the ore veins and every aquifer’s water and lava outright.

Two things sit outside the rule tree entirely, and neither asks it. MaterialSystem.erodedBadlandsExtension runs before the column walk and fills air with the default block to raise the terracotta pillars. MaterialSystem.frozenOceanExtension runs after it and writes snow and packed ice over air and over water. Both are selected by biome rather than by rule, and MaterialSystem holds the noises they need along with the one that offsets the badlands bands and the two behind the surface depth the conditions read; a noise-threshold condition asks RandomState for its own.

Carving, and who chooses the block

NoiseBasedChunkGenerator.generateCarvers does not carve the chunk it was given from the chunk it was given. It loops over a 17×17 neighbourhood of source chunks, asks each carver of that source chunk’s biome whether a cave or a canyon starts there, and has whatever does mark its shape into the centre chunk. That reach costs the dependency pyramid nothing: the neighbours are read only as memo holders for ChunkAccess.carverBiome, and the biome itself is recomputed from the biome source. The reach is already paid for, because every generation step from ChunkStatus.STRUCTURE_REFERENCES to ChunkStatus.FEATURES asks for structure starts eight chunks out anyway (the pyramid, drawn).

Two carver types are registered by WorldCarverTypes.bootstrap — cave and canyon, which are CaveWorldCarver and CanyonWorldCarver — and four carvers ship, because the cave type is configured three times: once for the surface caves, once for a deeper set, and once for the Nether. Each is a record carrying its own parameters, and its codec is the data-pack half: no configuration class stands between carver and file. None of them touches a block. A carver is handed a WorldGenerationContext for its heights and a CarverOutput to mark, and what it marks lands in a CarvingMask, a bit set that lives for one terrain task and is never saved.

And then the hook. When every source chunk has had its say, NoiseBasedChunkGenerator.applyCarvingMask walks the mask’s carved runs, each top down, and asks Aquifer.computeSubstance with a density of zero what belongs at each marked block. Aquifer.FluidStatus.at answers plain air above the local water table and the fluid below it — never null; the null is Aquifer.computeSubstance’s own, and it means do not carve here at all. So the water in a flooded cave was decided by the same object that decided the water in the stone around it, and a dry cave is air written one block at a time because the aquifer said so. What kind of block a cave may not eat through is decided by one tag, uncarvable (BlockTags.UNCARVABLE), which ships holding bedrock alone, so a cave cuts through the ore veins and the surface skin alike. If a grass or mycelium block was passed on the way down, the dirt below is re-skinned through MaterialSystem.topMaterial.

The Nether follows the same rule. Its nether_cave is the cave type with other numbers, and its holes are filled by the same question, put to the trivial aquifer a dimension with aquifers switched off gets — which answers from the global fluid picker alone: lava, the Nether’s default fluid, below its sea level of 32, and air above.

The seeding is one thing a data pack cannot reach here. NoiseBasedChunkGenerator.generateCarvers hardcodes a LegacyRandomSource whatever the settings say, so switching a dimension to the modern random family does not move a single cave. That setting, NoiseGeneratorSettings.useLegacyRandomSource, does reach past the level’s root random in one other way: it re-seeds BlendedNoise.

Questions players ask

Why is there always lava at the same depth, in every world? Because the two levels are anchored to different things. The sea level is a field of the noise settings and moves with them; the lava floor is not in the settings at all. NoiseBasedChunkGenerator.createFluidPicker builds the global fluid picker every aquifer falls back to, and it answers lava below Y −54 — or below the sea, where the sea is lower still — and the sea above. So the lava floor under every overworld cave, carved or not, is one fixed height rather than a depth below the sea, and a data pack moves it only through the sea: by sinking the sea beneath it, or by making the settings’ default fluid lava.

Why is the water in a cave already settled when I break into it? Because it was decided before the cave was, and placed as still water. Aquifer flags only the fluid it places near the border between two aquifer cells, and there only where it could flow — where the cells’ tables differ, in height or fluid or because one is dry, or where water sits directly on the lava floor — and the fill and the carving step record those positions for post-processing, so those few flow for real when the chunk starts ticking (what the chunk goes on holding). “Nothing flowed in” is a true statement about worldgen and not about the chunk’s first tick as part of a live world.

For a 1.21-era reader. NOISE, SURFACE and CARVERS are one status, ChunkStatus.TERRAIN, and the cell loop NoiseChunk ran is InterpolatedFunction’s. SurfaceSystem is MaterialSystem, and SurfaceRules are MaterialRule and MaterialCondition, with the ore veins among them as OreVeinRule; OreVeinifier is gone. ConfiguredWorldCarver and the carver configurations are gone — a carver is a record that carries its parameters — and NetherWorldCarver is gone, the Nether’s caves being the cave carver with other numbers.

Where to look

NoiseBasedChunkGenerator is the page in one class: NoiseBasedChunkGenerator.buildTerrain is the status, and NoiseBasedChunkGenerator.doFill, NoiseBasedChunkGenerator.buildSurface and NoiseBasedChunkGenerator.generateCarvers are its three steps; reading them in that order is the trace. Under the first, NoiseChunk is the workspace and InterpolatedFunction the six loops, and Aquifer.computeSubstance is the decision at the bottom of the write walk and repays being read twice, because the carving step calls it too. MaterialSystem.buildSurface is the column walk, and MaterialRule beside MaterialCondition is the data-pack half of it, with OreVeinRule for the veins. Then NoiseBasedChunkGenerator.applyCarvingMask, which is the sentence this page is built around, with CaveWorldCarver for the shape. NoiseGeneratorSettings is worth opening once to see how much of all this is data. Two doors the page does not open: MaterialRuleContext, for what a rule may read, and OreVeinRule.compile, for a vein in full.


Rules: names, never code · how the system works, not how the code reads · newest version only · every backticked name passes tools/verify_names.py.