Files

234 lines
8.0 KiB
Go

// Package noise is the small toolkit the uplift field is built from: value noise, fBm, domain warping and
// Worley crest lines. Ported from Scripts/Authoring/heightmap_noise.py, and the tuning is the point of the
// port, not the shapes. Read the constants in Docs/Terrain.md before changing any of them; every one of them
// is a round of measurement that has already been paid for.
//
// The one change of intent from the numpy original (D-47): what comes out of here is no longer the terrain.
// It is an uplift rate field that the fluvial pass integrates. Amplitudes are therefore far smaller and the
// shapes matter more than the heights.
package noise
import (
"math"
"math/rand/v2"
"salty/terrain/internal/field"
)
// Source is a per-pass random source. Never the global one: determinism is cross-cutting rule 12 and a
// shared source makes the result depend on which pass drew first.
type Source struct{ r *rand.Rand }
// NewSource seeds from the run seed and the pass index, so inserting a pass does not reshuffle the ones
// before it.
func NewSource(seed int64, pass uint64) *Source {
return &Source{r: rand.New(rand.NewPCG(uint64(seed), pass))}
}
func (s *Source) Float() float64 { return s.r.Float64() }
func (s *Source) Range(lo, hi float64) float64 { return lo + (hi-lo)*s.r.Float64() }
func (s *Source) IntN(n int) int { return s.r.IntN(n) }
func Smoothstep(t float64) float64 { return t * t * (3 - 2*t) }
func smoothstep32(t float32) float32 { return t * t * (3 - 2*t) }
// Lattice is one octave's random grid. Periodic: sampling outside it wraps, so warped or stretched
// coordinates never run off an edge.
type Lattice struct {
Cells int
Data []float32
}
// NewLattice draws cells*cells uniforms in index order, which is what makes the octave reproducible however
// it is later sampled in parallel.
func NewLattice(cells int, s *Source) *Lattice {
l := &Lattice{Cells: cells, Data: make([]float32, cells*cells)}
for i := range l.Data {
l.Data[i] = float32(s.Float())
}
return l
}
// Sample interpolates the periodic lattice at (u, v) in cell units.
func (l *Lattice) Sample(u, v float64) float32 {
c := l.Cells
i0 := int(math.Floor(u))
j0 := int(math.Floor(v))
tu := smoothstep32(float32(u - math.Floor(u)))
tv := smoothstep32(float32(v - math.Floor(v)))
i0 = ((i0 % c) + c) % c
j0 = ((j0 % c) + c) % c
i1 := (i0 + 1) % c
j1 := (j0 + 1) % c
top := l.Data[j0*c+i0]*(1-tu) + l.Data[j0*c+i1]*tu
bot := l.Data[j1*c+i0]*(1-tu) + l.Data[j1*c+i1]*tu
return top*(1-tv) + bot*tv
}
// Params are one fBm stack. Gain is the constant that matters most: eight octaves at 0.5 makes every octave
// as steep as the last and puts a third of the land above 50 degrees. 0.42 to 0.45.
type Params struct {
BaseCells int
Octaves int
Gain float64
Ridged bool
}
// FBM fills a size x size field with fractional Brownian motion in [0, 1] on the regular grid.
func FBM(size int, s *Source, p Params) *field.Field {
uv := identity(size)
return FBMAt(uv.u, uv.v, s, p)
}
// FBMAt samples the same stack at map coordinates, where 0..1 spans the map once. Feed it warped or
// anisotropic coordinates and the noise bends and stretches with them, which is how the ranges come out as
// long chains rather than blobs.
func FBMAt(u, v *field.Field, s *Source, p Params) *field.Field {
out := field.NewLike(u)
amplitude, cells, norm := 1.0, p.BaseCells, 0.0
// Lattices are built up front, in octave order, before any sampling: the draw order must not depend on
// the parallel loop below.
lattices := make([]*Lattice, p.Octaves)
for o := 0; o < p.Octaves; o++ {
lattices[o] = NewLattice(cells, s)
cells *= 2
}
cells = p.BaseCells
for o := 0; o < p.Octaves; o++ {
l := lattices[o]
amp := float32(amplitude)
c := float64(cells)
field.Rows(u.H, func(y0, y1 int) {
for y := y0; y < y1; y++ {
for x := 0; x < u.W; x++ {
i := y*u.W + x
n := l.Sample(float64(u.Data[i])*c, float64(v.Data[i])*c)
if p.Ridged {
n = 1 - float32(math.Abs(float64(n)*2-1))
n = n * n
}
out.Data[i] += n * amp
}
}
})
norm += amplitude
amplitude *= p.Gain
cells *= 2
}
inv := float32(1 / norm)
for i := range out.Data {
out.Data[i] *= inv
}
return out
}
// CellularEdges is Worley F2-F1 through periodic jittered feature points, mapped so the borders between cells
// read 1 and the interiors 0: a network of thin, branching crest lines.
//
// Kept light on purpose. At 30 % of the mountain height this turned the ranges into a honeycomb of polygon
// walls with flat floors (2026-09-17); 12 % through a stronger warp is the setting that survived.
func CellularEdges(u, v *field.Field, s *Source, cells int, jitter float64) *field.Field {
pts := make([]float32, cells*cells*2)
for i := range pts {
pts[i] = float32(s.Float()*jitter + (1-jitter)*0.5)
}
out := field.NewLike(u)
field.Rows(u.H, func(y0, y1 int) {
for y := y0; y < y1; y++ {
for x := 0; x < u.W; x++ {
i := y*u.W + x
su := float64(u.Data[i]) * float64(cells)
sv := float64(v.Data[i]) * float64(cells)
i0 := int(math.Floor(su))
j0 := int(math.Floor(sv))
fu := su - math.Floor(su)
fv := sv - math.Floor(sv)
f1, f2 := math.Inf(1), math.Inf(1)
for dj := -1; dj <= 1; dj++ {
for di := -1; di <= 1; di++ {
ci := ((i0+di)%cells + cells) % cells
cj := ((j0+dj)%cells + cells) % cells
px := float64(pts[(cj*cells+ci)*2]) + float64(di) - fu
py := float64(pts[(cj*cells+ci)*2+1]) + float64(dj) - fv
d := math.Hypot(px, py)
if d < f1 {
f2, f1 = f1, d
} else if d < f2 {
f2 = d
}
}
}
e := 1 - (f2-f1)/0.6
if e < 0 {
e = 0
} else if e > 1 {
e = 1
}
out.Data[i] = float32(e * e)
}
}
})
return out
}
type uvPair struct{ u, v *field.Field }
// identity is the unwarped coordinate pair: 0..1 across the map, matching numpy's mgrid / (size - 1).
func identity(size int) uvPair {
u := field.New(size, size, 1)
v := field.New(size, size, 1)
inv := float32(1) / float32(size-1)
for y := 0; y < size; y++ {
for x := 0; x < size; x++ {
u.Data[y*size+x] = float32(x) * inv
v.Data[y*size+x] = float32(y) * inv
}
}
return uvPair{u, v}
}
// Identity exposes the coordinate pair so passes can warp it.
func Identity(size int) (u, v *field.Field) {
p := identity(size)
return p.u, p.v
}
// Warp offsets a coordinate pair by a low-frequency field scaled by amount. This is what bends ridges so
// ranges curve instead of running straight.
func Warp(u, v, dx, dy *field.Field, amount float64) (*field.Field, *field.Field) {
wu := u.Clone()
wv := v.Clone()
a := float32(amount)
for i := range wu.Data {
wu.Data[i] += (dx.Data[i] - 0.5) * 2 * a
wv.Data[i] += (dy.Data[i] - 0.5) * 2 * a
}
return wu, wv
}
// WorldUV is the coordinate pair for noise that must not move when the map does: u and v count periods of
// *world* space from the world origin rather than fractions of the map.
//
// Everything else in this package takes coordinates where 0..1 spans the map once, which is right for a field
// whose whole job is to be shaped like the continent — the range grain, the continent outline, the lithology.
// It is wrong for anything that will one day be generated a tile at a time, because two tiles asking about the
// same physical place would get different values and every seam would show. That is rule 1 of the tiling plan
// in Docs/Terrain-Next.md, and this function is what obeying it looks like.
//
// periodM is how far the lattice runs before it repeats, so it must be comfortably larger than any world that
// will ever be generated; the octave count then sets the finest wavelength, periodM / (BaseCells * 2^octaves).
func WorldUV(w, h int, cellM, originXM, originYM, periodM float64) (u, v *field.Field) {
u = field.New(w, h, cellM)
v = field.New(w, h, cellM)
for y := 0; y < h; y++ {
vy := float32((originYM + float64(y)*cellM) / periodM)
for x := 0; x < w; x++ {
i := y*w + x
u.Data[i] = float32((originXM + float64(x)*cellM) / periodM)
v.Data[i] = vy
}
}
return u, v
}