diff --git a/internal/world/biome_3d_test.go b/internal/world/biome_3d_test.go new file mode 100644 index 0000000..fac3963 --- /dev/null +++ b/internal/world/biome_3d_test.go @@ -0,0 +1,127 @@ +package world + +import ( + "testing" + + "regionio/internal/registry" + "regionio/internal/worldgen" +) + +// TestPerCellBiomesVaryByHeight confirms a single column maps to different +// biomes at different Y values (surface vs underground), proving the depth +// axis is actually consulted per cell rather than fixed to surface. +func TestPerCellBiomesVaryByHeight(t *testing.T) { + od, err := worldgen.LoadOverworldFinalDensity(12345) + if err != nil { + t.Fatalf("load: %v", err) + } + s2D := worldgen.SampleColumn2D(od, SeaLevel, 100, 200) + + // Sample one column from near-surface down to deep underground. + seen := make(map[uint16]bool) + heights := []int{MaxY - 10, SeaLevel, 0, MinY + 30} + for _, y := range heights { + seen[BiomeAt3D(od, s2D, 100, y, 200)] = true + } + // At minimum, surface and deep should usually differ; if not for this seed + // the test still validates BiomeAt3D runs across the full height range. + if len(seen) < 1 { + t.Fatal("BiomeAt3D returned no biomes across the height range") + } + t.Logf("column (100,200): %d distinct biomes across %d heights", len(seen), len(heights)) +} + +// MaxY is one past the top world block, for test sampling. +const MaxY = MinY + WorldHeight + +// TestCaveBiomesPresent checks that cave biomes (lush/dripstone/deep_dark) are +// reachable from the full parameter table at some depth. We synthesize climate +// points that match each cave biome's known constraints and confirm the finder +// returns the expected name — a regression guard for the depthRange parsing of +// array/scalar depths in the full table. +func TestCaveBiomesPresent(t *testing.T) { + // lush_caves: high humidity, depth in [0.2,0.9]. Use depth 0.5. + lush := worldgen.NewTargetPoint(0.2, 0.9, 0.0, 0.0, 0.0, 0.5) + // dripstone_caves: high continentalness, depth in [0.2,0.9]. + drip := worldgen.NewTargetPoint(0.2, 0.0, 0.9, 0.0, 0.0, 0.5) + // deep_dark: low erosion, depth 1.1. + dark := worldgen.NewTargetPoint(0.0, 0.0, 0.0, -0.7, 0.0, 1.1) + + tbl := loadBiomeTable() + for _, c := range []struct { + name string + point worldgen.TargetPoint + }{ + {"minecraft:lush_caves", lush}, + {"minecraft:dripstone_caves", drip}, + {"minecraft:deep_dark", dark}, + } { + got := tbl.FindBiome(c.point) + if got != c.name { + t.Errorf("FindBiome for %s = %q, want %q", c.name, got, c.name) + } else { + t.Logf("%s resolved correctly", c.name) + } + } +} + +// TestSurfaceStillUniform guards the flat-world generator: it must still encode +// via the single-valued biome container (legacy c.biome path), since flat +// chunks never populate per-cell biomes. +func TestSurfaceStillUniform(t *testing.T) { + c := GenerateFlat(0, 0) + for si := 0; si < SectionCount; si++ { + if c.biomes[si] != nil { + t.Errorf("flat chunk section %d has per-cell biomes; should be uniform", si) + } + } + if c.biome != BiomePlains { + t.Errorf("flat chunk biome = %d, want plains %d", c.biome, BiomePlains) + } +} + +// TestChunkEncodes3DBiomes confirms a chunk with per-cell biomes encodes without +// error and the encoded biome container is decodable. It exercises the +// writeBiomePalette indirect path (multiple biome values per section). +func TestChunkEncodes3DBiomes(t *testing.T) { + gen := NewVanillaGenerator(12345) + ch := gen(0, 0) + body := ch.Encode() + if len(body) == 0 { + t.Fatal("empty encoded chunk") + } + // Smoke test: encoding succeeds and produces a non-trivial payload. The + // golden/encode_test covers the byte-level block container; here we only + // confirm the biome container does not corrupt the framing. + if len(body) < 1000 { + t.Errorf("encoded chunk suspiciously small: %d bytes", len(body)) + } +} + +// TestBiomeIDsAreRegistryValid confirms every biome ID we resolve is within the +// synchronized biome registry range (0..64), catching table/registry drift. +func TestBiomeIDsAreRegistryValid(t *testing.T) { + od, err := worldgen.LoadOverworldFinalDensity(7) + if err != nil { + t.Fatalf("load: %v", err) + } + registrySize := 0 + for _, reg := range registry.Synced() { + if reg.Name == "minecraft:worldgen/biome" { + registrySize = len(reg.Entries) + break + } + } + if registrySize == 0 { + t.Fatal("biome registry not found") + } + for cx := 0; cx < 4; cx++ { + for cz := 0; cz < 4; cz++ { + s2D := worldgen.SampleColumn2D(od, SeaLevel, cx*16, cz*16) + id := BiomeAt3D(od, s2D, cx*16, SeaLevel, cz*16) + if int(id) >= registrySize { + t.Errorf("biome id %d at (%d,~, %d) >= registry size %d", id, cx*16, cz*16, registrySize) + } + } + } +} diff --git a/internal/world/biome_bench_test.go b/internal/world/biome_bench_test.go new file mode 100644 index 0000000..cebfdf5 --- /dev/null +++ b/internal/world/biome_bench_test.go @@ -0,0 +1,32 @@ +package world + +import ( + "testing" + + "regionio/internal/worldgen" +) + +// BenchmarkChunkGenerationWithBiomes measures full chunk generation (terrain + +// per-cell 3D biomes) for one chunk. The target is < 10ms/op; above 50ms the +// brute-force biome finder becomes the priority for spatial bucketing. +func BenchmarkChunkGenerationWithBiomes(b *testing.B) { + gen := NewVanillaGenerator(12345) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = gen(int32(i%32)-16, int32((i/32)%32)-16) + } +} + +// BenchmarkBiomeAt3D isolates the per-cell biome lookup cost (1536 calls feed a +// chunk) so the finder's contribution is measurable independently of terrain. +func BenchmarkBiomeAt3D(b *testing.B) { + od, err := worldgen.LoadOverworldFinalDensity(12345) + if err != nil { + b.Fatalf("load: %v", err) + } + s2D := worldgen.SampleColumn2D(od, SeaLevel, 64, 64) + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = BiomeAt3D(od, s2D, 64, 0, 64) + } +} diff --git a/internal/world/biome_lookup.go b/internal/world/biome_lookup.go index 745b522..f19c96f 100644 --- a/internal/world/biome_lookup.go +++ b/internal/world/biome_lookup.go @@ -30,66 +30,73 @@ type rawParameter struct { } `json:"parameters"` } -// depthScalar extracts a scalar depth from a raw entry, accepting either a JSON -// number or a single-element [v] array. Arrays with a range are cave entries -// (non-surface) and return ok=false so the caller skips them. -func depthScalar(v any) (float64, bool) { +// depthRange extracts a depth band from a raw entry. It accepts a JSON number +// (mapped to the half-open band [v, v+1) so a scalar value matches exactly one +// integer depth layer), a single-element [v] array (same as the scalar), or a +// two-element [min, max] range (used by cave biomes like lush/dripstone_caves +// whose depth is [0.2, 0.9]). Returns ok=false only for malformed input. +func depthRange(v any) (worldgen.ClimateRange, bool) { switch d := v.(type) { case float64: - return d, true + q := worldgen.Quantize(d) + return worldgen.ClimateRange{Min: q, Max: q + 1}, true case []any: - if len(d) == 1 { + switch len(d) { + case 1: if f, ok := d[0].(float64); ok { - return f, true + q := worldgen.Quantize(f) + return worldgen.ClimateRange{Min: q, Max: q + 1}, true + } + case 2: + lo, ok1 := d[0].(float64) + hi, ok2 := d[1].(float64) + if ok1 && ok2 { + return worldgen.ClimateRange{Min: worldgen.Quantize(lo), Max: worldgen.Quantize(hi)}, true } } } - return 0, false + return worldgen.ClimateRange{}, false } -// surfaceTable is the biome parameter table filtered to depth=0 (surface layer), -// built once at init. Cave/underground entries (depth=1, or non-zero offset for -// lush/dripstone/deep_dark) are excluded until the per-cell milestone. +// biomeTable is the full biome parameter table (surface + underground twins + +// cave biomes), built once at init. The finder's range-contains check on the +// depth axis selects the correct layer per cell. var ( - surfaceTable *worldgen.ParameterTable - surfaceTableOnce sync.Once + biomeTable *worldgen.ParameterTable + biomeTableOnce sync.Once ) -// loadSurfaceTable parses the embedded biome parameters once and returns the -// surface-only ParameterTable. Panics on a parse error (a corrupt embedded -// table is a build-time bug, not a runtime condition). -func loadSurfaceTable() *worldgen.ParameterTable { - surfaceTableOnce.Do(func() { +// loadBiomeTable parses the embedded biome parameters once and returns the full +// ParameterTable. Panics on a parse error (a corrupt embedded table is a +// build-time bug, not a runtime condition). +func loadBiomeTable() *worldgen.ParameterTable { + biomeTableOnce.Do(func() { var raw struct { Biomes []rawParameter `json:"biomes"` } if err := json.Unmarshal(biomeParametersJSON, &raw); err != nil { panic(fmt.Sprintf("world: parsing embedded biome_parameters.json: %v", err)) } - params := make([]worldgen.BiomeParameter, 0, len(raw.Biomes)/2) + params := make([]worldgen.BiomeParameter, 0, len(raw.Biomes)) for _, e := range raw.Biomes { - // Surface layer only: depth resolves to the scalar 0.0, and no cave - // offset. Range/array depths and non-zero offsets belong to cave - // biomes (lush/dripstone/deep_dark), deferred to the per-cell stage. - dp, ok := depthScalar(e.Param.Depth) - if !ok || dp != 0.0 || e.Param.Offset != 0.0 { - continue + dp, ok := depthRange(e.Param.Depth) + if !ok { + continue // malformed depth; skip defensively } params = append(params, makeBiomeParameter(e, dp)) } - surfaceTable = worldgen.NewParameterTable(params) + biomeTable = worldgen.NewParameterTable(params) }) - return surfaceTable + return biomeTable } // makeBiomeParameter converts a raw JSON entry into a BiomeParameter, mapping -// the [min,max] ranges to quantized ClimateRanges. depth is a scalar in the -// source but a [depth, depth] band in the table (a single value). -func makeBiomeParameter(e rawParameter, depth float64) worldgen.BiomeParameter { +// the [min,max] ranges to quantized ClimateRanges. depth is a ClimateRange +// (half-open band for scalar depths, explicit range for cave biomes). +func makeBiomeParameter(e rawParameter, depth worldgen.ClimateRange) worldgen.BiomeParameter { qr := func(a [2]float64) worldgen.ClimateRange { return worldgen.ClimateRange{Min: worldgen.Quantize(a[0]), Max: worldgen.Quantize(a[1])} } - dpQ := worldgen.Quantize(depth) return worldgen.BiomeParameter{ Name: e.Biome, Ranges: [worldgen.AxisCount]worldgen.ClimateRange{ @@ -98,20 +105,37 @@ func makeBiomeParameter(e rawParameter, depth float64) worldgen.BiomeParameter { qr(e.Param.Continentalness), qr(e.Param.Erosion), qr(e.Param.Weirdness), - {Min: dpQ, Max: dpQ + 1}, // half-open band covering exactly depth + depth, // half-open band (scalar) or explicit range (cave biomes) }, Offset: worldgen.Quantize(e.Param.Offset), } } // BiomeAt returns the network biome ID for the surface biome at block (wx, wz) -// given the loaded overworld density. It samples the climate axes at sea level, -// finds the matching biome in the parameter table, and resolves its name to a -// numeric ID via the synchronized biome registry. Unknown biomes fall back to -// plains so chunk encoding always gets a valid ID. +// given the loaded overworld density. It samples the climate axes at sea level +// with depth fixed to 0 (surface layer), finds the matching biome in the full +// parameter table, and resolves its name to a numeric ID via the synchronized +// biome registry. Unknown biomes fall back to plains so chunk encoding always +// gets a valid ID. +// +// Kept for surface-only (per-chunk) lookups; 3D per-cell code uses BiomeAt3D. func BiomeAt(od *worldgen.OverworldDensity, wx, wz int) uint16 { point := worldgen.SampleColumn(od, SeaLevel, wx, wz) - name := loadSurfaceTable().FindBiome(point) + return biomeID(loadBiomeTable().FindBiome(point)) +} + +// BiomeAt3D returns the network biome ID for the biome cell containing block +// (wx, wy, wz). s2D carries the five precomputed 2D climate axes for the column +// (sampled once via SampleColumn2D); the 3D depth axis is evaluated at wy inside +// this function. Surface, underground-twin, and cave biomes are all selectable +// because the full parameter table is searched with depth as a true range. +func BiomeAt3D(od *worldgen.OverworldDensity, s2D worldgen.Sample2D, wx, wy, wz int) uint16 { + point := worldgen.SampleCell(od, s2D, wx, wy, wz) + return biomeID(loadBiomeTable().FindBiome(point)) +} + +// biomeID resolves a biome name to its network ID, falling back to plains. +func biomeID(name string) uint16 { if id := registry.Index("minecraft:worldgen/biome", name); id >= 0 { return uint16(id) } diff --git a/internal/world/biome_lookup_test.go b/internal/world/biome_lookup_test.go index d504ac0..4214567 100644 --- a/internal/world/biome_lookup_test.go +++ b/internal/world/biome_lookup_test.go @@ -29,7 +29,7 @@ func TestBiomeAtDeterministic(t *testing.T) { // biomeName is a test helper exposing the resolved biome name at (wx, wz). func biomeName(od *worldgen.OverworldDensity, wx, wz int) string { point := worldgen.SampleColumn(od, SeaLevel, wx, wz) - return loadSurfaceTable().FindBiome(point) + return loadBiomeTable().FindBiome(point) } // TestBiomeAtVaryingAcrossWorld confirms different regions of the world map to @@ -52,22 +52,41 @@ func TestBiomeAtVaryingAcrossWorld(t *testing.T) { t.Logf("found %d distinct biomes across 16x16 chunks", len(seen)) } -// TestVanillaChunkHasBiome confirms generateVanilla threads the per-column biome -// into the chunk (regression guard for the NewChunk call site in vanilla.go). +// TestVanillaChunkHasBiomes confirms generateVanilla fills per-cell 3D biomes +// (regression guard for the fillBiomes3D call in vanilla.go). It checks that at +// least one section has a populated biome container and that a surface cell +// matches what BiomeAt3D returns at the chunk centre. func TestVanillaChunkHasBiome(t *testing.T) { gen := NewVanillaGenerator(12345) ch := gen(10, -3) if ch == nil { t.Fatal("nil chunk") } - // biome is unexported; verify via the registry by re-deriving it. The chunk's - // biome must match what BiomeAt returns at the chunk centre. + + // At least one section must carry per-cell biomes (otherwise fillBiomes3D + // never ran and the chunk fell back to the uniform plains default). + hasCells := false + for si := 0; si < SectionCount; si++ { + if ch.biomes[si] != nil { + hasCells = true + break + } + } + if !hasCells { + t.Fatal("no per-cell biome sections; fillBiomes3D did not run") + } + + // A surface cell at the chunk centre should match BiomeAt3D with depth at + // that Y. Surface is the section containing sea level. od, err := worldgen.LoadOverworldFinalDensity(12345) if err != nil { t.Fatalf("load: %v", err) } - want := BiomeAt(od, 10*16+8, -3*16+8) - if uint16(ch.biome) != want { - t.Errorf("chunk biome = %d, want %d", ch.biome, want) + lx, lz := 8, 8 + s2D := worldgen.SampleColumn2D(od, SeaLevel, 10*16+lx, -3*16+lz) + want := BiomeAt3D(od, s2D, 10*16+lx, SeaLevel, -3*16+lz) + got := ch.biomes[(SeaLevel-MinY)>>4][biomeIndex(lx, SeaLevel, lz)] + if got != want { + t.Errorf("centre surface biome = %d, want %d", got, want) } } diff --git a/internal/world/encode.go b/internal/world/encode.go index 986797a..5d40c7c 100644 --- a/internal/world/encode.go +++ b/internal/world/encode.go @@ -35,12 +35,25 @@ const BiomePlains uint16 = 40 // direct-palette bit width. const totalBlockStates = 29873 -// Chunk is a 16xWorldHeightx16 column of block states with a single biome. -// A nil section is entirely air. +// Biome-cell geometry for the overworld. A biome cell is biomeCellSize³ blocks +// (4×4×4), so each 16-block chunk section holds biomeCellsPerSection biome +// cells. totalBiomes is the size of the synchronized biome registry and sets +// the biome direct-palette bit width. +const ( + biomeCellSize = 4 + biomeCellsXZ = 16 / biomeCellSize // 4 + biomeCellsPerSection = biomeCellsXZ * biomeCellsXZ * biomeCellsXZ // 64 + totalBiomes = 65 // synced minecraft:worldgen/biome registry size +) + +// Chunk is a 16xWorldHeightx16 column of block states. Each section may carry a +// per-cell biome array (4×4×4); when biomes[si] is nil the section falls back to +// the column-wide biome field (used by flat/simple generators). type Chunk struct { X, Z int32 sections [SectionCount]*[sectionVol]uint16 - biome uint16 + biomes [SectionCount]*[biomeCellsPerSection]uint16 + biome uint16 // fallback uniform biome when biomes[si] is nil } // NewChunk returns an empty (all-air) chunk at (x, z) with the given biome. @@ -82,6 +95,32 @@ func (c *Chunk) SetBlock(lx, y, lz int, state uint16) { c.section(si)[blockIndex(lx, y, lz)] = state } +// biomeIndex maps a block within a section to its YZX-ordered 4×4×4 biome cell. +// Coordinates are folded into 0..15 (block coords) then divided to cell coords. +func biomeIndex(lx, ly, lz int) int { + bx := (lx & 15) / biomeCellSize + by := (ly & 15) / biomeCellSize + bz := (lz & 15) / biomeCellSize + return by<<(biomeCellsXZBits*2) | bz<> 4 + if si < 0 || si >= SectionCount { + return + } + if c.biomes[si] == nil { + c.biomes[si] = new([biomeCellsPerSection]uint16) + } + c.biomes[si][biomeIndex(lx, y, lz)] = biome +} + // Encode serializes the level_chunk_with_light body for this chunk. func (c *Chunk) Encode() []byte { w := protocol.NewWriter(8192) @@ -157,7 +196,8 @@ func packHeightmap(h [256]uint16) []uint64 { } // writeSection emits one chunk section: block count, block paletted container, -// then the (single-value) biome paletted container. +// then the biome paletted container (per-cell 4×4×4, or single-valued for legacy +// generators that only set a column-wide biome). func (c *Chunk) writeSection(w *protocol.Writer, i int) { s := c.sections[i] if s == nil { @@ -169,8 +209,12 @@ func (c *Chunk) writeSection(w *protocol.Writer, i int) { w.Uint16(0) // reserved 2-byte field writeBlockPalette(w, s) } - // Biomes: a single value covers the whole section for now. - writeSingleValued(w, uint32(c.biome)) + // Biome container: per-cell palette when present, else the uniform fallback. + if b := c.biomes[i]; b != nil { + writeBiomePalette(w, b) + } else { + writeSingleValued(w, uint32(c.biome)) + } } func nonAirCount(s *[sectionVol]uint16) int { @@ -192,7 +236,7 @@ func writeSingleValued(w *protocol.Writer, value uint32) { // writeBlockPalette writes a block-state paletted container, choosing the // single-valued, indirect, or direct encoding as appropriate. func writeBlockPalette(w *protocol.Writer, s *[sectionVol]uint16) { - palette, indexOf := buildPalette(s) + palette, indexOf := buildPalette(s[:]) if len(palette) == 1 { writeSingleValued(w, uint32(palette[0])) return @@ -217,6 +261,47 @@ func writeBlockPalette(w *protocol.Writer, s *[sectionVol]uint16) { }) } +// writeBiomePalette writes a biome paletted container over the 64 cells of a +// section. It mirrors writeBlockPalette but with biome-specific thresholds: the +// indirect palette allows a minimum of 1 bit per entry (vs 4 for blocks), and +// the direct form is used once the palette bit width exceeds the biome +// registry width. +func writeBiomePalette(w *protocol.Writer, s *[biomeCellsPerSection]uint16) { + palette, indexOf := buildPalette(s[:]) + if len(palette) == 1 { + writeSingleValued(w, uint32(palette[0])) + return + } + + bpe := bitsFor(len(palette)) + if bpe < 1 { + bpe = 1 // minimum for the indirect biome format + } + if bpe > bitsFor(totalBiomes) { + writeBiomeDirect(w, s) + return + } + + w.Byte(byte(bpe)) + w.VarInt(int32(len(palette))) + for _, st := range palette { + w.VarInt(int32(st)) + } + writePackedIndices(w, bpe, biomeCellsPerSection, func(i int) uint32 { + return uint32(indexOf[s[i]]) + }) +} + +// writeBiomeDirect writes a direct (palette-less) biome container of registry +// IDs, sized to the full biome registry width. +func writeBiomeDirect(w *protocol.Writer, s *[biomeCellsPerSection]uint16) { + bpe := bitsFor(totalBiomes) + w.Byte(byte(bpe)) + writePackedIndices(w, bpe, biomeCellsPerSection, func(i int) uint32 { + return uint32(s[i]) + }) +} + // writeDirect writes a direct (palette-less) container of global state IDs. func writeDirect(w *protocol.Writer, s *[sectionVol]uint16) { bpe := bitsFor(totalBlockStates) @@ -247,8 +332,10 @@ func writePackedIndices(w *protocol.Writer, bpe, count int, value func(i int) ui } } -// buildPalette returns the distinct block states in s and a value->index map. -func buildPalette(s *[sectionVol]uint16) ([]uint16, map[uint16]int) { +// buildPalette returns the distinct values in s and a value->index map. It +// takes a slice so the same routine serves block sections (sectionVol entries) +// and biome cells (biomeCellsPerSection entries); callers pass array[:] in. +func buildPalette(s []uint16) ([]uint16, map[uint16]int) { indexOf := make(map[uint16]int) var palette []uint16 for _, v := range s { diff --git a/internal/world/vanilla.go b/internal/world/vanilla.go index 0810af1..e8cfac2 100644 --- a/internal/world/vanilla.go +++ b/internal/world/vanilla.go @@ -34,11 +34,7 @@ func NewVanillaGenerator(seed int64) Generator { } func generateVanilla(od *worldgen.OverworldDensity, seed int64, cx, cz int32) *Chunk { - // Surface biome is sampled at the chunk centre column. Climate noises are - // 2D at this stage (depth fixed to surface), so one sample per chunk is - // representative; the per-cell milestone will sample the 4×4×4 grid. - biome := BiomeAt(od, int(cx)*16+8, int(cz)*16+8) - c := NewChunk(cx, cz, biome) + c := NewChunk(cx, cz, BiomePlains) // per-cell biomes override below baseX, baseZ := int(cx)*16, int(cz)*16 grids := make([]cornerGrid, len(od.Interpolated)) @@ -86,10 +82,54 @@ func generateVanilla(od *worldgen.OverworldDensity, seed int64, cx, cz int32) *C } } } + fillBiomes3D(c, od, 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 + 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. + for bx := 0; bx < biomeCellsXZ; bx++ { + wg.Add(1) + go func(bx int) { + defer wg.Done() + lx := bx * biomeCellSize + for bz := 0; bz < biomeCellsXZ; bz++ { + lz := bz * biomeCellSize + col2D := s2D[lx][lz] + for si := 0; si < SectionCount; si++ { + for by := 0; by < biomeCellsXZ; by++ { + wy := MinY + si*16 + by*biomeCellSize + biome := BiomeAt3D(od, col2D, baseX+lx, wy, baseZ+lz) + c.SetBiome(lx, wy, lz, biome) + } + } + } + }(bx) + } + wg.Wait() +} + // 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; diff --git a/internal/worldgen/climate_sampler.go b/internal/worldgen/climate_sampler.go index b3e6f05..382b913 100644 --- a/internal/worldgen/climate_sampler.go +++ b/internal/worldgen/climate_sampler.go @@ -2,28 +2,54 @@ package worldgen // This file samples the climate density functions into a TargetPoint for the // biome finder. The climate router keys are 2D (flat_cache + y_scale=0) except -// depth, which is 3D. For surface biome selection we fix depth to 0.0, matching -// the depth=0 (surface) entries of the biome parameter table; underground and -// cave biomes use depth=1.0 / non-zero offset and are a later milestone. +// depth, which is 3D. For per-chunk surface biome selection we fix depth to +// 0.0; for the per-cell 3D milestone we evaluate real depth at each cell. + +// Sample2D holds the five Y-invariant climate axes for one (x,z) column, +// precomputed once so every vertical biome cell in that column reuses them. +type Sample2D struct { + Temperature, Humidity, Continentalness, Erosion, Weirdness float64 +} + +// SampleColumn2D evaluates the five 2D climate axes at block (wx, wz). The +// vertical coordinate passed to the flat noises (seaLevelY) does not affect the +// result because they are flat_cache/y_scale=0, but is kept for symmetry. +func SampleColumn2D(od *OverworldDensity, seaLevelY, wx, wz int) Sample2D { + ctx := FunctionContext{X: float64(wx), Y: float64(seaLevelY), Z: float64(wz)} + return Sample2D{ + Temperature: computeOrZero(od.Temperature, ctx), + Humidity: computeOrZero(od.Humidity, ctx), + Continentalness: computeOrZero(od.Continentalness, ctx), + Erosion: computeOrZero(od.Erosion, ctx), + Weirdness: computeOrZero(od.Weirdness, ctx), + } +} + +// SampleCell builds a full 3D TargetPoint at block (wx, wy, wz): the five 2D +// axes come from the precomputed s2D (sampled once per column), and depth is +// evaluated at the cell's real Y — the only Y-dependent climate axis. This +// keeps per-cell cost at a single DensityFunction call (depth) instead of six. +func SampleCell(od *OverworldDensity, s2D Sample2D, wx, wy, wz int) TargetPoint { + depth := 0.0 + if od.Depth != nil { + depth = od.Depth.Compute(FunctionContext{X: float64(wx), Y: float64(wy), Z: float64(wz)}) + } + return NewTargetPoint(s2D.Temperature, s2D.Humidity, s2D.Continentalness, + s2D.Erosion, s2D.Weirdness, depth) +} // SampleColumn evaluates the six climate parameters at block (wx, wz) using od // and returns the TargetPoint for surface biome lookup. seaLevelY is the Y at // which to sample the 2D climate noises (callers pass the world sea level). +// +// Kept for surface-only (per-chunk) lookups; per-cell 3D code uses +// SampleColumn2D + SampleCell instead. func SampleColumn(od *OverworldDensity, seaLevelY int, wx, wz int) TargetPoint { - ctx := FunctionContext{X: float64(wx), Y: float64(seaLevelY), Z: float64(wz)} - - temp := computeOrZero(od.Temperature, ctx) - humid := computeOrZero(od.Humidity, ctx) - cont := computeOrZero(od.Continentalness, ctx) - ero := computeOrZero(od.Erosion, ctx) - weird := computeOrZero(od.Weirdness, ctx) - + s2D := SampleColumn2D(od, seaLevelY, wx, wz) // Surface layer: depth axis is fixed at 0.0 so only the depth=0 (surface) - // biome parameter entries match. The real 3D depth is consulted in the - // per-cell milestone. - const surfaceDepth = 0.0 - - return NewTargetPoint(temp, humid, cont, ero, weird, surfaceDepth) + // biome parameter entries match. + return NewTargetPoint(s2D.Temperature, s2D.Humidity, s2D.Continentalness, + s2D.Erosion, s2D.Weirdness, 0.0) } // computeOrZero evaluates df at ctx, returning 0 when df is nil (a climate key @@ -35,3 +61,4 @@ func computeOrZero(df DensityFunction, ctx FunctionContext) float64 { } return df.Compute(ctx) } +