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

Blending at the old-chunk border

Verified against Minecraft 26.2 · Part XII · a chunk generated beside one an older version left behind, and the five places its generation is bent toward that neighbour.

You are walking east through a world you have had for years. Behind you the ground was decided by a version that is not this one; ahead of you it has not been decided at all. Put the debug screen up and the chunk-generation entry reads Blending: Old while you stand on the old side, and stops reading it once you cross. For roughly a hundred blocks either side of that line the game is generating chunks that are not free to be whatever the seed says — and directly against the seam they are hardly generated at all. The three splines that shape overworld terrain are swapped out for a ground height the game read off the old chunk’s blocks a moment earlier, the constant ten, and zero.

This is the deliberate exception the part’s premise names. Everywhere else in world generation, a chunk is a function of the seed and the data packs. Here it is a function of the seed, the data packs, and the blocks measured by up to a hundred and ninety-three chunks around it — which the game does not remember and has to go and measure, one column at a time.

The flag is a nullable field, and looking for it costs a disk read

A chunk is old if ChunkAccess.blendingData is non-null. That is the whole test: ChunkAccess.isOldNoiseGeneration returns exactly whether that field is set, and the field is final — it arrives through the constructor from SerializableChunkData, which reads a blending_data compound out of the chunk’s NBT, and nothing sets it afterwards. Which saves carry that key is util/datafix’s business, which this book skips; by the time world generation sees a chunk the key is either there or it is not.

For a 1.21-era reader. The class that used to read and write the chunk NBT is now SerializableChunkData, a record with a blending_data component and its own parse and write halves. The old ChunkSerializer name is gone.

The awkward part is that a chunk being generated cannot ask its neighbours whether they are old, because most of them do not exist yet. So Blender.of — the factory every noise-generation step calls — starts by asking the save file. WorldGenRegion.isOldChunkAround hands the question to the level’s ChunkMap, which inherits SimpleRegionStorage.isOldChunkAround and lands on IOWorker.isOldChunkAround. That walks the region files covering a square of radius seven, and for each region it needs a BitSet with one bit per chunk, built by scanning all 1,024 chunks in the region for two NBT fields and nothing else: DataVersion and blending_data. A chunk counts as old if its stored DataVersion is below 4882 or if it already carries a blending_data compound. The scan runs on the background executor, the caller joins it, and the bitset is kept in a 1,024-entry region cache.

Every chunk generated in every world pays for that lookup at least twice, whatever generator the dimension uses: ChunkStatusTasks.generateBiomes and ChunkStatusTasks.generateNoise both evaluate Blender.of eagerly, before knowing whether the answer can possibly be yes. The noise generator adds two more — NoiseBasedChunkGenerator.buildSurface, eagerly again, and NoiseBasedChunkGenerator.applyCarvers, the only call site written inside a supplier and so the only one that can be skipped. When nothing is old the bitsets come back empty and Blender.of hands back the shared empty blender, which is not an empty map but an anonymous subclass overriding the three answering methods with identities: alpha one, offset zero, density unchanged, resolver returned as given.

The cast

classwhat it decideswhen
Blenderthe four answers — the height alpha and offset, the blended density, the biome override, and where carvers may not digbuilt per step, used on the worldgen executor
BlendingDataone old chunk’s measurements: a ring of sixteen columns holding a height, a density profile and a biome column eachmeasured once per loaded chunk object
IOWorkerwhether any chunk within seven is old, from a per-region BitSet scanned out of the region fileson the background executor, joined by the caller
NoiseChunkwhere the answers enter the density graph: two pre-filled flat caches and one wrapperChunkStatus.BIOMES onward, cached on the chunk
NoiseRouterDatawhich router functions are blendable at all — three overworld splines and one post-process wrapperworld creation, once
CarvingMaskthe extra predicate that makes carvers treat old ground as already carvedChunkStatus.CARVERS
BelowZeroRetrogenthe separate world-deepening path that rides the same hooksChunkStatus.NOISE and ChunkStatus.BIOMES
SerializableChunkDatathe blending_data key: which chunks are flagged, and which measurements survive a savechunk load and save

One measurement, five consumers

BlendingData is gathered once and then read by five unrelated pieces of machinery at four chunk statuses — two inside the density graph, three nowhere near it.

