Tooling
This commit is contained in:
@@ -98,10 +98,57 @@ type Input struct {
|
||||
Sea []bool // the continent mask's ocean: the cells the solve held at base level
|
||||
SeaLevelM float64 // the base level the solve used, and the datum every depth here is measured from
|
||||
BreakM float64 // depth at the shelf break, positive metres
|
||||
AbyssM float64 // depth of the abyssal floor, positive metres
|
||||
Flow []float32
|
||||
Seed int64
|
||||
Cfg manifest.Coast
|
||||
|
||||
// AbyssM is how deep the open ocean is, in positive metres, and Abyss is the same thing per cell when a
|
||||
// world has one. A painted planet does: its sea classes carry their own `depth_m`, so the ocean is
|
||||
// already laid at several depths before this pass runs, and a derived shelf that bottomed out at one
|
||||
// global abyss would put a step at the shelf break wherever the two disagreed. Nil falls back to AbyssM,
|
||||
// which is what the square canvas has and what every caller had before.
|
||||
AbyssM float64
|
||||
Abyss []float32
|
||||
|
||||
// WrapX says the grid is a cylinder: column W-1 and column 0 are neighbours. A planet is measured once,
|
||||
// whole, so every march, every ray and every running sum in this pass has to cross the seam - the
|
||||
// alternative is a shelf, a fetch and a sediment budget that all stop dead at one meridian.
|
||||
WrapX bool
|
||||
|
||||
// NoisePeriodM is how far the sea-floor roughness runs before it repeats. It has to divide the
|
||||
// circumference exactly on a cylinder or the noise breaks at the seam like every other field; zero means
|
||||
// the flat-grid default, which is a multiple of the roughness wavelength and repeats wherever it likes
|
||||
// because a flat grid has no seam to break.
|
||||
NoisePeriodM float64
|
||||
|
||||
Flow []float32
|
||||
Seed int64
|
||||
Cfg manifest.Coast
|
||||
}
|
||||
|
||||
// abyssAt is how deep the open ocean is at one cell.
|
||||
func (in Input) abyssAt(i int) float64 {
|
||||
if in.Abyss != nil {
|
||||
return float64(in.Abyss[i])
|
||||
}
|
||||
return in.AbyssM
|
||||
}
|
||||
|
||||
// col brings a column index onto the grid: wrapped on a cylinder, refused past the edge of a flat one.
|
||||
func (g *Geometry) col(x int) (int, bool) {
|
||||
if g.WrapX {
|
||||
return ((x % g.W) + g.W) % g.W, true
|
||||
}
|
||||
if x < 0 || x >= g.W {
|
||||
return 0, false
|
||||
}
|
||||
return x, true
|
||||
}
|
||||
|
||||
// distAt reads the signed distance field with X wrapped on a cylinder and clamped otherwise. Y always clamps,
|
||||
// because the top and bottom of the map are the poles and not each other.
|
||||
func (g *Geometry) distAt(x, y int) float64 {
|
||||
if g.WrapX {
|
||||
x = ((x % g.W) + g.W) % g.W
|
||||
}
|
||||
return float64(g.Dist.AtClamped(x, y))
|
||||
}
|
||||
|
||||
// Result is the geometry the pass built and the accounting it kept.
|
||||
@@ -167,7 +214,7 @@ func Build(in Input) *Result {
|
||||
w, ht := h.W, h.H
|
||||
cellArea := h.CellM * h.CellM
|
||||
|
||||
g := Measure(in.Sea, w, ht, h.CellM)
|
||||
g := MeasureWrapped(in.Sea, w, ht, h.CellM, in.WrapX)
|
||||
res := &Result{Geometry: g, Exposure: field.NewLike(h), Change: field.NewLike(h)}
|
||||
|
||||
// Disabled, or a map with no coast on it: the sea floor is the flat plane at the abyssal depth, which is
|
||||
@@ -175,10 +222,11 @@ func Build(in Input) *Result {
|
||||
if !in.Cfg.Enabled || len(g.Waterline) == 0 {
|
||||
for i := range in.Sea {
|
||||
if in.Sea[i] {
|
||||
h.Data[i] = float32(in.SeaLevelM - in.AbyssM)
|
||||
h.Data[i] = float32(in.SeaLevelM - in.abyssAt(i))
|
||||
}
|
||||
}
|
||||
res.finish(h.Clone(), in)
|
||||
copy(res.Change.Data, h.Data)
|
||||
res.finish(in)
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -189,20 +237,23 @@ func Build(in Input) *Result {
|
||||
// earlier it would be a map of the sea floor: the ocean cells go from sea level to -180 m in one step, and
|
||||
// a few hundred metres of that swamps the few metres the surf and the sediment move, which is the thing
|
||||
// the map exists to show.
|
||||
before := h.Clone()
|
||||
// The "before" snapshot and the change map are the same array. Change is h minus before, so the snapshot
|
||||
// is taken *into* the field that will hold the answer and subtracted from in place at the end - one field
|
||||
// of 304 MB at planet scale rather than two, for a picture.
|
||||
copy(res.Change.Data, h.Data)
|
||||
|
||||
shoreExposure := fetch(g, in)
|
||||
res.Stats.ExposureP10, res.Stats.ExposureP50, res.Stats.ExposureP90 = shorePercentiles(shoreExposure, g)
|
||||
res.Stats.ExposureP10, res.Stats.ExposureP50, res.Stats.ExposureP90 = shorePercentiles(shoreExposure)
|
||||
carried := field.NewLike(h)
|
||||
for i, ref := range g.Ref {
|
||||
if ref >= 0 {
|
||||
carried.Data[i] = shoreExposure.Data[ref]
|
||||
carried.Data[i] = shoreExposure[ref]
|
||||
}
|
||||
}
|
||||
// Smoothed for the same reason the shelf width is: carrying a per-shore value by "the stretch nearest to
|
||||
// you" partitions the map into Voronoi wedges, and a wedge boundary inside the deposition band would put
|
||||
// a straight edge through a beach.
|
||||
res.Exposure = boxMean(carried, int(exposureSmoothM/h.CellM+0.5), 2)
|
||||
res.Exposure = boxMean(carried, int(exposureSmoothM/h.CellM+0.5), 2, g.WrapX)
|
||||
|
||||
cut := plane(h, g, res.Exposure, in)
|
||||
|
||||
@@ -210,14 +261,21 @@ func Build(in Input) *Result {
|
||||
// waterline cell, so a parallel loop would be accumulating into the same slot from several goroutines and
|
||||
// the float sum would depend on who got there first. Cross-cutting rule 12 is not negotiable here, and
|
||||
// one linear pass over the grid costs nothing next to the solve.
|
||||
supply := make([]float64, w*ht)
|
||||
// One entry per *waterline cell*, not per grid cell. There are a few hundred thousand of the first and
|
||||
// tens of millions of the second, and this used to be the second: 608 MB at planet scale for an array
|
||||
// that is only ever read at the shore. See Geometry.Ref.
|
||||
supply := make([]float64, len(g.Waterline))
|
||||
var cutM3, planedCells float64
|
||||
for i, c := range cut.Data {
|
||||
if c <= 0 {
|
||||
continue
|
||||
}
|
||||
ref := g.Ref[i]
|
||||
if ref < 0 {
|
||||
continue // no shore to credit it to; cannot happen for a cell the surf reached, but cheap to say
|
||||
}
|
||||
v := float64(c) * cellArea
|
||||
supply[g.Ref[i]] += v
|
||||
supply[ref] += v
|
||||
cutM3 += v
|
||||
planedCells++
|
||||
}
|
||||
@@ -235,7 +293,7 @@ func Build(in Input) *Result {
|
||||
res.Stats.BackshoreM = backshore
|
||||
res.Stats.BackshoreP90M = backshoreP90
|
||||
res.Stats.ShelfPctSea = shelfFraction(g, in, shelfW)
|
||||
res.finish(before, in)
|
||||
res.finish(in)
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -245,12 +303,13 @@ func Build(in Input) *Result {
|
||||
// beach the pass built out of cliff debris is land, and a low headland it planed under the waterline is not.
|
||||
// The statistics and the preview both ask what is above sea level, so they get an answer about the terrain
|
||||
// rather than about the mask that seeded it.
|
||||
func (r *Result) finish(before *field.Field, in Input) {
|
||||
func (r *Result) finish(in Input) {
|
||||
h := in.Height
|
||||
r.Sea = make([]bool, len(h.Data))
|
||||
sea, beach, drowned := 0, 0, 0
|
||||
for i := range h.Data {
|
||||
r.Change.Data[i] = h.Data[i] - before.Data[i]
|
||||
// Change came in holding the *before* heights; it leaves holding the difference.
|
||||
r.Change.Data[i] = h.Data[i] - r.Change.Data[i]
|
||||
r.Sea[i] = float64(h.Data[i]) < in.SeaLevelM
|
||||
if r.Sea[i] {
|
||||
sea++
|
||||
@@ -283,7 +342,7 @@ func (r *Result) finish(before *field.Field, in Input) {
|
||||
// hundred metres turns the wedge boundaries back into what they should have been, a shelf whose width varies
|
||||
// smoothly along the coast.
|
||||
func shelfWidth(h *field.Field, g *Geometry, in Input) *field.Field {
|
||||
out := field.NewLike(h)
|
||||
out := make([]float32, len(g.Waterline))
|
||||
steps := int(backshoreM/h.CellM + 0.5)
|
||||
lo := in.Cfg.ShelfKm.Lo() * 1000
|
||||
hi := in.Cfg.ShelfKm.Hi() * 1000
|
||||
@@ -295,8 +354,8 @@ func shelfWidth(h *field.Field, g *Geometry, in Input) *field.Field {
|
||||
for n := a; n < b; n++ {
|
||||
i := int(g.Waterline[n])
|
||||
x, y := i%g.W, i/g.W
|
||||
dx := float64(g.Dist.AtClamped(x+1, y) - g.Dist.AtClamped(x-1, y))
|
||||
dy := float64(g.Dist.AtClamped(x, y+1) - g.Dist.AtClamped(x, y-1))
|
||||
dx := g.distAt(x+1, y) - g.distAt(x-1, y)
|
||||
dy := g.distAt(x, y+1) - g.distAt(x, y-1)
|
||||
l := math.Hypot(dx, dy)
|
||||
if l < 1e-6 {
|
||||
dx, dy, l = 1, 0, 1
|
||||
@@ -304,9 +363,9 @@ func shelfWidth(h *field.Field, g *Geometry, in Input) *field.Field {
|
||||
dx, dy = dx/l, dy/l
|
||||
var relief float64
|
||||
for t := 1; t <= steps; t++ {
|
||||
px := x + int(math.Round(dx*float64(t)))
|
||||
px, ok := g.col(x + int(math.Round(dx*float64(t))))
|
||||
py := y + int(math.Round(dy*float64(t)))
|
||||
if px < 0 || py < 0 || px >= g.W || py >= g.H {
|
||||
if !ok || py < 0 || py >= g.H {
|
||||
break
|
||||
}
|
||||
if e := float64(h.Data[py*g.W+px]) - in.SeaLevelM; e > relief {
|
||||
@@ -317,19 +376,19 @@ func shelfWidth(h *field.Field, g *Geometry, in Input) *field.Field {
|
||||
if t > 1 {
|
||||
t = 1
|
||||
}
|
||||
out.Data[i] = float32(hi + (lo-hi)*noise.Smoothstep(t))
|
||||
out[n] = float32(hi + (lo-hi)*noise.Smoothstep(t))
|
||||
}
|
||||
})
|
||||
|
||||
carried := field.NewLike(h)
|
||||
for i, ref := range g.Ref {
|
||||
if ref >= 0 {
|
||||
carried.Data[i] = out.Data[ref]
|
||||
carried.Data[i] = out[ref]
|
||||
} else {
|
||||
carried.Data[i] = float32(hi)
|
||||
}
|
||||
}
|
||||
return boxMean(carried, int(shelfSmoothM/h.CellM+0.5), 2)
|
||||
return boxMean(carried, int(shelfSmoothM/h.CellM+0.5), 2, g.WrapX)
|
||||
}
|
||||
|
||||
// layShelf writes the sea floor: a gentle shelf out to the break, then the continental slope to the abyss.
|
||||
@@ -339,10 +398,21 @@ func shelfWidth(h *field.Field, g *Geometry, in Input) *field.Field {
|
||||
// land in every statistic downstream.
|
||||
func layShelf(h *field.Field, g *Geometry, in Input, shelfW *field.Field) {
|
||||
cfg := in.Cfg
|
||||
// The lattice has to come back to itself at the seam, so on a cylinder the period is the planet's and not
|
||||
// a multiple of the roughness wavelength. Without it the sea floor gains a metre-scale discontinuity down
|
||||
// one meridian - small, and exactly the kind of thing nobody finds by looking at the middle of the map.
|
||||
period := cfg.RoughWaveM * 256
|
||||
u, v := noise.WorldUV(g.W, g.H, h.CellM, 0, 0, period)
|
||||
rough := noise.FBMAt(u, v, noise.NewSource(in.Seed, srcShelf),
|
||||
noise.Params{BaseCells: 256, Octaves: 3, Gain: 0.5})
|
||||
if in.NoisePeriodM > 0 {
|
||||
period = in.NoisePeriodM
|
||||
}
|
||||
cells := 256
|
||||
if in.NoisePeriodM > 0 && cfg.RoughWaveM > 0 {
|
||||
cells = int(period/cfg.RoughWaveM + 0.5)
|
||||
if cells < 1 {
|
||||
cells = 1
|
||||
}
|
||||
}
|
||||
rough := shelfRoughness(g, in, period, cells)
|
||||
|
||||
exp := cfg.ShelfExponent
|
||||
if exp <= 0 {
|
||||
@@ -364,15 +434,24 @@ func layShelf(h *field.Field, g *Geometry, in Input, shelfW *field.Field) {
|
||||
if width <= 0 {
|
||||
width = cfg.ShelfKm.Hi() * 1000
|
||||
}
|
||||
// The open-ocean depth at *this* cell, so the derived slope arrives exactly where the ocean
|
||||
// already is rather than at one global number it may be hundreds of metres from. And the
|
||||
// break cannot be deeper than the water it is a break in: painted shallows - a 20 m surf
|
||||
// class against a 30 m break - are shelf all the way out, with no slope to run down.
|
||||
abyss := in.abyssAt(i)
|
||||
brk := in.BreakM
|
||||
if abyss < brk {
|
||||
brk = abyss
|
||||
}
|
||||
var depth float64
|
||||
if d < width {
|
||||
depth = in.BreakM * math.Pow(d/width, exp)
|
||||
depth = brk * math.Pow(d/width, exp)
|
||||
} else {
|
||||
t := (d - width) / slopeW
|
||||
if t > 1 {
|
||||
t = 1
|
||||
}
|
||||
depth = in.BreakM + (in.AbyssM-in.BreakM)*noise.Smoothstep(t)
|
||||
depth = brk + (abyss-brk)*noise.Smoothstep(t)
|
||||
}
|
||||
taper := depth / 10
|
||||
if taper > 1 {
|
||||
@@ -385,6 +464,28 @@ func layShelf(h *field.Field, g *Geometry, in Input, shelfW *field.Field) {
|
||||
})
|
||||
}
|
||||
|
||||
// shelfRoughness is the noise on the sea floor, built in row bands.
|
||||
//
|
||||
// In bands because at planet scale the two coordinate fields and the result are three arrays of 76 million
|
||||
// floats - 900 MB for a field whose amplitude is ten metres. The lattices are rebuilt from the same seeded
|
||||
// source for every band, so the bands agree exactly where they meet; that is the same trick, for the same
|
||||
// reason, as internal/planet's ocean roughness.
|
||||
func shelfRoughness(g *Geometry, in Input, period float64, cells int) *field.Field {
|
||||
out := field.New(g.W, g.H, g.CellM)
|
||||
const bandRows = 512
|
||||
params := noise.Params{BaseCells: cells, Octaves: 3, Gain: 0.5}
|
||||
for y0 := 0; y0 < g.H; y0 += bandRows {
|
||||
y1 := y0 + bandRows
|
||||
if y1 > g.H {
|
||||
y1 = g.H
|
||||
}
|
||||
u, v := noise.WorldUV(g.W, y1-y0, g.CellM, 0, float64(y0)*g.CellM, period)
|
||||
band := noise.FBMAt(u, v, noise.NewSource(in.Seed, srcShelf), params)
|
||||
copy(out.Data[y0*g.W:y1*g.W], band.Data)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fetch is how open the water is in front of each waterline cell: rays cast seaward until they hit land,
|
||||
// weighted by the cosine of their angle from the shore normal, and averaged.
|
||||
//
|
||||
@@ -402,8 +503,8 @@ func layShelf(h *field.Field, g *Geometry, in Input, shelfW *field.Field) {
|
||||
// sheltered lagoon. A percentile is also a global statistic, which rule 1 of the tiling plan rules out: two
|
||||
// tiles would stretch by different anchors and their shared bay would be two different colours. So the
|
||||
// anchors are fixed and physical, and the units are "fraction of the fetch range the rays got".
|
||||
func fetch(g *Geometry, in Input) *field.Field {
|
||||
out := field.New(g.W, g.H, g.CellM)
|
||||
func fetch(g *Geometry, in Input) []float32 {
|
||||
out := make([]float32, len(g.Waterline))
|
||||
dirs := in.Cfg.FetchDirections
|
||||
if dirs < 4 {
|
||||
dirs = 4
|
||||
@@ -424,8 +525,8 @@ func fetch(g *Geometry, in Input) *field.Field {
|
||||
x0, y0 := i%g.W, i/g.W
|
||||
// The seaward normal: the distance field increases inland, so its gradient points away from the
|
||||
// water and the negative of it is the direction this stretch of shore faces.
|
||||
nx := -float64(g.Dist.AtClamped(x0+1, y0) - g.Dist.AtClamped(x0-1, y0))
|
||||
ny := -float64(g.Dist.AtClamped(x0, y0+1) - g.Dist.AtClamped(x0, y0-1))
|
||||
nx := -(g.distAt(x0+1, y0) - g.distAt(x0-1, y0))
|
||||
ny := -(g.distAt(x0, y0+1) - g.distAt(x0, y0-1))
|
||||
if l := math.Hypot(nx, ny); l > 1e-6 {
|
||||
nx, ny = nx/l, ny/l
|
||||
} else {
|
||||
@@ -441,10 +542,13 @@ func fetch(g *Geometry, in Input) *field.Field {
|
||||
}
|
||||
reach := maxSteps
|
||||
for t := 1; t <= maxSteps; t++ {
|
||||
px := x0 + int(math.Round(cs[k]*float64(t)))
|
||||
px, ok := g.col(x0 + int(math.Round(cs[k]*float64(t))))
|
||||
py := y0 + int(math.Round(sn[k]*float64(t)))
|
||||
if px < 0 || py < 0 || px >= g.W || py >= g.H {
|
||||
break // off the map is open water, and the mask keeps the border at sea
|
||||
if !ok || py < 0 || py >= g.H {
|
||||
// Off the map is open water, and the mask keeps the border at sea. On a cylinder a
|
||||
// ray never runs off in X at all - it comes round - so this is the poles, where the
|
||||
// synthetic polar ocean is genuinely open.
|
||||
break
|
||||
}
|
||||
if !in.Sea[py*g.W+px] {
|
||||
reach = t
|
||||
@@ -464,20 +568,20 @@ func fetch(g *Geometry, in Input) *field.Field {
|
||||
} else if t > 1 {
|
||||
t = 1
|
||||
}
|
||||
out.Data[i] = float32(noise.Smoothstep(t))
|
||||
out[n] = float32(noise.Smoothstep(t))
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// shorePercentiles reports the fetch distribution over the waterline itself, before it is carried anywhere.
|
||||
func shorePercentiles(shore *field.Field, g *Geometry) (p10, p50, p90 float64) {
|
||||
if len(g.Waterline) == 0 {
|
||||
func shorePercentiles(shore []float32) (p10, p50, p90 float64) {
|
||||
if len(shore) == 0 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
vals := make([]float64, 0, len(g.Waterline))
|
||||
for _, i := range g.Waterline {
|
||||
vals = append(vals, float64(shore.Data[i]))
|
||||
vals := make([]float64, 0, len(shore))
|
||||
for _, v := range shore {
|
||||
vals = append(vals, float64(v))
|
||||
}
|
||||
sort.Float64s(vals)
|
||||
at := func(f float64) float64 {
|
||||
@@ -630,16 +734,35 @@ func deposit(h *field.Field, g *Geometry, exposure *field.Field, in Input, suppl
|
||||
continue
|
||||
}
|
||||
shallow := (cfg.DepositDepthM - depth) / cfg.DepositDepthM
|
||||
shelter := shelterFloor + (1-shelterFloor)*math.Pow(1-float64(exposure.Data[i]), cfg.ShelterBias)
|
||||
// Clamped, and not defensively. `ShelterBias` is fractional, so `math.Pow` of a negative base is NaN
|
||||
// - and one NaN here spreads through the drift kernel into every cell of the budget and comes out as
|
||||
// a laid volume of NaN with no other symptom. Exposure is a smoothed field, so it is 0..1 only to
|
||||
// within the rounding of however it was smoothed; relying on the smoother to bound it is relying on
|
||||
// an invariant a hundred lines away. Found when the coverage became separable and the divisor changed
|
||||
// from float32 to float64: the ratio went over 1 by five parts in a hundred thousand, and 1720 cells
|
||||
// of a 200x40 test came out NaN.
|
||||
e := float64(exposure.Data[i])
|
||||
if e < 0 {
|
||||
e = 0
|
||||
} else if e > 1 {
|
||||
e = 1
|
||||
}
|
||||
shelter := shelterFloor + (1-shelterFloor)*math.Pow(1-e, cfg.ShelterBias)
|
||||
want.Data[i] = float32(shelter * shallow)
|
||||
}
|
||||
norm := boxBlur(want, radius, 3)
|
||||
norm := boxBlur(want, radius, 3, g.WrapX)
|
||||
// want is still needed below; norm and share are not, past the loops that read them. Dropping the
|
||||
// references is what lets the collector reclaim 304 MB apiece at planet scale before the next one is
|
||||
// allocated, rather than after.
|
||||
|
||||
// The supply is per waterline cell and the blur works on a grid, so it is scattered back onto the cells
|
||||
// its stretches of shore sit at. Distinct slots are distinct cells, so nothing collides.
|
||||
share := field.NewLike(h)
|
||||
for i, v := range supply {
|
||||
for slot, v := range supply {
|
||||
if v <= 0 {
|
||||
continue
|
||||
}
|
||||
i := int(g.Waterline[slot])
|
||||
nb := float64(norm.Data[i])
|
||||
if nb < 1e-9 {
|
||||
unplaced += v // nowhere within a drift length will take it
|
||||
@@ -647,7 +770,8 @@ func deposit(h *field.Field, g *Geometry, exposure *field.Field, in Input, suppl
|
||||
}
|
||||
share.Data[i] = float32(v / nb)
|
||||
}
|
||||
spread := boxBlur(share, radius, 3)
|
||||
spread := boxBlur(share, radius, 3, g.WrapX)
|
||||
share, norm = nil, nil
|
||||
|
||||
// place walks the grid in index order, which keeps the running totals deterministic: the writes are to
|
||||
// distinct cells but the sums are not, so this one stays serial.
|
||||
@@ -734,25 +858,65 @@ func shelfFraction(g *Geometry, in Input, shelfW *field.Field) float64 {
|
||||
// width with the mass-preserving kernel shrank every shelf near the border to nothing and put the whole
|
||||
// margin below the break. Blurring a field of ones with the same kernel gives exactly the coverage to divide
|
||||
// by, so the two share their arithmetic and cannot drift apart.
|
||||
func boxMean(f *field.Field, radius, passes int) *field.Field {
|
||||
// The coverage is *separable*, which is what keeps this affordable at planet scale.
|
||||
//
|
||||
// Blurring a field of ones is the obvious way to get the divisor, and it was the first way: two more full
|
||||
// fields plus a second boxBlur's two temporaries, which at 76 million cells is 1.2 GB for a quantity that
|
||||
// depends on nothing but the distance to the edge. But the blur is a row pass and a column pass, and applying
|
||||
// a 1-D operation to a field that is constant along the other axis leaves it constant along that axis - so
|
||||
// the coverage factorises as cx(x)*cy(y) for every pass count, exactly. Two vectors of W and H entries say
|
||||
// everything the field said.
|
||||
func boxMean(f *field.Field, radius, passes int, wrapX bool) *field.Field {
|
||||
if radius < 1 || passes < 1 {
|
||||
return f.Clone()
|
||||
}
|
||||
ones := field.NewLike(f)
|
||||
ones.Fill(1)
|
||||
sum := boxBlur(f, radius, passes)
|
||||
cover := boxBlur(ones, radius, passes)
|
||||
out := field.NewLike(f)
|
||||
for i := range out.Data {
|
||||
if c := cover.Data[i]; c > 1e-6 {
|
||||
out.Data[i] = sum.Data[i] / c
|
||||
} else {
|
||||
out.Data[i] = f.Data[i]
|
||||
cx := boxCover(f.W, radius, passes, wrapX)
|
||||
cy := boxCover(f.H, radius, passes, false) // Y never wraps: the top and bottom of a map are the poles
|
||||
out := boxBlur(f, radius, passes, wrapX)
|
||||
for y := 0; y < f.H; y++ {
|
||||
row := y * f.W
|
||||
for x := 0; x < f.W; x++ {
|
||||
if c := cx[x] * cy[y]; c > 1e-6 {
|
||||
out.Data[row+x] /= float32(c)
|
||||
} else {
|
||||
out.Data[row+x] = f.Data[row+x]
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// boxCover is what a line of ones comes back as after the same running-sum passes boxBlur applies: 1 in the
|
||||
// middle and less than 1 within a kernel of each end, or 1 everywhere when the line wraps.
|
||||
func boxCover(n, radius, passes int, wrap bool) []float64 {
|
||||
cur := make([]float64, n)
|
||||
for i := range cur {
|
||||
cur[i] = 1
|
||||
}
|
||||
if wrap {
|
||||
return cur // every cell has a full window; nothing runs off a cylinder
|
||||
}
|
||||
next := make([]float64, n)
|
||||
inv := 1 / float64(2*radius+1)
|
||||
for p := 0; p < passes; p++ {
|
||||
var sum float64
|
||||
for i := 0; i <= radius && i < n; i++ {
|
||||
sum += cur[i]
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
next[i] = sum * inv
|
||||
if hi := i + radius + 1; hi < n {
|
||||
sum += cur[hi]
|
||||
}
|
||||
if lo := i - radius; lo >= 0 {
|
||||
sum -= cur[lo]
|
||||
}
|
||||
}
|
||||
cur, next = next, cur
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
// boxBlur is a separable running-sum box blur: O(n) whatever the radius, which is what makes a 300 m drift
|
||||
// kernel cost the same as a 30 m one.
|
||||
//
|
||||
@@ -762,7 +926,7 @@ func boxMean(f *field.Field, radius, passes int) *field.Field {
|
||||
// neighbour's share of it — and dividing each output by its own truncated window size breaks that symmetry at
|
||||
// the border, which cost 4 % of the sediment budget on a coast that ran off the edge of the map. Zero padding
|
||||
// keeps K(i,j) = K(j,i) everywhere, and a cell outside the map has no want, so nothing is owed to it.
|
||||
func boxBlur(f *field.Field, radius, passes int) *field.Field {
|
||||
func boxBlur(f *field.Field, radius, passes int, wrapX bool) *field.Field {
|
||||
cur := f.Clone()
|
||||
if radius < 1 || passes < 1 {
|
||||
return cur
|
||||
@@ -773,6 +937,22 @@ func boxBlur(f *field.Field, radius, passes int) *field.Field {
|
||||
field.Rows(f.H, func(y0, y1 int) {
|
||||
for y := y0; y < y1; y++ {
|
||||
row := y * f.W
|
||||
if wrapX {
|
||||
// On a cylinder every cell has a *full* window in X, so the running sum wraps instead of
|
||||
// being truncated. That makes the row pass lossless rather than zero-padded, which the
|
||||
// mass balance is happy with for the same reason it was happy before: the kernel stays
|
||||
// symmetric, K(i,j) = K(j,i), and now nothing runs off the side at all.
|
||||
var sum float64
|
||||
for k := -radius; k <= radius; k++ {
|
||||
sum += float64(cur.Data[row+wrapCol(k, f.W)])
|
||||
}
|
||||
for x := 0; x < f.W; x++ {
|
||||
next.Data[row+x] = float32(sum * inv)
|
||||
sum += float64(cur.Data[row+wrapCol(x+radius+1, f.W)])
|
||||
sum -= float64(cur.Data[row+wrapCol(x-radius, f.W)])
|
||||
}
|
||||
continue
|
||||
}
|
||||
var sum float64
|
||||
for x := 0; x <= radius && x < f.W; x++ {
|
||||
sum += float64(cur.Data[row+x])
|
||||
@@ -810,3 +990,7 @@ func boxBlur(f *field.Field, radius, passes int) *field.Field {
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
// wrapCol brings a column index onto a cylinder of width w. A free function rather than a Geometry method
|
||||
// because boxBlur is handed a plain field and has no geometry to ask.
|
||||
func wrapCol(x, w int) int { return ((x % w) + w) % w }
|
||||
|
||||
@@ -8,52 +8,7 @@ import (
|
||||
"salty/terrain/internal/manifest"
|
||||
)
|
||||
|
||||
// TestEdtMatchesBruteForce is the one test the whole package rests on. Everything else 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.
|
||||
// Felzenszwalb's transform is exact, so the comparison is against an exhaustive search and the tolerance is
|
||||
// float32 rounding, not a percentage.
|
||||
func TestEdtMatchesBruteForce(t *testing.T) {
|
||||
const w, h = 41, 37
|
||||
seed := uint32(99)
|
||||
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
|
||||
|
||||
d2, near := edt(seeds, w, h)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
best := math.Inf(1)
|
||||
for sy := 0; sy < h; sy++ {
|
||||
for sx := 0; sx < w; sx++ {
|
||||
if !seeds[sy*w+sx] {
|
||||
continue
|
||||
}
|
||||
dx, dy := float64(x-sx), float64(y-sy)
|
||||
if d := dx*dx + dy*dy; d < best {
|
||||
best = d
|
||||
}
|
||||
}
|
||||
}
|
||||
i := y*w + x
|
||||
if math.Abs(float64(d2[i])-best) > 1e-3 {
|
||||
t.Fatalf("cell (%d,%d): d2 %g, brute force %g", x, y, d2[i], best)
|
||||
}
|
||||
// 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("cell (%d,%d): nearest %d is not a seed", x, y, n)
|
||||
}
|
||||
dx, dy := float64(x-n%w), float64(y-n/w)
|
||||
if math.Abs(dx*dx+dy*dy-best) > 1e-3 {
|
||||
t.Fatalf("cell (%d,%d): nearest seed %d is at %g, not %g", x, y, n, dx*dx+dy*dy, best)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The exact distance transform this pass is built on is tested in internal/dt, where it now lives.
|
||||
|
||||
// TestSignedDistanceIsMetresEitherWay checks the sign convention and the unit on a straight coast, where the
|
||||
// answer is arithmetic. The cells asked about are named explicitly: the map's own border is forced to sea by
|
||||
@@ -312,7 +267,7 @@ func TestBoxBlurIsMassPreservingAndSymmetric(t *testing.T) {
|
||||
before += float64(f.Data[y*64+x])
|
||||
}
|
||||
}
|
||||
out := boxBlur(f, 5, 3)
|
||||
out := boxBlur(f, 5, 3, false)
|
||||
var after float64
|
||||
for _, v := range out.Data {
|
||||
after += float64(v)
|
||||
@@ -323,7 +278,7 @@ func TestBoxBlurIsMassPreservingAndSymmetric(t *testing.T) {
|
||||
|
||||
one := field.New(64, 64, 1)
|
||||
one.Data[32*64+32] = 1
|
||||
k := boxBlur(one, 5, 3)
|
||||
k := boxBlur(one, 5, 3, false)
|
||||
for d := 1; d <= 16; d++ {
|
||||
l, r := k.Data[32*64+32-d], k.Data[32*64+32+d]
|
||||
if math.Abs(float64(l-r)) > 1e-7 {
|
||||
@@ -349,3 +304,231 @@ func TestDisabledIsThePreCoastBehaviour(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- the cylinder ------------------------------------------------------------------------------------
|
||||
//
|
||||
// A planet is measured once, whole, so every march, every ray and every running sum in this pass has to cross
|
||||
// the seam. The twins below are the flat-grid tests' questions asked again on a cylinder, and the shape of
|
||||
// each one is the same: build a world, build the *same* world rotated half a turn, and require the answer to
|
||||
// follow the ground rather than the grid. A pass that stops at column zero passes every flat test there is.
|
||||
|
||||
// rotate shifts a grid half a turn in X. On a cylinder that is not a change to the world at all, so anything
|
||||
// this pass measures has to come out rotated with it and not otherwise different.
|
||||
func rotate(f *field.Field, sea []bool, by int) (*field.Field, []bool) {
|
||||
w, h := f.W, f.H
|
||||
g := field.New(w, h, f.CellM)
|
||||
s := make([]bool, len(sea))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
src := y*w + x
|
||||
dst := y*w + (x+by)%w
|
||||
g.Data[dst] = f.Data[src]
|
||||
s[dst] = sea[src]
|
||||
}
|
||||
}
|
||||
return g, s
|
||||
}
|
||||
|
||||
// islandFixture is a round island on an otherwise open ocean, centred where the caller asks. Put the centre at
|
||||
// x=0 and it straddles the seam.
|
||||
func islandFixture(w, h, cx, cy, radius int, cellM, heightM float64) (*field.Field, []bool) {
|
||||
f := field.New(w, h, cellM)
|
||||
sea := make([]bool, w*h)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*w + x
|
||||
dx := x - cx
|
||||
if dx > w/2 {
|
||||
dx -= w
|
||||
} else if dx < -w/2 {
|
||||
dx += w
|
||||
}
|
||||
dy := y - cy
|
||||
if dx*dx+dy*dy <= radius*radius {
|
||||
f.Data[i] = float32(heightM)
|
||||
} else {
|
||||
sea[i] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return f, sea
|
||||
}
|
||||
|
||||
// The whole pass, twice, on the same island in two places. Everything it produces has to be the same world
|
||||
// rotated - which is the one assertion that catches a march, a ray or a running sum stopping at the seam,
|
||||
// because on a flat grid the two would differ and nobody would know which was right.
|
||||
func TestTheWholePassIsRotationInvariantOnACylinder(t *testing.T) {
|
||||
const w, h, r = 256, 96, 22
|
||||
const cellM = 40.0
|
||||
cfg := testCfg()
|
||||
|
||||
// Away from the seam.
|
||||
a, aSea := islandFixture(w, h, w/2, h/2, r, cellM, 60)
|
||||
ra := Build(Input{Height: a, Sea: aSea, SeaLevelM: 0, BreakM: 30, AbyssM: 180,
|
||||
WrapX: true, Seed: 7, Cfg: cfg})
|
||||
// The same island astride it, which is the same island.
|
||||
b, bSea := islandFixture(w, h, 0, h/2, r, cellM, 60)
|
||||
rb := Build(Input{Height: b, Sea: bSea, SeaLevelM: 0, BreakM: 30, AbyssM: 180,
|
||||
WrapX: true, Seed: 7, Cfg: cfg})
|
||||
|
||||
want, _ := rotate(a, aSea, w/2) // a rotated to sit where b does
|
||||
worst, at := 0.0, -1
|
||||
for i := range want.Data {
|
||||
if d := math.Abs(float64(want.Data[i] - b.Data[i])); d > worst {
|
||||
worst, at = d, i
|
||||
}
|
||||
}
|
||||
// Exactly zero when everything wraps, measured: the same island in two places is the same arithmetic in a
|
||||
// different order, and the order happens not to matter here. The tolerance is set just under what each
|
||||
// broken piece actually costs rather than at a comfortable round number - forcing the ray march flat gives
|
||||
// 0.224 m, forcing the box blur flat gives 7.6e-5 m, and a tolerance loose enough to pass the second is a
|
||||
// test that does not cover the running sums it claims to.
|
||||
if worst > 2e-5 {
|
||||
t.Errorf("the same island at the seam and away from it differ by %g m at cell %d (%d,%d); "+
|
||||
"something in the pass stops at column zero", worst, at, at%w, at/w)
|
||||
}
|
||||
|
||||
// And the accounting follows the ground too.
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
a, b float64
|
||||
tolRel float64
|
||||
}{
|
||||
{"shoreline", ra.Stats.ShorelineKm, rb.Stats.ShorelineKm, 1e-9},
|
||||
{"surf cut", ra.Stats.CutM3, rb.Stats.CutM3, 1e-3},
|
||||
{"laid", ra.Stats.LaidM3, rb.Stats.LaidM3, 1e-3},
|
||||
{"shelf share", ra.Stats.ShelfPctSea, rb.Stats.ShelfPctSea, 1e-6},
|
||||
{"exposure p50", ra.Stats.ExposureP50, rb.Stats.ExposureP50, 1e-6},
|
||||
} {
|
||||
if c.a == 0 && c.b == 0 {
|
||||
t.Errorf("%s is zero in both runs; this comparison measured nothing", c.name)
|
||||
continue
|
||||
}
|
||||
if rel := math.Abs(c.a-c.b) / math.Max(math.Abs(c.a), 1e-12); rel > c.tolRel {
|
||||
t.Errorf("%s: %.6g at the seam against %.6g away from it", c.name, c.b, c.a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The flat grid must not have changed. A cylinder is opt-in, and every template drawn before it existed was
|
||||
// drawn against the old behaviour.
|
||||
func TestAFlatGridIsUnchangedByTheCylinderOption(t *testing.T) {
|
||||
const w, h, split = 200, 40, 120
|
||||
f1, sea1 := coastFixture(w, h, split, 8, 5)
|
||||
r1 := Build(Input{Height: f1, Sea: sea1, SeaLevelM: 0, BreakM: 30, AbyssM: 180, Seed: 7, Cfg: testCfg()})
|
||||
|
||||
// Land at both ends and water in the middle: on a flat grid the two coasts are unrelated, on a cylinder
|
||||
// they are one landmass. The flat answer has to be the flat answer.
|
||||
if r1.Geometry.WrapX {
|
||||
t.Fatal("a caller that asked for nothing got a cylinder")
|
||||
}
|
||||
f2, sea2 := coastFixture(w, h, split, 8, 5)
|
||||
r2 := Build(Input{Height: f2, Sea: sea2, SeaLevelM: 0, BreakM: 30, AbyssM: 180, WrapX: false,
|
||||
Seed: 7, Cfg: testCfg()})
|
||||
for i := range f1.Data {
|
||||
if f1.Data[i] != f2.Data[i] {
|
||||
t.Fatalf("cell %d differs between two flat runs", i)
|
||||
}
|
||||
}
|
||||
_ = r2
|
||||
}
|
||||
|
||||
// The drift kernel on a cylinder: still mass-preserving, still symmetric, and now symmetric *across the seam*
|
||||
// as well. The deposition balance rests on K(i,j) = K(j,i), and a row pass that truncated at column zero
|
||||
// would break it exactly where a coast crosses the meridian.
|
||||
func TestBoxBlurWrapsWithoutLosingMass(t *testing.T) {
|
||||
const w, h = 64, 64
|
||||
f := field.New(w, h, 1)
|
||||
// Support astride the seam, which on a flat grid would run off both ends.
|
||||
var before float64
|
||||
for y := 20; y < 44; y++ {
|
||||
for _, x := range []int{w - 3, w - 2, w - 1, 0, 1, 2} {
|
||||
f.Data[y*w+x] = 1
|
||||
before++
|
||||
}
|
||||
}
|
||||
out := boxBlur(f, 5, 3, true)
|
||||
var after float64
|
||||
for _, v := range out.Data {
|
||||
after += float64(v)
|
||||
}
|
||||
if rel := math.Abs(after-before) / before; rel > 1e-4 {
|
||||
t.Errorf("wrapping moved the total from %.4f to %.4f (%.4f%%)", before, after, rel*100)
|
||||
}
|
||||
// And the flat kernel would have lost some of it, which is what says this test measures the wrap.
|
||||
flat := boxBlur(f, 5, 3, false)
|
||||
var flatSum float64
|
||||
for _, v := range flat.Data {
|
||||
flatSum += float64(v)
|
||||
}
|
||||
if flatSum >= before*0.999 {
|
||||
t.Error("the flat kernel kept everything too; move the support onto the seam")
|
||||
}
|
||||
|
||||
one := field.New(w, h, 1)
|
||||
one.Data[32*w+0] = 1 // a single grain exactly on the seam
|
||||
k := boxBlur(one, 5, 3, true)
|
||||
for d := 1; d <= 16; d++ {
|
||||
l, r := k.Data[32*w+wrapCol(-d, w)], k.Data[32*w+wrapCol(d, w)]
|
||||
if math.Abs(float64(l-r)) > 1e-7 {
|
||||
t.Fatalf("the wrapped kernel is not symmetric at offset %d: %g against %g", d, l, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A per-cell abyss is what lets a derived shelf meet a *painted* ocean floor. Without it the slope runs down
|
||||
// to one global depth and steps to whatever the painting said, which on a planet whose sea classes carry
|
||||
// 20, 120 and 512 m is a cliff at the shelf break in every strait.
|
||||
func TestThePerCellAbyssIsWhereTheSlopeEnds(t *testing.T) {
|
||||
// A tall coast, so the shelf comes out at its narrowest (600 m) and the 3.2 km of ocean has room for the
|
||||
// 1.6 km of continental slope behind it. On a low coast the shelf is 3 km wide and the slope never
|
||||
// finishes, which is correct behaviour and would read here as a failure.
|
||||
const w, h, split = 700, 24, 400
|
||||
const cellM = 8.0
|
||||
f, sea := coastFixture(w, h, split, cellM, 400)
|
||||
abyss := make([]float32, w*h)
|
||||
for i := range abyss {
|
||||
abyss[i] = 400 // deeper than the 180 m a global AbyssM would give
|
||||
}
|
||||
cfg := shelfOnlyCfg()
|
||||
cfg.RoughnessM = 0
|
||||
Build(Input{Height: f, Sea: sea, SeaLevelM: 0, BreakM: 30, AbyssM: 180, Abyss: abyss,
|
||||
Seed: 7, Cfg: cfg})
|
||||
|
||||
// The far end of the ocean, well past shelf plus slope, has to be at the painted depth and not at AbyssM.
|
||||
deepest := 0.0
|
||||
for y := 0; y < h; y++ {
|
||||
if d := -float64(f.Data[y*w+0]); d > deepest {
|
||||
deepest = d
|
||||
}
|
||||
}
|
||||
if math.Abs(deepest-400) > 1 {
|
||||
t.Errorf("the sea floor bottoms out at %.1f m; the painted abyss is 400 m", deepest)
|
||||
}
|
||||
}
|
||||
|
||||
// The separable coverage has to be the field it replaced, exactly. It is an optimisation of a divisor, and an
|
||||
// optimisation of a divisor that is only nearly right moves every smoothed value on the map.
|
||||
func TestTheSeparableCoverageIsTheFieldItReplaced(t *testing.T) {
|
||||
for _, wrapX := range []bool{false, true} {
|
||||
for _, radius := range []int{1, 4, 11, 40, 97} { // including radii past the grid, where the coast pass really runs
|
||||
for _, passes := range []int{1, 2, 3} {
|
||||
const w, h = 37, 29
|
||||
ones := field.New(w, h, 1)
|
||||
ones.Fill(1)
|
||||
want := boxBlur(ones, radius, passes, wrapX)
|
||||
cx := boxCover(w, radius, passes, wrapX)
|
||||
cy := boxCover(h, radius, passes, false)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
got := cx[x] * cy[y]
|
||||
if d := math.Abs(got - float64(want.Data[y*w+x])); d > 1e-6 {
|
||||
t.Fatalf("wrap=%v r=%d p=%d at (%d,%d): %.8f against the blurred field's %.8f",
|
||||
wrapX, radius, passes, x, y, got, want.Data[y*w+x])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package coast
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
)
|
||||
|
||||
// paintedSeaFixture is a straight coast whose whole sea is painted at one depth, the way a planet's ocean
|
||||
// class is. The sea is made wide enough to hold the derived margin and a stretch of open water past it, so
|
||||
// the test can ask the question that matters: how much of what the author painted survives.
|
||||
func paintedSeaFixture(w, h, split int, cellM, backshoreM, paintedM float64) (*field.Field, []bool, []float32) {
|
||||
f, sea := coastFixture(w, h, split, cellM, backshoreM)
|
||||
abyss := make([]float32, w*h)
|
||||
for i := range abyss {
|
||||
if sea[i] {
|
||||
abyss[i] = float32(paintedM)
|
||||
}
|
||||
}
|
||||
return f, sea, abyss
|
||||
}
|
||||
|
||||
// TestTheDerivedMarginDoesNotSwallowThePaintedOcean is the D-64 regression, stated as the property rather
|
||||
// than as the number that was wrong.
|
||||
//
|
||||
// The sea floor near a shore is derived and the sea floor away from it is the painting; the break depth is
|
||||
// what joins them. Set the break far shallower than the paint and the join stops being a join: the derived
|
||||
// profile is then a shallow bench that runs from the waterline out to the full reach of the margin, and on a
|
||||
// planet whose straits are narrower than twice that reach it *is* the ocean. That is not visible in a profile
|
||||
// test - the shape is monotone and correct at any break depth - so this measures the volume instead.
|
||||
//
|
||||
// 512 m is the first template's `ocean` class. 30 m was the inherited square-canvas break, 130 m is the
|
||||
// planet default.
|
||||
func TestTheDerivedMarginDoesNotSwallowThePaintedOcean(t *testing.T) {
|
||||
const w, h, split = 1400, 20, 1200
|
||||
const cellM, painted = 8.0, 512.0
|
||||
cfg := shelfOnlyCfg()
|
||||
|
||||
measure := func(breakM float64) (shallow float64, atBreak, far float64) {
|
||||
f, sea, abyss := paintedSeaFixture(w, h, split, cellM, 5, painted)
|
||||
Build(Input{Height: f, Sea: sea, SeaLevelM: 0, BreakM: breakM, AbyssM: painted,
|
||||
Abyss: abyss, Seed: 7, Cfg: cfg})
|
||||
y := h / 2
|
||||
n, under50 := 0, 0
|
||||
for x := 0; x < split; x++ {
|
||||
n++
|
||||
if -float64(f.Data[y*w+x]) < 50 {
|
||||
under50++
|
||||
}
|
||||
}
|
||||
// Just inside the widest shelf, and well past the shelf and the slope together.
|
||||
shelfCells := int(cfg.ShelfKm.Hi()*1000/cellM) - 2
|
||||
reachCells := int((cfg.ShelfKm.Hi() + cfg.SlopeKm) * 1000 / cellM)
|
||||
return float64(under50) / float64(n),
|
||||
-float64(f.Data[y*w+split-1-shelfCells]),
|
||||
-float64(f.Data[y*w+split-1-reachCells-20])
|
||||
}
|
||||
|
||||
oldShallow, oldBreak, oldFar := measure(30)
|
||||
newShallow, newBreak, newFar := measure(130)
|
||||
t.Logf("break 30 m: %.0f%% of this sea shallower than 50 m, %.0f m at the break, %.0f m offshore",
|
||||
oldShallow*100, oldBreak, oldFar)
|
||||
t.Logf("break 130 m: %.0f%% of this sea shallower than 50 m, %.0f m at the break, %.0f m offshore",
|
||||
newShallow*100, newBreak, newFar)
|
||||
|
||||
// Both must reach the painting in open water: the margin is a join, never a replacement.
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
far float64
|
||||
}{{"30 m", oldFar}, {"130 m", newFar}} {
|
||||
if c.far < painted-2 {
|
||||
t.Errorf("break %s: open water is %.0f m, want the painted %.0f m", c.name, c.far, painted)
|
||||
}
|
||||
}
|
||||
// The break is where it was asked for, which is what makes it the knob worth having.
|
||||
if newBreak < 110 || newBreak > 140 {
|
||||
t.Errorf("the shelf break is at %.0f m, want about 130 m", newBreak)
|
||||
}
|
||||
// And the shallow bench shrinks. This is the whole defect: at a 30 m break every cell of the derived
|
||||
// margin is shallower than 50 m by construction, so the bench is as wide as the margin reaches.
|
||||
if !(newShallow < oldShallow*0.75) {
|
||||
t.Errorf("shallow water is %.0f%% of the sea at a 130 m break against %.0f%% at 30 m; deepening the "+
|
||||
"break has to shrink the bench or it is not doing anything", newShallow*100, oldShallow*100)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPaintedShallowStraitIsStillShallow is the other half, and it is what stops the fix above from being a
|
||||
// blunt instrument: the break can never be deeper than the water it is a break in. An author who paints a
|
||||
// 20 m surf class gets 20 m of water, not a 130 m trench dug through it.
|
||||
func TestAPaintedShallowStraitIsStillShallow(t *testing.T) {
|
||||
const w, h, split = 1400, 20, 1200
|
||||
const cellM, painted = 8.0, 20.0
|
||||
f, sea, abyss := paintedSeaFixture(w, h, split, cellM, 5, painted)
|
||||
Build(Input{Height: f, Sea: sea, SeaLevelM: 0, BreakM: 130, AbyssM: painted,
|
||||
Abyss: abyss, Seed: 7, Cfg: shelfOnlyCfg()})
|
||||
|
||||
y := h / 2
|
||||
for x := 0; x < split; x++ {
|
||||
if d := -float64(f.Data[y*w+x]); d > painted+1 {
|
||||
t.Fatalf("%.0f m offshore: %.1f m of water over a sea painted at %.0f m",
|
||||
float64(split-x)*cellM, d, painted)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user