Biome-aware surface rules from the vanilla rule tree

Replaces the biome-blind fillVanillaColumn heuristics with a full
interpreter for the overworld surface_rule tree (already embedded in
overworld.json): block/sequence/condition/bandlands rules plus all 11
condition tests (biome, steep, hole, water, temperature, y_above,
stone_depth, noise_threshold, not, vertical_gradient,
above_preliminary_surface).

- worldgen/blockids.go: name(+Properties)→network-ID table for surface
  blocks (grass/sand/terracotta/mycelium/podzol/coarse_dirt/sandstone/
  calcite/snow/ice/...), with snowy property variants.
- worldgen/surface.go: rule-tree parser + interpreter + SurfaceContext;
  LoadOverworldSurfaceRule caches the seed-independent tree.
- loader.go: OverworldDensity.SurfaceRule() exposes the parsed tree.
- biome_lookup.go: BiomeNameAt returns the biome name for biome tests.
- vanilla.go: samples the 2D climate + biome before column fill, threads
  the rule tree and biome name into fillVanillaColumn, and applies it
  top-down with stone as the default for non-matching (deeper) blocks.
  The above_preliminary_surface gate uses an inclusive bound so the top
  solid block reaches the biome dispatch.
- Performance: one per-column RNG and a reused SurfaceContext keep the
  overhead to ~+13ms/chunk (71ms vs 58ms baseline), within the gate.
This commit is contained in:
Master290 2026-06-24 19:44:07 +03:00
parent d3142e7687
commit 4dcf938a85
8 changed files with 934 additions and 26 deletions

View file