flowchart TB
    OLD["An old chunk: its blocks on disk, plus a blending_data tag"]
    BD["BlendingData: a ring of 16 columns round the chunk edge, each a height, a density profile and a biome column"]
    B["Blender: 193 chunk positions consulted for height and biome, the inner 9 of them also for density"]
    OLD --> BD
    BD --> B
    B --> R1["BIOMES: getBiomeResolver returns the nearest old biome, or defers"]
    B --> R2["BIOMES and NOISE: blend_alpha and blend_offset, two flat caches filled in the NoiseChunk constructor"]
    B --> R3["NOISE: blend_density, a marker wrapped round the final slide"]
    BD --> R4["CARVERS: an extra carving mask, so carvers skip old ground"]
    BD --> R5["FEATURES: border ticks on leaves and fluids, on the old chunk only"]
    R4 -.- SN["static on Blender, straight off each chunk's BlendingData — the two maps are not consulted"]
    R5 -.- SN

Two maps, not one. Blender.of sweeps the square from seven chunks west to seven chunks east and clips it to a circle — the test is that the squared offsets sum to no more than sixty-four — which leaves 193 positions. Every one that yields data goes into the height-and-biome map; only the inner three by three also goes into the density map. That asymmetry is the whole reason the terrain seam is a hundred blocks wide and the cave seam is a handful.

A neighbour yields data only if it passes two tests in BlendingData.getOrUpdateBlendingData: it carries a BlendingData, and ChunkAccess.getHighestGeneratedStatus is not before ChunkStatus.BIOMES. The second test is what keeps this honest — during the BIOMES step the dependency window only guarantees neighbours at STRUCTURE_STARTS, so a half-built chunk in the queue contributes nothing, and in practice the only chunks that pass are ones loaded whole from the save.

Seven — the radius in chunks the height blend reaches, which Blender derives from the twenty-seven-cell height range: four quart cells per section across seven sections, less one, plus three, converted back to chunks. It sits inside the radius-eight dependency window every noise step declares, so none of the 193 reads can trip WorldGenRegion.getChunk’s out-of-range crash.

Sixteen columns, read out of blocks

BlendingData does not store a copy of the old terrain. It stores sixteen columns in a ring round the chunk’s edge, measured out of that same old chunk’s own blocks the first time anyone asks — BlendingData.getOrUpdateBlendingData fetches one chunk and hands it to its own BlendingData.calculateData. Seven of them are inside indices — the corner and three more along the north edge, three along the west — at block coordinates 0, 4, 8 and 12. The other nine are outside indices, sampled at block coordinate 15 along the east and south edges, which in cell arithmetic belong to the next chunk’s first cell. Sixteen slots is what BlendingData.Packed’s codec validates the saved height array against, and it is exactly the array length the class computes from a chunk being four quarts wide.

Which of the sixteen get filled depends on which way the chunk faces new ground. BlendingData.sideByGenerationAge asks each of the eight Direction8 neighbours whether it is old, and BlendingData.calculateData fills only the columns on the sides that are not — the ones facing chunks the game is about to generate. There is one call site in the whole game and it passes false, so “sides by generation age” only ever means “sides facing new chunks”. The method also guards on BlendingData.hasCalculatedData, so a chunk object measures itself once and never again: whichever sides were new at that moment are the sides it will carry until it is unloaded.

Each filled column gets three things. A height: BlendingData.getHeightAtXZ starts at the WORLD_SURFACE_WG heightmap if the chunk has one primed and at the top of the old area if not, then walks straight down looking for one of eleven block types — podzol, gravel, grass, stone, coarse dirt, sand, red sand, mycelium, a snow block, terracotta or dirt — and returns the first Y at which it finds one, or the bottom of the old area if it never does. A density profile: for each eight-block-tall cell, BlendingData reads fifteen consecutive blocks downward, scoring each plus or minus one for whether it is ground, and divides by fifteen — ground meaning not air, not a leaf, not a log, not a mushroom block and with a non-empty collision shape, so a cave counts as air and a tree does not count as terrain. One more pass rewrites the two cells straddling the measured height so the surface lands where the height said it did. And a biome column: one Biome holder per four-block layer of the old area, read straight out of that chunk’s own biome container.

Of those three, only the heights are saved. BlendingData.pack writes the minimum section, the maximum section and the sixteen doubles, and omits the heights entirely if none of them was ever measured; the density array is declared transient and the biome list is not in the codec at all. Unload the region and come back and the game re-reads the old chunk’s blocks to rebuild both.

Following one chunk through

