// Package field is the one array type the whole generator passes around: a square-ish grid of float32 in a // known unit, with the cell size in metres attached so no pass has to be told the scale twice. // // Determinism (cross-cutting rule 12) is a property of this package as much as of the passes. Everything // parallel here partitions rows into disjoint, contiguous ranges and writes only into its own range, so the // result does not depend on how the goroutines were scheduled. Nothing reduces through a channel. package field import ( "math" "runtime" "sort" "sync" ) // Field is a W x H grid, row-major, with CellM metres between neighbouring samples. type Field struct { W, H int CellM float64 Data []float32 } func New(w, h int, cellM float64) *Field { return &Field{W: w, H: h, CellM: cellM, Data: make([]float32, w*h)} } // NewLike is an empty field with another's shape and scale. func NewLike(f *Field) *Field { return New(f.W, f.H, f.CellM) } func (f *Field) Idx(x, y int) int { return y*f.W + x } func (f *Field) At(x, y int) float32 { return f.Data[y*f.W+x] } func (f *Field) Set(x, y int, v float32) { f.Data[y*f.W+x] = v } func (f *Field) Len() int { return len(f.Data) } // AtClamped samples with edge clamping, which is what every stencil in the generator wants at the border. func (f *Field) AtClamped(x, y int) float32 { if x < 0 { x = 0 } else if x >= f.W { x = f.W - 1 } if y < 0 { y = 0 } else if y >= f.H { y = f.H - 1 } return f.Data[y*f.W+x] } func (f *Field) Clone() *Field { c := New(f.W, f.H, f.CellM) copy(c.Data, f.Data) return c } func (f *Field) Fill(v float32) { for i := range f.Data { f.Data[i] = v } } func (f *Field) MinMax() (float32, float32) { if len(f.Data) == 0 { return 0, 0 } lo, hi := f.Data[0], f.Data[0] for _, v := range f.Data { if v < lo { lo = v } if v > hi { hi = v } } return lo, hi } func (f *Field) Mean() float64 { if len(f.Data) == 0 { return 0 } // Summed as float64 in index order: the same total every run, whatever the machine. var sum float64 for _, v := range f.Data { sum += float64(v) } return sum / float64(len(f.Data)) } // Percentile sorts a copy, so it costs a copy and a sort; used for thresholds, not in inner loops. func (f *Field) Percentile(p float64) float32 { if len(f.Data) == 0 { return 0 } c := make([]float32, len(f.Data)) copy(c, f.Data) sort.Slice(c, func(i, j int) bool { return c[i] < c[j] }) i := int(p / 100 * float64(len(c)-1)) if i < 0 { i = 0 } else if i >= len(c) { i = len(c) - 1 } return c[i] } // Normalise maps the field onto [0, 1]. A flat field becomes zero rather than a division by nothing. func (f *Field) Normalise() { lo, hi := f.MinMax() span := float64(hi - lo) if span < 1e-9 { f.Fill(0) return } for i, v := range f.Data { f.Data[i] = float32((float64(v) - float64(lo)) / span) } } // Slope returns rise over run per cell, the central difference used by the layer rules and the statistics. func (f *Field) Slope() *Field { out := NewLike(f) inv := float32(1.0 / (2.0 * f.CellM)) Rows(f.H, func(y0, y1 int) { for y := y0; y < y1; y++ { for x := 0; x < f.W; x++ { gx := (f.AtClamped(x+1, y) - f.AtClamped(x-1, y)) * inv gy := (f.AtClamped(x, y+1) - f.AtClamped(x, y-1)) * inv out.Data[out.Idx(x, y)] = float32(math.Hypot(float64(gx), float64(gy))) } } }) return out } // Curvature is the Laplacian in metres per cell squared: positive on ridges and convex shoulders, negative in // gullies and sediment traps. Ported from heightmap_erosion.curvature, which blurs lightly first. func (f *Field) Curvature() *Field { h := f.Blur(2) out := NewLike(f) inv := float32(1.0 / f.CellM) Rows(f.H, func(y0, y1 int) { for y := y0; y < y1; y++ { for x := 0; x < f.W; x++ { lap := h.AtClamped(x-1, y) + h.AtClamped(x+1, y) + h.AtClamped(x, y-1) + h.AtClamped(x, y+1) - 4*h.At(x, y) out.Data[out.Idx(x, y)] = lap * inv } } }) return out } // Blur is the five-point box blur the numpy pipeline used, repeated. Edge-clamped, so it does not darken // the border the way a zero-padded one would. func (f *Field) Blur(passes int) *Field { cur := f.Clone() if passes <= 0 { return cur } next := NewLike(f) for p := 0; p < passes; p++ { Rows(f.H, func(y0, y1 int) { for y := y0; y < y1; y++ { for x := 0; x < cur.W; x++ { s := cur.At(x, y) + cur.AtClamped(x-1, y) + cur.AtClamped(x+1, y) + cur.AtClamped(x, y-1) + cur.AtClamped(x, y+1) next.Data[next.Idx(x, y)] = s / 5 } } }) cur, next = next, cur } return cur } // Rows runs fn over disjoint contiguous row ranges, one per core. The ranges are fixed before any goroutine // starts and each writes only into its own, so the output is identical at any GOMAXPROCS. Every parallel // loop in the generator goes through here; none spawns goroutines of its own. func Rows(h int, fn func(y0, y1 int)) { workers := runtime.GOMAXPROCS(0) if workers > h { workers = h } if workers <= 1 { fn(0, h) return } var wg sync.WaitGroup step := (h + workers - 1) / workers for y0 := 0; y0 < h; y0 += step { y1 := y0 + step if y1 > h { y1 = h } wg.Add(1) go func(a, b int) { defer wg.Done() fn(a, b) }(y0, y1) } wg.Wait() }