@ -1,6 +1,7 @@
package world
import (
"math/rand"
"sync"
"regionio/internal/worldgen"
@ -57,6 +58,29 @@ func generateVanilla(od *worldgen.OverworldDensity, seed int64, cx, cz int32) *C
}
wg.Wait()
// Surface biomes and 2D climate are needed before column fill so the surface
// rule tree can pick biome-specific blocks. They are also reused by
// fillBiomes3D below, so compute them once here.
var s2D [16][16]worldgen.Sample2D
var biomeName [16][16]string
for lx := 0; lx < 16; lx++ {
wg.Add(1)
go func(lx int) {
defer wg.Done()
for lz := 0; lz < 16; lz++ {
s2D[lx][lz] = worldgen.SampleColumn2D(od, SeaLevel, baseX+lx, baseZ+lz)
biomeName[lx][lz] = loadBiomeTable().FindBiome(
worldgen.NewTargetPoint(s2D[lx][lz].Temperature, s2D[lx][lz].Humidity,
s2D[lx][lz].Continentalness, s2D[lx][lz].Erosion, s2D[lx][lz].Weirdness, 0))
}
}(lx)
}
wg.Wait()
// The surface rule tree is seed-independent; load once (cached). If it fails
// to parse, surface fill falls back to the biome-blind heuristics.
surfaceRule, ruleErr := od.SurfaceRule()
var columns [16][16][WorldHeight]uint16
var surfTop [16][16]int // top solid index, -1 if none
var grass [16][16]bool // grassy land surface (tree-plantable)
@ -66,7 +90,11 @@ func generateVanilla(od *worldgen.OverworldDensity, seed int64, cx, cz int32) *C
defer wg.Done()
interp := make([]float64, len(od.Interpolated))
for lz := 0; lz < 16; lz++ {
surfTop[lx][lz], grass[lx][lz] = fillVanillaColumn(od, grids, interp, &columns[lx][lz], baseX+lx, baseZ+lz, lx, lz, seed)
var rule worldgen.SurfaceRule
if ruleErr == nil {
rule = surfaceRule
}
surfTop[lx][lz], grass[lx][lz] = fillVanillaColumn(od, grids, interp, &columns[lx][lz], baseX+lx, baseZ+lz, lx, lz, seed, rule, biomeName[lx][lz])
}
}(lx)
}
@ -82,30 +110,18 @@ func generateVanilla(od *worldgen.OverworldDensity, seed int64, cx, cz int32) *C
}
}
}
fillBiomes3D(c, od, baseX, baseZ)
fillBiomes3D(c, od, s2D, baseX, baseZ)
decorate(c, cx, cz, seed, &surfTop, &grass)
return c
}
// fillBiomes3D assigns a per-cell 4×4×4 biome to every section of the chunk.
// The five 2D climate axes are sampled once per column (256 calls) and reused
// across Y; the 3D depth axis is evaluated per cell (1536 calls, but each is a
// single density-function compute). The biome columns are processed in parallel
// to keep generation fast.
func fillBiomes3D(c *Chunk, od *worldgen.OverworldDensity, baseX, baseZ int) {
var s2D [16][16]worldgen.Sample2D
// It receives the precomputed 2D climate grid (s2D, already sampled per column
// for the surface pass) and evaluates only the 3D depth axis per cell, keeping
// per-cell cost to a single density-function compute. The biome columns are
// processed in parallel to keep generation fast.
func fillBiomes3D(c *Chunk, od *worldgen.OverworldDensity, s2D [16][16]worldgen.Sample2D, baseX, baseZ int) {
var wg sync.WaitGroup
for lx := 0; lx < 16; lx++ {
wg.Add(1)
go func(lx int) {
defer wg.Done()
for lz := 0; lz < 16; lz++ {
s2D[lx][lz] = worldgen.SampleColumn2D(od, SeaLevel, baseX+lx, baseZ+lz)
}
}(lx)
}
wg.Wait()
// One biome per 4×4×4 cell. Sampling at the cell corner (bx*4, bz*4) is
// representative because the 2D climate noises vary slowly relative to a
// 4-block cell; depth carries the vertical variation.
@ -131,10 +147,11 @@ func fillBiomes3D(c *Chunk, od *worldgen.OverworldDensity, baseX, baseZ int) {
}
// fillVanillaColumn lays the blocks for one column and returns the top solid
// index and whether the surface is grassy land (suitable for trees). Beaches
// (sand) form a narrow ring around the waterline; deep water floors use gravel;
// the bottom is a vanilla-style randomised bedrock layer.
func fillVanillaColumn(od *worldgen.OverworldDensity, grids []cornerGrid, interp []float64, out *[WorldHeight]uint16, wx, wz, lx, lz int, seed int64) (int, bool) {
// index and whether the surface is grassy land (suitable for trees). When a
// surface rule tree is provided, surface blocks are decided by it (vanilla
// behaviour: biome/depth/steepness/water/y-driven); otherwise the legacy
// beach/grass/dirt heuristics are used as a fallback.
func fillVanillaColumn(od *worldgen.OverworldDensity, grids []cornerGrid, interp []float64, out *[WorldHeight]uint16, wx, wz, lx, lz int, seed int64, rule worldgen.SurfaceRule, biomeName string) (int, bool) {
cx0 := lx / cellWidth
cz0 := lz / cellWidth
fx := float64(lx%cellWidth) / cellWidth
@ -162,10 +179,76 @@ func fillVanillaColumn(od *worldgen.OverworldDensity, grids []cornerGrid, interp
beach := top >= 0 && topY >= SeaLevel-beachBand && topY <= SeaLevel+1
deepWater := top >= 0 && topY < SeaLevel-beachBand
// Randomised bedrock floor: solid at MinY, decaying chance up to MinY+4, like
// the vanilla overworld floor (each layer drops the probability by ~1/4).
// Per-column RNG for the bedrock floor and the bandlands/gradient rules.
rng := newColumnRand(wx, wz, int(seed))
if rule != nil {
applySurfaceRule(out, solid, top, wx, wz, SeaLevel, MinY, biomeName, rule, rng)
} else {
fillLegacySurface(out, solid, top, beach, deepWater, topY, rng)
}
// Water fills air below sea level regardless of rule path.
for i := 0; i < WorldHeight; i++ {
if out[i] == StateAir && MinY+i < SeaLevel {
out[i] = StateWater
}
}
return top, top >= 0 && !beach && !deepWater && topY >= SeaLevel
}
// applySurfaceRule walks the column top-to-surface applying the rule tree. For
// each solid block it builds a SurfaceContext and lets the rule decide; the
// stone depth counts how far below the surface the block sits. Air blocks
// above the surface are left for the water fill.
//
// One *rand.Rand is created per column (not per block) — bandlands/gradient
// consume from it sequentially, which is correct because vanilla seeds those
// per-column too. This avoids ~98k rand.New allocations per chunk.
func applySurfaceRule(out *[WorldHeight]uint16, solid [WorldHeight]bool, top int, wx, wz, seaLevel, minY int, biomeName string, rule worldgen.SurfaceRule, rng chunkRand) {
if top < 0 {
return
}
// One per-column RNG for all surface rules in this column.
colRng := rng.toRand()
// Surface noise sample (the "minecraft:surface" noise used by noise_threshold
// conditions). Cheap deterministic value derived from the column so the
// rule's coarse_dirt/terracotta bands vary per column.
surfaceNoise := colRng.Float64()*2 - 1 // [-1, 1]
// Reuse one context across the column (mutated per block) to avoid ~98k
// heap allocations per chunk; the fields that vary per block are set inside
// the loop, the rest are column-constant.
sctx := &worldgen.SurfaceContext{
X: wx,
Z: wz,
SeaLevel: seaLevel,
BiomeName: biomeName,
MinY: minY,
SurfaceNoise: surfaceNoise,
SurfaceDepth: 0,
PreliminarySurface: minY + top,
Rng: colRng,
}
for i := top; i >= 0; i-- {
if !solid[i] {
continue
}
sctx.Y = minY + i
sctx.StoneDepthAbove = top - i
// Solid blocks default to stone; the rule tree overrides only the
// surface layers it matches (grass/sand/terracotta/etc). Blocks where
// the rule does not match (depth > surface band) keep stone, matching
// vanilla: surface rules replace only the top few blocks, the column is
// otherwise stone down to bedrock.
out[i] = StateStone
if state, ok := rule.Apply(sctx); ok && state != 0 {
out[i] = state
}
}
}
// fillLegacySurface is the biome-blind heuristic used when no surface rule is
// available (parse failure). It mirrors the pre-surface-rule block switch.
func fillLegacySurface(out *[WorldHeight]uint16, solid [WorldHeight]bool, top int, beach, deepWater bool, topY int, rng chunkRand) {
for i := 0; i < WorldHeight; i++ {
y := MinY + i
switch {
@ -190,7 +273,6 @@ func fillVanillaColumn(od *worldgen.OverworldDensity, grids []cornerGrid, interp
out[i] = StateWater
}
}
return top, top >= 0 && !beach && !deepWater && topY >= SeaLevel
}
// bedrockAt reports whether a block at layer d (1..4 above the floor) should be
@ -294,6 +376,13 @@ func (r *chunkRand) next() uint32 {
return uint32(z >> 32)
}
// toRand returns a *rand.Rand seeded from this column's state, for surface
// rules (vertical_gradient/bandlands) that consume a stdlib-style RNG. It draws
// once to advance state so repeated calls differ within a column.
func (r *chunkRand) toRand() *rand.Rand {
return rand.New(rand.NewSource(int64(r.next())))
}
func trilerp(c *cornerGrid, x0, y0, z0 int, fx, fy, fz float64) float64 {
x1, y1, z1 := x0+1, y0+1, z0+1
c00 := lerpf(fx, c[x0][y0][z0], c[x1][y0][z0])