sequenceDiagram
    participant CST as ChunkStatusTasks
    participant Blender as Blender
    participant CM as ChunkMap
    participant BD as BlendingData
    participant NBC as NoiseBasedChunkGenerator
    participant NC as NoiseChunk

    Note over CST: ChunkStatus.BIOMES, on the worldgen executor
    CST->>Blender: of — build a blender for this chunk
    Blender->>CM: isOldChunkAround, radius 7 chunks
    CM-->>Blender: yes, from a bitset scanned out of the region files
    loop 193 positions, clipped to a circle
        Blender->>BD: getOrUpdateBlendingData
        BD->>BD: measure the sides facing new chunks, once per chunk object
    end
    Blender-->>CST: a live blender, two maps of BlendingData
    CST->>NBC: createBiomes with the blender
    NBC->>NC: forChunk — 25 columns of alpha and offset, filled in the constructor
    NBC->>NBC: wrap the biome resolver, then wrap that in BelowZeroRetrogen
    Note over CST,NC: ChunkStatus.NOISE, the same NoiseChunk, cached on the chunk
    NC->>Blender: blendDensity, once per sampled point inside the marker
    Blender-->>NC: the old density outright, or a lerp toward the noise

The order matters in one non-obvious way. NoiseChunk is created at BIOMES, not at NOISE, because the biome step needs the chunk’s climate sampler, and it is then cached on ChunkAccess. So the blender that reaches the density graph is the one built at BIOMES — the later three are passed to ChunkAccess.getOrCreateNoiseChunk, find it already created, and are thrown away.

The twenty-five columns are the other thing worth noticing. Before any router mapping runs, the NoiseChunk constructor loops over the chunk’s five-by-five grid of quart columns, calls Blender.blendOffsetAndFactor for each, and fills two NoiseChunk.FlatCache instances. Only afterwards does NoiseChunk.wrapNew swap DensityFunctions.BlendAlpha and DensityFunctions.BlendOffset — by object identity, the same trick the beardifier uses — for those already-full caches (density functions). An empty blender skips the swap: the two singletons stay the constants one and zero that they are, and a blend_density marker is replaced by its own child.

What the blender actually answers

Three questions, three different shapes of answer, and only two of them are blends.

Height, as an alpha and an offset. Blender.blendOffsetAndFactor first checks whether the sample point sits exactly on a measured column; if it does, it returns alpha zero and the old height, put through Blender.heightToOffset — a rational function, not a polynomial. If not, it walks every measured height in the height map, keeps those within twenty-seven quart cells — a hundred and eight blocks — and averages them weighted by the inverse fourth power of distance, with alpha the smoothstep of the nearest distance over twenty-eight. Nothing in range means alpha one and offset zero: the identity.

Alpha is then used as a mixing weight in NoiseRouterData.splineWithBlending, which interpolates from a fixed target at alpha zero to the real spline at alpha one, and this is where the page’s opening claim comes from. Exactly three router functions are built that way — offset, factor and jaggedness, for each of the three overworld variants — and their targets are, respectively, DensityFunctions.BlendOffset, NoiseRouterData.BLENDING_FACTOR (the constant ten) and NoiseRouterData.BLENDING_JAGGEDNESS (zero). Against the seam alpha is zero, so overworld terrain is not shaped by its splines at all: the ground height is whatever BlendingData measured, the factor is ten and the jaggedness is nothing. The shipped data pack agrees — blend_alpha appears in nine density function files and blend_offset in three, all of them under the three overworld directories.

Because offset and factor are also the two inputs to NoiseRouterData.preliminarySurfaceLevel, which Aquifer.NoiseBasedAquifer reads to place its fluid levels, the water table follows the old ground. Nobody wrote a rule for that: it falls out of the aquifer sampling a blended function.

Density, as a lerp with a very short reach. Blender.blendDensity is called per sample from inside NoiseChunk.BlendDensity, the wrapper the blend_density marker becomes. It measures distance in cells with the Y difference doubled — cells are twice as tall as they are wide — keeps neighbours within two, and mixes toward the noise with an alpha of the closest distance over three. An exact hit returns the old density with no mixing at all. This one wrapper is in every dimension’s router: blend_density appears in all seven shipped noise settings, while the alpha and offset nodes are overworld-only.

