// 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 }