208 lines
7.8 KiB
Go
208 lines
7.8 KiB
Go
package coast
|
|
|
|
import (
|
|
"math"
|
|
|
|
"salty/terrain/internal/dt"
|
|
"salty/terrain/internal/field"
|
|
)
|
|
|
|
// The coast is a *distance*, not a line. Every coastal process is written in terms of how far a cell is from
|
|
// the waterline and which stretch of waterline it belongs to: the shelf deepens with distance offshore, the
|
|
// surf planes the land within a reach of it, sediment settles in the shallows behind it, and shelter is a
|
|
// property of a stretch of shore that every cell near it inherits. So the first thing the pass builds is an
|
|
// exact signed distance field with a feature index, and everything after it is a lookup.
|
|
//
|
|
// Exact, not a chamfer approximation: Felzenszwalb & 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 that is wider along the grid axes than across them.
|
|
|
|
// 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, 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
|
|
// deterministic.
|
|
Waterline []int32
|
|
|
|
// ShoreM is the length of the land/sea boundary in metres, counted as boundary edges. It overestimates a
|
|
// diagonal coast by about 4/pi, as any edge-counted perimeter does.
|
|
ShoreM float64
|
|
}
|
|
|
|
// 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 {
|
|
land[i] = !s
|
|
if s {
|
|
anySea = true
|
|
} else {
|
|
anyLand = true
|
|
}
|
|
}
|
|
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
|
|
}
|
|
if !anySea || !anyLand {
|
|
return g // an all-land or all-sea map has no coast; every pass below is a no-op on it
|
|
}
|
|
|
|
// 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] {
|
|
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
|
|
for y := 0; y < h; y++ {
|
|
for x := 0; x < w; x++ {
|
|
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++
|
|
}
|
|
}
|
|
}
|
|
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
|
|
}
|