Biome, as a replacement. Blender.getBiomeResolver wraps the biome source in a resolver that asks Blender.blendBiome first and only falls through when it declines. And it is not a blend: it finds the nearest measured biome within twenty-seven cells, adds twelve cells’ worth of a fixed shift noise to that distance, divides by twenty-eight, and returns the old biome if the result is below one half and nothing at all if it is above. So the biome boundary is a hard line at roughly half the terrain blending distance, roughened by noise — one biome or the other, never a mixture, which is the only answer a palette of biome holders can represent.

The two consumers that never touch the noise router

Carvers are told to stay out. At CARVERS, Blender.addAroundOldChunksCarvingMaskFilter collects the BlendingData of all eight Direction8 neighbours plus the chunk’s own, turns each into a Blender.DistanceGetter measuring distance to a box eight blocks either side of the chunk centre in X and Z — a whole chunk wide — and as tall as that chunk’s old area, and installs the minimum of them as a CarvingMask.Mask on the chunk’s carving mask. A position within four blocks of any such box — after each axis is displaced by the same shift noise, scaled by four — reads as already carved. Since WorldCarver.carveEllipsoid skips any position the mask already reports, the effect is that carvers refuse to dig into or right up against old ground. The mask is additional: it is consulted by CarvingMask.get alongside the real bits and never written to the saved array.

Leaves and water at the seam are marked for a second look. At FEATURES, after decoration, ChunkStatusTasks.generateFeatures calls Blender.generateBorderTicks — and this is the one hook that fires on the old side rather than the new one. It returns immediately unless the chunk being generated carries a BlendingData of its own, which a chunk generated fresh today never does: it acts only on an old chunk that is still being carried through the statuses, one saved before FEATURES or one being deepened. Given such a chunk it sweeps four Y levels — one below and one at the bottom of the old area, one at and one above the top — across all 256 columns, and then, for each of the four horizontal neighbours that is not old, walks the whole sixteen-wide face from the bottom of the old area up to that column’s MOTION_BLOCKING height. Every leaf block and every non-empty fluid it passes goes to ChunkAccess.markPosForPostProcessing. Nothing is changed: the positions are queued for the post-processing pass that runs when the chunk becomes live, which is what makes water at the seam flow and orphaned leaves decay instead of hanging there. The step’s ordering is what makes the heightmap read safe — ChunkStatusTasks.generateFeatures primes the four final heightmaps before decorating, so MOTION_BLOCKING is current by the time the border walk reads it.

The other passenger

BelowZeroRetrogen is not blending, but it rides the same hooks and is easy to mistake for it. A chunk carrying one is being deepened rather than blended: ChunkAccess.getHighestGeneratedStatus folds its BelowZeroRetrogen.targetStatus in, ChunkStatusTasks.generateNoise calls BelowZeroRetrogen.replaceOldBedrock and then BelowZeroRetrogen.applyBedrockMask if there are holes, and BelowZeroRetrogen.getBiomeResolver wraps the blender’s resolver in one more layer that keeps three cave biomes from the new generation and takes everything else from the chunk’s existing biome column.

Questions players ask

Why is the seam wide for hills and narrow for caves? Two maps, two radii. Height and biome are averaged over every measured column within a hundred and eight blocks, gathered from up to 193 chunks; density is mixed only from the nine chunks nearest the one being generated, over two cells. The ground slopes for a hundred blocks and the caves change their mind in eight.

Can the blend leave a visible edge anyway? Yes, twice over. A column whose old surface is none of the eleven blocks the height scan recognises reports the bottom of the old area instead, and that bogus height is averaged in with the rest. And the biome switch is a threshold rather than a ramp, so the surface rules can change block for block along a line while the terrain under them is still sloping.

Does any of this happen in a brand-new world? The lookup does, the work does not — every chunk asks Blender.of whether an old chunk is near, and it returns the empty blender when none is. Even the lookup is cheap after the first time: the per-region bitset it consults is memoised behind IOWorker.getOrCreateOldDataForRegion, so the scan runs once per region and not once per chunk. Development builds can switch the whole thing off with the SharedConstants.DEBUG_DISABLE_BLENDING flag, which short-circuits Blender.of, the carving-mask filter and the border ticks alike.

Where to look

Start at Blender.of and read down: the two maps, the two radii, the three answering methods. Then BlendingData.calculateData and BlendingData.getHeightAtXZ for what a measurement is, and BlendingData.Packed for what survives a save. NoiseChunk’s constructor and NoiseChunk.wrapNew show where the answers enter the density graph, NoiseRouterData which three functions were built to receive them, and ChunkStatusTasks when each hook fires.


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