diff --git a/internal/worldgen/biome.go b/internal/worldgen/biome.go index ac410f8..5175c06 100644 --- a/internal/worldgen/biome.go +++ b/internal/worldgen/biome.go @@ -1,6 +1,9 @@ package worldgen -import "math" +import ( + "math" + "sort" +) // This file reproduces net.minecraft.world.level.biome.Climate, the multi-noise // biome selector. A point in climate space is six quantized coordinates @@ -81,35 +84,145 @@ type BiomeParameter struct { // ParameterTable is the set of biome parameters the finder searches. type ParameterTable struct { entries []tableEntry + root *biomeSearchNode } type tableEntry struct { param BiomeParameter } +// biomeSearchNode indexes parameter ranges by a bounding volume. Its lower +// bound is safe for vanilla's fitDistance metric, allowing exact nearest +// searches without scanning every climate entry for each biome cell. +type biomeSearchNode struct { + min, max [AxisCount]int64 + minOffsetAbs int64 + left, right *biomeSearchNode + indices []int +} + +const biomeSearchLeafSize = 16 + // NewParameterTable builds a searchable table from raw biome parameters. func NewParameterTable(params []BiomeParameter) *ParameterTable { t := &ParameterTable{entries: make([]tableEntry, len(params))} for i, p := range params { t.entries[i] = tableEntry{param: p} } + indices := make([]int, len(params)) + for i := range indices { + indices[i] = i + } + t.root = buildBiomeSearchTree(t.entries, indices) return t } // FindBiome returns the parameter with the lowest vanilla fitness. Table order // is the deterministic tie breaker because equal fitness never replaces best. func (t *ParameterTable) FindBiome(point TargetPoint) string { - var best string - bestDist := int64(math.MaxInt64) - - for _, e := range t.entries { - d := fitDistance(point, e.param.Ranges, e.param.Offset) - if d < bestDist { - bestDist = d - best = e.param.Name + bestDist, bestIndex := int64(math.MaxInt64), len(t.entries) + var visit func(*biomeSearchNode) + visit = func(node *biomeSearchNode) { + if node == nil || biomeNodeLowerBound(point, node) > bestDist { + return + } + if node.indices != nil { + for _, index := range node.indices { + d := fitDistance(point, t.entries[index].param.Ranges, t.entries[index].param.Offset) + if d < bestDist || d == bestDist && index < bestIndex { + bestDist, bestIndex = d, index + } + } + return + } + leftDistance := biomeNodeLowerBound(point, node.left) + rightDistance := biomeNodeLowerBound(point, node.right) + if leftDistance <= rightDistance { + visit(node.left) + visit(node.right) + } else { + visit(node.right) + visit(node.left) } } - return best + visit(t.root) + if bestIndex == len(t.entries) { + return "" + } + return t.entries[bestIndex].param.Name +} + +func buildBiomeSearchTree(entries []tableEntry, indices []int) *biomeSearchNode { + if len(indices) == 0 { + return nil + } + node := &biomeSearchNode{minOffsetAbs: math.MaxInt64} + for axis := 0; axis < AxisCount; axis++ { + node.min[axis], node.max[axis] = math.MaxInt64, math.MinInt64 + } + for _, index := range indices { + param := entries[index].param + if offset := absInt64(param.Offset); offset < node.minOffsetAbs { + node.minOffsetAbs = offset + } + for axis, r := range param.Ranges { + if r.Min < node.min[axis] { + node.min[axis] = r.Min + } + if r.Max > node.max[axis] { + node.max[axis] = r.Max + } + } + } + if len(indices) <= biomeSearchLeafSize { + node.indices = append([]int(nil), indices...) + return node + } + axis := 0 + for candidate := 1; candidate < AxisCount; candidate++ { + if node.max[candidate]-node.min[candidate] > node.max[axis]-node.min[axis] { + axis = candidate + } + } + sort.SliceStable(indices, func(i, j int) bool { + left := entries[indices[i]].param.Ranges[axis] + right := entries[indices[j]].param.Ranges[axis] + leftMid := left.Min + (left.Max-left.Min)/2 + rightMid := right.Min + (right.Max-right.Min)/2 + if leftMid != rightMid { + return leftMid < rightMid + } + return indices[i] < indices[j] + }) + middle := len(indices) / 2 + node.left = buildBiomeSearchTree(entries, indices[:middle]) + node.right = buildBiomeSearchTree(entries, indices[middle:]) + return node +} + +func biomeNodeLowerBound(point TargetPoint, node *biomeSearchNode) int64 { + if node == nil { + return math.MaxInt64 + } + values := [AxisCount]int64{point.Temperature, point.Humidity, point.Continentalness, point.Erosion, point.Depth, point.Weirdness} + var total int64 + for axis, value := range values { + var distance int64 + if value < node.min[axis] { + distance = node.min[axis] - value + } else if value > node.max[axis] { + distance = value - node.max[axis] + } + total += distance * distance + } + return total + node.minOffsetAbs*node.minOffsetAbs +} + +func absInt64(value int64) int64 { + if value < 0 { + return -value + } + return value } // containsAll reports whether every range contains its corresponding coordinate. diff --git a/internal/worldgen/biome_test.go b/internal/worldgen/biome_test.go index a7d12d1..f2af422 100644 --- a/internal/worldgen/biome_test.go +++ b/internal/worldgen/biome_test.go @@ -1,6 +1,9 @@ package worldgen import ( + "math" + "math/rand" + "strconv" "testing" ) @@ -48,6 +51,45 @@ func TestParameterTableDistanceOffsetAndTies(t *testing.T) { } } +func TestParameterTableIndexMatchesLinearSearch(t *testing.T) { + random := rand.New(rand.NewSource(12345)) + params := make([]BiomeParameter, 2048) + for i := range params { + params[i].Name = "biome-" + strconv.Itoa(i) + for axis := 0; axis < AxisCount; axis++ { + lo := random.Int63n(40001) - 20000 + hi := lo + random.Int63n(5001) + params[i].Ranges[axis] = ClimateRange{Min: lo, Max: hi} + } + params[i].Offset = random.Int63n(1001) - 500 + } + // Duplicate an entry under a later name to exercise the original-order tie + // break across separate leaves of the search tree. + params[len(params)-1] = params[0] + params[len(params)-1].Name = "later duplicate" + + table := NewParameterTable(params) + for sample := 0; sample < 5000; sample++ { + point := TargetPoint{ + Temperature: random.Int63n(50001) - 25000, + Humidity: random.Int63n(50001) - 25000, + Continentalness: random.Int63n(50001) - 25000, + Erosion: random.Int63n(50001) - 25000, + Depth: random.Int63n(50001) - 25000, + Weirdness: random.Int63n(50001) - 25000, + } + want, bestDist := "", int64(math.MaxInt64) + for _, param := range params { + if distance := fitDistance(point, param.Ranges, param.Offset); distance < bestDist { + want, bestDist = param.Name, distance + } + } + if got := table.FindBiome(point); got != want { + t.Fatalf("sample %d: indexed FindBiome = %q, linear = %q", sample, got, want) + } + } +} + func TestFitnessVectorsAgainstVanillaRuntime(t *testing.T) { zero := [AxisCount]ClimateRange{} temperatureRange := zero diff --git a/internal/worldgen/random.go b/internal/worldgen/random.go index 5662b0d..1781380 100644 --- a/internal/worldgen/random.go +++ b/internal/worldgen/random.go @@ -91,6 +91,13 @@ func NewXoroshiro(seed int64) *Xoroshiro { return newXoroshiroFrom(s.lo, s.hi) } +// SetSeed resets the source exactly like XoroshiroRandomSource.setSeed. +func (x *Xoroshiro) SetSeed(seed int64) { + s := upgradeSeedTo128bit(uint64(seed)) + x.lo, x.hi = s.lo, s.hi + x.haveGaussian = false +} + func newXoroshiroFrom(lo, hi uint64) *Xoroshiro { if lo == 0 && hi == 0 { lo, hi = goldenRatio64, silverRatio64 @@ -320,6 +327,100 @@ func (f *legacyPositional) At(x, y, z int) RandomSource { return NewLegacy(positionSeed(x, y, z) ^ int64(f.seed)) } +// WorldgenRandom is the adapter used by ChunkGenerator.applyBiomeDecoration in +// vanilla 26.1.2. Its public random methods retain BitRandomSource's +// next(bits) semantics, while each bit draw is backed by one Xoroshiro long. +// This is intentionally different from calling Xoroshiro's public methods +// directly. +type WorldgenRandom struct { + source *Xoroshiro + gaussian float64 + haveGaussian bool +} + +func NewWorldgenRandom(seed int64) *WorldgenRandom { + return &WorldgenRandom{source: NewXoroshiro(seed)} +} + +func (r *WorldgenRandom) SetSeed(seed int64) { + r.source.SetSeed(seed) + r.haveGaussian = false +} + +func (r *WorldgenRandom) SetDecorationSeed(seed int64, blockX, blockZ int) int64 { + r.SetSeed(seed) + a := r.NextLong() | 1 + b := r.NextLong() | 1 + decorationSeed := int64(blockX)*a + int64(blockZ)*b ^ seed + r.SetSeed(decorationSeed) + return decorationSeed +} + +func (r *WorldgenRandom) SetFeatureSeed(decorationSeed int64, featureIndex, stage int) { + r.SetSeed(decorationSeed + int64(featureIndex) + int64(10000*stage)) +} + +func (r *WorldgenRandom) next(bits uint) int32 { + return int32(uint64(r.source.NextLong()) >> (64 - bits)) +} + +func (r *WorldgenRandom) NextInt() int32 { return r.next(32) } + +func (r *WorldgenRandom) NextIntN(bound int32) int32 { + if bound&-bound == bound { + return int32((int64(bound) * int64(r.next(31))) >> 31) + } + for { + j := r.next(31) + k := j % bound + if j-k+(bound-1) >= 0 { + return k + } + } +} + +func (r *WorldgenRandom) NextLong() int64 { + return int64(r.next(32))<<32 + int64(r.next(32)) +} + +func (r *WorldgenRandom) NextDouble() float64 { + hi := int64(r.next(26)) + lo := int64(r.next(27)) + return float64(hi<<27+lo) * 0x1.0p-53 +} + +func (r *WorldgenRandom) NextFloat() float32 { return float32(r.next(24)) * 0x1.0p-24 } +func (r *WorldgenRandom) NextBoolean() bool { return r.next(1) != 0 } + +func (r *WorldgenRandom) NextGaussian() float64 { + if r.haveGaussian { + r.haveGaussian = false + return r.gaussian + } + for { + u := 2*r.NextDouble() - 1 + v := 2*r.NextDouble() - 1 + s := u*u + v*v + if s == 0 || s >= 1 { + continue + } + factor := math.Sqrt(-2 * math.Log(s) / s) + r.gaussian = v * factor + r.haveGaussian = true + return u * factor + } +} + +func (r *WorldgenRandom) ConsumeCount(n int) { + for i := 0; i < n; i++ { + r.next(32) + } +} + +func (r *WorldgenRandom) ForkPositional() PositionalRandomFactory { + return r.source.ForkPositional() +} + func javaStringHashCode(s string) int32 { var h int32 for i := 0; i < len(s); i++ { diff --git a/internal/worldgen/random_test.go b/internal/worldgen/random_test.go index 8c69c46..6764028 100644 --- a/internal/worldgen/random_test.go +++ b/internal/worldgen/random_test.go @@ -120,3 +120,27 @@ func TestDecorationAndFeatureSeedVectors(t *testing.T) { } } } + +func TestXoroshiroWorldgenDecorationVector(t *testing.T) { + random := NewWorldgenRandom(0) + decorationSeed := random.SetDecorationSeed(12345, 0, 0) + if decorationSeed != 12345 { + t.Fatalf("decoration seed = %d, want 12345", decorationSeed) + } + random.SetFeatureSeed(decorationSeed, 10, 6) + if got := random.NextIntN(16); got != 3 { + t.Fatalf("nextInt(16) = %d, want 3", got) + } + if got := random.NextIntN(16); got != 9 { + t.Fatalf("second nextInt(16) = %d, want 9", got) + } + if got := random.NextIntN(65); got != 21 { + t.Fatalf("nextInt(65) = %d, want 21", got) + } + if got := random.NextFloat(); math.Abs(float64(got-0.4394614)) > 1e-7 { + t.Fatalf("nextFloat = %.9f, want 0.4394614", got) + } + if got := random.NextDouble(); math.Abs(got-0.6041286197351282) > 1e-15 { + t.Fatalf("nextDouble = %.17f, want 0.6041286197351282", got) + } +} diff --git a/internal/worldgen/simplex_noise.go b/internal/worldgen/simplex_noise.go new file mode 100644 index 0000000..5e58575 --- /dev/null +++ b/internal/worldgen/simplex_noise.go @@ -0,0 +1,68 @@ +package worldgen + +import "math" + +// simplexNoise is the 2D path of vanilla's SimplexNoise. Biome placement +// counts use a single octave constructed from LegacyRandomSource seed 2345. +type simplexNoise struct { + p [256]int +} + +func newSimplexNoise(random RandomSource) *simplexNoise { + // SimplexNoise always consumes three offsets even though its 2D sampler + // does not add them to the input coordinates. + random.NextDouble() + random.NextDouble() + random.NextDouble() + n := &simplexNoise{} + for i := range n.p { + n.p[i] = i + } + for i := range n.p { + j := i + int(random.NextIntN(int32(256-i))) + n.p[i], n.p[j] = n.p[j], n.p[i] + } + return n +} + +func (n *simplexNoise) perm(index int) int { return n.p[index&255] } + +func (n *simplexNoise) value2D(x, y float64) float64 { + sqrt3 := math.Sqrt(3) + f2 := 0.5 * (sqrt3 - 1) + g2 := (3 - sqrt3) / 6 + skew := (x + y) * f2 + i, j := int(math.Floor(x+skew)), int(math.Floor(y+skew)) + unskew := float64(i+j) * g2 + x0, y0 := x-(float64(i)-unskew), y-(float64(j)-unskew) + i1, j1 := 0, 1 + if x0 > y0 { + i1, j1 = 1, 0 + } + x1, y1 := x0-float64(i1)+g2, y0-float64(j1)+g2 + x2, y2 := x0-1+2*g2, y0-1+2*g2 + ii, jj := i&255, j&255 + g0 := n.perm(ii+n.perm(jj)) % 12 + g1 := n.perm(ii+i1+n.perm(jj+j1)) % 12 + g2i := n.perm(ii+1+n.perm(jj+1)) % 12 + return 70 * (simplexCorner(g0, x0, y0, 0.5) + + simplexCorner(g1, x1, y1, 0.5) + simplexCorner(g2i, x2, y2, 0.5)) +} + +func simplexCorner(gradientIndex int, x, y, radius float64) float64 { + attenuation := radius - x*x - y*y + if attenuation < 0 { + return 0 + } + attenuation *= attenuation + g := gradient[gradientIndex] + return attenuation * attenuation * (g[0]*x + g[1]*y) +} + +var biomeInfoNoise = newSimplexNoise(NewLegacy(2345)) + +// BiomeInfoNoise is Biome.BIOME_INFO_NOISE.getValue(x, z, false). It is +// world-seed independent and drives noise-based vegetation attempt counts. +func BiomeInfoNoise(x, z float64) float64 { + return biomeInfoNoise.value2D(x, z) +} diff --git a/internal/worldgen/simplex_noise_test.go b/internal/worldgen/simplex_noise_test.go new file mode 100644 index 0000000..e72f68c --- /dev/null +++ b/internal/worldgen/simplex_noise_test.go @@ -0,0 +1,25 @@ +package worldgen + +import ( + "math" + "testing" +) + +func TestBiomeInfoNoiseVanillaVectors(t *testing.T) { + tests := []struct { + x, z int + want float64 + }{ + {0, 0, 0}, + {16, 0, 0.6210549241139091}, + {0, 16, -0.005784055694146521}, + {-16, -16, -0.3789716250634575}, + {123, 456, 0.43256906665489797}, + } + for _, test := range tests { + got := BiomeInfoNoise(float64(test.x)/80, float64(test.z)/80) + if math.Float64bits(got) != math.Float64bits(test.want) { + t.Errorf("BiomeInfoNoise(%d,%d) = %.17g, want %.17g", test.x, test.z, got, test.want) + } + } +}