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 }