worldgen: add vanilla biome search and decoration RNG
This commit is contained in:
parent
736f387b41
commit
fd32c65da9
6 changed files with 383 additions and 10 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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++ {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
68
internal/worldgen/simplex_noise.go
Normal file
68
internal/worldgen/simplex_noise.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
25
internal/worldgen/simplex_noise_test.go
Normal file
25
internal/worldgen/simplex_noise_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue