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
+126 -119
View File
@@ -3,6 +3,7 @@ package coast
import (
"math"
"salty/terrain/internal/dt"
"salty/terrain/internal/field"
)
@@ -16,113 +17,35 @@ import (
// whatever the radius, so there is nothing to buy by approximating, and a chamfer's 2 % anisotropy would show
// up directly as a shelf that is wider along the grid axes than across them.
// edt returns, for every cell, the squared distance in cells to the nearest seed cell and the 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.
//
// 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 map has no seeds anywhere,
// which the caller checks for.
func edt(seed []bool, w, h int) (d2 []float32, near []int32) {
d2 = make([]float32, w*h)
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
colN := make([]int32, w*h) // that seed's row, or -1
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], colN[i] = bigD, -1
} else {
colD[i], colN[i] = float32(y-best), 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], colN[i] = d, int32(best)
}
}
}
}
})
field.Rows(h, func(y0, y1 int) {
f := make([]float64, w)
v := make([]int, w)
z := make([]float64, w+1)
for y := y0; y < y1; y++ {
row := y * w
for x := 0; x < w; x++ {
d := float64(colD[row+x])
f[x] = d * d
}
k := 0
v[0] = 0
z[0] = math.Inf(-1)
z[1] = math.Inf(1)
for q := 1; q < w; 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 < w; q++ {
for z[k+1] < float64(q) {
k++
}
dx := float64(q - v[k])
d2[row+q] = float32(dx*dx + f[v[k]])
if n := colN[row+v[k]]; n < 0 {
near[row+q] = -1
} else {
near[row+q] = n*int32(w) + int32(v[k])
}
}
}
})
return d2, near
}
// 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)
}
// The transform itself lives in internal/dt, because three unrelated things need it: this pass, the region
// partitioner that decides which landmasses are close enough to solve together, and the template classifier
// that dissolves an artist's decorative stroke into the nearest class that means something. It also knows
// how to wrap, which is what a planet needs and what wrapX below asks for.
// Geometry is the coastline as the rest of the pass sees it.
type Geometry struct {
W, H int
CellM float64
// WrapX is set when the grid is a cylinder: column W-1 and column 0 are neighbours, so the shoreline,
// the distance field and the perimeter all cross the seam.
WrapX bool
// Dist is metres to the waterline: positive inland, negative offshore.
Dist *field.Field
// Ref is, for every cell, the waterline cell whose stretch of shore it belongs to. A land cell takes the
// sea cell nearest to it, which is on the waterline by construction; a sea cell takes the waterline cell
// nearest to the land cell nearest to it, which is the stretch of shore facing it. Every per-shore
// quantity — shelter, shelf width, the backshore relief — is computed once on the waterline and read
// everywhere else through this.
// Ref is, for every cell, an index into Waterline: the stretch of shore that cell belongs to, or -1. A
// land cell takes the sea cell nearest to it, which is on the waterline by construction; a sea cell takes
// the waterline cell nearest to the land cell nearest to it, which is the stretch of shore facing it.
// Every per-shore quantity - shelter, shelf width, the sediment supply - is computed once per waterline
// cell and read everywhere else through this.
//
// **An index into Waterline rather than a cell index**, which is worth a sentence because it decides what
// the pass costs. There are tens of millions of cells and a few hundred thousand waterline cells, so a
// per-shore quantity indexed by *slot* is a couple of megabytes where one indexed by cell is hundreds:
// the sediment supply used to be a `[]float64` over the whole grid, 608 MB at planet scale for an array
// that is only ever read at the waterline. RefCell turns one back into the other where a cell is what is
// wanted.
Ref []int32
// Waterline is the sea cells that touch land, in row-major order so anything iterating them is
@@ -134,8 +57,17 @@ type Geometry struct {
ShoreM float64
}
// Measure builds the signed distance field and the shore reference from a land/sea mask.
// Measure builds the signed distance field and the shore reference from a land/sea mask on a flat grid.
func Measure(sea []bool, w, h int, cellM float64) *Geometry {
return MeasureWrapped(sea, w, h, cellM, false)
}
// MeasureWrapped is Measure with the option of a cylinder, where the left and right edges of the grid are
// neighbours. A planet is measured once, whole, rather than a landmass at a time: the pass costs tens of
// nanoseconds a cell, and cutting it up would truncate the fetch across every strait, split the sediment
// budget whose conservation is the one thing here that is not derived from something already measured, and
// leave the shoreline length and the exposure percentiles as statistics that do not pool.
func MeasureWrapped(sea []bool, w, h int, cellM float64, wrapX bool) *Geometry {
anySea, anyLand := false, false
land := make([]bool, len(sea))
for i, s := range sea {
@@ -146,7 +78,7 @@ func Measure(sea []bool, w, h int, cellM float64) *Geometry {
anyLand = true
}
}
g := &Geometry{W: w, H: h, CellM: cellM, Dist: field.New(w, h, cellM), Ref: make([]int32, w*h)}
g := &Geometry{W: w, H: h, CellM: cellM, WrapX: wrapX, Dist: field.New(w, h, cellM), Ref: make([]int32, w*h)}
for i := range g.Ref {
g.Ref[i] = -1
}
@@ -154,33 +86,50 @@ func Measure(sea []bool, w, h int, cellM float64) *Geometry {
return g // an all-land or all-sea map has no coast; every pass below is a no-op on it
}
d2Sea, nearSea := edt(sea, w, h) // for a land cell: how far to water, and where
d2Land, nearLand := edt(land, w, h) // for a sea cell: how far to land, and where
for i := range sea {
if sea[i] {
g.Dist.Data[i] = float32(-math.Sqrt(float64(d2Land[i])) * cellM)
} else {
g.Dist.Data[i] = float32(math.Sqrt(float64(d2Sea[i])) * cellM)
}
}
// The waterline: sea cells with land in the eight-neighbourhood, which is d2Land of 1 or 2.
for i := range sea {
if sea[i] && d2Land[i] <= 2.001 {
// The waterline first, and straight off the mask rather than out of a transform. It is "a sea cell with
// land in its eight-neighbourhood", which is a local question, and asking it here rather than reading it
// out of d2Land is what lets the two transforms below be released in turn instead of held together.
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*w + x
if !sea[i] || !touchesLand(sea, w, h, x, y, wrapX) {
continue
}
g.Waterline = append(g.Waterline, int32(i))
}
}
// Land first: how far to water, and which waterline stretch that is.
//
// The two transforms are never both alive. At planet scale each one is a distance array and a feature
// index over 76 million cells - 600 MB the pair - and holding all four at once was 1.2 GB on top of the
// 600 MB this function returns. The order below is what avoids it, and it needs one observation: a sea
// cell's stretch of shore is the stretch its *nearest land cell* already belongs to, so the second pass
// can read the answer out of Ref rather than out of the first pass's feature index.
d2Sea, nearSea := dt.Transform(sea, w, h, wrapX)
for i := range sea {
if sea[i] {
if l := nearLand[i]; l >= 0 {
g.Ref[i] = nearSea[l]
}
} else {
g.Ref[i] = nearSea[i]
continue
}
g.Dist.Data[i] = float32(math.Sqrt(float64(d2Sea[i])) * cellM)
if n := nearSea[i]; n >= 0 {
g.Ref[i] = slotOf(g.Waterline, n)
}
}
d2Sea, nearSea = nil, nil
// Then sea: how far to land, and the shore that land already answered for.
d2Land, nearLand := dt.Transform(land, w, h, wrapX)
for i := range sea {
if !sea[i] {
continue
}
g.Dist.Data[i] = float32(-math.Sqrt(float64(d2Land[i])) * cellM)
if l := nearLand[i]; l >= 0 {
g.Ref[i] = g.Ref[l]
}
}
d2Land, nearLand = nil, nil
// Perimeter by boundary edges, which is what a shoreline length means on a grid.
edges := 0
@@ -189,6 +138,8 @@ func Measure(sea []bool, w, h int, cellM float64) *Geometry {
i := y*w + x
if x+1 < w && sea[i] != sea[i+1] {
edges++
} else if x+1 == w && wrapX && sea[i] != sea[y*w] {
edges++
}
if y+1 < h && sea[i] != sea[i+w] {
edges++
@@ -198,3 +149,59 @@ func Measure(sea []bool, w, h int, cellM float64) *Geometry {
g.ShoreM = float64(edges) * cellM
return g
}
// RefCell is the cell index of the waterline stretch a cell belongs to, or -1. Ref itself is a slot; this is
// for the few places that want the cell.
func (g *Geometry) RefCell(i int) int32 {
if r := g.Ref[i]; r >= 0 {
return g.Waterline[r]
}
return -1
}
// touchesLand reports whether a cell has land in its eight-neighbourhood: X wrapped on a cylinder, Y bounded,
// because the top and bottom of the map are the poles and not each other.
func touchesLand(sea []bool, w, h, x, y int, wrapX bool) bool {
for dy := -1; dy <= 1; dy++ {
ny := y + dy
if ny < 0 || ny >= h {
continue
}
for dx := -1; dx <= 1; dx++ {
if dx == 0 && dy == 0 {
continue
}
nx := x + dx
if wrapX {
nx = ((nx % w) + w) % w
} else if nx < 0 || nx >= w {
continue
}
if !sea[ny*w+nx] {
return true
}
}
}
return false
}
// slotOf finds a cell's index in the waterline, or -1.
//
// A binary search rather than a cell-indexed lookup table, which would be another four bytes a cell - 300 MB
// at planet scale for an array read once. The waterline is built in row-major order and is therefore sorted,
// so the search is eighteen comparisons against a few hundred thousand entries and runs only on land cells.
func slotOf(waterline []int32, cell int32) int32 {
lo, hi := 0, len(waterline)
for lo < hi {
mid := int(uint(lo+hi) >> 1)
if waterline[mid] < cell {
lo = mid + 1
} else {
hi = mid
}
}
if lo < len(waterline) && waterline[lo] == cell {
return int32(lo)
}
return -1
}