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
+174
View File
@@ -0,0 +1,174 @@
// Package dt is the exact Euclidean distance transform, with a feature index and an optional cylinder.
//
// It lives on its own because three different things need it and two of them are nowhere near the coast:
// the coastal pass writes every one of its processes as "how far is this cell from the waterline and which
// stretch of shore does it belong to"; the region partitioner dilates the land mask to decide which
// landmasses are close enough to be solved together; and the template classifier dissolves the decorative
// stroke an artist drew by handing each of its pixels to the nearest pixel that means something.
//
// Exact, not a chamfer approximation: Felzenszwalb and Huttenlocher's transform is two 1-D passes and O(n)
// whatever the radius, so there is nothing to buy by approximating, and a chamfer's 2 % anisotropy would
// show up directly as a shelf wider along the grid axes than across them.
package dt
import (
"math"
"salty/terrain/internal/field"
)
// Transform returns, for every cell, the squared distance in cells to the nearest seed cell and the flat
// index of that seed. A column pass finds the nearest seed in each column; a row pass takes the lower
// envelope of the parabolas those distances define.
//
// With wrapX the row pass is periodic, so the left and right edges of the grid are neighbours. That is what
// a planet needs: a landmass straddling the seam is one landmass, and the shelf in front of it is one shelf.
//
// Cells in a column with no seed at all are given a cost above any real distance rather than an infinity, so
// the envelope arithmetic never sees a NaN; they are then never chosen unless the grid has no seeds
// anywhere, in which case every near index comes back -1.
func Transform(seed []bool, w, h int, wrapX bool) (d2 []float32, near []int32) {
return transform(seed, w, h, wrapX, true)
}
// Distance2 is Transform without the feature index, for a caller that only wants "how far".
//
// It is a separate entry point rather than a nil argument because the saving is the point: at planet scale
// the index and the column scratch it needs are two more arrays of four bytes a cell, which is most of a
// gigabyte for an answer nobody reads. The region partitioner only asks whether a cell is within a margin
// of land.
func Distance2(seed []bool, w, h int, wrapX bool) []float32 {
d2, _ := transform(seed, w, h, wrapX, false)
return d2
}
func transform(seed []bool, w, h int, wrapX, wantNear bool) (d2 []float32, near []int32) {
d2 = make([]float32, w*h)
if wantNear {
near = make([]int32, w*h)
}
bigF := float64(w*w+h*h) * 4 // above any achievable dx² + dy²
bigD := float32(math.Sqrt(bigF))
colD := make([]float32, w*h) // distance in cells to the nearest seed in this column
var colN []int32 // that seed's row, or -1; only needed for the feature index
if wantNear {
colN = make([]int32, w*h)
}
field.Rows(w, func(x0, x1 int) {
for x := x0; x < x1; x++ {
best := -1
for y := 0; y < h; y++ {
i := y*w + x
if seed[i] {
best = y
}
if best < 0 {
colD[i] = bigD
if wantNear {
colN[i] = -1
}
} else {
colD[i] = float32(y - best)
if wantNear {
colN[i] = int32(best)
}
}
}
best = -1
for y := h - 1; y >= 0; y-- {
i := y*w + x
if seed[i] {
best = y
}
if best >= 0 {
if d := float32(best - y); d < colD[i] {
colD[i] = d
if wantNear {
colN[i] = int32(best)
}
}
}
}
}
})
// The row pass. On a cylinder the row is laid out three times - one turn to the left, the row itself,
// one turn to the right - and the answer is read out of the middle copy. From a cell in the middle copy
// the three images of any column sit at offsets d, d-w and d+w, whose smallest absolute value is the
// cyclic distance, so the envelope returns exactly the wrapped answer with no special cases in it.
span := w
off := 0
if wrapX {
span = 3 * w
off = w
}
field.Rows(h, func(y0, y1 int) {
f := make([]float64, span)
v := make([]int, span)
z := make([]float64, span+1)
for y := y0; y < y1; y++ {
row := y * w
for j := 0; j < span; j++ {
d := float64(colD[row+srcX(j, off, w)])
f[j] = d * d
}
k := 0
v[0] = 0
z[0] = math.Inf(-1)
z[1] = math.Inf(1)
for q := 1; q < span; q++ {
s := intersect(f, v[k], q)
for s <= z[k] {
k--
s = intersect(f, v[k], q)
}
k++
v[k] = q
z[k] = s
z[k+1] = math.Inf(1)
}
k = 0
for q := 0; q < span; q++ {
for z[k+1] < float64(q) {
k++
}
if q < off || q >= off+w {
continue // a replica column; only the middle copy is the answer
}
dx := float64(q - v[k])
o := row + q - off
d2[o] = float32(dx*dx + f[v[k]])
if !wantNear {
continue
}
sx := srcX(v[k], off, w)
if n := colN[row+sx]; n < 0 {
near[o] = -1
} else {
near[o] = n*int32(w) + int32(sx)
}
}
}
})
return d2, near
}
// srcX maps a column of the (possibly replicated) row back to a real column.
func srcX(j, off, w int) int {
x := j - off
for x < 0 {
x += w
}
for x >= w {
x -= w
}
return x
}
// intersect is where the parabolas rooted at p and q cross.
func intersect(f []float64, p, q int) float64 {
return ((f[q] + float64(q*q)) - (f[p] + float64(p*p))) / float64(2*q-2*p)
}
+116
View File
@@ -0,0 +1,116 @@
package dt
import (
"math"
"testing"
)
func scatter(w, h int, seed uint32) []bool {
seeds := make([]bool, w*h)
for i := range seeds {
seed = seed*1664525 + 1013904223
seeds[i] = seed>>20&7 == 0
}
seeds[0] = true // guarantee at least one
return seeds
}
// brute is the definition: the smallest squared distance to any seed, with dx measured the short way round
// when the grid is a cylinder.
func brute(seeds []bool, w, h, x, y int, wrapX bool) float64 {
best := math.Inf(1)
for sy := 0; sy < h; sy++ {
for sx := 0; sx < w; sx++ {
if !seeds[sy*w+sx] {
continue
}
dx := float64(x - sx)
if wrapX {
if d := math.Abs(dx); d > float64(w)/2 {
dx = float64(w) - d
}
}
dy := float64(y - sy)
if d := dx*dx + dy*dy; d < best {
best = d
}
}
}
return best
}
func check(t *testing.T, w, h int, wrapX bool) {
t.Helper()
seeds := scatter(w, h, 99)
d2, near := Transform(seeds, w, h, wrapX)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
want := brute(seeds, w, h, x, y, wrapX)
i := y*w + x
if math.Abs(float64(d2[i])-want) > 1e-3 {
t.Fatalf("wrap=%v cell (%d,%d): d2 %g, brute force %g", wrapX, x, y, d2[i], want)
}
// The feature index must be a seed, and it must be one at exactly that distance.
n := int(near[i])
if n < 0 || !seeds[n] {
t.Fatalf("wrap=%v cell (%d,%d): nearest %d is not a seed", wrapX, x, y, n)
}
got := brute(onlyAt(w, h, n), w, h, x, y, wrapX)
if math.Abs(got-want) > 1e-3 {
t.Fatalf("wrap=%v cell (%d,%d): nearest seed %d is at %g, not %g", wrapX, x, y, n, got, want)
}
}
}
}
func onlyAt(w, h, i int) []bool {
s := make([]bool, w*h)
s[i] = true
return s
}
// The one test the coastal pass rests on. Everything there is written in terms of "how far is this cell from
// the waterline and which stretch does it belong to", so a distance transform that is subtly wrong would not
// fail loudly - it would put the shelf break in slightly the wrong place everywhere. The transform is exact,
// so the comparison is against an exhaustive search and the tolerance is float32 rounding.
func TestMatchesBruteForce(t *testing.T) { check(t, 41, 37, false) }
// And the same on a cylinder, which is what a planet is. The failure this catches is a shelf that stops dead
// at the seam.
func TestMatchesBruteForceOnACylinder(t *testing.T) { check(t, 41, 37, true) }
// A seed on one edge must be found from the other edge, and by the short way round.
func TestWrapFindsTheSeedAcrossTheSeam(t *testing.T) {
const w, h = 9, 3
seeds := make([]bool, w*h)
seeds[h/2*w+0] = true // one seed, at column 0 of the middle row
d2, near := Transform(seeds, w, h, true)
// Column 8 is one step from column 0 the short way round, eight steps the long way.
if got := d2[h/2*w+8]; math.Abs(float64(got)-1) > 1e-6 {
t.Errorf("d2 at column 8 = %g, want 1", got)
}
if got := near[h/2*w+8]; got != int32(h/2*w) {
t.Errorf("near at column 8 = %d, want %d", got, h/2*w)
}
// The far side of the cylinder is four steps away either way.
if got := d2[h/2*w+4]; math.Abs(float64(got)-16) > 1e-6 {
t.Errorf("d2 at column 4 = %g, want 16", got)
}
// Without the wrap the same grid gives eight.
d2f, _ := Transform(seeds, w, h, false)
if got := d2f[h/2*w+8]; math.Abs(float64(got)-64) > 1e-6 {
t.Errorf("unwrapped d2 at column 8 = %g, want 64", got)
}
}
func TestNoSeedsAtAll(t *testing.T) {
const w, h = 5, 4
seeds := make([]bool, w*h)
_, near := Transform(seeds, w, h, true)
for i, n := range near {
if n != -1 {
t.Fatalf("cell %d reports a nearest seed %d on an empty grid", i, n)
}
}
}