This commit is contained in:
Rainer Leit
2026-09-25 17:02:24 +03:00
parent cc43ed8dc8
commit 9597629951
2149 changed files with 460234 additions and 1770 deletions
+236
View File
@@ -0,0 +1,236 @@
package field
// SlidingMax is the maximum over a square window, separable and O(1) a cell whatever the radius.
//
// The naive form is a loop over the window, which is what internal/stats' localRelief used to do and is fine
// on the 500 m window it uses at 8 m cells - until the map is a planet. 28 million land cells times a 63-cell
// radius is 1.1e11 comparisons, which is not a slow diagnostic, it is one nobody will ever see the end of.
// The monotonic deque is the standard answer: each index enters and leaves once, so the row pass is linear in
// the row however wide the window.
//
// wrapX makes the row pass periodic, which is what a cylinder needs; the column pass always clamps, because
// the top and bottom of the map are the poles and not each other.
func SlidingMax(f *Field, radius int, wrapX bool) *Field {
return sliding(f, radius, wrapX, func(inDeque, arriving float32) bool { return inDeque <= arriving })
}
// SlidingMin is the same window, the other way up. The pair is what local relief is made of.
func SlidingMin(f *Field, radius int, wrapX bool) *Field {
return sliding(f, radius, wrapX, func(inDeque, arriving float32) bool { return inDeque >= arriving })
}
// LocalRelief is max minus min over a square window: the standard field measure of how rugged a place is, and
// the one thing slope cannot tell you. A 5 m hummock and a 500 m mountainside both stand at 30 degrees.
//
// Two sliding passes and a subtract, so it costs the same as one of them twice and nothing per radius. It
// holds two fields at once at the peak, which at planet scale is 600 MB - worth saying, because the naive
// version held none and could not finish.
func LocalRelief(f *Field, radius int, wrapX bool) *Field {
hi := SlidingMax(f, radius, wrapX)
lo := SlidingMin(f, radius, wrapX)
for i := range hi.Data {
hi.Data[i] -= lo.Data[i]
}
return hi
}
// sliding is the shared separable pass. keep reports whether the value already at the back of the deque can
// be dropped when a new one arrives, which is the only thing that differs between the maximum and the
// minimum: the deque holds indices whose values are monotone, so its front is always the answer for the live
// window and anything the arriving value dominates can never be the answer again.
func sliding(f *Field, radius int, wrapX bool, keep func(inDeque, arriving float32) bool) *Field {
if radius < 1 {
return f.Clone()
}
w, h := f.W, f.H
row := New(w, h, f.CellM)
buf := make([]float32, 0, w+2*radius)
idx := make([]int, 0, w+2*radius)
for y := 0; y < h; y++ {
// The row, extended by the radius at each end so the deque never has to special-case an edge.
buf = buf[:0]
for x := -radius; x < w+radius; x++ {
sx := x
if wrapX {
sx = ((sx % w) + w) % w
} else if sx < 0 {
sx = 0
} else if sx >= w {
sx = w - 1
}
buf = append(buf, f.Data[y*w+sx])
}
slide(buf, idx[:0], 2*radius+1, keep, func(i int, v float32) {
if i < w {
row.Data[y*w+i] = v
}
})
}
out := New(w, h, f.CellM)
col := make([]float32, 0, h+2*radius)
for x := 0; x < w; x++ {
col = col[:0]
for y := -radius; y < h+radius; y++ {
sy := y
if sy < 0 {
sy = 0
} else if sy >= h {
sy = h - 1
}
col = append(col, row.Data[sy*w+x])
}
slide(col, idx[:0], 2*radius+1, keep, func(i int, v float32) {
if i < h {
out.Data[i*w+x] = v
}
})
}
return out
}
// slide walks a padded line with a monotonic deque and reports the window's answer ending at each output
// position.
func slide(line []float32, dq []int, window int, keep func(inDeque, arriving float32) bool,
emit func(i int, v float32)) {
dq = dq[:0]
for i, v := range line {
for len(dq) > 0 && keep(line[dq[len(dq)-1]], v) {
dq = dq[:len(dq)-1]
}
dq = append(dq, i)
if dq[0] <= i-window {
dq = dq[1:]
}
if out := i - window + 1; out >= 0 {
emit(out, line[dq[0]])
}
}
}
// BoxSmooth blurs a field in place with `passes` of a separable box blur of the given radius, clamping at the
// edges. Two passes are near enough to a Gaussian for anything here and cost four linear sweeps.
//
// Deterministic by construction: fixed traversal order, running sums, no goroutines. It lives here rather than
// in the pass that first wanted it because two now do - the coastal detail pass smooths the signed distance to
// the shoreline, and the tile bake smooths the interpolated sea floor.
func BoxSmooth(data []float32, w, h, radius, passes int) {
if radius < 1 || passes < 1 || len(data) < w*h {
return
}
tmp := make([]float32, len(data))
for p := 0; p < passes; p++ {
boxRows(data, tmp, w, h, radius)
boxCols(tmp, data, w, h, radius)
}
}
func boxRows(src, dst []float32, w, h, radius int) {
n := float32(2*radius + 1)
for y := 0; y < h; y++ {
row := y * w
var sum float32
for k := -radius; k <= radius; k++ {
sum += src[row+clampIdx(k, w)]
}
for x := 0; x < w; x++ {
dst[row+x] = sum / n
sum += src[row+clampIdx(x+radius+1, w)] - src[row+clampIdx(x-radius, w)]
}
}
}
func boxCols(src, dst []float32, w, h, radius int) {
n := float32(2*radius + 1)
for x := 0; x < w; x++ {
var sum float32
for k := -radius; k <= radius; k++ {
sum += src[clampIdx(k, h)*w+x]
}
for y := 0; y < h; y++ {
dst[y*w+x] = sum / n
sum += src[clampIdx(y+radius+1, h)*w+x] - src[clampIdx(y-radius, h)*w+x]
}
}
}
func clampIdx(i, n int) int {
if i < 0 {
return 0
}
if i >= n {
return n - 1
}
return i
}
// BoxSmoothMasked is BoxSmooth restricted to the cells the mask selects: a cell outside it is neither read
// nor written, so the blur never averages across the boundary.
//
// That distinction is the whole reason it exists. The coastal detail pass damps the metre-scale texture near
// the shore, and an unmasked blur there does not damp texture, it bridges the waterline: measured on a
// fixture with forty metres of water against the land, the plain blur lifted the sea floor by twenty metres.
// The step at a shoreline is a landform, not roughness, and a filter that cannot tell them apart is the wrong
// filter.
//
// Separable and weighted: the row pass carries a running sum of values and of weights, the column pass sums
// those, and the quotient is the mean over the masked cells in the window. Deterministic, like BoxSmooth.
func BoxSmoothMasked(data []float32, mask []bool, w, h, radius, passes int) {
if radius < 1 || passes < 1 || len(data) < w*h || len(mask) < w*h {
return
}
n := w * h
val := make([]float32, n)
wgt := make([]float32, n)
tv := make([]float32, n)
tw := make([]float32, n)
for p := 0; p < passes; p++ {
for i := 0; i < n; i++ {
if mask[i] {
val[i], wgt[i] = data[i], 1
} else {
val[i], wgt[i] = 0, 0
}
}
boxRowsSum(val, tv, w, h, radius)
boxRowsSum(wgt, tw, w, h, radius)
boxColsSum(tv, val, w, h, radius)
boxColsSum(tw, wgt, w, h, radius)
for i := 0; i < n; i++ {
if mask[i] && wgt[i] > 0 {
data[i] = val[i] / wgt[i]
}
}
}
}
// boxRowsSum and boxColsSum are the running sums BoxSmooth uses, without the division: a masked blur needs
// the weight sum as well as the value sum, and dividing in the middle would be dividing by the wrong thing.
func boxRowsSum(src, dst []float32, w, h, radius int) {
for y := 0; y < h; y++ {
row := y * w
var sum float32
for k := -radius; k <= radius; k++ {
sum += src[row+clampIdx(k, w)]
}
for x := 0; x < w; x++ {
dst[row+x] = sum
sum += src[row+clampIdx(x+radius+1, w)] - src[row+clampIdx(x-radius, w)]
}
}
}
func boxColsSum(src, dst []float32, w, h, radius int) {
for x := 0; x < w; x++ {
var sum float32
for k := -radius; k <= radius; k++ {
sum += src[clampIdx(k, h)*w+x]
}
for y := 0; y < h; y++ {
dst[y*w+x] = sum
sum += src[clampIdx(y+radius+1, h)*w+x] - src[clampIdx(y-radius, h)*w+x]
}
}
}