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
+114
View File
@@ -0,0 +1,114 @@
package check
import (
"math"
"testing"
"salty/terrain/internal/fluvial"
"salty/terrain/internal/uplift"
"salty/terrain/internal/world"
)
// The claim the whole fault feature rests on, end to end: a difference in uplift rate across a line survives
// the solve as an escarpment, on the side the fault raises.
//
// It is here rather than in internal/uplift because everything up there tests the *rate* field - that it is
// asymmetric, that two frames agree about it, that it tapers at the tips - and none of that says the solve
// leaves anything behind. A fault is applied as a rate precisely so that erosion cannot remove it, and
// "erosion cannot remove it" is a statement about a thousand steps of stream power, not about a weight
// function. Measured on the real planet it comes out at 2.7 to 50 m of scarp for throws of 139 to 399 m, all
// five facing the right way; this is that in miniature and fast enough to run every time.
func TestAFaultLeavesAScarpAfterTheSolve(t *testing.T) {
const w, h = 400, 400
const cellM = 8.0
const steps = 400
const dtYr = 1500.0
const runYears = steps * dtYr
p := world.Planet{CellM: cellM, W: w, H: h, PadY: 0, NoisePeriodM: float64(w) * cellM}
if err := p.Validate(); err != nil {
t.Fatal(err)
}
f := world.Whole(p)
// One straight east-west trace across the middle of the grid. Straight on purpose: the question is what
// the solve does to the step, and a curve would only make the measurement harder to read.
midM := float64(h) * cellM / 2
pts := make([][2]float64, 17)
for i := range pts {
pts[i] = [2]float64{float64(i) * float64(w) * cellM / 16, midM}
}
trace := uplift.FaultTrace{PointsM: pts, ThrowM: 300, LengthM: float64(w) * cellM}
delta := uplift.FaultDelta(f, []uplift.FaultTrace{trace}, runYears)
if delta == nil {
t.Fatal("the trace reached nothing")
}
// A quiet landscape to put it in: the sea along the left edge as base level, and a low uniform rate
// everywhere else so that anything standing up is the fault's doing and not the background's.
base := make([]bool, w*h)
rate := make([]float32, w*h)
height := make([]float32, w*h)
const backgroundMYr = 4.5e-5 // 0.045 mm/yr, the shipped highland foreland
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*w + x
if x < 12 {
base[i] = true
continue
}
r := backgroundMYr + float64(delta[i])
if r < 0 {
r = 0
}
rate[i] = float32(r)
height[i] = float32(20 + 4*math.Sin(float64(x)/23)*math.Cos(float64(y)/31))
}
}
g := fluvial.NewGrid(w, h, cellM, base)
g.SetElevationRange(-2000, 4000)
g.Run(height, rate, nil, fluvial.Params{
K: 5e-5, M: 0.5, N: 1, DtYr: dtYr, Steps: steps, Diffusion: 0.02, FillEvery: 1,
TalusSlope: math.Tan(35 * math.Pi / 180), ThermalEvery: 4, ThermalPasses: 24,
CriticalSlope: math.Tan(35 * math.Pi / 180), SlopeCap: 0.9, MaxHillslopeSub: 24,
}, nil)
// The trace runs east-west, so the two sides are north and south of it. nearestOnTrace signs a point by
// the cross product, which for a west-to-east trace puts the *north* side at d > 0 - the steep, upthrown
// side of a fault that is not reversed.
const offCells = 75 // 600 m either side, the same offset the planet-scale measurement used
midCell := h / 2
mean := func(row int) float64 {
sum, n := 0.0, 0
for x := 40; x < w-40; x++ {
sum += float64(height[row*w+x])
n++
}
return sum / float64(n)
}
up := mean(midCell - offCells)
down := mean(midCell + offCells)
if up <= down {
t.Fatalf("no scarp: the upthrown side averages %.1f m and the downthrown side %.1f m", up, down)
}
// Big enough to be terrain rather than noise, and well under the throw, because erosion takes most of a
// fault's displacement away - which is the whole reason a fault has to be applied as a rate and not as a
// shape. The planet-scale measurement puts the survivor at a few per cent to a fifth of the throw.
if step := up - down; step < 5 {
t.Errorf("the scarp is only %.1f m across a 300 m throw; that is not an escarpment", step)
} else if step > trace.ThrowM {
t.Errorf("the scarp is %.1f m against a %.0f m throw; nothing should exceed its own displacement",
step, trace.ThrowM)
}
// And it is *at the fault*, not a general tilt of the map: the step across the trace has to be far
// sharper than the same distance measured entirely on one side of it.
across := up - down
within := math.Abs(mean(midCell-offCells) - mean(midCell-2*offCells))
if across <= within {
t.Errorf("the step across the trace is %.1f m and a step of the same span on one side of it is "+
"%.1f m; that is a tilted map, not a fault", across, within)
}
}
+185
View File
@@ -0,0 +1,185 @@
package check
import (
"runtime"
"testing"
"salty/terrain/internal/fluvial"
"salty/terrain/internal/manifest"
"salty/terrain/internal/region"
"salty/terrain/internal/template"
"salty/terrain/internal/thermal"
"salty/terrain/internal/uplift"
"salty/terrain/internal/world"
)
const planetLegend = `{"classes":[
{"name":"sea","rgb":[0,0,255],"sea":true,"depth_m":400},
{"name":"plain","rgb":[150,200,100],"uplift_mm_yr":0.08,"k_mult":1.0},
{"name":"range","rgb":[60,160,100],"uplift_mm_yr":0.9,"k_mult":0.6}
]}`
// syntheticPlanet paints a small world with three landmasses, one of them across the seam, and returns it
// classified and projected. It is the smallest thing that exercises everything a real bake does: a cylinder,
// several regions, a seam, and two uplift classes.
func syntheticPlanet(t *testing.T, seed int64) (*manifest.Manifest, *template.Map, *region.Partition) {
t.Helper()
lg, err := template.Parse([]byte(planetLegend))
if err != nil {
t.Fatal(err)
}
const w, paintH, pad = 128, 64, 6
p := world.Planet{CellM: 40, W: w, H: paintH + 2*pad, PadY: pad, NoisePeriodM: w * 40}
if err := p.Validate(); err != nil {
t.Fatal(err)
}
sea := uint8(lg.Index("sea"))
plain := uint8(lg.Index("plain"))
rng := uint8(lg.Index("range"))
m := &template.Map{P: p, L: lg, Class: make([]uint8, p.W*p.H), Sea: make([]bool, p.W*p.H)}
for i := range m.Class {
m.Class[i], m.Sea[i] = sea, true
}
put := func(x0, y0, w0, h0 int, c uint8) {
for y := y0; y < y0+h0; y++ {
for x := x0; x < x0+w0; x++ {
i := (y+pad)*p.W + p.WrapX(x)
m.Class[i], m.Sea[i] = c, false
}
}
}
put(20, 10, 30, 24, plain) // a plain
put(30, 16, 12, 10, rng) // with a range in it
put(70, 30, 22, 20, rng) // a mountainous island
put(-4, 44, 10, 12, plain) // and one across the seam
part, err := region.Build(m, 4, 4)
if err != nil {
t.Fatal(err)
}
if len(part.Regions) < 3 {
t.Fatalf("got %d regions, want at least 3", len(part.Regions))
}
seam := false
for _, r := range part.Regions {
seam = seam || r.Seam
}
if !seam {
t.Fatal("no region straddles the seam; the test is not testing what it claims")
}
man := manifest.Defaults()
man.Source.Seed = seed
man.Planet = &manifest.Planet{UpliftVariation: 0.3}
return man, m, part
}
// solvePlanet runs the whole painted path: cut each region, build its painted geology, solve it, composite
// the land back. It is deliberately the same sequence internal/planet uses.
func solvePlanet(t *testing.T, seed int64, steps int) []float32 {
t.Helper()
man, m, part := syntheticPlanet(t, seed)
rates, ks := m.L.Rates(), m.L.Erodibilities()
out := make([]float32, m.P.W*m.P.H)
params := fluvial.Params{
K: 5e-5, M: 0.5, N: 1, DtYr: 1500, Steps: steps, Diffusion: 0.02, FillEvery: 1,
TalusSlope: thermal.TalusFromDegrees(35), ThermalEvery: 4, ThermalPasses: 2,
CriticalSlope: thermal.TalusFromDegrees(35), SlopeCap: 0.9, MaxHillslopeSub: 24,
}
for _, rg := range part.Regions {
class, land := part.Cut(m, rg)
up := uplift.FromTemplate(uplift.Paint{
Frame: rg.Frame, Class: class, Land: land,
Rates: rates, Ks: ks, Variation: man.Planet.UpliftVariation,
}, man)
h := up.Height.Clone()
g := fluvial.NewGrid(rg.Frame.W, rg.Frame.H, rg.Frame.P.CellM, up.Base)
g.SetSeed(man.Source.Seed)
g.SetFrame(rg.Frame)
g.SetElevationRange(-2000, 4000)
g.Run(h.Data, up.Rate.Data, up.K.Data, params, nil)
part.Composite(out, m, rg, h.Data)
}
return out
}
// The painted path's half of cross-cutting rule 12. The square canvas already has this assertion; a planet
// adds three ways to break it that the square canvas cannot reach - the classifier's parallel reduction, the
// region flood, and regions solved several at a time - so it gets its own.
func TestPaintedPlanetIsDeterministicAcrossGOMAXPROCS(t *testing.T) {
was := runtime.GOMAXPROCS(1)
defer runtime.GOMAXPROCS(was)
var want string
for _, procs := range []int{1, 2, 4, 8, 16} {
runtime.GOMAXPROCS(procs)
got := hash(solvePlanet(t, 7, 60))
if want == "" {
want = got
continue
}
if got != want {
t.Fatalf("GOMAXPROCS %d gives %s, GOMAXPROCS 1 gives %s", procs, got, want)
}
}
}
func TestSameSeedSamePlanet(t *testing.T) {
a := hash(solvePlanet(t, 11, 40))
b := hash(solvePlanet(t, 11, 40))
if a != b {
t.Fatalf("two runs of the same seed differ: %s and %s", a, b)
}
if c := hash(solvePlanet(t, 12, 40)); c == a {
t.Fatal("two different seeds give the same planet")
}
}
// The invariant the whole per-landmass decomposition rests on, asserted directly.
//
// Solving a landmass in a box of its own is only the same answer as solving the planet whole because ocean
// cells are held fixed at sea level and nothing in the solve can move them: ComputeReceivers makes every
// outlet its own receiver, so no flow path crosses water, and StreamPower, both diffusions, the repose clamp
// and thermal all skip a fixed cell. If that ever stopped being true, regions would start lying to each
// other and nothing else in the suite would say so.
func TestOceanCellsAreUntouchedByTheSolve(t *testing.T) {
man, m, part := syntheticPlanet(t, 7)
rates, ks := m.L.Rates(), m.L.Erodibilities()
params := fluvial.Params{
K: 5e-5, M: 0.5, N: 1, DtYr: 1500, Steps: 80, Diffusion: 0.02, FillEvery: 1,
TalusSlope: thermal.TalusFromDegrees(35), ThermalEvery: 4, ThermalPasses: 2,
CriticalSlope: thermal.TalusFromDegrees(35), SlopeCap: 0.9, MaxHillslopeSub: 24,
}
checked := 0
for _, rg := range part.Regions {
class, land := part.Cut(m, rg)
up := uplift.FromTemplate(uplift.Paint{
Frame: rg.Frame, Class: class, Land: land,
Rates: rates, Ks: ks, Variation: 0.3,
}, man)
h := up.Height.Clone()
g := fluvial.NewGrid(rg.Frame.W, rg.Frame.H, rg.Frame.P.CellM, up.Base)
g.SetSeed(man.Source.Seed)
g.SetFrame(rg.Frame)
g.SetElevationRange(-2000, 4000)
g.Run(h.Data, up.Rate.Data, up.K.Data, params, nil)
for i, isBase := range up.Base {
if !isBase {
continue
}
checked++
if h.Data[i] != float32(man.SeaLevelM) {
t.Fatalf("region %d: ocean cell %d came out at %g m, not sea level. The composite writes "+
"only land for exactly this reason, and it is now unsafe", rg.ID, i, h.Data[i])
}
}
}
if checked == 0 {
t.Fatal("no ocean cells were checked")
}
}
+242 -58
View File
@@ -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 }
+231 -48
View File
@@ -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])
}
}
}
}
}
}
}
+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
}
@@ -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)
}
}
}
+50
View File
@@ -0,0 +1,50 @@
package detail
// Classes is what the painted class asks of the detail passes, per cell, already blended.
//
// It exists because two classes can have the same uplift rate and the same erodibility - which is everything
// the geology grid knows about them - and still be completely different ground. A desert and a wet lowland
// are both "low, slowly rising"; what separates them is at two metres, in how much running water crosses
// them, how sharp their ledges stay and how much of them is dune.
//
// **Four fields rather than a class index and a lookup table**, which is what this was. The index is the
// right thing to carry - a class is a name, and a name is never interpolated - but the *numbers* it stands
// for are quantities, and quantities interpolate. Kept as a lookup, a desert meeting a lowland changed from
// seven metres of dune amplitude to two in the width of one cell, along a line the painter drew with a mouse,
// and it read exactly as what it was: a boundary in a picture rather than a change in the ground. Blended,
// the same boundary is a few hundred metres of one becoming the other, which is what the edge of a sand sea
// looks like from the ground.
//
// The blending happens where the fields are built (see planet.blendedClasses), because that is where the
// class raster and the tile's margin both are; by the time a pass reads one it is just a number per cell.
//
// Nil means every cell uses the pipeline's own numbers, which is what happens on a template whose legend
// overrides nothing.
type Classes struct {
Droplets []float32 // per cell: droplets a cell spawns
AmpLo []float32 // per cell: detail noise amplitude on flat ground
AmpHi []float32 // per cell: and on steep ground
Contrast []float32 // per cell: strata hardness contrast
}
// droplets, amp and contrast read a cell, falling back to the uniform value when there is no table.
func (c *Classes) droplets(i int, def float64) float64 {
if c == nil || c.Droplets == nil {
return def
}
return float64(c.Droplets[i])
}
func (c *Classes) amp(i int, defLo, defHi float64) (float64, float64) {
if c == nil || c.AmpLo == nil {
return defLo, defHi
}
return float64(c.AmpLo[i]), float64(c.AmpHi[i])
}
func (c *Classes) contrast(i int, def float64) float64 {
if c == nil || c.Contrast == nil {
return def
}
return float64(c.Contrast[i])
}
+720
View File
@@ -0,0 +1,720 @@
package detail
import (
"math"
"sort"
"salty/terrain/internal/dt"
"salty/terrain/internal/field"
"salty/terrain/internal/manifest"
"salty/terrain/internal/noise"
"salty/terrain/internal/world"
)
// Pass 11b: the shore at two metres.
//
// The coastal pass on the geology grid (internal/coast) decides where the shore *is*: it lays the shelf,
// planes a platform within a reach of the waterline, leaves a cliff where that reach ends, and carries the
// sediment it cut along the shore into the bays. All of that is right and almost none of it is visible,
// because the surf reach is 110 m and a geology cell is 8: a beach is fourteen cells wide, a berm is a
// quarter of one cell high, and a wave-cut notch is a fifth of one.
//
// The surf reach is the only length in the generator set by physics rather than by the canvas - it is how far
// a wave runs up, and a wave does not know how big the map is - so it does not shrink when the cell does. At
// the 2 m detail cell the same 110 m is 55 cells, which is enough to hold a real profile. That is the whole
// argument for this being a pass of its own rather than a knob on the one above.
//
// Everything here is measured against that reach and against the exposure the geology pass computed, so the
// two cannot disagree about where the shore is: this pass re-evaluates the same
// reach = SurfReachM * (0.35 + 0.65*exposure) that plane() used, and draws the profile the geology grid was
// too coarse to hold.
//
// It is local, which is what lets it run per tile: nothing here reads or writes further from the waterline
// than two surf reaches, which is 220 m against a tile margin of 244. Measured rather than reasoned - the
// pass reaches 110 to 136 m on the fixtures in TestThePassFitsInsideTheTileMargin - but the 220 is a hard
// limit rather than a measurement, because past it a cell has no stretch of shore to belong to at all.
// CoastalParams is pass 11b's input.
type CoastalParams struct {
Cfg manifest.CoastDetail
Surf manifest.Coast // the geology pass's own numbers: the reach and the platform grade come from it
Seed int64
Frame world.Frame
PeriodM float64 // the detail noise period, for the crenulation lattice
SeaLevelM float64
// Exposure is the geology pass's fetch field sampled onto this tile, 0 sheltered to 1 open water.
//
// It cannot be computed here and must not be: fetch is cast fifteen hundred metres in sixteen directions
// and a tile is five kilometres across, so a tile has no way of knowing whether the water in front of it
// is a bay or an ocean. It is exactly the quantity D-53's rule says has to come from the pass that ran
// over the whole cylinder. Nil means the bake predates the field, and then every coast is treated as
// fully exposed - which is what the geology pass's own percentiles say most coast is anyway.
Exposure []float32
Hardness *Hardness
}
// CoastalStats is what the pass moved, for the tile record. The cliff branch conserves: what it cuts off the
// face it lays at the foot, per stretch of shore, and ScreeM3 is reported beside CutM3 so a run where the two
// have drifted apart says so rather than quietly losing rock.
type CoastalStats struct {
ShoreCells int `json:"shore_cells"`
CliffFrac float64 `json:"cliff_fraction"`
CutM3 float64 `json:"cliff_cut_m3"`
ScreeM3 float64 `json:"scree_laid_m3"`
BeachM3 float64 `json:"beach_net_m3"`
// How high the land stands behind this tile's shore, over its waterline cells. It is the input the
// beach-or-cliff decision is made from, so it is reported rather than left to be inferred from the
// fraction: a run with no cliffs anywhere is either a coast with no cliffs on it or a threshold in the
// wrong place, and these two numbers are the only thing that tells the two apart.
BackshoreP50M float64 `json:"backshore_p50_m"`
BackshoreP90M float64 `json:"backshore_p90_m"`
}
// coastalTaper is how far past the surf reach the profile fades out, as a fraction of the reach. The taper
// exists so the pass hands back to the droplets rather than ending in a line across the ground.
const coastalTaper = 0.5
// beachFace is the slope of the swash face of a sand beach, which is what sets where the berm crest sits: a
// berm bh metres high has its crest bh/beachFace metres inland. 1:10 is the ordinary figure for medium sand,
// and it is the one number here that is a property of the sediment rather than of the wave.
const beachFace = 0.1
// RunCoastal cuts the shore profile. Height is modified in place; land is the detail land mask as the passes
// above left it and is not updated - the waterline this pass works from is the one they agreed on.
func RunCoastal(h *field.Field, land []bool, p CoastalParams) CoastalStats {
var st CoastalStats
cfg := p.Cfg
if !cfg.Enabled {
return st
}
reachMax := p.Surf.SurfReachM
if reachMax <= 0 {
return st
}
w, ht := h.W, h.H
cellM := h.CellM
// The shoreline, which is not the land mask's boundary.
//
// On a coastal plain the ground crosses sea level at a grade of about one in a hundred, so whether a cell
// is land is decided by centimetres over a strip forty metres wide and the mask's boundary is a band of
// speckle rather than a curve. Everything this pass does is measured from that boundary, and measuring
// from speckle went wrong twice: it put a separate two-metre berm on every island in the band, and - less
// visibly and worse - it wrecked the backshore, because a cell two hundred metres inland had its nearest
// waterline cell in a puddle beside it rather than out at the coast, so the real shore was left measuring
// the height of the land behind almost nothing.
//
// So the shoreline is derived: the signed distance to the raw boundary, smoothed, thresholded back. That
// is a curve, it is within a few metres of the mask's own boundary, and everything below is measured from
// it. Taking the waterline on the land side of it is a half-cell choice, recorded rather than hidden.
rough := boundaryOf(land, w, ht)
sd := signedDistance(rough, land, w, ht, cellM)
smoothShore(sd, w, ht, int(cfg.ShoreSmoothM/cellM+0.5))
wet := make([]bool, len(sd))
for i, v := range sd {
wet[i] = v > 0
}
line := boundaryOf(wet, w, ht)
shore := make([]int32, 0, 4096)
for i, on := range line {
if on {
shore = append(shore, int32(i))
}
}
if len(shore) == 0 {
return st
}
st.ShoreCells = len(shore)
// One transform, seeded on the waterline itself, answers both halves of every question this pass asks:
// how far a cell is from the shore, and which stretch of shore it belongs to. The geology pass needs two
// because it wants the sea side and the land side to answer different things; here they answer the same.
//
// wrapX is false and has to be: a tile is a rectangle cut out of the cylinder with a margin on it, and
// the seam is the tiling's business rather than the pass's. A tile that wrapped its own left edge onto
// its own right would be inventing a shore.
d2, near := dt.Transform(line, w, ht, false)
// Per stretch of shore: how open it is, how far the surf reaches, how high the land behind it stands, and
// how far the whole profile is displaced in or out. Indexed by slot rather than by cell, which is the
// same economy the geology pass keeps - a tile has millions of cells and thousands of shore cells.
n := len(shore)
expo := make([]float64, n)
reach := make([]float64, n)
cren := make([]float64, n)
crenNoise := p.crenulation(h)
for s, ci := range shore {
e := 1.0
if p.Exposure != nil {
e = float64(p.Exposure[ci])
if e < 0 {
e = 0
} else if e > 1 {
e = 1
}
}
expo[s] = e
reach[s] = reachMax * (0.35 + 0.65*e)
if crenNoise != nil {
cren[s] = cfg.CrenulationM * (2*float64(crenNoise.Data[ci]) - 1)
}
}
// The signed distance to that shoreline, which needs no smoothing of its own: the curve it is measured
// from is already smooth.
dist := make([]float32, len(d2))
for i := range d2 {
dm := math.Sqrt(float64(d2[i])) * cellM
if wet[i] {
dist[i] = float32(dm)
} else {
dist[i] = float32(-dm)
}
}
// Which stretch of shore each cell belongs to.
slot := make([]int32, len(d2))
// Two surf reaches is the outer limit of the whole pass, on both sides, and it is a limit rather than a
// consequence: it is the window the backshore is measured in, so it is the furthest any cell has a stretch
// of shore to belong to at all, and it is what makes the margin claim one number. 220 m at the default
// reach, against a tile margin of 244.
backOuter := 2 * reachMax
for i := range d2 {
dm := math.Sqrt(float64(d2[i])) * cellM
slot[i] = -1
if dm > backOuter || near[i] < 0 {
continue
}
if s := slotOf(shore, near[i]); s >= 0 {
slot[i] = int32(s)
}
}
back := marchBackshore(h, dist, wet, shore, reach, p.SeaLevelM)
cliff := make([]float64, n)
for s := range back {
cliff[s] = cliffiness(back[s], cfg.CliffFromM, cfg.CliffToM)
st.CliffFrac += cliff[s]
}
st.CliffFrac /= float64(n)
st.BackshoreP50M, st.BackshoreP90M = percentiles(back)
// The roughness fade, before the profile is drawn on top of it.
//
// The profile is only a few tens of metres wide, so on its own the ground goes from a drawn beach to full
// dune amplitude and droplet rills within the width of its taper, and the beach reads as a ribbon laid on
// the terrain rather than as part of it. This blends the surface towards a smoothed copy of itself over a
// wider band: the relief is untouched - the smoothing radius is metres, not tens of them - and what fades
// is the metre-scale texture, so the backshore comes out smoother than the hillside behind it. Which is
// what a backshore is: sand and dune over whatever the hillside is made of.
smoothShoreRoughness(h, dist, wet, reachMax, cfg.SmoothReachM)
// The profile. Two targets blended by how high the land behind stands, and the result blended into the
// surface by how far the cell is from the shore, so the pass fades out rather than ending in a line.
cut := make([]float64, n)
for i := range dist {
s := slot[i]
if s < 0 {
continue
}
x := float64(dist[i]) - cren[s]
r := reach[s]
now := float64(h.Data[i])
bh := p.Surf.BermM * (0.35 + 0.65*expo[s])
// The two branches carry their own reach as well as their own shape, which the first version of this
// did not: a beach is over within a few tens of metres of the water, and holding its berm out to the
// full surf reach cut a ninety-metre terrace into the land behind every beach on the map.
crest := bh / beachFace
face := math.Min(back[s], cfg.CliffMaxM)
wb := branchWeight(x, crest, math.Min(crest+cfg.BermBackM, backOuter), r*0.5, math.Min(r, backOuter))
wc := branchWeight(x, r,
math.Min(r+face/max64(cfg.CliffGrade, 1e-3), backOuter),
r*0.5, math.Min(r*(1+coastalTaper), backOuter))
if wb <= 0 && wc <= 0 {
continue
}
// A beach is a veneer of sediment, not a landform that fills a fjord. Without the cap the equilibrium
// profile is a *target depth*, so a shore with forty metres of water a hundred metres off it - a
// drowned valley, which is an ordinary thing on a real coast - gets thirty-seven metres of sand
// invented to bring the floor up to the curve. Capped, the beach is a few metres of sediment laid on
// whatever is there, and where the water is deep it simply runs out. That is what a steep-to shore is.
tb := beachTarget(x, bh, cfg.DeanA, p.SeaLevelM)
if fill := now + cfg.BeachFillM; tb > fill {
tb = fill
}
tc := cliffTarget(x, r, face, p.Surf.PlatformGrade, cfg.CliffGrade, p.SeaLevelM)
// The platform is rock, and rock does not plane flat: hard bands stand out as ledges and reefs and
// soft ones cut down into runnels. It goes into the cliff target *before* the clamp below, which is
// the difference between a ledge and a wall built out of the sea: a band that resisted is rock the
// surf did not take, so it is still below where the ground started.
if p.Hardness != nil && cfg.PlatformReliefM > 0 {
if win := platformWindow(x, r); win > 0 {
hard := p.Hardness.At(i, now/cellM)
tc += cfg.PlatformReliefM * (2*hard - 1) * win
}
}
// The cliff branch never builds, on either side of the waterline. A shore platform and the face above
// it are what is left after the sea took rock away, so a target above the ground is the pass
// proposing to invent a headland, and the honest answer to that is to leave the ground where it is.
// It is also what keeps the platform from being laid out across deep water: it planes what is
// shallower than it and passes over what is not.
if tc > now {
tc = now
}
dCliff := cliff[s] * wc * (tc - now) // never positive, by the clamp above
dBeach := (1 - cliff[s]) * wb * (tb - now)
h.Data[i] = float32(now + dCliff + dBeach)
cut[s] -= dCliff
st.BeachM3 += dBeach
}
area := cellM * cellM
for _, c := range cut {
st.CutM3 += c * area
}
st.BeachM3 *= area
st.ScreeM3 = layScree(h, dist, shore, reach, cut, cfg, area)
return st
}
// cliffiness is how much of a cliff a stretch of shore is: 0 where the land behind it is at beach height, 1
// where it stands a cliff's worth above the water, smooth in between so the two profiles do not switch over
// from one shore cell to the next.
func cliffiness(backM, from, to float64) float64 {
if to <= from {
if backM >= to {
return 1
}
return 0
}
t := (backM - from) / (to - from)
if t <= 0 {
return 0
}
if t >= 1 {
return 1
}
return noise.Smoothstep(t)
}
// beachTarget is the equilibrium beach: a swash face rising to a berm crest above water, and Dean's profile
// below it.
//
// depth = A * x^(2/3) is the standard equilibrium profile, and A is a property of the sand rather than of the
// wave - it is the shape a beach returns to whatever the last storm did to it, which is exactly the right
// thing for a generator to draw, because what a generator has is the long-run average and never the storm.
// The berm is the other half: its crest sits at the wave runup limit, runup scales with wave height and wave
// height with fetch, so a berm on an exposed coast stands higher than one at the back of a bay. That is why
// the crest height arrives already scaled by exposure.
func beachTarget(x, bermM, deanA, seaLevelM float64) float64 {
if x >= 0 {
crest := bermM / beachFace
if crest <= 0 {
return seaLevelM
}
if x >= crest {
return seaLevelM + bermM
}
return seaLevelM + bermM*x/crest
}
return seaLevelM - deanA*math.Pow(-x, 2.0/3.0)
}
// cliffTarget is a shore platform out to the foot and a face above it, up to faceM high.
//
// faceM is capped rather than being the backshore itself, and the cap is what stops the pass carving a
// seventy-degree wall four hundred metres up a coastal range: the only other thing that stops the face is the
// ground rising faster than it does, and ground behind a mountain coast does. A sea cliff is what the surf
// undercut; above that height the face is a hillslope and it belongs to the solve.
//
// The foot is at the surf reach, which is not a choice: it is where plane() stopped cutting on the geology
// grid, so the cliff is already there and already in the right place. What this does is give it a *face*. At
// 8 m the step from the platform to the backshore is one cell, and upsampled by four it is a four-cell ramp
// at whatever angle the interpolation chose; at 2 m the same height can stand at the angle a cliff stands at.
//
// Seaward of the waterline the platform simply continues at its own grade, which is what a shore platform
// does - it is cut across the intertidal and runs on a little way below low water before the sea floor takes
// over.
func cliffTarget(x, reachM, faceM, platformGrade, cliffGrade, seaLevelM float64) float64 {
if x < 0 {
return seaLevelM - platformGrade*(-x)
}
if x <= reachM {
return seaLevelM + platformGrade*x
}
foot := seaLevelM + platformGrade*reachM
t := foot + cliffGrade*(x-reachM)
if top := seaLevelM + faceM; t > top {
return top
}
return t
}
// branchWeight is how much of a branch's target a cell takes: all of it inside that branch's core, and
// smoothstepping to none at its outer limit, so the pass hands back to the droplets and the noise instead of
// ending in a line across the ground.
func branchWeight(x, coreLand, outLand, coreSea, outSea float64) float64 {
if x >= 0 {
return taperTo(x, coreLand, outLand)
}
return taperTo(-x, coreSea, outSea)
}
func taperTo(d, core, out float64) float64 {
if d <= core {
return 1
}
if d >= out || out <= core {
return 0
}
return noise.Smoothstep((out - d) / (out - core))
}
// platformWindow fades the strata relief in across the shore platform and out at both ends of it: nothing at
// the foot of the cliff, where the face takes over, and nothing where the platform runs out under water.
//
// It reaches seaward as well as inland, because a shore platform does: it is cut across the intertidal and
// carries on a little below low water, and that submerged half is where the ledges and the reefs are.
func platformWindow(x, reachM float64) float64 {
if reachM <= 0 {
return 0
}
lo, hi := -reachM*0.5, reachM
if x <= lo || x >= hi {
return 0
}
t := (x - lo) / (hi - lo)
return noise.Smoothstep(math.Min(t*4, 1)) * noise.Smoothstep(math.Min((1-t)*4, 1))
}
// layScree puts back what the face lost, at the foot, at the angle of repose.
//
// The cliff branch only ever cuts, so it has a volume to account for, and a cliff that shed its face into
// nothing would be the one place in this generator where rock disappears. It goes where it goes on a real
// coast: an apron at the foot, thickest against the face and thinning seaward, at the angle blocky debris
// stands at. The volume is matched per stretch of shore rather than per tile, so the apron under a cliff is
// the apron that cliff produced.
//
// Marched along the shore normal, for the same reason marchBackshore is: a stretch of shore inside a bay owns
// no cells at all a hundred metres out, because the nearest-shore wedges converge there, so an apron scattered
// over those cells simply had nowhere to go. Measured on region 11 before the change, the aprons gained 2085
// of the 3030 cubic metres the faces lost and the rest was silently dropped. A march has a line of cells to
// put it on whatever the coast does, and the normalisation is the same one: a stretch of shore owns a strip
// one cell wide, so a scattered wedge and a marched line cover the same area on a straight coast and agree.
func layScree(h *field.Field, dist []float32, shore []int32, reach, cut []float64,
cfg manifest.CoastDetail, area float64) float64 {
if cfg.ScreeDeg <= 0 || cfg.ScreeReachM <= 0 {
return 0
}
w, ht := h.W, h.H
cellM := h.CellM
at := func(x, y int) float64 {
if x < 0 {
x = 0
} else if x >= w {
x = w - 1
}
if y < 0 {
y = 0
} else if y >= ht {
y = ht - 1
}
return float64(dist[y*w+x])
}
var laid float64
var line [128]int32
var wgt [128]float64
for s, ci := range shore {
if cut[s] <= 0 {
continue
}
x, y := int(ci)%w, int(ci)/w
dx := at(x+1, y) - at(x-1, y)
dy := at(x, y+1) - at(x, y-1)
l := math.Hypot(dx, dy)
if l < 1e-9 {
continue
}
dx, dy = dx/l, dy/l
lo := int((reach[s]-cfg.ScreeReachM)/cellM + 0.5)
hi := int(reach[s]/cellM + 0.5)
if lo < 0 {
lo = 0
}
nsteps, total := 0, 0.0
for t := lo; t <= hi && nsteps < len(line); t++ {
px := x + int(math.Round(dx*float64(t)))
py := y + int(math.Round(dy*float64(t)))
if px < 0 || px >= w || py < 0 || py >= ht {
break
}
v := screeWedge(float64(t)*cellM, reach[s], cfg.ScreeReachM)
if v <= 0 {
continue
}
line[nsteps], wgt[nsteps] = int32(py*w+px), v
total += v
nsteps++
}
if total <= 0 {
continue
}
for k := 0; k < nsteps; k++ {
add := cut[s] * wgt[k] / total
h.Data[line[k]] += float32(add)
laid += add
}
}
return laid * area
}
// crenulation is the noise that moves the whole profile in and out along the shore.
//
// It is applied to the *distance* rather than to the height, which is what makes it a crenulate coastline
// rather than a rough one: the profile stays a profile and the shoreline wanders. And it is read at the
// nearest waterline cell rather than at the cell being written, so it varies along the shore and not across
// it - read per cell, a two-dimensional noise field would ripple the profile in the cross-shore direction
// too, and a beach with corrugations up its face is not a beach.
func (p CoastalParams) crenulation(h *field.Field) *field.Field {
if p.Cfg.CrenulationM <= 0 || p.Cfg.CrenulationWaveM <= 0 || p.PeriodM <= 0 {
return nil
}
f := p.Frame
u, v := noise.WorldUV(f.W, f.H, h.CellM, f.OriginXM(), f.OriginYM(), p.PeriodM)
base := int(p.PeriodM/p.Cfg.CrenulationWaveM + 0.5)
if base < 2 {
base = 2
}
return noise.FBMAt(u, v, noise.NewSource(p.Seed, srcCoastal),
noise.Params{BaseCells: base, Octaves: 3, Gain: 0.5})
}
// slotOf is where a waterline cell sits in the shore list, which is sorted because it was built by scanning.
// -1 for a cell that is not on the list, which the distance transform should never hand back and which is
// cheaper to rule out here than to debug as an index out of range at planet scale.
func slotOf(shore []int32, cell int32) int {
k := sort.Search(len(shore), func(k int) bool { return shore[k] >= cell })
if k < len(shore) && shore[k] == cell {
return k
}
return -1
}
// smoothShore blurs a signed distance field, in place.
//
// Smoothing the *distance* is the point, and it is worth saying what the two obvious alternatives do instead.
// Smoothing the mask only moves the speckle around: it is a majority vote over a band that is half land and
// half water, so it produces different speckle. Smoothing the heightmap flattens the berm along with it. The
// distance is the one field whose smoothing has exactly the wanted effect - the shoreline becomes a curve, a
// few metres from where the mask put it, and nothing else about the ground changes at all.
//
// Two passes rather than one, because one leaves a box kernel's corners in the isolines and they show in a
// hillshade on ground this flat.
func smoothShore(sd []float32, w, h, radius int) {
field.BoxSmooth(sd, w, h, radius, 2)
}
// percentiles sorts a copy and reads the median and the P90 off it. A few thousand shore cells a tile, so a
// sort is nothing; this is the one place in the detail passes where that is true, and it is why there is no
// histogram here the way there is in internal/stats.
func percentiles(v []float64) (p50, p90 float64) {
if len(v) == 0 {
return 0, 0
}
c := append([]float64(nil), v...)
sort.Float64s(c)
return c[len(c)/2], c[int(float64(len(c)-1)*0.9)]
}
func max64(a, b float64) float64 {
if a > b {
return a
}
return b
}
// boundaryOf is the cells of a mask that are orthogonally against a cell that is not, which is to say its
// edge on the inside.
func boundaryOf(mask []bool, w, h int) []bool {
out := make([]bool, len(mask))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*w + x
if !mask[i] {
continue
}
if (x > 0 && !mask[i-1]) || (x < w-1 && !mask[i+1]) ||
(y > 0 && !mask[i-w]) || (y < h-1 && !mask[i+w]) {
out[i] = true
}
}
}
return out
}
// signedDistance is metres to the nearest boundary cell, positive inside the mask.
//
// Distance2 rather than Transform, because this one is thrown away after it has been smoothed and thresholded
// back into a shoreline: nothing asks it which stretch of shore a cell belongs to, and the feature index and
// the scratch it needs are two more arrays of four bytes a cell.
func signedDistance(boundary, mask []bool, w, h int, cellM float64) []float32 {
d2 := dt.Distance2(boundary, w, h, false)
out := make([]float32, len(d2))
for i := range d2 {
d := float32(math.Sqrt(float64(d2[i])) * cellM)
if mask[i] {
out[i] = d
} else {
out[i] = -d
}
}
return out
}
// marchBackshore is how high the land stands behind each stretch of shore: the mean height between one and
// two surf reaches inland, walked in along the shore normal.
//
// It is the window measureBackshore uses on the geology grid and for the same reason - it is clear of
// everything the surf planed, whatever the exposure there was - and it is what decides whether a stretch of
// shore is a beach or the foot of a cliff.
//
// **Walked rather than gathered**, and that is the whole of this function. The obvious implementation is to
// scatter every cell in the band onto the stretch of shore nearest to it, which costs one pass and no marches
// at all; it was the first one, and it is wrong in a way that only shows up on a real coastline. A cell two
// hundred metres inland belongs to exactly one shore cell, so on a concave shore - the inside of every bay,
// which is half of any coastline - the wedges converge and most shore cells are left owning nothing at all in
// the band. Their backshore then reads zero, which is not "the land behind is at sea level", it is "I did not
// look", and the two are indistinguishable afterwards. Measured on region 11: the median backshore over
// 69 km of waterline read 0.0 m while the mean height of the land 110 to 220 m inland was 1.9 m.
//
// A march gives every stretch of shore its own samples, whichever way the coast bends. Where it walks off the
// land - a spit narrower than a surf reach - the count stops rising, and a backshore of zero then means what
// it says.
func marchBackshore(h *field.Field, dist []float32, wet []bool, shore []int32, reach []float64,
seaLevelM float64) []float64 {
w, ht := h.W, h.H
cellM := h.CellM
at := func(x, y int) float64 {
if x < 0 {
x = 0
} else if x >= w {
x = w - 1
}
if y < 0 {
y = 0
} else if y >= ht {
y = ht - 1
}
return float64(dist[y*w+x])
}
out := make([]float64, len(shore))
for s, ci := range shore {
x, y := int(ci)%w, int(ci)/w
// Inland is up the gradient of the signed distance, which is smooth here because the shoreline it is
// measured from is a curve rather than the raw mask's boundary.
dx := at(x+1, y) - at(x-1, y)
dy := at(x, y+1) - at(x, y-1)
l := math.Hypot(dx, dy)
if l < 1e-9 {
continue
}
dx, dy = dx/l, dy/l
lo := int(reach[s]/cellM + 0.5)
hi := 2 * lo
var sum float64
var count int
for t := lo; t <= hi; t++ {
px := x + int(math.Round(dx*float64(t)))
py := y + int(math.Round(dy*float64(t)))
if px < 0 || px >= w || py < 0 || py >= ht {
break
}
j := py*w + px
if !wet[j] {
break
}
sum += float64(h.Data[j]) - seaLevelM
count++
}
if count > 0 {
out[s] = sum / float64(count)
}
}
return out
}
// screeWedge is the shape of the apron along the march: a wedge under the foot of the cliff, thickest against
// the face and thinning to nothing a scree reach seaward of it. Zero past the foot, because an apron lying
// *on* the cliff is not an apron.
func screeWedge(x, reachM, screeM float64) float64 {
if x > reachM {
return 0
}
d := reachM - x
if d >= screeM {
return 0
}
return 1 - d/screeM
}
// smoothShoreRoughness damps the metre-scale texture near the shore, in place.
//
// A blur of a few cells, mixed in by how close a cell is to the waterline. The radius is what keeps it a
// *roughness* fade rather than a shape one: at six metres it takes the top off the detail noise and the
// droplet rills and leaves everything the solve built, which is tens of metres across at the very least.
//
// Full strength within half a surf reach either side, then off over reachM more. Both sides on purpose - the
// shallows get the same treatment as the backshore, because a shore is a *place* rather than a line and it is
// smoother than either the land or the sea bed away from it.
//
// **Masked, and that is not a detail.** A plain blur across the waterline does not damp texture, it bridges
// the shoreline: the step there is a landform and not roughness. Measured on a fixture with forty metres of
// water against the land, an unmasked blur lifted the sea floor by twenty metres, which is a beach the size
// of the drowned valley it was supposed to leave alone.
func smoothShoreRoughness(h *field.Field, dist []float32, wet []bool, surfReachM, reachM float64) {
if reachM <= 0 {
return
}
radius := int(shoreRoughM/h.CellM + 0.5)
if radius < 1 {
return
}
soft := append([]float32(nil), h.Data...)
dry := make([]bool, len(wet))
for i, on := range wet {
dry[i] = !on
}
field.BoxSmoothMasked(soft, wet, h.W, h.H, radius, 2)
field.BoxSmoothMasked(soft, dry, h.W, h.H, radius, 2)
core := surfReachM * 0.5
out := core + reachM
for i := range h.Data {
d := math.Abs(float64(dist[i]))
if d >= out {
continue
}
w := 1.0
if d > core {
w = noise.Smoothstep((out - d) / (out - core))
}
h.Data[i] += float32(w * (float64(soft[i]) - float64(h.Data[i])))
}
}
// shoreRoughM is the wavelength the shore fade takes off. It is deliberately short: this is meant to remove
// the texture the detail passes added and nothing the solve built, and the solve's finest feature is a gully
// tens of metres across.
const shoreRoughM = 6
@@ -0,0 +1,402 @@
package detail
import (
"math"
"testing"
"salty/terrain/internal/field"
"salty/terrain/internal/manifest"
"salty/terrain/internal/world"
)
const testCellM = 2.0
// coastalCfg is the manifest's own block, so the tests fail when a default moves rather than measuring a copy
// of it that nothing ships.
func coastalCfg() (manifest.CoastDetail, manifest.Coast) {
m := manifest.Defaults()
return m.Pipeline.CoastDetail, m.Pipeline.Coast
}
func coastPlanet(w, h int) world.Planet {
return world.Planet{CellM: testCellM, W: w, H: h, PadY: 0, NoisePeriodM: float64(w) * testCellM}
}
// straightCoast is a world cut in half: land to the left of shoreM, sea to the right. The land rises to backM
// over one surf reach and then holds, so the backshore window the pass measures in is exactly backM and the
// beach-or-cliff decision in a test is the number the test set.
//
// A straight coast rather than an island on purpose: the profile is then one dimensional, so "what did the
// pass do" is a column that can be read off and compared against the arithmetic it is meant to be.
func straightCoast(w, h int, shoreM, backM, reachM, seaDepthM float64) (*field.Field, []bool) {
f := field.New(w, h, testCellM)
land := make([]bool, w*h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*w + x
inland := shoreM - float64(x)*testCellM
if inland >= 0 {
land[i] = true
t := inland / reachM
if t > 1 {
t = 1
}
f.Data[i] = float32(backM * t * t * (3 - 2*t))
} else {
f.Data[i] = float32(-seaDepthM)
}
}
}
return f, land
}
func runCoastal(t *testing.T, f *field.Field, land []bool, p world.Planet, x0, y0 int, backM float64) CoastalStats {
t.Helper()
cfg, surf := coastalCfg()
return RunCoastal(f, land, CoastalParams{
Cfg: cfg, Surf: surf, Seed: 7,
Frame: world.Frame{P: p, X0: x0, Y0: y0, W: f.W, H: f.H},
PeriodM: 1000, SeaLevelM: 0,
})
}
// Rule 1, for this pass: everything is keyed on absolute world position - the crenulation lattice through
// noise.WorldUV, the distance through a transform whose seeds are the same cells - so a window cut out of a
// bigger world and run on its own comes back bit-identical inside its margin.
//
// This is the test for the mistake the rule exists for: a noise field indexed by grid index instead of world
// position looks perfect on any one tile and puts a seam down every tile boundary. Measured by breaking it -
// passing a zero origin to WorldUV moves the interior by up to 3.6 m.
//
// It is *not* the test for the margin being big enough; the coast here is in the middle of the window, so the
// answer would be the same with no margin at all. TestThePassFitsInsideTheTileMargin is that one.
func TestATileInteriorIsWhatOneWholeRunWouldHaveGiven(t *testing.T) {
const w, h = 512, 192
p := coastPlanet(w, h)
whole, land := straightCoast(w, h, 420, 40, 110, 6)
runCoastal(t, whole, land, p, 0, 0, 40)
// The same world, cut out with a margin and run on its own. 130 cells is 260 m, which is past the pass's
// own outer limit of two surf reaches.
const margin = 130
const cx0, cw = 160, 192
cut := field.New(cw+2*margin, h, testCellM)
cutLand := make([]bool, len(cut.Data))
src, srcLand := straightCoast(w, h, 420, 40, 110, 6)
for y := 0; y < h; y++ {
for x := 0; x < cut.W; x++ {
sx := cx0 - margin + x
cut.Data[y*cut.W+x] = src.Data[y*w+sx]
cutLand[y*cut.W+x] = srcLand[y*w+sx]
}
}
runCoastal(t, cut, cutLand, p, cx0-margin, 0, 40)
var worst float64
for y := 0; y < h; y++ {
for x := 0; x < cw; x++ {
a := whole.Data[y*w+cx0+x]
b := cut.Data[y*cut.W+margin+x]
if d := math.Abs(float64(a) - float64(b)); d > worst {
worst = d
}
}
}
if worst != 0 {
t.Fatalf("a tile's interior differs from the whole run by up to %g m; every hash and lattice in this "+
"pass is supposed to be keyed on world position", worst)
}
}
// The cliff branch only cuts, so it owes an apron. This is the one hard conservation statement in the pass:
// what comes off the face is what lands at its foot, per stretch of shore rather than per tile, so the debris
// under a cliff is that cliff's debris.
func TestTheScreeIsExactlyWhatTheCliffLost(t *testing.T) {
const w, h = 320, 128
p := coastPlanet(w, h)
f, land := straightCoast(w, h, 400, 60, 110, 6)
st := runCoastal(t, f, land, p, 0, 0, 60)
if st.CutM3 <= 0 {
t.Fatalf("a 60 m backshore cut nothing off its face; cliff fraction %.2f", st.CliffFrac)
}
if st.CliffFrac < 0.99 {
t.Fatalf("a 60 m backshore is %.0f%% cliff, not a cliff coast", st.CliffFrac*100)
}
// Float32 heights, so the tolerance is the accumulation of a few million of them rather than zero.
if rel := math.Abs(st.ScreeM3-st.CutM3) / st.CutM3; rel > 1e-9 {
t.Fatalf("the face lost %.3f m3 and the apron gained %.3f m3, a relative gap of %g",
st.CutM3, st.ScreeM3, rel)
}
}
// A beach coast and a cliff coast are the same code with one number changed, and the number is the height of
// the land behind the shore. This checks the two come out as different landforms rather than as the same one
// scaled: a berm above the waterline on the beach, and no berm at all on the cliff.
func TestTheBackshoreDecidesBetweenABeachAndACliff(t *testing.T) {
const w, h = 320, 96
p := coastPlanet(w, h)
cfg, surf := coastalCfg()
// The swash zone: the strip just inland of the waterline. A berm is ground *standing* above the water
// there, so the measurement is a height and not a change - the first version of this measured how much
// the pass raised the ground and read 6 m on a beach, all of it the foreshore being filled up from the
// flat sea floor the fixture starts with. What was being measured was the fixture.
crest := func(f *field.Field) float64 {
var top float64
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
inland := 400 - float64(x)*testCellM
if inland < 0 || inland > float64(cfg.BermBackM) {
continue
}
if v := float64(f.Data[y*w+x]); v > top {
top = v
}
}
}
return top
}
beach, beachLand := straightCoast(w, h, 400, 3, 110, 6)
beachStats := runCoastal(t, beach, beachLand, p, 0, 0, 3)
cliff, cliffLand := straightCoast(w, h, 400, 60, 110, 6)
cliffStats := runCoastal(t, cliff, cliffLand, p, 0, 0, 60)
if beachStats.CliffFrac > 0.01 {
t.Errorf("a 3 m backshore came out %.0f%% cliff", beachStats.CliffFrac*100)
}
if cliffStats.CliffFrac < 0.99 {
t.Errorf("a 60 m backshore came out only %.0f%% cliff", cliffStats.CliffFrac*100)
}
// With no exposure field every shore is treated as fully exposed, so the berm stands at the manifest's
// full height.
gotBerm := crest(beach)
if want := surf.BermM; gotBerm < want*0.8 || gotBerm > want*1.2 {
t.Errorf("the beach's swash zone tops out at %.2f m; a berm should stand about %.2f", gotBerm, want)
}
// The cliff coast has a shore platform there instead, which runs up at the platform grade and nothing
// more: a cliff does not get a berm, it gets the rock the surf planed.
gotPlatform := crest(cliff)
if want := surf.PlatformGrade * cfg.BermBackM; gotPlatform > want*1.5 {
t.Errorf("the cliff's swash zone tops out at %.2f m; the platform should reach about %.2f",
gotPlatform, want)
}
if gotPlatform >= gotBerm {
t.Errorf("the cliff coast (%.2f m) stands as high in the swash zone as the beach (%.2f m); the two "+
"branches are not producing different landforms", gotPlatform, gotBerm)
}
}
// The claim that lets the pass run per tile at all: it never reaches further from the waterline than the tile
// margin, so a tile's margin holds everything its interior needed.
//
// The margin is the droplets' - three lifetimes, 244 m at the defaults - and this pass has to fit inside a
// number that was measured for something else. Two surf reaches is its own hard limit, and it is a limit
// rather than a consequence: past it a cell has no stretch of shore to belong to at all.
//
// The test asserts both ends. Past the margin, nothing may move; and something must move a good way out, or
// the test would pass just as well on a pass that did nothing.
func TestThePassFitsInsideTheTileMargin(t *testing.T) {
const w, h = 512, 96
p := coastPlanet(w, h)
m := manifest.Defaults()
marginM := float64(MarginCells(m.Pipeline.Particle)) * testCellM
for _, backM := range []float64{3, 40, 300, 600} {
f, land := straightCoast(w, h, 500, backM, 110, 6)
before := f.Clone()
runCoastal(t, f, land, p, 0, 0, backM)
var reachedM float64
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*w + x
if f.Data[i] == before.Data[i] {
continue
}
if d := math.Abs(500 - float64(x)*testCellM); d > reachedM {
reachedM = d
}
}
}
if reachedM > marginM {
t.Errorf("backshore %.0f m: the pass reached %.0f m from the waterline, past the %.0f m tile "+
"margin it has to fit inside", backM, reachedM, marginM)
}
if reachedM < 40 {
t.Errorf("backshore %.0f m: the pass only reached %.0f m, which is not a shore profile",
backM, reachedM)
}
t.Logf("backshore %3.0f m: reached %3.0f m of the %.0f m margin", backM, reachedM, marginM)
}
}
// Dean's profile is the one piece of published geomorphology in this pass, so it is worth checking that what
// comes out is actually it rather than something that merely slopes the right way. Away from the crenulation
// and inside the full-weight strip, the depth under water must be A*x^(2/3).
func TestTheForeshoreIsDeansProfile(t *testing.T) {
const w, h = 320, 64
p := coastPlanet(w, h)
cfg, surf := coastalCfg()
// Shallow water on purpose. A beach may lay at most BeachFillM of sediment on what is already there, so a
// fixture with a deep flat floor would measure the cap rather than the curve - which is what the first
// version of this did, at 40 m, and it read a flat profile 3 m above the floor. At 3 m the equilibrium
// curve sits above the floor by less than the cap everywhere it is sampled.
f, land := straightCoast(w, h, 300, 3, 110, 3)
runCoastal(t, f, land, p, 0, 0, 3)
// One row, and the crenulation read off the pass's own noise by inverting the profile at a known depth
// would be circular - so instead the check is against the *shape*: the ratio of depths at two offsets
// must be (x1/x2)^(2/3) whatever the crenulation shifted them by, and that is what is asserted.
y := h / 2
depthAt := func(offsetM float64) float64 {
x := int((300 + offsetM) / testCellM)
return -float64(f.Data[y*w+x])
}
d1, d2 := depthAt(20), depthAt(45)
if d1 <= 0 || d2 <= d1 {
t.Fatalf("the foreshore is not going down: %.2f m at 20 m out, %.2f m at 45 m", d1, d2)
}
// Solve for the shift the crenulation applied, then check A.
// d1 = A*(20+s)^(2/3), d2 = A*(45+s)^(2/3)
var best, bestErr = 0.0, math.Inf(1)
for s := -cfg.CrenulationM; s <= cfg.CrenulationM; s += 0.01 {
want := math.Pow((45+s)/(20+s), 2.0/3.0)
if e := math.Abs(d2/d1 - want); e < bestErr {
best, bestErr = s, e
}
}
if bestErr > 0.02 {
t.Fatalf("the two depths %.3f and %.3f are not in a 2/3-power ratio at any crenulation inside "+
"+/-%.0f m (best miss %.3f)", d1, d2, cfg.CrenulationM, bestErr)
}
gotA := d1 / math.Pow(20+best, 2.0/3.0)
if math.Abs(gotA-cfg.DeanA) > 0.01 {
t.Fatalf("Dean's A came out %.3f against the manifest's %.3f (crenulation %.2f m)",
gotA, cfg.DeanA, best)
}
_ = surf
}
// speckledCoast is a coastal plain: land rising at one in a hundred, with a little roughness on it. That is
// enough to make the land mask a forty-metre band of speckle rather than a line, which is what a real one is
// - measured on region 11 of the first painted planet, where the shore wandered eighteen cells between rows
// three apart and a row crossed sea level three times.
func speckledCoast(w, h int, shoreM, grade, roughM float64) (*field.Field, []bool) {
f := field.New(w, h, testCellM)
land := make([]bool, w*h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*w + x
inland := shoreM - float64(x)*testCellM
// A hash of the cell, so the roughness is the same every run and has no structure in it.
k := uint32(x*374761393+y*668265263) * 2246822519
k ^= k >> 13
u := float64(k%10007)/10007.0 - 0.5
v := grade*inland + roughM*u
f.Data[i] = float32(v)
land[i] = v > 0
}
}
return f, land
}
// What a coastal plain does to a shoreline, and the reason the signed distance is smoothed before the profile
// is measured from it.
//
// The pass rebuilds the surface as a monotonic function of that distance, so its output crosses sea level
// once along any line across the shore however ragged the input was. Without the smoothing it instead builds
// a separate berm on every island in the speckle, which is what the first run of the pass did: a string of
// beads down the whole coast.
func TestACoastalPlainComesOutWithOneShorelineAndNotABeadedOne(t *testing.T) {
const w, h = 320, 128
p := coastPlanet(w, h)
f, land := speckledCoast(w, h, 400, 0.01, 0.30)
crossings := func(g *field.Field) float64 {
total := 0
for y := 0; y < h; y++ {
n := 0
for x := 1; x < w; x++ {
a, b := g.Data[y*w+x-1], g.Data[y*w+x]
if (a <= 0) != (b <= 0) {
n++
}
}
total += n
}
return float64(total) / float64(h)
}
before := crossings(f)
if before < 3 {
t.Fatalf("the fixture is not speckled: %.1f sea-level crossings a row", before)
}
runCoastal(t, f, land, p, 0, 0, 4)
after := crossings(f)
if after > 1.05 {
t.Errorf("the shore came out with %.2f sea-level crossings a row (%.1f before); a shoreline crosses "+
"once, and more than that is a bead on the beach for every island in the mask", after, before)
}
t.Logf("sea-level crossings a row: %.1f before, %.2f after", before, after)
}
// A beach is a veneer of sediment and not a landform that fills a fjord.
//
// The equilibrium profile is a target *depth*, so on a shore with forty metres of water a hundred metres off
// it - a drowned valley, which is an ordinary thing on a real coast - an uncapped beach branch invents
// thirty-seven metres of sand to bring the floor up to the curve. Capped, the beach lays a few metres on
// whatever is there and runs out where the water gets deep, which is what a steep-to shore is.
func TestABeachDoesNotFillADrownedValley(t *testing.T) {
const w, h = 320, 96
p := coastPlanet(w, h)
cfg, _ := coastalCfg()
f, land := straightCoast(w, h, 400, 3, 110, 40)
before := f.Clone()
runCoastal(t, f, land, p, 0, 0, 3)
var worst float64
for i := range f.Data {
if d := float64(f.Data[i]) - float64(before.Data[i]); d > worst {
worst = d
}
}
if worst > cfg.BeachFillM+0.01 {
t.Fatalf("the beach laid %.2f m of sediment where the cap is %.2f; a shore with deep water close in "+
"is a steep-to shore, not a bay to be filled", worst, cfg.BeachFillM)
}
if worst < cfg.BeachFillM*0.5 {
t.Fatalf("the beach laid only %.2f m; the fixture is meant to press against the %.2f m cap",
worst, cfg.BeachFillM)
}
}
// The pass is off when the manifest says so, and off means nothing at all rather than a cheaper version of
// itself. Worth a test because it is the switch somebody reaches for when a coast looks wrong, and a switch
// that half works is worse than no switch.
func TestTheSwitchTurnsItOff(t *testing.T) {
const w, h = 128, 64
p := coastPlanet(w, h)
f, land := straightCoast(w, h, 150, 40, 110, 6)
before := f.Clone()
cfg, surf := coastalCfg()
cfg.Enabled = false
st := RunCoastal(f, land, CoastalParams{
Cfg: cfg, Surf: surf, Seed: 7,
Frame: world.Frame{P: p, X0: 0, Y0: 0, W: w, H: h},
PeriodM: 1000, SeaLevelM: 0,
})
if st.ShoreCells != 0 {
t.Errorf("a disabled pass reported %d shore cells", st.ShoreCells)
}
for i := range f.Data {
if f.Data[i] != before.Data[i] {
t.Fatalf("a disabled pass moved cell %d from %g to %g", i, before.Data[i], f.Data[i])
}
}
}
+29
View File
@@ -0,0 +1,29 @@
package detail
import "salty/terrain/internal/manifest"
// MarginCells is the overlap a tile must carry for the particle pass, in detail cells.
//
// Rule 2 of the tiling plan says to size a margin by how far the pass can move material, and for droplets
// that is not simply the lifetime. Within one round a droplet travels at most its lifetime, plus one cell for
// the cut brush. Across rounds the error compounds: a droplet in round two reads heights the round-one
// droplets moved, so the cut edge's influence walks a lifetime further in with every round.
//
// Taking that literally would make the margin `rounds * lifetime`, which at the defaults is 640 cells against
// a 2500-cell tile. Measured instead, at lifetime 12 and 8 rounds (TestHowFarTheCutEdgeReachesIn), the worst
// difference between a tile and the same ground in one whole run falls off much faster than that:
//
// cells in from the cut edge: 0 4 8 12 16 20 24 32 40
// worst difference, metres: 7.97 2.53 0.72 0.49 0.44 0.18 0.03 0.00 0.00
//
// It is the first lifetime that carries almost all of it, and by three the error is gone - a droplet has to be
// unlucky in the same way several rounds running for it to keep propagating, and that stops happening. Three
// lifetimes plus the brush is the margin, which at the default lifetime of 40 is 122 detail cells, 244 m, or
// about five per cent of a 5 km tile on each side.
func MarginCells(cfg manifest.Particle) int {
life := cfg.Lifetime
if life < 1 {
life = 1
}
return 3*life + 2
}
+142
View File
@@ -0,0 +1,142 @@
package detail
import (
"math"
"salty/terrain/internal/field"
"salty/terrain/internal/manifest"
"salty/terrain/internal/noise"
"salty/terrain/internal/world"
)
// Why the detail passes need a noise period of their own, and why it is short.
//
// noise.Lattice allocates cells² floats an octave, and the cell count is the period divided by the
// wavelength. Asking for an eight-metre finest octave on a hundred-kilometre period means a lattice of
// 12500² - one and a half gigabytes for the top octave alone - so world-period noise simply cannot reach
// detail wavelengths with this lattice.
//
// A short period can, and the cost is that the texture repeats. At a kilometre that is invisible: what
// repeats is a few metres of surface roughness, not anything with a shape, and the structure it sits on comes
// from the solve and from the paint, neither of which repeats at all. The period still has to divide the
// circumference exactly or the pattern breaks at the seam, which the manifest checks.
// DetailNoiseParams is pass 9.
type DetailNoiseParams struct {
Cfg manifest.Detail
Seed int64
Frame world.Frame
PeriodM float64 // the short period above; must divide the circumference
SeaLevelM float64
// Classes gives each cell its own amplitude. Nil means the manifest's pair everywhere.
Classes *Classes
}
// slopeFull is the slope at which detail noise reaches its full amplitude - about 27 degrees. Flat ground
// gets the low end and steep ground the high end, which is the same instinct as the droplets' slope gate: a
// meadow is smooth and a scree face is not, and noise applied evenly makes the meadow look like sandpaper.
const slopeFull = 0.5
// shoreTaperM is how far either side of the water the amplitude is faded in. A few metres of noise at the
// waterline turns the shallows into a scatter of one-cell islands, which is the same failure the coastal pass
// tapers its own sea-floor roughness to avoid.
const shoreTaperM = 12
// seabedAmp is how much of the flat-ground amplitude the sea bed gets. A sea bed is not a hillside: what is
// down there is bedform and scattered rock, and it is the shape of the shelf that carries the eye rather than
// its surface. It is a constant rather than a knob because the knob that matters is how deep the texture
// reaches, which is Detail.SeabedM, and two dials for one effect is one too many.
const seabedAmp = 0.45
// lattice builds the noise field both halves of this pass read, on world coordinates.
//
// BaseCells is chosen so the finest octave lands near two cells, which is as fine as a grid can carry.
func (p DetailNoiseParams) lattice(cellM float64) *field.Field {
oct := p.Cfg.Octaves
f := p.Frame
u, v := noise.WorldUV(f.W, f.H, cellM, f.OriginXM(), f.OriginYM(), p.PeriodM)
finest := 2 * cellM
base := int(p.PeriodM/(finest*math.Pow(2, float64(oct-1))) + 0.5)
if base < 2 {
base = 2
}
return noise.FBMAt(u, v, noise.NewSource(p.Seed, srcDetail),
noise.Params{BaseCells: base, Octaves: oct, Gain: 0.45})
}
func (p DetailNoiseParams) off() bool {
lo, hi := p.Cfg.AmplitudeM.Lo(), p.Cfg.AmplitudeM.Hi()
return p.Cfg.Octaves < 1 || (lo == 0 && hi == 0)
}
// RunDetailNoise adds surface texture at wavelengths the geology grid cannot hold.
//
// It is texture and nothing more. The relief, the valleys and the divides all came from the solve; this is
// what the ground does between them, and its amplitude is metres rather than tens of metres on purpose - the
// lesson from the first pipeline is that noise piled on top of erosion reads as noise, not as ground.
func RunDetailNoise(h *field.Field, land []bool, p DetailNoiseParams) {
if p.off() {
return
}
lo, hi := p.Cfg.AmplitudeM.Lo(), p.Cfg.AmplitudeM.Hi()
n := p.lattice(h.CellM)
slope := h.Slope()
for i := range h.Data {
if !land[i] {
continue
}
above := float64(h.Data[i]) - p.SeaLevelM
if above <= 0 {
continue
}
t := float64(slope.Data[i]) / slopeFull
if t > 1 {
t = 1
} else if t < 0 {
t = 0
}
cLo, cHi := p.Classes.amp(i, lo, hi)
amp := cLo + (cHi-cLo)*t
if above < shoreTaperM {
amp *= above / shoreTaperM
}
h.Data[i] += float32(amp * (2*float64(n.Data[i]) - 1))
}
}
// RunSeabedNoise is the same texture, under water.
//
// It is a second entry point rather than a branch inside the first because of *when* it can run. Passes 9 to
// 12 work with the sea flattened to sea level, so while they are running there is no sea bed to texture: the
// floor does not come back until the tile bake restores it, which is after pass 12 and just before the shore
// is drawn. So this runs there, on the same lattice, keyed the same way, and a cell gets the same value it
// would have got from one whole-world run.
//
// What it is for: a coast where the land is rough to the last cell and the water is glass from the first
// reads as a cut-out rather than as a shore, and the line between the two is the land mask's own boundary -
// the one thing in the picture that is a decision rather than a landform.
//
// Flat-ground amplitude only, and less of it: the slope term is what makes a scree face rough and there are
// no scree faces down here. Faded in from nothing at the waterline, so the pass cannot turn the shallows into
// a scatter of one-cell islands, and out to nothing at SeabedM.
func RunSeabedNoise(h *field.Field, p DetailNoiseParams) {
if p.off() || p.Cfg.SeabedM <= 0 {
return
}
lo, hi := p.Cfg.AmplitudeM.Lo(), p.Cfg.AmplitudeM.Hi()
n := p.lattice(h.CellM)
for i := range h.Data {
d := p.SeaLevelM - float64(h.Data[i])
if d <= 0 || d >= p.Cfg.SeabedM {
continue
}
cLo, _ := p.Classes.amp(i, lo, hi)
amp := cLo * seabedAmp * math.Min(d/shoreTaperM, 1) * (1 - noise.Smoothstep(d/p.Cfg.SeabedM))
if amp == 0 {
continue
}
h.Data[i] += float32(amp * (2*float64(n.Data[i]) - 1))
}
}
+419
View File
@@ -0,0 +1,419 @@
package detail
import (
"math"
"salty/terrain/internal/field"
"salty/terrain/internal/manifest"
"salty/terrain/internal/world"
)
// brush is the 3x3 kernel a droplet's cut goes through, weights summing to one.
//
// A one-cell footprint leaves every droplet path as a rill one cell wide, which reads across the lowlands as
// brush strokes. Deposits are *not* spread through it and land on the droplet's own bilinear cell instead:
// spread through the brush, a pit's rim rises faster than its floor, the pit never fills, and every droplet
// that drains into it adds to the rim until there is a mound.
var brush = [9]struct {
dx, dy int
w float64
}{
{0, 0, 0.36},
{0, 1, 0.12}, {0, -1, 0.12}, {1, 0, 0.12}, {-1, 0, 0.12},
{1, 1, 0.04}, {1, -1, 0.04}, {-1, 1, 0.04}, {-1, -1, 0.04},
}
// Maps are the derivative fields the droplets leave behind: how much water passed, how much bedrock was
// scraped, how much sediment was laid. The layer rules read them - scraped bedrock and convex ridges paint as
// rock, sediment fans and basins as meadow.
type Maps struct {
Flow, Wear, Deposit []float32
}
func newMaps(n int) *Maps {
return &Maps{Flow: make([]float32, n), Wear: make([]float32, n), Deposit: make([]float32, n)}
}
// ParticleParams is one particle pass over one tile.
type ParticleParams struct {
Cfg manifest.Particle
Seed int64
Frame world.Frame // the tile's cut, at detail resolution: what the hashes are keyed on
SeaLevelM float64
Hardness *Hardness
// Classes gives each cell its own droplet density, which is the difference between a rain-fed landscape
// and an arid one: drop it and the dendritic gully network thins to isolated channels.
Classes *Classes
}
// ParticleStats is what the pass moved, in metres.
type ParticleStats struct {
Droplets int
Rounds int
LargestCut, LargestFill float64
}
// RunParticle erodes a tile in place with hydraulic droplets.
//
// h is in metres and land marks the cells droplets may spawn on. Everything inside works in *cell heights* -
// metres over the cell size - so a slope of 1 is 45 degrees and every constant in the manifest means the same
// thing at any resolution, which is how the numpy was tuned and why the numbers carry across.
//
// Determinism, which is the part that is not a port. The numpy draws spawn cells from an RNG stream; that is
// index-dependent, so a cell would get different droplets depending on which tile it fell in and every seam
// would show. Here a cell's droplet count and every one of their choices is a hash of (seed, world position),
// so a droplet spawned in a tile's interior is bit-identical to the one spawned when that cell falls inside a
// neighbour's margin.
//
// The pass runs in rounds, which is the numpy's batching kept deliberately rather than inherited: droplets
// within a round read the height as it was when the round began and scatter their deltas into per-band
// buffers summed afterwards in band order, so two droplets in one cell in one round do not see each other and
// the result does not depend on which goroutine ran. Feedback - a channel deepening as more water follows it -
// comes from the rounds, not from within one.
func RunParticle(h *field.Field, land []bool, p ParticleParams) (*Maps, ParticleStats) {
var st ParticleStats
cellM := h.CellM
w, ht := h.W, h.H
maps := newMaps(w * ht)
cfg := p.Cfg
if cfg.Lifetime <= 0 || (cfg.DropletsPerCell <= 0 && p.Classes == nil) {
return maps, st
}
// Into cell heights, and back at the end.
hc := make([]float64, w*ht)
inv := 1 / cellM
for i, v := range h.Data {
hc[i] = float64(v) * inv
}
// The numpy spawns on land standing at least two metres clear of the water, which keeps droplets out of
// the surf zone where they would only churn the beach the coastal pass laid.
spawnAbove := (p.SeaLevelM + 2) / cellM
lifetime := cfg.Lifetime
inertia := cfg.Inertia
capacityF := cfg.Capacity
minSlope := cfg.MinSlope
depositRate := cfg.DepositRate
erodeRate := cfg.ErodeRate * orOne(cfg.Scale)
maxChange := cfg.MaxChange * orOne(cfg.Scale)
evaporation := cfg.Evaporation
gravity := cfg.Gravity
maxSpeed := cfg.MaxSpeed
maxLoad := cfg.MaxLoad
minErode := math.Max(cfg.MinErodeSlope, 1e-6)
limit := float64(w) - 2.001
limitY := float64(ht) - 2.001
// How many droplets each cell spawns, and therefore how many rounds. Counting first costs one pass over
// the tile and makes the round count a property of the world rather than of the loop.
total := 0
for y := 0; y < ht; y++ {
for x := 0; x < w; x++ {
i := y*w + x
if !land[i] || hc[i] <= spawnAbove {
continue
}
wx, wy := p.Frame.PlanetXY(x, y)
total += int(p.Classes.droplets(i, cfg.DropletsPerCell) + hashXY(p.Seed, wx, wy, 0))
}
}
if total == 0 {
return maps, st
}
// Rounds comes from the manifest and *not* from the droplet count, which is the one place this departs
// from the numpy on purpose. Derived from the count it would depend on how big a piece of the world was
// being worked on, so a droplet would land in a different round in a tile than in the whole map and the
// seams would not close.
rounds := cfg.Rounds
if rounds < 1 {
rounds = 1
}
st.Droplets, st.Rounds = total, rounds
reach := lifetime + 2 // a droplet steps one cell at a time; the brush adds one more
// A fixed band size, not one per core: a cell's contributions are summed band by band and floating-point
// addition is not associative, so a partition that moved with GOMAXPROCS would move the last bit with it.
const bandRows = 64
bands := field.FixedBandCount(ht, bandRows)
type buf struct {
y0, y1 int // the rows this band may touch
dh []float64
flow, wear, dep []float32
}
bufs := make([]buf, bands)
for round := 0; round < rounds; round++ {
field.FixedBands(ht, bandRows, func(b, y0, y1 int) {
lo := y0 - reach
if lo < 0 {
lo = 0
}
hi := y1 + reach
if hi > ht {
hi = ht
}
n := (hi - lo) * w
bf := &bufs[b]
if len(bf.dh) != n {
bf.dh = make([]float64, n)
bf.flow = make([]float32, n)
bf.wear = make([]float32, n)
bf.dep = make([]float32, n)
} else {
clear(bf.dh)
clear(bf.flow)
clear(bf.wear)
clear(bf.dep)
}
bf.y0, bf.y1 = lo, hi
add := func(x, y int, dh, flow, wear, dep float64) {
if y < lo || y >= hi || x < 0 || x >= w {
return
}
j := (y-lo)*w + x
bf.dh[j] += dh
bf.flow[j] += float32(flow)
bf.wear[j] += float32(wear)
bf.dep[j] += float32(dep)
}
for y := y0; y < y1; y++ {
for x := 0; x < w; x++ {
i := y*w + x
if !land[i] || hc[i] <= spawnAbove {
continue
}
wx, wy := p.Frame.PlanetXY(x, y)
count := int(p.Classes.droplets(i, cfg.DropletsPerCell) + hashXY(p.Seed, wx, wy, 0))
for j := 0; j < count; j++ {
if int(hashXY(p.Seed, wx, wy, int32(100+j))*float64(rounds)) != round {
continue
}
px := clampF(float64(x)+hashXY(p.Seed, wx, wy, int32(3*j+1)), 1, limit)
py := clampF(float64(y)+hashXY(p.Seed, wx, wy, int32(3*j+2)), 1, limitY)
runDroplet(hc, land, w, px, py, dropletConst{
lifetime: lifetime, inertia: inertia, capacityF: capacityF,
minSlope: minSlope, depositRate: depositRate, erodeRate: erodeRate,
maxChange: maxChange, evaporation: evaporation, gravity: gravity,
maxSpeed: maxSpeed, maxLoad: maxLoad, minErode: minErode,
limitX: limit, limitY: limitY,
}, p.Hardness, add)
}
}
}
})
// Summed in band order, never drained from a channel: the result must not depend on which goroutine
// finished first (cross-cutting rule 12).
for b := range bufs {
bf := &bufs[b]
if bf.dh == nil {
continue
}
for y := bf.y0; y < bf.y1; y++ {
src := (y - bf.y0) * w
dst := y * w
for x := 0; x < w; x++ {
hc[dst+x] += bf.dh[src+x]
maps.Flow[dst+x] += bf.flow[src+x]
maps.Wear[dst+x] += bf.wear[src+x]
maps.Deposit[dst+x] += bf.dep[src+x]
}
}
}
}
for i := range h.Data {
after := float32(hc[i] * cellM)
if d := float64(after - h.Data[i]); d < st.LargestCut {
st.LargestCut = d
} else if d > st.LargestFill {
st.LargestFill = d
}
h.Data[i] = after
}
// Wear and deposit are in cell heights; report them in metres like everything else.
for i := range maps.Wear {
maps.Wear[i] = float32(float64(maps.Wear[i]) * cellM)
maps.Deposit[i] = float32(float64(maps.Deposit[i]) * cellM)
}
st.LargestCut = -st.LargestCut
return maps, st
}
type dropletConst struct {
lifetime int
inertia, capacityF, minSlope float64
depositRate, erodeRate, maxChange float64
evaporation, gravity, maxSpeed float64
maxLoad, minErode float64
limitX, limitY float64
}
// runDroplet is one droplet's whole life. It reads the height as it was at the start of the round and reports
// what it moved through add; it never writes to the shared map itself.
func runDroplet(h []float64, land []bool, w int, px, py float64, c dropletConst, hard *Hardness,
add func(x, y int, dh, flow, wear, dep float64)) {
dx, dy := 0.0, 0.0
speed, water, sediment := 1.0, 1.0, 0.0
for step := 0; step < c.lifetime; step++ {
hcv, gx, gy, x0, y0, fx, fy := sampleBilinear(h, w, px, py)
dx = dx*c.inertia - gx*(1-c.inertia)
dy = dy*c.inertia - gy*(1-c.inertia)
length := math.Hypot(dx, dy)
if length <= 1e-9 {
return // standing water: it cannot pick a direction, so it stops
}
dx /= length
dy /= length
nx, ny := px+dx, py+dy
inside := nx >= 1 && nx <= c.limitX && ny >= 1 && ny <= c.limitY
hn, _, _, _, _, _, _ := sampleBilinear(h, w, clampF(nx, 1, c.limitX), clampF(ny, 1, c.limitY))
dh := 0.0
if inside {
dh = hn - hcv
}
slope := math.Max(-dh, c.minSlope)
capacity := math.Min(slope*speed*water*c.capacityF, c.maxLoad)
hardness := 0.0
if hard != nil {
hardness = hard.At(y0*w+x0, hcv)
}
// Flat ground resists cutting. The gate has to sit well above the median lowland slope or the
// meadows come out brushed with rills, which is the lesson 0.25 encodes.
holds := math.Hypot(gx, gy) / c.minErode
if holds > 1 {
holds = 1
}
holds *= holds
deposit, erode := 0.0, 0.0
if dh > 0 {
deposit = math.Min(dh, sediment) // uphill: fill the pit it is climbing out of
} else if sediment > capacity {
deposit = (sediment - capacity) * c.depositRate
}
if dh <= 0 && sediment <= capacity {
erode = math.Min((capacity-sediment)*c.erodeRate, -dh) * (1 - hardness) * holds
}
// The sea is a sink: the droplet drops its whole load at the mouth, which is what makes a fan. It is
// the land mask that decides, not a height comparison - the sea floor is held at sea level while the
// detail passes run (the same invariant the solve keeps), so there is no depth to compare against.
intoSea := false
if inside {
nxi, nyi := int(nx+0.5), int(ny+0.5)
if nxi >= 0 && nxi < w && nyi >= 0 && nyi*w+nxi < len(land) {
intoSea = !land[nyi*w+nxi]
}
}
if intoSea {
deposit, erode = sediment, 0
} else {
deposit = math.Min(deposit, c.maxChange)
erode = math.Min(erode, c.maxChange)
}
// Neither the cut nor the deposit may touch water. Both stencils straddle the waterline whenever a
// droplet is within a cell of it, and the sea floor is held at sea level here and put back afterwards,
// so anything written there would be silently thrown away - sediment that should have built a beach,
// quietly deleted. The cut is simply skipped, because cutting a sea floor that is a placeholder means
// nothing; the deposit is given to the droplet's own cell, which is land for as long as it is alive.
onLand := func(x, y int) bool {
if x < 0 || x >= w || y < 0 {
return false
}
i := y*w + x
return i < len(land) && land[i]
}
if erode > 0 {
for _, b := range brush {
if onLand(x0+b.dx, y0+b.dy) {
add(x0+b.dx, y0+b.dy, -erode*b.w, 0, 0, 0)
}
}
}
if deposit > 0 {
put := func(x, y int, amount float64) {
if !onLand(x, y) {
x, y = x0, y0
}
add(x, y, amount, 0, 0, 0)
}
put(x0, y0, deposit*(1-fx)*(1-fy))
put(x0+1, y0, deposit*fx*(1-fy))
put(x0, y0+1, deposit*(1-fx)*fy)
put(x0+1, y0+1, deposit*fx*fy)
}
add(x0, y0, 0, water, erode, deposit)
sediment += erode - deposit
speed = math.Min(math.Sqrt(math.Max(0, speed*speed-dh*c.gravity)), c.maxSpeed)
water *= 1 - c.evaporation
if !inside || intoSea || water <= 0.001 {
return
}
px, py = nx, ny
}
}
// sampleBilinear is the height and its gradient at a float position, with the integer cell and the
// fractions the caller needs to scatter back. The caller keeps the position inside [1, size-2].
func sampleBilinear(h []float64, w int, px, py float64) (hc, gx, gy float64, x0, y0 int, fx, fy float64) {
x0 = int(px)
y0 = int(py)
fx = px - float64(x0)
fy = py - float64(y0)
i := y0*w + x0
h00 := h[i]
h10 := h[i+1]
h01 := h[i+w]
h11 := h[i+w+1]
gx = (h10-h00)*(1-fy) + (h11-h01)*fy
gy = (h01-h00)*(1-fx) + (h11-h10)*fx
hc = h00*(1-fx)*(1-fy) + h10*fx*(1-fy) + h01*(1-fx)*fy + h11*fx*fy
return hc, gx, gy, x0, y0, fx, fy
}
func clampF(v, lo, hi float64) float64 {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
func orOne(v float64) float64 {
if v <= 0 {
return 1
}
return v
}
// hashXY is splitmix64's finaliser over the seed and a world position, in [0, 1). The same arithmetic as the
// router's jitter and for the same reason: everything random has to be a hash of where a thing is, never of
// the order it was visited in.
func hashXY(seed int64, x, y int, k int32) float64 {
h := uint64(seed)*0x9e3779b97f4a7c15 + 0x243f6a8885a308d3
h ^= uint64(uint32(int32(x)))*0x9e3779b97f4a7c15 +
uint64(uint32(int32(y)))*0xc2b2ae3d27d4eb4f +
uint64(uint32(k))*0x165667b19e3779f9
h ^= h >> 30
h *= 0xbf58476d1ce4e5b9
h ^= h >> 27
h *= 0x94d049bb133111eb
h ^= h >> 31
return float64(h>>11) / float64(uint64(1)<<53)
}
@@ -0,0 +1,386 @@
package detail
import (
"math"
"runtime"
"testing"
"salty/terrain/internal/field"
"salty/terrain/internal/manifest"
"salty/terrain/internal/world"
)
func testPlanet(t *testing.T) world.Planet {
t.Helper()
// 256 detail columns of 2 m is a 512 m circumference. Small, and a whole number of cells.
p := world.Planet{CellM: 2, W: 256, H: 96, PadY: 0, NoisePeriodM: 512}
if err := p.Validate(); err != nil {
t.Fatal(err)
}
return p
}
// a ridge running down the middle with some texture, so the droplets have something to cut.
func testTerrain(f world.Frame) (*field.Field, []bool) {
h := field.New(f.W, f.H, f.P.CellM)
land := make([]bool, f.W*f.H)
for y := 0; y < f.H; y++ {
for x := 0; x < f.W; x++ {
wx, wy := f.PlanetXY(x, y)
fx := float64(wx)
fy := float64(wy)
v := 120 * math.Exp(-math.Pow((fy-48)/22, 2))
v += 9 * math.Sin(fx*0.21) * math.Cos(fy*0.17)
v += 4 * math.Sin(fx*0.63+fy*0.41)
i := y*f.W + x
h.Data[i] = float32(v)
land[i] = v > 3
}
}
return h, land
}
func testCfg() manifest.Particle {
c := manifest.Defaults().Pipeline.Particle
c.DropletsPerCell = 1.5 // dense, so a small grid still gets a meaningful number
c.Lifetime = 12
c.Rounds = 1
return c
}
// The seam property the whole tiling rests on: a cell in a tile's interior must come out exactly as it would
// have in one big run, because every droplet that can reach it spawned inside the tile's margin.
//
// Rounds is 1 here, which is where the margin of lifetime+2 is *exactly* sufficient: a droplet that affects an
// interior cell passed within brush range of it, so it spawned at most lifetime cells away and every height it
// read on the way is inside the margin. With more rounds the margin's own heights start to matter and the
// match becomes very close rather than exact, which the test below measures instead of assuming.
func TestATilesInteriorMatchesTheWholeMap(t *testing.T) {
p := testPlanet(t)
cfg := testCfg()
const margin = 14 // lifetime 12 + 2
whole := world.Whole(p)
hw, landw := testTerrain(whole)
RunParticle(hw, landw, ParticleParams{Cfg: cfg, Seed: 7, Frame: whole})
// A tile covering columns 40..119, with the margin either side.
const x0, w = 40, 80
tf := world.Frame{P: p, X0: x0 - margin, Y0: 0, W: w + 2*margin, H: p.H}
ht, landt := testTerrain(tf)
RunParticle(ht, landt, ParticleParams{Cfg: cfg, Seed: 7, Frame: tf})
worst, at := 0.0, [2]int{}
for y := margin; y < p.H-margin; y++ {
for x := margin; x < margin+w; x++ {
got := float64(ht.Data[y*tf.W+x])
want := float64(hw.Data[y*p.W+(x0-margin+x)])
if d := math.Abs(got - want); d > worst {
worst, at = d, [2]int{x, y}
}
}
}
if worst > 1e-4 {
t.Errorf("the tile's interior differs from the whole map by %.6f m at %v; the margin is not doing "+
"its job, or something is keyed on a tile-local index", worst, at)
}
}
// With more than one round the margin's own heights feed back, so the match stops being exact and the
// question becomes how deep into a tile the edge's influence reaches. That is a measurement, not a guess:
// this runs a wide margin and reports the worst error at each depth, and the assertion is set at the depth
// the bake actually uses.
func TestHowFarTheCutEdgeReachesIn(t *testing.T) {
p := testPlanet(t)
cfg := testCfg()
cfg.Rounds = 8
const margin = 48
whole := world.Whole(p)
hw, landw := testTerrain(whole)
RunParticle(hw, landw, ParticleParams{Cfg: cfg, Seed: 7, Frame: whole})
const x0, w = 60, 60
tf := world.Frame{P: p, X0: x0 - margin, Y0: 0, W: w + 2*margin, H: p.H}
ht, landt := testTerrain(tf)
RunParticle(ht, landt, ParticleParams{Cfg: cfg, Seed: 7, Frame: tf})
// worst error among cells exactly d columns in from the cut's left edge.
at := func(d int) float64 {
worst := 0.0
x := d
// The whole run and the tile run share their top and bottom edges, so those cancel; only a couple of
// rows are dropped to keep the bilinear sampler's own clamp out of it.
for y := 2; y < p.H-2; y++ {
got := float64(ht.Data[y*tf.W+x])
want := float64(hw.Data[y*p.W+p.WrapX(x0-margin+x)])
if e := math.Abs(got - want); e > worst {
worst = e
}
}
return worst
}
for _, d := range []int{0, 4, 8, 12, 16, 20, 24, 32, 40, 48} {
t.Logf(" %2d cells in from the cut edge (%.0f m): worst %.4f m", d, float64(d)*p.CellM, at(d))
}
// At the margin the bake uses, the edge must have stopped mattering.
if e := at(MarginCells(cfg)); e > 0.05 {
t.Errorf("at the bake's margin of %d cells the edge still moves the ground by %.4f m",
MarginCells(cfg), e)
}
}
// A tile that straddles the seam must get the same answer as one that does not, which is what keying every
// hash on the world position buys.
func TestTheSeamIsNotSpecial(t *testing.T) {
p := testPlanet(t)
cfg := testCfg()
a := world.Frame{P: p, X0: 0, Y0: 0, W: 64, H: p.H}
ha, landa := testTerrain(a)
RunParticle(ha, landa, ParticleParams{Cfg: cfg, Seed: 7, Frame: a})
// The same physical columns, reached from a frame that starts on the far side of the seam.
b := world.Frame{P: p, X0: p.W - 32, Y0: 0, W: 64, H: p.H}
hb, landb := testTerrain(b)
RunParticle(hb, landb, ParticleParams{Cfg: cfg, Seed: 7, Frame: b})
// Frame b's column 32+k is planet column k, which is frame a's column k. Only compare cells far enough
// from both frames' edges that they saw the same droplets.
const edge = 14
checked := 0
for y := edge; y < p.H-edge; y++ {
for k := edge; k < 32-edge; k++ {
got := hb.Data[y*b.W+32+k]
want := ha.Data[y*a.W+k]
if math.Abs(float64(got-want)) > 1e-4 {
t.Fatalf("planet column %d row %d: %.6f across the seam, %.6f at the origin", k, y, got, want)
}
checked++
}
}
if checked == 0 {
t.Fatal("nothing was compared")
}
}
func TestParticleIsDeterministicAcrossGOMAXPROCS(t *testing.T) {
was := runtime.GOMAXPROCS(1)
defer runtime.GOMAXPROCS(was)
p := testPlanet(t)
cfg := testCfg()
cfg.Rounds = 4
f := world.Whole(p)
var want []float32
for _, procs := range []int{1, 2, 4, 8, 16} {
runtime.GOMAXPROCS(procs)
h, land := testTerrain(f)
RunParticle(h, land, ParticleParams{Cfg: cfg, Seed: 7, Frame: f})
if want == nil {
want = append([]float32(nil), h.Data...)
continue
}
for i := range want {
if h.Data[i] != want[i] {
t.Fatalf("GOMAXPROCS %d differs at cell %d: %v against %v", procs, i, h.Data[i], want[i])
}
}
}
}
// The brakes are lessons, not choices, and this is the one that matters most: below the slope gate water
// deposits but barely cuts, so lowland soil holds and meadows stay meadows instead of coming out brushed with
// rills.
func TestTheSlopeGateProtectsFlatGround(t *testing.T) {
p := testPlanet(t)
f := world.Whole(p)
cfg := testCfg()
cfg.DropletsPerCell = 4
// A gentle ramp well below min_erode_slope 0.25: 0.05 m over a 2 m cell is a slope of 0.025.
flat := func() (*field.Field, []bool) {
h := field.New(f.W, f.H, f.P.CellM)
land := make([]bool, f.W*f.H)
for y := 0; y < f.H; y++ {
for x := 0; x < f.W; x++ {
i := y*f.W + x
h.Data[i] = float32(40 + 0.05*float64(y))
land[i] = true
}
}
return h, land
}
h, land := flat()
before := append([]float32(nil), h.Data...)
_, st := RunParticle(h, land, ParticleParams{Cfg: cfg, Seed: 7, Frame: f})
if st.Droplets == 0 {
t.Fatal("no droplets spawned")
}
worst := 0.0
for i := range h.Data {
if d := math.Abs(float64(h.Data[i] - before[i])); d > worst {
worst = d
}
}
t.Logf("%d droplets over flat ground moved at most %.4f m", st.Droplets, worst)
if worst > 0.25 {
t.Errorf("flat ground moved %.3f m; the slope gate is not holding", worst)
}
// And with the gate opened right up, the same ground does get cut - so the test above is measuring the
// gate and not simply a pass that does nothing.
open := cfg
open.MinErodeSlope = 0.001
h2, land2 := flat()
RunParticle(h2, land2, ParticleParams{Cfg: open, Seed: 7, Frame: f})
moved := 0.0
for i := range h2.Data {
if d := math.Abs(float64(h2.Data[i] - before[i])); d > moved {
moved = d
}
}
if moved <= worst {
t.Errorf("opening the gate moved %.4f m against %.4f m closed; the test is not measuring the gate",
moved, worst)
}
}
// The sea is a sink, and it is the land mask that says so rather than a height comparison: the sea floor is
// held at sea level while the detail passes run, exactly as the fluvial solve holds it, so there is no depth
// to compare against. What a droplet reaching the water does is drop its whole load, which is what builds a
// fan at a river mouth.
func TestADropletEndsAtTheWaterAndLeavesItsLoadThere(t *testing.T) {
p := testPlanet(t)
f := world.Whole(p)
cfg := testCfg()
cfg.DropletsPerCell = 3
h := field.New(f.W, f.H, f.P.CellM)
land := make([]bool, f.W*f.H)
const shore = 60
for y := 0; y < f.H; y++ {
for x := 0; x < f.W; x++ {
i := y*f.W + x
if y >= shore {
h.Data[i] = 0 // the sea, held at sea level
continue
}
// A slope running down to the shore, steep enough to be well past the cutting gate.
h.Data[i] = float32(2 * float64(shore-y))
land[i] = true
}
}
before := append([]float32(nil), h.Data...)
maps, st := RunParticle(h, land, ParticleParams{Cfg: cfg, Seed: 7, Frame: f})
if st.Droplets == 0 {
t.Fatal("no droplets spawned")
}
// Nothing in the water moved.
for y := shore; y < f.H; y++ {
for x := 0; x < f.W; x++ {
i := y*f.W + x
if h.Data[i] != before[i] {
t.Fatalf("sea cell (%d,%d) moved from %v to %v", x, y, before[i], h.Data[i])
}
}
}
// And the last row of land carries more deposit than the slope above it: that is the fan.
rowDeposit := func(y int) float64 {
s := 0.0
for x := 0; x < f.W; x++ {
s += float64(maps.Deposit[y*f.W+x])
}
return s
}
atShore := rowDeposit(shore - 1)
upslope := rowDeposit(shore / 2)
t.Logf("deposit at the shore %.2f m against %.2f m halfway up the slope", atShore, upslope)
if atShore <= upslope {
t.Errorf("the shore row took %.3f m of deposit and the mid-slope row %.3f m; the sea is not acting "+
"as a sink", atShore, upslope)
}
}
// A desert and a wet lowland can have the same uplift rate and the same erodibility - which is everything the
// geology grid knows about them - and still be completely different ground. The per-class detail tables are
// where that difference lives, and the droplet density is the load-bearing one: drop it and the dendritic
// gully network thins out to isolated channels.
func TestAClassCanAskForLessRunningWater(t *testing.T) {
p := testPlanet(t)
f := world.Whole(p)
cfg := testCfg()
cfg.DropletsPerCell = 2.0
// Two classes over the same terrain: the left half wet, the right half arid.
run := func(classes *Classes) (ParticleStats, float64) {
h, land := testTerrain(f)
before := append([]float32(nil), h.Data...)
_, st := RunParticle(h, land, ParticleParams{Cfg: cfg, Seed: 7, Frame: f, Classes: classes})
moved := 0.0
for i := range h.Data {
moved += math.Abs(float64(h.Data[i] - before[i]))
}
return st, moved
}
wet, wetMoved := run(nil)
arid := uniformClasses(f.W*f.H, 0.1)
dry, dryMoved := run(arid)
t.Logf("wet %d droplets moved %.0f m of material; arid %d droplets moved %.0f m",
wet.Droplets, wetMoved, dry.Droplets, dryMoved)
if dry.Droplets >= wet.Droplets/10 {
t.Errorf("the arid class spawned %d droplets against %d wet; a twentieth of the density should show",
dry.Droplets, wet.Droplets)
}
if dryMoved >= wetMoved/2 {
t.Errorf("the arid class moved %.0f m against %.0f m wet; it should be far less dissected",
dryMoved, wetMoved)
}
if dry.Droplets == 0 {
t.Error("the arid class spawned nothing at all; that is not a desert, that is a table")
}
}
// And with no override, a class table changes nothing - which is what keeps every template that does not use
// one exactly where it was.
func TestClassTablesMatchingThePipelineChangeNothing(t *testing.T) {
p := testPlanet(t)
f := world.Whole(p)
cfg := testCfg()
a, landA := testTerrain(f)
RunParticle(a, landA, ParticleParams{Cfg: cfg, Seed: 7, Frame: f})
b, landB := testTerrain(f)
RunParticle(b, landB, ParticleParams{Cfg: cfg, Seed: 7, Frame: f,
Classes: uniformClasses(f.W*f.H, cfg.DropletsPerCell)})
for i := range a.Data {
if a.Data[i] != b.Data[i] {
t.Fatalf("cell %d differs: %v against %v", i, a.Data[i], b.Data[i])
}
}
}
// uniformClasses is a class table that says the same thing everywhere, which is what the two tests above
// want: one to make the whole map arid, the other to say nothing at all and prove it changes nothing.
func uniformClasses(n int, droplets float64) *Classes {
c := &Classes{
Droplets: make([]float32, n),
AmpLo: make([]float32, n),
AmpHi: make([]float32, n),
Contrast: make([]float32, n),
}
for i := range c.Droplets {
c.Droplets[i] = float32(droplets)
}
return c
}
+90
View File
@@ -0,0 +1,90 @@
// Package detail is the pipeline below the geology grid: the passes that decide how the ground reads to
// somebody standing on it.
//
// Every one of them is local, which is what makes the detail grid tileable at all (internal/tile): noise is
// pointwise, thermal weathering propagates a cell at a time, and a droplet travels at most its lifetime in
// cells. And every one of them is a port of tuned numpy from Scripts/Authoring/heightmap_erosion.py rather
// than a reimplementation. Docs/Terrain.md is explicit about which of its constants are lessons rather than
// choices, and they all carry across unchanged:
//
// - the droplet slope gate at 0.25, which must sit well above the median lowland slope or the meadows come
// out brushed with rills;
// - the per-step cut cap, because droplets share cells and a crowd in one runs away to infinity without it;
// - the load cap, which bounds the mound a droplet leaves where it stops;
// - cuts through a 3x3 brush and deposits on the droplet's own cell, because spreading the deposit makes a
// pit's rim rise faster than its floor, so the pit never fills and every droplet feeds a mound;
// - and thermal weathering shedding half the *largest* excess rather than half the mean.
//
// What does not carry across is how the randomness is drawn. The numpy picks spawn cells from an RNG stream,
// which is index-dependent: the same cell would get different droplets depending on which tile it fell in and
// every seam would show. Here everything is a hash of the absolute world position.
package detail
import (
"math"
"salty/terrain/internal/noise"
"salty/terrain/internal/world"
)
// Hardness is rock hardness in [0, 1] as a function of position and *elevation*: horizontal bands with a slow
// tilt, and a slow change of rock type across the map. Erosion is scaled by (1 - hardness), so a hard band
// holds a shelf on a cut face.
//
// It is orthogonal to the lithology field the fluvial solve uses and both are kept, which is the point:
// lithology varies with where you are and enters the solve at geology resolution; strata varies with how deep
// you have cut and scales the droplets at detail resolution. One puts different rock in different valleys,
// the other puts ledges on a cliff.
type Hardness struct {
W, H int
period float64 // vertical period in cell heights
contrast float64
classes *Classes
tilt []float32
kind []float32
}
// Pass indices for the detail passes' seeded sources, above everything uplift and coast use.
const (
srcTilt = 40
srcKind = 41
srcDetail = 42
srcDroplet = 43
srcCoastal = 44
)
// NewHardness builds the two fields on world coordinates, so two tiles covering the same rock agree.
//
// noisePeriodM is the world period rather than the detail passes' short one: where the rock changes and how
// the bands tilt are kilometre-scale properties, and a lattice coarse enough for them costs nothing.
func NewHardness(f world.Frame, seed int64, noisePeriodM, strataPeriodM, contrast float64, classes *Classes) *Hardness {
u, v := noise.WorldUV(f.W, f.H, f.P.CellM, f.OriginXM(), f.OriginYM(), noisePeriodM)
tilt := noise.FBMAt(u, v, noise.NewSource(seed, srcTilt), noise.Params{BaseCells: 96, Octaves: 3, Gain: 0.5})
kind := noise.FBMAt(u, v, noise.NewSource(seed, srcKind), noise.Params{BaseCells: 64, Octaves: 3, Gain: 0.5})
period := strataPeriodM / f.P.CellM
if period < 1e-3 {
period = 1e-3
}
return &Hardness{W: f.W, H: f.H, period: period, contrast: contrast, classes: classes,
tilt: tilt.Data, kind: kind.Data}
}
// At is the hardness at cell i for material standing at heightCells, in cell heights.
func (hd *Hardness) At(i int, heightCells float64) float64 {
if hd == nil {
return 0
}
contrast := hd.classes.contrast(i, hd.contrast)
if contrast == 0 {
return 0
}
band := 0.5 + 0.5*math.Sin(2*math.Pi*(heightCells/hd.period+float64(hd.tilt[i])*2))
v := 0.5 + contrast*(band-0.5)*(0.4+0.8*float64(hd.kind[i]))
if v < 0.05 {
return 0.05
}
if v > 0.95 {
return 0.95
}
return v
}
+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)
}
}
}
+17 -7
View File
@@ -20,7 +20,8 @@ import (
type DataMapOptions struct {
// Sea marks cells to render as flat water rather than data. Optional.
Sea []bool
// Size is the output side in pixels; the field is point-sampled down to it.
// Size is the output width in pixels; the field is point-sampled down to it and the height follows the
// field's own aspect.
Size int
// Log renders log10 of the value, for anything with a heavy tail — drainage area spans seven decades and
// is unreadable linearly.
@@ -64,9 +65,10 @@ func WriteDataMap(path string, f *Field, opt DataMapOptions) error {
span = 1
}
img := image.NewRGBA(image.Rect(0, 0, size, size))
for y := 0; y < size; y++ {
sy := y * f.H / size
sizeH := aspectH(f, size)
img := image.NewRGBA(image.Rect(0, 0, size, sizeH))
for y := 0; y < sizeH; y++ {
sy := y * f.H / sizeH
for x := 0; x < size; x++ {
sx := x * f.W / size
i := sy*f.W + sx
@@ -123,9 +125,13 @@ func WriteBasinMap(path string, w, h int, receiver []int32, sea []bool, size int
}
}
img := image.NewRGBA(image.Rect(0, 0, size, size))
for y := 0; y < size; y++ {
sy := y * h / size
sizeH := int(float64(size)*float64(h)/float64(w) + 0.5)
if sizeH < 1 {
sizeH = 1
}
img := image.NewRGBA(image.Rect(0, 0, size, sizeH))
for y := 0; y < sizeH; y++ {
sy := y * h / sizeH
for x := 0; x < size; x++ {
sx := x * w / size
i := sy*w + sx
@@ -191,6 +197,10 @@ func sampleStops(s [][3]float64, t float64) [3]float64 {
return [3]float64{a[0] + (b[0]-a[0])*u, a[1] + (b[1]-a[1])*u, a[2] + (b[2]-a[2])*u}
}
// HSV is exported because the region map colours its regions the same way the basin map colours its basins:
// a hash of the id straight to a hue, so neighbours get unrelated colours and a boundary is a hard edge.
func HSV(hue, sat, val float64) [3]float64 { return hsv(hue, sat, val) }
func hsv(hue, sat, val float64) [3]float64 {
h6 := hue * 6
i := int(h6)
+95 -10
View File
@@ -176,26 +176,111 @@ func (f *Field) Blur(passes int) *Field {
// starts and each writes only into its own, so the output is identical at any GOMAXPROCS. Every parallel
// loop in the generator goes through here; none spawns goroutines of its own.
func Rows(h int, fn func(y0, y1 int)) {
workers := runtime.GOMAXPROCS(0)
if workers > h {
workers = h
}
if workers <= 1 {
fn(0, h)
RowsIndexed(h, func(_, y0, y1 int) { fn(y0, y1) })
}
// RowsIndexed is Rows with the band number, which is what a parallel loop needs when it has to reduce
// something rather than only write into its own rows: it gives each goroutine a pre-allocated indexed
// slot to accumulate into, so the reduction can be replayed in band order afterwards instead of
// depending on which goroutine finished first. Size the slots with BandCount.
func RowsIndexed(h int, fn func(band, y0, y1 int)) {
step := rowStep(h)
if step >= h {
fn(0, 0, h)
return
}
var wg sync.WaitGroup
step := (h + workers - 1) / workers
band := 0
for y0 := 0; y0 < h; y0 += step {
y1 := y0 + step
if y1 > h {
y1 = h
}
wg.Add(1)
go func(a, b int) {
go func(k, a, b int) {
defer wg.Done()
fn(a, b)
}(y0, y1)
fn(k, a, b)
}(band, y0, y1)
band++
}
wg.Wait()
}
// FixedBands is RowsIndexed with a partition that does not depend on the core count: bands of exactly rows
// rows, run by however many workers there are.
//
// It exists for one reason. A parallel loop that only writes into its own rows can be partitioned any way at
// all, which is what Rows does. A loop that *reduces* into overlapping buffers cannot: floating-point addition
// is not associative, so summing a cell's contributions in a different grouping gives a different last bit,
// and the result would depend on GOMAXPROCS. The particle pass is that loop. Fix the partition and the
// arithmetic is fixed with it.
func FixedBands(h, rows int, fn func(band, y0, y1 int)) {
if rows < 1 {
rows = 1
}
n := FixedBandCount(h, rows)
workers := runtime.GOMAXPROCS(0)
if workers > n {
workers = n
}
if workers <= 1 {
for b := 0; b < n; b++ {
y0 := b * rows
y1 := min(y0+rows, h)
fn(b, y0, y1)
}
return
}
next := make(chan int)
go func() {
for b := 0; b < n; b++ {
next <- b
}
close(next)
}()
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for b := range next {
y0 := b * rows
y1 := min(y0+rows, h)
fn(b, y0, y1)
}
}()
}
wg.Wait()
}
// FixedBandCount is how many bands FixedBands will make.
func FixedBandCount(h, rows int) int {
if rows < 1 {
rows = 1
}
if h <= 0 {
return 0
}
return (h + rows - 1) / rows
}
// BandCount is how many ranges Rows and RowsIndexed split h into. It is fixed by h and GOMAXPROCS, so it
// can be called to size a reduction before the loop starts.
func BandCount(h int) int {
step := rowStep(h)
if step >= h {
return 1
}
return (h + step - 1) / step
}
func rowStep(h int) int {
workers := runtime.GOMAXPROCS(0)
if workers > h {
workers = h
}
if workers <= 1 {
return h
}
return (h + workers - 1) / workers
}
+271
View File
@@ -0,0 +1,271 @@
package field
import (
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
)
// Palette is how a preview is drawn: the hypsometric ramp, the water, the rivers, the ice and the light.
//
// It is a file rather than a set of constants because it is the one part of a bake that is purely a matter of
// taste, and taste is the thing most likely to want swapping. Nothing in it changes a height; a palette is
// read only by WritePreview, and two bakes of the same world under two palettes are the same terrain.
//
// The zero value is not usable - use DefaultPalette, which holds the numbers the generator shipped with.
type Palette struct {
// LandStops is the hypsometric ramp, from sea level at t = 0 to the top of the land at t = 1. Stops must
// be in ascending t; the ends are clamped rather than extrapolated.
LandStops []Stop `json:"land_stops"`
SeaShallow RGB `json:"sea_shallow"`
SeaDeep RGB `json:"sea_deep"`
River RGB `json:"river"`
// Ice is drawn wherever the snow mask says so, whatever height the ground is. Pure white is a poor
// choice: it has nowhere left to go under the hillshade, so an ice sheet comes out as a flat cut-out
// with no shape in it.
Ice RGB `json:"ice"`
// LandTopPercentile is where the ramp's top is taken from, over land elevations. Not the maximum: one
// 2800 m summit over a continent whose land is mostly under 300 m puts every other cell in the bottom
// tenth of the ramp, and the map then says far more about one pixel than about the terrain.
LandTopPercentile float64 `json:"land_top_percentile"`
// LandTopM puts the top of the ramp at a fixed height instead, in metres above sea level. Zero keeps
// the percentile, which is what every preview did before this existed.
//
// It is here because a relative ramp is a picture that lies about scale, and it lies hardest exactly
// where it matters. A lowland continent 47 m high, drawn against its own 99.5th percentile, gets the
// whole ramp - green, tan, bare rock and snow - so its 40 m hills come out with the same white caps a
// 2800 m range would, and a plain whose median slope is 0.6 degrees reads as an alpine massif. That was
// measured on this planet's central landmass and it is the most misleading thing the generator draws.
//
// The percentile stays the default all the same, because the alternative fails the other way: an
// absolute ramp over a world with no mountains is a flat green shape with nothing legible on it, and
// judging "is there drainage here" needs the contrast. What is added is the *choice*, plus a line in
// the run summary saying which ceiling a picture was drawn against - a relative picture is fine as long
// as nobody reads it as an absolute one.
LandTopM float64 `json:"land_top_m"`
// The light. Azimuth is degrees clockwise from north and altitude is degrees above the horizon; the
// north-west at 45 degrees is the convention every DEM hillshade uses and is what these default to.
SunAzimuthDeg float64 `json:"sun_azimuth_deg"`
SunAltitudeDeg float64 `json:"sun_altitude_deg"`
// Ambient is how lit the fully shaded side is and Gain how much the lit side brightens. Ambient at zero
// makes a shadow a hole.
Ambient float64 `json:"ambient"`
Gain float64 `json:"gain"`
}
// Stop is one entry in the hypsometric ramp.
type Stop struct {
T float64 `json:"t"`
RGB RGB `json:"rgb"`
}
// RGB is a colour in 0..255, kept as float64 so the hillshade can multiply it before it is clamped.
type RGB [3]float64
func (c RGB) String() string { return fmt.Sprintf("[%s, %s, %s]", num(c[0]), num(c[1]), num(c[2])) }
// num prints a float without trailing zeros, so a palette reads as numbers rather than as measurements.
func num(v float64) string { return strconv.FormatFloat(v, 'g', -1, 64) }
// DefaultPalette is what the generator ships with: salt-marsh green at sea level through farmland and rock to
// snow, with the stops chosen so the lowland does not read as one flat colour - which is where most of a map
// is, and where a badly chosen ramp hides everything.
func DefaultPalette() *Palette {
return &Palette{
LandStops: []Stop{
{0.00, RGB{72, 106, 68}},
{0.08, RGB{104, 132, 74}},
{0.20, RGB{142, 152, 88}},
{0.38, RGB{164, 148, 104}},
{0.58, RGB{150, 128, 106}},
{0.75, RGB{138, 130, 128}},
{0.88, RGB{176, 174, 174}},
{1.00, RGB{246, 246, 250}},
},
SeaShallow: RGB{56, 104, 136},
SeaDeep: RGB{18, 40, 72},
River: RGB{70, 132, 180},
Ice: RGB{232, 238, 245},
LandTopPercentile: 99.5,
SunAzimuthDeg: 315, // north-west
SunAltitudeDeg: 45,
Ambient: 0.45,
Gain: 0.75,
}
}
// LoadPalette reads a palette, filling anything the file leaves out from the default. Unknown keys are an
// error: a misspelt colour that silently keeps the default is a palette that does not do what it says.
func LoadPalette(path string) (*Palette, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
clean, err := StripJSONComments(raw)
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
p := DefaultPalette()
dec := json.NewDecoder(strings.NewReader(string(clean)))
dec.DisallowUnknownFields()
if err := dec.Decode(p); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
if err := p.Validate(); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return p, nil
}
// Validate refuses a palette that would draw nonsense.
func (p *Palette) Validate() error {
if len(p.LandStops) < 2 {
return fmt.Errorf("land_stops needs at least two entries, got %d", len(p.LandStops))
}
for i, s := range p.LandStops {
if i > 0 && s.T <= p.LandStops[i-1].T {
return fmt.Errorf("land_stops must ascend: stop %d is at t %.3f, after %.3f",
i, s.T, p.LandStops[i-1].T)
}
for k, v := range s.RGB {
if v < 0 || v > 255 {
return fmt.Errorf("land_stops[%d].rgb[%d] is %v, outside 0..255", i, k, v)
}
}
}
if p.LandTopPercentile <= 0 || p.LandTopPercentile > 100 {
return fmt.Errorf("land_top_percentile is %v, outside 0..100", p.LandTopPercentile)
}
if p.LandTopM < 0 {
return fmt.Errorf("land_top_m is %v; it is metres above sea level, so positive, or zero to use "+
"land_top_percentile instead", p.LandTopM)
}
if p.SunAltitudeDeg <= 0 || p.SunAltitudeDeg >= 90 {
return fmt.Errorf("sun_altitude_deg is %v; it is degrees above the horizon", p.SunAltitudeDeg)
}
if p.Ambient < 0 || p.Ambient > 1 {
return fmt.Errorf("ambient is %v, outside 0..1", p.Ambient)
}
if p.Gain < 0 {
return fmt.Errorf("gain is %v", p.Gain)
}
return nil
}
// ramp samples the hypsometric stops, clamped at both ends.
func (p *Palette) ramp(t float64) RGB {
if t <= p.LandStops[0].T {
return p.LandStops[0].RGB
}
for i := 1; i < len(p.LandStops); i++ {
if t <= p.LandStops[i].T {
a, b := p.LandStops[i-1], p.LandStops[i]
u := (t - a.T) / (b.T - a.T)
return RGB{
a.RGB[0] + (b.RGB[0]-a.RGB[0])*u,
a.RGB[1] + (b.RGB[1]-a.RGB[1])*u,
a.RGB[2] + (b.RGB[2]-a.RGB[2])*u,
}
}
}
return p.LandStops[len(p.LandStops)-1].RGB
}
// Write saves a palette as something a person can read and edit.
//
// Hand-formatted rather than through MarshalIndent, and that is not stubbornness: MarshalIndent re-indents
// whatever a custom marshaler returns, so there is no way to keep a colour on one line through it, and its
// default puts every channel of every stop on a line of its own - sixty lines for eight stops, a table whose
// shape is invisible. The comments are the other half: this is a file somebody opens to change one number,
// and it should say what the numbers are.
func (p *Palette) Write(path string) error {
var b strings.Builder
line := func(format string, a ...any) { fmt.Fprintf(&b, format+"\n", a...) }
line("{")
line(` "_comment": "How a preview is drawn. Nothing here changes a height - two bakes of the same ` +
`world under two palettes are the same terrain. Point a planet manifest at this file with ` +
`\"palette\": \"<path relative to the manifest>\"; leave it out and these numbers are used ` +
`anyway. Keys beginning with an underscore are comments.",`)
line("")
line(` "_comment_land_stops": "The hypsometric ramp: sea level at t 0 to the top of the land at t 1. ` +
`The top is a percentile rather than the maximum, so one high summit cannot push a whole continent ` +
`into the bottom of the ramp.",`)
line(` "land_stops": [`)
for i, s := range p.LandStops {
comma := ","
if i == len(p.LandStops)-1 {
comma = ""
}
line(` { "t": %-6s "rgb": %s }%s`, num(s.T)+",", s.RGB, comma)
}
line(" ],")
line("")
line(` "sea_shallow": %s,`, p.SeaShallow)
line(` "sea_deep": %s,`, p.SeaDeep)
line(` "river": %s,`, p.River)
line(` "_comment_ice": "Drawn wherever a class is marked snow, whatever height the ground stands at. ` +
`Not pure white: white has nowhere left to go under the hillshade, so an ice sheet comes out as a ` +
`flat cut-out with no shape in it at all.",`)
line(` "ice": %s,`, p.Ice)
line("")
line(` "_comment_top": "Where the top of the hypsometric ramp sits. The percentile is relative to the ` +
`world being drawn, which is the only way a low continent is legible at all and is also a picture ` +
`that lies about scale: a 47 m lowland gets the same rock and snow a 2800 m range would. Set ` +
`land_top_m to a height in metres for an absolute ramp instead; the run summary says which ceiling ` +
`every preview was drawn against.",`)
line(` "land_top_percentile": %s,`, num(p.LandTopPercentile))
line(` "land_top_m": %s,`, num(p.LandTopM))
line("")
line(` "_comment_light": "Azimuth is degrees clockwise from north and altitude degrees above the ` +
`horizon; north-west at 45 is what every DEM hillshade uses. Ambient is how lit the shaded side ` +
`is - at zero a shadow is a hole - and gain how much the lit side brightens.",`)
line(` "sun_azimuth_deg": %s,`, num(p.SunAzimuthDeg))
line(` "sun_altitude_deg": %s,`, num(p.SunAltitudeDeg))
line(` "ambient": %s,`, num(p.Ambient))
line(` "gain": %s`, num(p.Gain))
line("}")
return os.WriteFile(path, []byte(b.String()), 0o644)
}
// StripJSONComments removes every object key beginning with an underscore, at any depth.
//
// Every manifest in this repository carries its commentary that way, and a loader that refuses unknown
// fields - which both the legend and the palette do, because a misspelt key silently ignored is a setting
// that does not do what it says - has to let them through.
func StripJSONComments(data []byte) ([]byte, error) {
var v any
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
return json.Marshal(stripUnderscored(v))
}
func stripUnderscored(v any) any {
switch t := v.(type) {
case map[string]any:
out := make(map[string]any, len(t))
for k, val := range t {
if strings.HasPrefix(k, "_") {
continue
}
out[k] = stripUnderscored(val)
}
return out
case []any:
for i := range t {
t[i] = stripUnderscored(t[i])
}
return t
default:
return v
}
}
+89 -4
View File
@@ -7,6 +7,7 @@ import (
"image"
"image/png"
"io"
"math"
"os"
"path/filepath"
)
@@ -45,6 +46,48 @@ func WriteGray8(path string, w, h int, values []uint8, level png.CompressionLeve
return encode(path, img, level)
}
// WriteRGB writes tightly packed 8-bit RGB, three bytes a pixel. It is what a categorical map wants: a class
// raster has no ramp to run through a palette, only a colour per class.
// WriteRGBA writes colour with a separate alpha plane, which is what an overlay sheet is: strokes on a
// transparent background, where blank is decided by alpha rather than by a reserved colour.
//
// Non-premultiplied (NRGBA), deliberately. A mark's colour has to come back out of the file exactly as it
// went in, because the classifier reads exact colours against a tolerance; premultiplying would scale every
// channel by the alpha and round on the way, and a mark would classify as something else or as nothing.
func WriteRGBA(path string, w, h int, px []uint8, alpha []uint8, level png.CompressionLevel) error {
if len(px) != w*h*3 {
return fmt.Errorf("%s: %d bytes for a %dx%d RGB image", path, len(px), w, h)
}
if len(alpha) != w*h {
return fmt.Errorf("%s: %d alpha bytes for a %dx%d image", path, len(alpha), w, h)
}
img := image.NewNRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
row := img.Pix[y*img.Stride:]
src := px[y*w*3:]
a := alpha[y*w:]
for x := 0; x < w; x++ {
row[x*4], row[x*4+1], row[x*4+2], row[x*4+3] = src[x*3], src[x*3+1], src[x*3+2], a[x]
}
}
return encode(path, img, level)
}
func WriteRGB(path string, w, h int, px []uint8, level png.CompressionLevel) error {
if len(px) != w*h*3 {
return fmt.Errorf("%s: %d bytes for a %dx%d RGB image", path, len(px), w, h)
}
img := image.NewNRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
row := img.Pix[y*img.Stride:]
src := px[y*w*3:]
for x := 0; x < w; x++ {
row[x*4], row[x*4+1], row[x*4+2], row[x*4+3] = src[x*3], src[x*3+1], src[x*3+2], 255
}
}
return encode(path, img, level)
}
func encode(path string, img image.Image, level png.CompressionLevel) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
@@ -163,18 +206,60 @@ func isqrt(n int) int {
return r
}
// WriteHillshade writes an 8-bit relief shade of a height field, at any scale.
//
// It is not WriteThumbnail with a bigger number, and the difference is the whole reason it exists.
// WriteThumbnail's shading term is a raw gradient over the cell size, which is fine on a 512-pixel picture of
// a whole map where the gradients are small, and saturates to pure black and white the moment it is used on
// real ground at two metres a cell. The result reads as flat-topped terraces with hard edges - a mountainside
// rendered as a staircase - and it is convincing enough to be mistaken for a defect in the terrain. It was.
//
// This is the standard DEM hillshade instead: the surface normal against a light from the north-west at 45
// degrees, which is bounded by construction and says the same thing at any cell size.
func WriteHillshade(path string, h *Field, size int, exaggeration float64) error {
sizeH := aspectH(h, size)
small := h
if size != h.W || sizeH != h.H {
small = h.Resample(size, sizeH)
}
exag := exaggeration
if exag <= 0 {
exag = 1
}
px := make([]uint8, size*sizeH)
Rows(sizeH, func(y0, y1 int) {
for y := y0; y < y1; y++ {
for x := 0; x < size; x++ {
gx := float64(small.AtClamped(x+1, y)-small.AtClamped(x-1, y)) * exag
gy := float64(small.AtClamped(x, y+1)-small.AtClamped(x, y-1)) * exag
slope := math.Atan(math.Hypot(gx, gy) / (2 * small.CellM))
aspect := math.Atan2(gy, -gx)
lum := math.Cos(slope)*math.Cos(math.Pi/4) +
math.Sin(slope)*math.Sin(math.Pi/4)*math.Cos(3*math.Pi/4-aspect)
v := 0.25 + 0.75*math.Max(0, lum)
if v > 1 {
v = 1
}
px[y*size+x] = uint8(v * 255)
}
}
})
return WriteGray8(path, size, sizeH, px, png.BestSpeed)
}
// WriteThumbnail writes a small 8-bit preview of a height field, hillshaded so the drainage is actually
// visible: a flat grey ramp hides exactly the thing this generator exists to produce.
func WriteThumbnail(path string, h *Field, size int) error {
small := h.Resample(size, size)
sizeH := aspectH(h, size)
small := h.Resample(size, sizeH)
lo, hi := small.MinMax()
span := float64(hi - lo)
if span < 1e-6 {
span = 1
}
// Light from the north-west at 45 degrees, the convention every DEM hillshade uses.
px := make([]uint8, size*size)
for y := 0; y < size; y++ {
px := make([]uint8, size*sizeH)
for y := 0; y < sizeH; y++ {
for x := 0; x < size; x++ {
gx := float64(small.AtClamped(x+1, y) - small.AtClamped(x-1, y))
gy := float64(small.AtClamped(x, y+1) - small.AtClamped(x, y-1))
@@ -189,7 +274,7 @@ func WriteThumbnail(path string, h *Field, size int) error {
px[y*size+x] = uint8(lum * 255)
}
}
return WriteGray8(path, size, size, px, png.BestSpeed)
return WriteGray8(path, size, sizeH, px, png.BestSpeed)
}
var _ io.Writer = (*bufio.Writer)(nil)
+78 -71
View File
@@ -24,65 +24,39 @@ type PreviewOptions struct {
// Sea marks cells below sea level. Optional.
Sea []bool
SeaLevelM float64
// Snow marks land that is permanently under ice. Optional, and it exists because the hypsometric ramp
// tops out at snow by *elevation*: a polar cap fifty metres above the water therefore comes out the same
// green as a meadow, and an ice sheet that reads as a meadow is a map lying about the one thing it is
// for. No height is touched; only the colour.
Snow []bool
// RiverKm2 is the drainage area at which a channel starts being drawn.
RiverKm2 float64
Size int
// Size is the output width in pixels. The height follows the field's own aspect, so a 2:1 planet comes
// out 2:1 rather than squashed into a square; on the square canvas the two are the same number and
// nothing changes.
Size int
// Crop is a sub-rectangle in map coordinates (x0, y0, x1, y1 in 0..1), rendered at full resolution.
// A whole continent at 1500 px puts ten kilometres into a hundred pixels, which is enough to see that
// there is drainage and not nearly enough to see whether it is the right *kind* of drainage. Judging
// hill country against real hill country needs a crop.
Crop [4]float64
// Palette is how the picture is drawn: the ramp, the water, the rivers, the ice and the light. Nil is
// the generator's own, which is what every caller wanted before this was a file.
Palette *Palette
// Hillshade exaggerates the vertical before shading. Lowland relief is a few tens of metres over
// kilometres and disappears at true scale, which is the same reason every printed relief map lies.
Exaggeration float64
}
// rgb is a colour in 0..255 kept as float64 so the hillshade can multiply it before it is clamped.
type rgb = [3]float64
type stop struct {
t float64
c rgb
}
var (
// A hypsometric ramp: salt-marsh green at sea level through farmland and rock to snow. Stops are chosen
// so the lowland does not read as one flat colour, which is where most of the map is.
landStops = []stop{
{0.00, rgb{72, 106, 68}},
{0.08, rgb{104, 132, 74}},
{0.20, rgb{142, 152, 88}},
{0.38, rgb{164, 148, 104}},
{0.58, rgb{150, 128, 106}},
{0.75, rgb{138, 130, 128}},
{0.88, rgb{176, 174, 174}},
{1.00, rgb{246, 246, 250}},
}
seaShallow = rgb{56, 104, 136}
seaDeep = rgb{18, 40, 72}
riverTint = rgb{70, 132, 180}
)
func ramp(t float64) rgb {
if t <= 0 {
return landStops[0].c
}
for i := 1; i < len(landStops); i++ {
if t <= landStops[i].t {
a, b := landStops[i-1], landStops[i]
u := (t - a.t) / (b.t - a.t)
return rgb{
a.c[0] + (b.c[0]-a.c[0])*u,
a.c[1] + (b.c[1]-a.c[1])*u,
a.c[2] + (b.c[2]-a.c[2])*u,
}
}
}
return landStops[len(landStops)-1].c
}
// rgb is the palette's colour type under the name the drawing code uses.
type rgb = RGB
// WritePreview renders the field at opt.Size and writes an RGB PNG.
func WritePreview(path string, h *Field, opt PreviewOptions) error {
//
// It returns the height the hypsometric ramp topped out at, in metres, which a caller is expected to print.
// The ramp is relative by default and a relative picture is only honest when the reader is told so: without
// that line, a 47 m plain drawn with snow on its hills is indistinguishable from an alpine one.
func WritePreview(path string, h *Field, opt PreviewOptions) (topM float64, err error) {
size := opt.Size
if size <= 0 {
size = 1024
@@ -103,7 +77,8 @@ func WritePreview(path string, h *Field, opt PreviewOptions) error {
size = h.W
}
}
small := h.Resample(size, size)
sizeH := aspectH(h, size)
small := h.Resample(size, sizeH)
exag := opt.Exaggeration
if exag <= 0 {
exag = 1
@@ -116,7 +91,12 @@ func WritePreview(path string, h *Field, opt PreviewOptions) error {
// uniform green with a white dot on it — which says far more about one pixel than about the terrain. The
// percentile lets the tint span the distribution that is actually there; the few cells above it clamp to
// snow, which is what they should look like anyway.
sea := resampleMask(opt.Sea, h.W, h.H, size)
pal := opt.Palette
if pal == nil {
pal = DefaultPalette()
}
sea := resampleMask(opt.Sea, h.W, h.H, size, sizeH)
snow := resampleMask(opt.Snow, h.W, h.H, size, sizeH)
landVals := make([]float64, 0, len(small.Data))
for i, v := range small.Data {
if sea != nil && sea[i] {
@@ -125,9 +105,11 @@ func WritePreview(path string, h *Field, opt PreviewOptions) error {
landVals = append(landVals, float64(v))
}
landMax := 1.0
if len(landVals) > 0 {
if pal.LandTopM > 0 {
landMax = pal.LandTopM
} else if len(landVals) > 0 {
sort.Float64s(landVals)
landMax = landVals[int(0.995*float64(len(landVals)-1))]
landMax = landVals[int(pal.LandTopPercentile/100*float64(len(landVals)-1))]
}
if landMax <= 0 {
landMax = 1
@@ -142,11 +124,11 @@ func WritePreview(path string, h *Field, opt PreviewOptions) error {
var flow *Field
riverA := opt.RiverKm2 * 1e6
if opt.Flow != nil && riverA > 0 {
flow = opt.Flow.Resample(size, size)
flow = opt.Flow.Resample(size, sizeH)
}
img := image.NewRGBA(image.Rect(0, 0, size, size))
for y := 0; y < size; y++ {
img := image.NewRGBA(image.Rect(0, 0, size, sizeH))
for y := 0; y < sizeH; y++ {
for x := 0; x < size; x++ {
i := y*size + x
elev := float64(small.Data[i])
@@ -158,21 +140,30 @@ func WritePreview(path string, h *Field, opt PreviewOptions) error {
d = math.Min(1, (opt.SeaLevelM-elev)/(opt.SeaLevelM-seaMin))
}
c = rgb{
seaShallow[0] + (seaDeep[0]-seaShallow[0])*d,
seaShallow[1] + (seaDeep[1]-seaShallow[1])*d,
seaShallow[2] + (seaDeep[2]-seaShallow[2])*d,
pal.SeaShallow[0] + (pal.SeaDeep[0]-pal.SeaShallow[0])*d,
pal.SeaShallow[1] + (pal.SeaDeep[1]-pal.SeaShallow[1])*d,
pal.SeaShallow[2] + (pal.SeaDeep[2]-pal.SeaShallow[2])*d,
}
} else {
c = ramp(math.Min(1, math.Max(0, elev)/landMax))
c = pal.ramp(math.Min(1, math.Max(0, elev)/landMax))
if snow != nil && snow[i] {
// Ice, whatever height it stands at. It still takes the hillshade below rather than
// being stamped flat, so a dome and the valleys cut into it still read.
c = pal.Ice
}
// Hillshade from the north-west at 45 degrees, the DEM convention. Applied to land only;
// shading the sea floor would draw attention to bathymetry nobody will ever see.
gx := float64(small.AtClamped(x+1, y)-small.AtClamped(x-1, y)) * exag
gy := float64(small.AtClamped(x, y+1)-small.AtClamped(x, y-1)) * exag
slope := math.Atan(math.Hypot(gx, gy) / (2 * small.CellM))
aspect := math.Atan2(gy, -gx)
lum := math.Cos(slope)*math.Cos(math.Pi/4) +
math.Sin(slope)*math.Sin(math.Pi/4)*math.Cos(3*math.Pi/4-aspect)
lum = 0.45 + 0.75*math.Max(0, lum)
alt := pal.SunAltitudeDeg * math.Pi / 180
// Azimuth is clockwise from north; the shading wants the direction the light comes *from*
// measured the way Atan2 returns it, which is this quarter turn away.
az := (90 - pal.SunAzimuthDeg) * math.Pi / 180
lum := math.Cos(slope)*math.Sin(alt) +
math.Sin(slope)*math.Cos(alt)*math.Cos(az-aspect)
lum = pal.Ambient + pal.Gain*math.Max(0, lum)
for k := range c {
c[k] *= lum
}
@@ -185,7 +176,7 @@ func WritePreview(path string, h *Field, opt PreviewOptions) error {
w := math.Min(1, math.Log10(a/riverA)/2.2)
blend := 0.45 + 0.55*w
for k := range c {
c[k] = c[k]*(1-blend) + riverTint[k]*blend
c[k] = c[k]*(1-blend) + pal.River[k]*blend
}
}
}
@@ -195,38 +186,54 @@ func WritePreview(path string, h *Field, opt PreviewOptions) error {
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
return landMax, err
}
f, err := os.Create(path)
if err != nil {
return err
return landMax, err
}
defer f.Close()
bw := bufio.NewWriterSize(f, 1<<20)
enc := png.Encoder{CompressionLevel: png.DefaultCompression}
if err := enc.Encode(bw, img); err != nil {
return err
return landMax, err
}
return bw.Flush()
return landMax, bw.Flush()
}
// resampleMask takes a boolean mask down to the preview size by nearest neighbour; a mask has no meaningful
// average.
func resampleMask(mask []bool, w, h, size int) []bool {
func resampleMask(mask []bool, w, h, sw, sh int) []bool {
if mask == nil {
return nil
}
out := make([]bool, size*size)
for y := 0; y < size; y++ {
sy := y * (h - 1) / (size - 1)
for x := 0; x < size; x++ {
sx := x * (w - 1) / (size - 1)
out[y*size+x] = mask[sy*w+sx]
out := make([]bool, sw*sh)
for y := 0; y < sh; y++ {
sy := 0
if sh > 1 {
sy = y * (h - 1) / (sh - 1)
}
for x := 0; x < sw; x++ {
sx := 0
if sw > 1 {
sx = x * (w - 1) / (sw - 1)
}
out[y*sw+x] = mask[sy*w+sx]
}
}
return out
}
// aspectH is the output height that keeps a field's shape. Every writer in this package uses it, so a
// rectangular world is never silently squashed into a square image.
func aspectH(f *Field, w int) int {
h := int(float64(w)*float64(f.H)/float64(f.W) + 0.5)
if h < 1 {
h = 1
}
return h
}
func clamp8(v float64) uint8 {
if v <= 0 {
return 0
@@ -0,0 +1,291 @@
package field
import (
"image"
"image/png"
"os"
"path/filepath"
"strings"
"testing"
)
// The hypsometric ramp tops out at snow by *elevation*, so an ice cap fifty metres above the water came out
// the same green as a meadow - a map lying about the one thing it is for. The snow mask fixes the colour and
// nothing else, and it must still take the hillshade rather than being stamped flat, or a dome and the
// valleys cut into it read as a white cut-out.
func TestSnowRendersAsIceAndStillTakesTheHillshade(t *testing.T) {
// The ramp's top is the 99.5th percentile of *land* elevation, so the ice cap only reads as meadow when
// there is real high ground on the map to set that percentile. A cap alone on an empty map is the highest
// thing there is and the ramp would call it snow anyway - which is how the first version of this test
// managed to pass for the wrong reason.
const w, h = 96, 64
f := New(w, h, 8)
sea := make([]bool, w*h)
snow := make([]bool, w*h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*w + x
d2 := float64((x-24)*(x-24) + (y-32)*(y-32))
v := 40 - d2/60 // a low ice dome on the left
if x > 56 {
// and a 700 m range on the right, which is what sets the top of the ramp
v = 700 - float64((x-76)*(x-76)+(y-32)*(y-32))*0.7
}
if v < 0 {
v = 0
sea[i] = true
}
f.Data[i] = float32(v)
snow[i] = !sea[i] && x <= 56
}
}
dir := t.TempDir()
plain := filepath.Join(dir, "plain.png")
iced := filepath.Join(dir, "iced.png")
opt := PreviewOptions{Sea: sea, SeaLevelM: 0, Size: w}
if _, err := WritePreview(plain, f, opt); err != nil {
t.Fatal(err)
}
opt.Snow = snow
if _, err := WritePreview(iced, f, opt); err != nil {
t.Fatal(err)
}
a, b := readRGBA(t, plain), readRGBA(t, iced)
cx, cy := 24, 32 // the ice dome's summit
pr, pg, pb, _ := a.At(cx, cy).RGBA()
sr, sg, sb, _ := b.At(cx, cy).RGBA()
t.Logf("land at the summit: plain rgb(%d,%d,%d), iced rgb(%d,%d,%d)",
pr>>8, pg>>8, pb>>8, sr>>8, sg>>8, sb>>8)
// Ice is much lighter than the ramp's low-ground green, and it is not green: blue is at least green.
if sr <= pr || sb <= pb {
t.Errorf("the iced summit is not lighter than the plain one")
}
if sb < sg {
t.Errorf("the ice reads green (b %d < g %d); it should be neutral to slightly blue", sb>>8, sg>>8)
}
// It still takes the hillshade: the lit and shaded flanks of the dome must differ.
lr, _, _, _ := b.At(cx-12, cy-12).RGBA() // north-west flank, towards the light
dr, _, _, _ := b.At(cx+12, cy+12).RGBA() // south-east flank, away from it
t.Logf("ice flanks: lit %d, shaded %d", lr>>8, dr>>8)
if lr <= dr {
t.Errorf("the ice is flat: lit flank %d against shaded %d, so it was stamped rather than shaded",
lr>>8, dr>>8)
}
// And the water is untouched. The probe has to be a cell that really is sea - the first version used the
// corner, which on this map is land, so it was comparing two ice pixels and calling the difference a bug.
sx, sy := -1, -1
for i, isSea := range sea {
if isSea {
sx, sy = i%w, i/w
break
}
}
if sx < 0 {
t.Fatal("the test terrain has no sea in it")
}
wr, wg, wb, _ := a.At(sx, sy).RGBA()
xr, xg, xb, _ := b.At(sx, sy).RGBA()
if wr != xr || wg != xg || wb != xb {
t.Errorf("the sea at %d,%d changed: rgb(%d,%d,%d) became rgb(%d,%d,%d); the mask should only touch land",
sx, sy, wr>>8, wg>>8, wb>>8, xr>>8, xg>>8, xb>>8)
}
}
func readRGBA(t *testing.T, path string) image.Image {
t.Helper()
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
img, err := png.Decode(f)
if err != nil {
t.Fatal(err)
}
return img
}
// A palette is a file somebody edits, so it has to survive the trip to disk and back unchanged - and it has
// to keep the comments the writer puts in, because a loader that refuses unknown keys would otherwise choke
// on its own output.
func TestPaletteRoundTripsThroughDiskWithItsComments(t *testing.T) {
path := filepath.Join(t.TempDir(), "p.json")
want := DefaultPalette()
if err := want.Write(path); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), "_comment") {
t.Error("the written palette carries no commentary")
}
got, err := LoadPalette(path)
if err != nil {
t.Fatalf("reading back what Write produced: %v", err)
}
if len(got.LandStops) != len(want.LandStops) {
t.Fatalf("%d stops, want %d", len(got.LandStops), len(want.LandStops))
}
for i := range want.LandStops {
if got.LandStops[i] != want.LandStops[i] {
t.Errorf("stop %d: %v, want %v", i, got.LandStops[i], want.LandStops[i])
}
}
if got.Ice != want.Ice || got.SeaDeep != want.SeaDeep || got.River != want.River {
t.Errorf("colours differ: %v %v %v", got.Ice, got.SeaDeep, got.River)
}
if got.SunAzimuthDeg != want.SunAzimuthDeg || got.Ambient != want.Ambient {
t.Errorf("light differs: %v %v", got.SunAzimuthDeg, got.Ambient)
}
}
// A palette fills what it leaves out from the default, so a two-line file is a valid one.
func TestAPartialPaletteKeepsTheDefaults(t *testing.T) {
path := filepath.Join(t.TempDir(), "p.json")
if err := os.WriteFile(path, []byte(`{"_why": "just the sea", "sea_deep": [1, 2, 3]}`), 0o644); err != nil {
t.Fatal(err)
}
got, err := LoadPalette(path)
if err != nil {
t.Fatal(err)
}
if got.SeaDeep != (RGB{1, 2, 3}) {
t.Errorf("sea_deep = %v, want the file's", got.SeaDeep)
}
if got.Ice != DefaultPalette().Ice {
t.Errorf("ice = %v, want the default", got.Ice)
}
}
// And a misspelt key is an error rather than a setting that silently does nothing.
func TestAMisspeltPaletteKeyIsRefused(t *testing.T) {
path := filepath.Join(t.TempDir(), "p.json")
if err := os.WriteFile(path, []byte(`{"sea_dep": [1,2,3]}`), 0o644); err != nil {
t.Fatal(err)
}
if _, err := LoadPalette(path); err == nil {
t.Fatal("accepted a misspelt key")
}
}
// The palette actually reaches the picture: swapping the sea colour changes the sea.
func TestThePaletteIsWhatGetsDrawn(t *testing.T) {
const w, h = 32, 32
f := New(w, h, 8)
sea := make([]bool, w*h)
for i := range sea {
sea[i] = i%w < w/2
if !sea[i] {
f.Data[i] = 50
}
}
dir := t.TempDir()
a := filepath.Join(dir, "a.png")
b := filepath.Join(dir, "b.png")
if _, err := WritePreview(a, f, PreviewOptions{Sea: sea, Size: w}); err != nil {
t.Fatal(err)
}
pal := DefaultPalette()
pal.SeaShallow, pal.SeaDeep = RGB{255, 0, 0}, RGB{255, 0, 0}
if _, err := WritePreview(b, f, PreviewOptions{Sea: sea, Size: w, Palette: pal}); err != nil {
t.Fatal(err)
}
ar, ag, ab, _ := readRGBA(t, a).At(2, 2).RGBA()
br, bg, bb, _ := readRGBA(t, b).At(2, 2).RGBA()
if br>>8 != 255 || bg>>8 != 0 || bb>>8 != 0 {
t.Errorf("the sea is rgb(%d,%d,%d), want the palette's red", br>>8, bg>>8, bb>>8)
}
if ar == br && ag == bg && ab == bb {
t.Error("the palette changed nothing")
}
}
// The ramp is relative by default and that is a picture which lies about scale: a lowland continent 47 m high
// gets the same rock and snow a 2800 m range would, because the top of the ramp is a percentile of whatever
// world it is drawing. land_top_m is the way out, and the point of the test is that the two differ.
func TestAnAbsoluteRampDrawsALowContinentAsLowGround(t *testing.T) {
const w = 96
f := New(w, w, 10)
sea := make([]bool, w*w)
for y := 0; y < w; y++ {
for x := 0; x < w; x++ {
i := y*w + x
dx, dy := float64(x-w/2)/float64(w/2), float64(y-w/2)/float64(w/2)
d := dx*dx + dy*dy
if d > 0.8 {
sea[i] = true
f.Data[i] = -50
continue
}
// A 40 m hill on a continent, which is a plain by any reading.
f.Data[i] = float32(40 * (1 - d/0.8))
}
}
dir := t.TempDir()
rel := filepath.Join(dir, "relative.png")
abs := filepath.Join(dir, "absolute.png")
top, err := WritePreview(rel, f, PreviewOptions{Sea: sea, Size: w})
if err != nil {
t.Fatal(err)
}
if top > 45 {
t.Fatalf("the relative ramp should top out near the highest land, about 40 m; got %.1f", top)
}
pal := DefaultPalette()
pal.LandTopM = 2000
top, err = WritePreview(abs, f, PreviewOptions{Sea: sea, Size: w, Palette: pal})
if err != nil {
t.Fatal(err)
}
if top != 2000 {
t.Fatalf("an absolute ramp tops out where it is told: got %.1f, want 2000", top)
}
// And the pictures differ: the summit is high on the ramp in one and at the bottom of it in the other.
relTop := brightestLand(t, rel, sea, w)
absTop := brightestLand(t, abs, sea, w)
if relTop <= absTop {
t.Errorf("the relative picture should carry the summit far higher up the ramp: %d vs %d",
relTop, absTop)
}
}
// brightestLand is the highest luma any land pixel reached, which is how far up the hypsometric ramp the
// summit got: the ramp ends in near-white snow and starts in dark green.
func brightestLand(t *testing.T, path string, sea []bool, w int) int {
t.Helper()
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
img, err := png.Decode(f)
if err != nil {
t.Fatal(err)
}
best := 0
b := img.Bounds()
for y := 0; y < b.Dy(); y++ {
for x := 0; x < b.Dx(); x++ {
if sea[y*w+x] {
continue
}
r, g, bl, _ := img.At(b.Min.X+x, b.Min.Y+y).RGBA()
if v := int(r+g+bl) >> 8; v > best {
best = v
}
}
}
return best
}
+236
View File
@@ -0,0 +1,236 @@
package field
// SlidingMax is the maximum over a square window, separable and O(1) a cell whatever the radius.
//
// The naive form is a loop over the window, which is what internal/stats' localRelief used to do and is fine
// on the 500 m window it uses at 8 m cells - until the map is a planet. 28 million land cells times a 63-cell
// radius is 1.1e11 comparisons, which is not a slow diagnostic, it is one nobody will ever see the end of.
// The monotonic deque is the standard answer: each index enters and leaves once, so the row pass is linear in
// the row however wide the window.
//
// wrapX makes the row pass periodic, which is what a cylinder needs; the column pass always clamps, because
// the top and bottom of the map are the poles and not each other.
func SlidingMax(f *Field, radius int, wrapX bool) *Field {
return sliding(f, radius, wrapX, func(inDeque, arriving float32) bool { return inDeque <= arriving })
}
// SlidingMin is the same window, the other way up. The pair is what local relief is made of.
func SlidingMin(f *Field, radius int, wrapX bool) *Field {
return sliding(f, radius, wrapX, func(inDeque, arriving float32) bool { return inDeque >= arriving })
}
// LocalRelief is max minus min over a square window: the standard field measure of how rugged a place is, and
// the one thing slope cannot tell you. A 5 m hummock and a 500 m mountainside both stand at 30 degrees.
//
// Two sliding passes and a subtract, so it costs the same as one of them twice and nothing per radius. It
// holds two fields at once at the peak, which at planet scale is 600 MB - worth saying, because the naive
// version held none and could not finish.
func LocalRelief(f *Field, radius int, wrapX bool) *Field {
hi := SlidingMax(f, radius, wrapX)
lo := SlidingMin(f, radius, wrapX)
for i := range hi.Data {
hi.Data[i] -= lo.Data[i]
}
return hi
}
// sliding is the shared separable pass. keep reports whether the value already at the back of the deque can
// be dropped when a new one arrives, which is the only thing that differs between the maximum and the
// minimum: the deque holds indices whose values are monotone, so its front is always the answer for the live
// window and anything the arriving value dominates can never be the answer again.
func sliding(f *Field, radius int, wrapX bool, keep func(inDeque, arriving float32) bool) *Field {
if radius < 1 {
return f.Clone()
}
w, h := f.W, f.H
row := New(w, h, f.CellM)
buf := make([]float32, 0, w+2*radius)
idx := make([]int, 0, w+2*radius)
for y := 0; y < h; y++ {
// The row, extended by the radius at each end so the deque never has to special-case an edge.
buf = buf[:0]
for x := -radius; x < w+radius; x++ {
sx := x
if wrapX {
sx = ((sx % w) + w) % w
} else if sx < 0 {
sx = 0
} else if sx >= w {
sx = w - 1
}
buf = append(buf, f.Data[y*w+sx])
}
slide(buf, idx[:0], 2*radius+1, keep, func(i int, v float32) {
if i < w {
row.Data[y*w+i] = v
}
})
}
out := New(w, h, f.CellM)
col := make([]float32, 0, h+2*radius)
for x := 0; x < w; x++ {
col = col[:0]
for y := -radius; y < h+radius; y++ {
sy := y
if sy < 0 {
sy = 0
} else if sy >= h {
sy = h - 1
}
col = append(col, row.Data[sy*w+x])
}
slide(col, idx[:0], 2*radius+1, keep, func(i int, v float32) {
if i < h {
out.Data[i*w+x] = v
}
})
}
return out
}
// slide walks a padded line with a monotonic deque and reports the window's answer ending at each output
// position.
func slide(line []float32, dq []int, window int, keep func(inDeque, arriving float32) bool,
emit func(i int, v float32)) {
dq = dq[:0]
for i, v := range line {
for len(dq) > 0 && keep(line[dq[len(dq)-1]], v) {
dq = dq[:len(dq)-1]
}
dq = append(dq, i)
if dq[0] <= i-window {
dq = dq[1:]
}
if out := i - window + 1; out >= 0 {
emit(out, line[dq[0]])
}
}
}
// BoxSmooth blurs a field in place with `passes` of a separable box blur of the given radius, clamping at the
// edges. Two passes are near enough to a Gaussian for anything here and cost four linear sweeps.
//
// Deterministic by construction: fixed traversal order, running sums, no goroutines. It lives here rather than
// in the pass that first wanted it because two now do - the coastal detail pass smooths the signed distance to
// the shoreline, and the tile bake smooths the interpolated sea floor.
func BoxSmooth(data []float32, w, h, radius, passes int) {
if radius < 1 || passes < 1 || len(data) < w*h {
return
}
tmp := make([]float32, len(data))
for p := 0; p < passes; p++ {
boxRows(data, tmp, w, h, radius)
boxCols(tmp, data, w, h, radius)
}
}
func boxRows(src, dst []float32, w, h, radius int) {
n := float32(2*radius + 1)
for y := 0; y < h; y++ {
row := y * w
var sum float32
for k := -radius; k <= radius; k++ {
sum += src[row+clampIdx(k, w)]
}
for x := 0; x < w; x++ {
dst[row+x] = sum / n
sum += src[row+clampIdx(x+radius+1, w)] - src[row+clampIdx(x-radius, w)]
}
}
}
func boxCols(src, dst []float32, w, h, radius int) {
n := float32(2*radius + 1)
for x := 0; x < w; x++ {
var sum float32
for k := -radius; k <= radius; k++ {
sum += src[clampIdx(k, h)*w+x]
}
for y := 0; y < h; y++ {
dst[y*w+x] = sum / n
sum += src[clampIdx(y+radius+1, h)*w+x] - src[clampIdx(y-radius, h)*w+x]
}
}
}
func clampIdx(i, n int) int {
if i < 0 {
return 0
}
if i >= n {
return n - 1
}
return i
}
// BoxSmoothMasked is BoxSmooth restricted to the cells the mask selects: a cell outside it is neither read
// nor written, so the blur never averages across the boundary.
//
// That distinction is the whole reason it exists. The coastal detail pass damps the metre-scale texture near
// the shore, and an unmasked blur there does not damp texture, it bridges the waterline: measured on a
// fixture with forty metres of water against the land, the plain blur lifted the sea floor by twenty metres.
// The step at a shoreline is a landform, not roughness, and a filter that cannot tell them apart is the wrong
// filter.
//
// Separable and weighted: the row pass carries a running sum of values and of weights, the column pass sums
// those, and the quotient is the mean over the masked cells in the window. Deterministic, like BoxSmooth.
func BoxSmoothMasked(data []float32, mask []bool, w, h, radius, passes int) {
if radius < 1 || passes < 1 || len(data) < w*h || len(mask) < w*h {
return
}
n := w * h
val := make([]float32, n)
wgt := make([]float32, n)
tv := make([]float32, n)
tw := make([]float32, n)
for p := 0; p < passes; p++ {
for i := 0; i < n; i++ {
if mask[i] {
val[i], wgt[i] = data[i], 1
} else {
val[i], wgt[i] = 0, 0
}
}
boxRowsSum(val, tv, w, h, radius)
boxRowsSum(wgt, tw, w, h, radius)
boxColsSum(tv, val, w, h, radius)
boxColsSum(tw, wgt, w, h, radius)
for i := 0; i < n; i++ {
if mask[i] && wgt[i] > 0 {
data[i] = val[i] / wgt[i]
}
}
}
}
// boxRowsSum and boxColsSum are the running sums BoxSmooth uses, without the division: a masked blur needs
// the weight sum as well as the value sum, and dividing in the middle would be dividing by the wrong thing.
func boxRowsSum(src, dst []float32, w, h, radius int) {
for y := 0; y < h; y++ {
row := y * w
var sum float32
for k := -radius; k <= radius; k++ {
sum += src[row+clampIdx(k, w)]
}
for x := 0; x < w; x++ {
dst[row+x] = sum
sum += src[row+clampIdx(x+radius+1, w)] - src[row+clampIdx(x-radius, w)]
}
}
}
func boxColsSum(src, dst []float32, w, h, radius int) {
for x := 0; x < w; x++ {
var sum float32
for k := -radius; k <= radius; k++ {
sum += src[clampIdx(k, h)*w+x]
}
for y := 0; y < h; y++ {
dst[y*w+x] = sum
sum += src[clampIdx(y+radius+1, h)*w+x] - src[clampIdx(y-radius, h)*w+x]
}
}
}
@@ -0,0 +1,91 @@
package field
import (
"math"
"math/rand/v2"
"testing"
)
// The deque has to give the same answer as the loop it replaces, on every cell, including the edges and the
// seam. It is O(1) a cell against O(radius squared), which is the difference between a diagnostic and a hang
// at planet scale - and an optimisation that is only nearly right is worse than the version that was slow.
func TestSlidingWindowsMatchTheNaiveLoop(t *testing.T) {
r := rand.New(rand.NewPCG(7, 9))
const w, h = 61, 37
f := New(w, h, 8)
for i := range f.Data {
f.Data[i] = float32(r.NormFloat64() * 50)
}
naive := func(cx, cy, radius int, wrapX, wantMax bool) float32 {
best := float32(math.Inf(1))
if wantMax {
best = float32(math.Inf(-1))
}
for y := cy - radius; y <= cy+radius; y++ {
sy := y
if sy < 0 {
sy = 0
} else if sy >= h {
sy = h - 1
}
for x := cx - radius; x <= cx+radius; x++ {
sx := x
if wrapX {
sx = ((sx % w) + w) % w
} else if sx < 0 {
sx = 0
} else if sx >= w {
sx = w - 1
}
v := f.Data[sy*w+sx]
if (wantMax && v > best) || (!wantMax && v < best) {
best = v
}
}
}
return best
}
for _, radius := range []int{1, 3, 8, 20} {
for _, wrapX := range []bool{false, true} {
hi := SlidingMax(f, radius, wrapX)
lo := SlidingMin(f, radius, wrapX)
rel := LocalRelief(f, radius, wrapX)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*w + x
if got, want := hi.Data[i], naive(x, y, radius, wrapX, true); got != want {
t.Fatalf("max r=%d wrap=%v at (%d,%d): %v want %v", radius, wrapX, x, y, got, want)
}
if got, want := lo.Data[i], naive(x, y, radius, wrapX, false); got != want {
t.Fatalf("min r=%d wrap=%v at (%d,%d): %v want %v", radius, wrapX, x, y, got, want)
}
if got := rel.Data[i]; got != hi.Data[i]-lo.Data[i] {
t.Fatalf("relief r=%d at (%d,%d): %v against %v", radius, x, y, got,
hi.Data[i]-lo.Data[i])
}
}
}
}
}
}
// A radius of zero is a no-op rather than an error, which is what a caller with a window smaller than one
// cell should get.
func TestASlidingWindowOfNothingIsTheFieldItself(t *testing.T) {
f := New(4, 3, 8)
for i := range f.Data {
f.Data[i] = float32(i)
}
for _, got := range []*Field{SlidingMax(f, 0, true), SlidingMin(f, 0, false)} {
for i := range f.Data {
if got.Data[i] != f.Data[i] {
t.Fatalf("radius 0 changed cell %d", i)
}
}
}
if rel := LocalRelief(f, 0, true); rel.Data[5] != 0 {
t.Errorf("relief over a single cell is zero, got %v", rel.Data[5])
}
}
+94
View File
@@ -0,0 +1,94 @@
package field
import "math"
// SmoothEdgePreserving relaxes a height field towards its neighbours with a weight that falls away as the
// step between them grows, so a channel wall or a ridge crest survives a pass that takes a grid-cut facet
// off. It is a port of the bilateral smooth in the World Orogen browser generator, which has one for exactly
// this reason - to blend the artefacts its own routing leaves without rounding the landforms off with them.
//
// It is a filter and not a process. It conserves nothing, it has no time in it, and running it inside the
// solve loop would act as an uncontrolled extra diffusivity: that changes the steady-state slope, which is
// U/K, which is the one knob the whole generator's relief hangs on. It runs once, after the solve, and it is
// off by default. The point of having it is that the alternative - raising diffusion_m2_yr until the
// artefacts go - is measured to smooth away the landforms too, at about 0.05.
//
// Two deviations from the reference, both about units.
//
// The weight is 1/(1 + |dh|/(d*slopeRef)) rather than 1/(1 + |dh|*sensitivity). A sensitivity in 1/m is a
// height threshold, and a height threshold means one thing on a 32 m geology cell and something four times
// as aggressive on an 8 m one, so the same painted world would come out differently at two resolutions -
// which is the property Docs/Terrain-Next.md section 4.D says the generator lives or dies by. slopeRef is a
// rise over run and carries across. Ground steeper than it is preserved; ground gentler is relaxed.
//
// And a diagonal neighbour is sqrt(2) further away, so it carries both its own distance in the slope and an
// inverse-distance geometric weight - which is what a Gaussian would give those two offsets.
//
// The waterline is a wall, not a value. A neighbour that is not land is skipped entirely rather than clamped:
// clamping to sea level would pull the shore down, and clamping the other way would drown the beach the
// coastal pass built. Sea cells are never written.
//
// scratch must be at least len(h); it is used as the destination of each pass.
func SmoothEdgePreserving(h []float32, w, hgt int, cellM float64, land []bool, passes int, slopeRef float64, scratch []float32) {
if passes <= 0 || slopeRef <= 0 || cellM <= 0 {
return
}
if passes > smoothMaxPasses {
passes = smoothMaxPasses
}
tmp := scratch[:len(h)]
// dh/(d*slopeRef) per face, folded into one reciprocal each.
invCard := float32(1 / (cellM * slopeRef))
invDiag := float32(1 / (cellM * math.Sqrt2 * slopeRef))
const geomDiag = float32(1 / math.Sqrt2)
for p := 0; p < passes; p++ {
src := h
Rows(hgt, func(y0, y1 int) {
for y := y0; y < y1; y++ {
for x := 0; x < w; x++ {
i := y*w + x
if !land[i] {
tmp[i] = src[i]
continue
}
c := src[i]
var sumW, sumH float32
face := func(nx, ny int, inv, geom float32) {
if nx < 0 || ny < 0 || nx >= w || ny >= hgt {
return
}
ni := ny*w + nx
if !land[ni] {
return
}
n := src[ni]
d := n - c
if d < 0 {
d = -d
}
wk := geom / (1 + d*inv)
sumW += wk
sumH += wk * n
}
face(x-1, y, invCard, 1)
face(x+1, y, invCard, 1)
face(x, y-1, invCard, 1)
face(x, y+1, invCard, 1)
face(x-1, y-1, invDiag, geomDiag)
face(x+1, y-1, invDiag, geomDiag)
face(x-1, y+1, invDiag, geomDiag)
face(x+1, y+1, invDiag, geomDiag)
tmp[i] = (c + sumH) / (1 + sumW)
}
}
})
copy(h, tmp)
}
}
// smoothMaxPasses is a hard ceiling, not a default. Past about three passes the edge weight has stopped
// protecting anything - every face inside a landform is gentler than slopeRef by then - and what is left is a
// box blur with extra steps.
const smoothMaxPasses = 4
+106
View File
@@ -0,0 +1,106 @@
package field
import (
"math"
"testing"
)
// The claim the smooth has to earn: it takes the ripple off and leaves the landform. Two surfaces in one
// grid - a plane carrying a small corrugation, and a cliff far steeper than slopeRef - and the pass has to
// treat them differently or it is a box blur with extra arithmetic.
func TestSmoothTakesTheRippleAndLeavesTheCliff(t *testing.T) {
const (
w, h = 128, 128
cellM = 8.0
slopeRef = 0.3
)
land := make([]bool, w*h)
for i := range land {
land[i] = true
}
hgt := make([]float32, w*h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
v := 100.0
if x >= w/2 {
v = 400.0 // a 300 m cliff at mid-grid: 37.5 rise over run, a hundred times slopeRef
}
// A 4 m corrugation at a four-cell wavelength, which is 0.125 rise over run - under slopeRef.
v += 2 * math.Sin(2*math.Pi*float64(y)/4)
hgt[y*w+x] = float32(v)
}
}
before := make([]float32, len(hgt))
copy(before, hgt)
SmoothEdgePreserving(hgt, w, h, cellM, land, 2, slopeRef, make([]float32, w*h))
// The ripple, measured well away from the cliff.
rip := func(f []float32, x int) float64 {
lo, hi := math.Inf(1), math.Inf(-1)
for y := 8; y < h-8; y++ {
v := float64(f[y*w+x])
lo, hi = math.Min(lo, v), math.Max(hi, v)
}
return hi - lo
}
ripBefore, ripAfter := rip(before, w/4), rip(hgt, w/4)
// The cliff, measured across the step on a row far from the edges.
step := func(f []float32) float64 {
y := h / 2
return float64(f[y*w+w/2] - f[y*w+w/2-1])
}
stepBefore, stepAfter := step(before), step(hgt)
t.Logf("ripple %.2f -> %.2f m (%.0f%% removed); cliff %.1f -> %.1f m (%.0f%% kept)",
ripBefore, ripAfter, 100*(1-ripAfter/ripBefore), stepBefore, stepAfter, 100*stepAfter/stepBefore)
if ripAfter > 0.5*ripBefore {
t.Errorf("the ripple is still %.0f%% of what it was; the pass is not smoothing", 100*ripAfter/ripBefore)
}
if stepAfter < 0.9*stepBefore {
t.Errorf("the cliff lost %.0f%% of its height; the edge weight is not preserving", 100*(1-stepAfter/stepBefore))
}
}
// The waterline is a wall. A sea cell is never written, and a land cell beside one is never pulled towards
// sea level - clamping either way would move the shore, and the coastal pass owns the shore.
func TestSmoothNeverReachesAcrossTheWaterline(t *testing.T) {
const (
w, h = 64, 64
cellM = 8.0
)
land := make([]bool, w*h)
hgt := make([]float32, w*h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*w + x
if x < w/2 {
hgt[i] = -40 // sea floor
} else {
land[i] = true
hgt[i] = 60 // a plateau meeting it at a hundred-metre cliff
}
}
}
before := make([]float32, len(hgt))
copy(before, hgt)
SmoothEdgePreserving(hgt, w, h, cellM, land, 4, 0.3, make([]float32, w*h))
for i := range hgt {
if !land[i] && hgt[i] != before[i] {
t.Fatalf("a sea cell moved %.4f m", hgt[i]-before[i])
}
}
// The first land column has three land neighbours and five sea ones. On a flat plateau it must not move
// at all: the sea neighbours contribute nothing, and the land ones are all at its own height.
worst := float32(0)
for y := 1; y < h-1; y++ {
if d := hgt[y*w+w/2] - before[y*w+w/2]; math.Abs(float64(d)) > float64(worst) {
worst = d
}
}
t.Logf("worst move on the shore column: %.6f m", worst)
if math.Abs(float64(worst)) > 1e-3 {
t.Errorf("the shore column moved %.4f m; the pass is reading across the waterline", worst)
}
}
@@ -22,6 +22,12 @@ type bucketPQ struct {
const bucketWidthM = 0.01
// reposeOrderBuckets is how far ClampToRepose scatters a cell from its own bucket, in buckets, either way.
// Sixteen buckets is sixteen centimetres: enough that a tie spreads over about thirty of them and the pop
// order stops tracking the raster, far below the metres the clamp's ordering could ever depend on. See
// pushJittered.
const reposeOrderBuckets = 16
func newBucketPQ(loM, hiM float64) *bucketPQ {
if hiM <= loM {
hiM = loM + 1
@@ -54,6 +60,34 @@ func (q *bucketPQ) push(elev float32, idx int32) {
q.count++
}
// pushJittered is push with the bucket chosen from the elevation plus j buckets, so cells at the same
// elevation land in different buckets instead of in one and pop in hash order rather than in reverse raster
// order.
//
// Only ClampToRepose wants this. The flood already scatters its own order through the epsilon it adds to the
// height, so its cells rarely share a bucket; the clamp reads the raw surface, where flat ground puts every
// cell in one bucket and the LIFO below then processes them bottom-right to top-left, every time, everywhere.
//
// Half a bucket is not enough - it splits a tie across two buckets and halves the correlation instead of
// removing it - so the caller scatters over reposeOrderBuckets, and that is safe for a reason worth writing
// down. The clamp's order can only matter between two cells whose heights differ by about the talus
// allowance, which is metres: a cell popped early is marked closed and never lowered again, but every cell
// popping after it stands within the jitter width of it, so its limit is the other cell's height plus the
// full allowance and cannot bind. Reordering cells that are centimetres apart therefore cannot break a
// constraint that only bites metres apart. The bound is talus*cell/2, which is 2.8 m at 35 degrees on an 8 m
// cell; the constant below is two orders of magnitude inside it.
func (q *bucketPQ) pushJittered(elev float32, idx int32, j float32) {
b := int((float64(elev)-q.lo)/q.width + float64(j))
if b < q.cur {
b = q.cur
}
if b >= len(q.buckets) {
b = len(q.buckets) - 1
}
q.buckets[b] = append(q.buckets[b], idx)
q.count++
}
// pop returns the lowest cell. The cursor only moves forward, so the total scan cost over a whole flood is
// the number of buckets, not the number of pops.
func (q *bucketPQ) pop() int32 {
+170
View File
@@ -0,0 +1,170 @@
package fluvial
import (
"math"
"sort"
"testing"
)
// The diagnosis on a surface with no history.
//
// A planar hillslope is the one case where the right answer is known in closed form: the specific catchment
// area - the upslope area per unit contour length - is the distance from the divide, and it is the same at
// every point along a contour. Nothing about a plane distinguishes one flow line from its neighbour, so a
// router that says otherwise is inventing the difference.
//
// D8 cannot say otherwise quietly. Every cell on the plane picks the same steepest neighbour, so the flow
// lines run exactly parallel and never converge: a cell either sits on a line and carries the whole tube, or
// sits off one and carries a single cell for ever. The ratio between them is the statistic below, and it is
// why the flanks of a bake come out combed - stream power reads A^m off the lie and cuts each line in.
//
// There is no erosion in here. One fill, one receiver pass, one stack, one accumulate, and whatever comes
// out belongs to the router and to nothing else.
// planarRamp is a plane tilted by aspectDeg from the x axis, with a whisper of noise to break exact ties.
// The aspect matters: at 0 or 45 degrees the plane is aligned with a D8 direction and the answer is
// degenerate in the other direction, so the test asks at 22.5, which is the worst case and the honest one.
func planarRamp(n int, cellM, slope, aspectDeg float64) []float32 {
t := aspectDeg * math.Pi / 180
cs, sn := math.Cos(t), math.Sin(t)
h := make([]float32, n*n)
for y := 0; y < n; y++ {
for x := 0; x < n; x++ {
d := (float64(x)*cs + float64(y)*sn) * cellM
// A millimetre of hash noise: enough that no two neighbours are bit-identical, far below
// anything the router could read as structure.
j := float64(hashXY(1, int32(x), int32(y), 99)) * 1e-3
h[y*n+x] = float32(4000 - d*slope + j)
}
}
return h
}
// concentration is max over median of the drainage area in a band of cells all the same distance from the
// divide. On a plane the true value is 1: every cell in the band drains the same strip above it.
func concentration(t *testing.T, area []float32, n int, cellM, aspectDeg, lo, hi float64) float64 {
th := aspectDeg * math.Pi / 180
cs, sn := math.Cos(th), math.Sin(th)
dmax := (float64(n-1)*cs + float64(n-1)*sn) * cellM
var band []float64
for y := 2; y < n-2; y++ {
for x := 2; x < n-2; x++ {
d := (float64(x)*cs + float64(y)*sn) * cellM
if d >= lo*dmax && d <= hi*dmax {
band = append(band, float64(area[y*n+x]))
}
}
}
if len(band) < 100 {
t.Fatalf("contour band has only %d cells", len(band))
}
sort.Float64s(band)
med := band[len(band)/2]
if med <= 0 {
t.Fatalf("median area in the band is %g", med)
}
return band[len(band)-1] / med
}
const (
flowN = 256
flowCellM = 10.0
flowSlope = 0.1
flowAspect = 22.5
)
func TestD8ConcentratesFlowOnAPlanarSlope(t *testing.T) {
h := planarRamp(flowN, flowCellM, flowSlope, flowAspect)
g := NewGrid(flowN, flowN, flowCellM, nil)
g.SetSeed(37125)
g.FillDepressions(h, 1e-3)
g.ComputeReceivers(h)
g.BuildStack()
g.Accumulate()
c := concentration(t, g.Area, flowN, flowCellM, flowAspect, 0.6, 0.7)
leaves := 0
for i := range g.Area {
if g.Area[i] <= float32(flowCellM*flowCellM)*1.001 {
leaves++
}
}
t.Logf("D8: concentration max/median = %.1f, leaf cells = %.1f%%",
c, 100*float64(leaves)/float64(flowN*flowN))
if c < 5 {
t.Errorf("D8 concentration is %.1f; this test exists because it is large, so either the router "+
"changed or the measurement is wrong", c)
}
}
func TestMFDDoesNotConcentrateFlowOnAPlanarSlope(t *testing.T) {
h := planarRamp(flowN, flowCellM, flowSlope, flowAspect)
g := NewGrid(flowN, flowN, flowCellM, nil)
g.SetSeed(37125)
g.FillDepressions(h, 1e-3)
g.ComputeReceivers(h)
g.AccumulateMFD(h, 1)
c := concentration(t, g.Area, flowN, flowCellM, flowAspect, 0.6, 0.7)
leaves := 0
for i := range g.Area {
if g.Area[i] <= float32(flowCellM*flowCellM)*1.001 {
leaves++
}
}
t.Logf("MFD: concentration max/median = %.2f, leaf cells = %.1f%%",
c, 100*float64(leaves)/float64(flowN*flowN))
if c > 2 {
t.Errorf("MFD concentration is %.2f on a plane, where the true answer is 1; the partition is not "+
"spreading flow across the contour", c)
}
}
// TestMFDConservesArea is the test the panic in AccumulateMFD cannot be: the walk releasing every cell says
// nothing about how much area arrived. Over a closed basin the total that reaches the outlets has to be the
// whole grid, because there is nowhere else for it to go.
func TestMFDConservesArea(t *testing.T) {
const n = 128
const cellM = 10.0
// A bowl, so every flow path ends at the one interior minimum rather than at the border.
h := make([]float32, n*n)
for y := 0; y < n; y++ {
for x := 0; x < n; x++ {
dx, dy := float64(x)-n/2, float64(y)-n/2
j := float64(hashXY(7, int32(x), int32(y), 99)) * 1e-3
h[y*n+x] = float32(100 + 0.02*(dx*dx+dy*dy) + j)
}
}
g := NewGrid(n, n, cellM, nil)
g.SetSeed(9342)
g.ComputeReceivers(h)
g.AccumulateMFD(h, 1)
// Every cell that sends nothing on is a sink: the bowl's floor and the fixed border. What rests in them
// is the whole grid's area.
var rest float64
for i := 0; i < n*n; i++ {
x, y := i%n, i/n
lower := false
for k := 0; k < 8; k++ {
nx, ny := x+dx8[k], y+dy8[k]
if nx < 0 || ny < 0 || nx >= n || ny >= n {
continue
}
if h[ny*n+nx] < h[i] {
lower = true
break
}
}
if !lower || g.fixed[i] {
rest += float64(g.Area[i])
}
}
want := float64(n*n) * cellM * cellM
if rel := math.Abs(rest-want) / want; rel > 1e-4 {
t.Errorf("area resting in sinks is %.0f m2, the grid is %.0f m2: %.2e relative, float32 is not enough",
rest, want, rel)
} else {
t.Logf("area conserved to %.2e relative in float32", rel)
}
}
+45 -4
View File
@@ -64,6 +64,11 @@ type Params struct {
CriticalSlope float64
SlopeCap float64 // where the flux stops stiffening, as a fraction of Sc
MaxHillslopeSub int // the sub-step budget that bound buys
// MFDExponent selects multiple-flow-direction drainage area over D8's single receiver, and is the
// exponent on the partition. 0 keeps the old Accumulate, which is what every bake before this ran and
// what the A/B comparison needs. See mfd.go for why one is the right default.
MFDExponent float64
}
// Grid holds the flow topology and the scratch it is built from. Allocated once and reused across every
@@ -85,7 +90,11 @@ type Grid struct {
Stack []int32 // every node after its receiver
Area []float32 // drainage area, m²
seed uint64 // the jitter's seed; see jitter.go and SetSeed
cancel <-chan struct{} // closed to abandon a run mid-solve; see SetCancel
seed uint64 // the jitter's seed; see jitter.go and SetSeed
originX int32 // where this grid sits on the planet; see SetFrame. Zero is "this grid is the world"
originY int32
planetW int32 // the cylinder's width, or 0 when there is no cylinder
donorOff []int32
donorList []int32
cursor []int32
@@ -93,6 +102,13 @@ type Grid struct {
pq *bucketPQ
fifo []int32
scratch []float32
// Multiple-flow accumulation. mfdPending is how many strictly higher neighbours a cell still owes before
// it may be released; a byte, because a cell has eight neighbours and cannot owe more. See mfd.go.
mfdPending []uint8
mfdQueue []int32
mfdMode mfdPow
mfdExp float64
}
// SetElevationRange sizes the flood's bucket queue. Called once, with the manifest's elevation range plus a
@@ -116,6 +132,7 @@ func NewGrid(w, h int, cellM float64, base []bool) *Grid {
Receiver: make([]int32, n), Length: make([]float32, n), Stack: make([]int32, 0, n),
Area: make([]float32, n), donorOff: make([]int32, n+1), donorList: make([]int32, n),
closed: make([]bool, n), fifo: make([]int32, 0, n), scratch: make([]float32, n),
mfdPending: make([]uint8, n),
}
g.fixed = make([]bool, n)
for i := range g.fixed {
@@ -178,7 +195,7 @@ func (g *Grid) FillDepressions(h []float32, epsilon float32) {
}
g.closed[ni] = true
if h[ni] <= celev {
h[ni] = celev + epsilon*(0.5+hash01(g.seed, ni))
h[ni] = celev + epsilon*(0.5+hashXY(g.seed, g.worldX(nx), g.worldY(ny), jitterFloodEpsilon))
g.fifo = append(g.fifo, ni)
} else {
g.pq.push(h[ni], ni)
@@ -239,7 +256,7 @@ func (g *Grid) ComputeReceivers(h []float32) {
}
// The tie-break, not a change of gradient: the comparison is jittered, the slope that
// is kept is not, so Length and the stream-power update see the true geometry.
sj := s * (1 + 1e-3*(hash01(g.seed, i*8+int32(k))-0.5))
sj := s * (1 + 1e-3*(hashXY(g.seed, g.worldX(x), g.worldY(y), int32(k)+jitterReceiverTie)-0.5))
if sj > bestJitter {
bestJitter, best, bestLen = sj, ni, l
}
@@ -298,6 +315,11 @@ func (g *Grid) BuildStack() {
}
}
// Scratch hands out the grid's spare float32 buffer, which is the width of the grid and is dead between
// steps. It is here so a pass that runs once after the solve - the edge-preserving smooth - does not allocate
// a second copy of the height field at planet scale just to have somewhere to write.
func (g *Grid) Scratch() []float32 { return g.scratch }
// scratchInt32 reuses the float32 scratch as int32 storage; same width, and it saves a 12 MB allocation per
// step at the geology grid.
func (g *Grid) scratchInt32() []int32 {
@@ -458,6 +480,14 @@ func clampAt(a []float32, w, h, x, y int) float32 {
return a[y*w+x]
}
// SetCancel gives the solve a way to be abandoned part way through.
//
// It is checked once a step rather than inside one, which is the right granularity: a step is milliseconds on
// a small region and a couple of seconds on a big one, so the longest a caller waits is one step, and nothing
// inside a step is safe to leave half done. The height field is left wherever the solve had got to, which is
// what a cancelled run means - it is not a checkpoint and nothing downstream should read it as one.
func (g *Grid) SetCancel(ch <-chan struct{}) { g.cancel = ch }
// Run is the whole solve. Progress is reported through log, which is what a five-minute budget needs to be
// steerable: a run that is going wrong should say so at step 500, not at the end.
func (g *Grid) Run(h []float32, uplift, k []float32, p Params, log func(step int, total int, elapsedPct float64)) {
@@ -466,12 +496,23 @@ func (g *Grid) Run(h []float32, uplift, k []float32, p Params, log func(step int
fill = 1
}
for step := 0; step < p.Steps; step++ {
if g.cancel != nil {
select {
case <-g.cancel:
return
default:
}
}
if step%fill == 0 {
g.FillDepressions(h, 1e-3)
}
g.ComputeReceivers(h)
g.BuildStack()
g.Accumulate()
if p.MFDExponent > 0 {
g.AccumulateMFD(h, p.MFDExponent)
} else {
g.Accumulate()
}
g.StreamPower(h, uplift, k, p)
if p.CriticalSlope > 0 {
// The clamp still runs, and it still has to: a belt rising at millimetres a year asks for slopes
+58 -8
View File
@@ -18,7 +18,16 @@ import (
// bucket, so what it leaves is pyramids with faces aligned to the grid — the blocky, ruler-cut facets that
// are visible in any preview of a mountain belt here. Nothing about that is geology; it is the D8 stencil
// printed onto the landscape. Nonlinear diffusion approaches the same limiting angle *asymptotically* and
// through a symmetric five-point stencil, so there is no cut, no facet and no preferred direction.
// through a symmetric stencil, so there is no cut, no facet and no preferred direction.
//
// The stencil is nine-point, and it has to be. Run's design is that the clamp cuts and this rounds off what
// it cut before the next step sees it - but the clamp cuts along all eight neighbour directions and a
// five-point stencil transports across four, so it cannot touch a diagonally-cut facet at all. That was not a
// refinement, it was a hole in the stated design. The weights are 4/6 cardinal and 1/6 diagonal, which is the
// isotropic nine-point Laplacian: on h = (a/2)(x^2+y^2) the cardinal faces sum to 2ad^2 and the diagonals to
// 4ad^2, so (1/6)(8ad^2 + 4ad^2) = 2ad^2 = dx^2 * grad2(h), exactly what the five-point gave. coeff is
// therefore unchanged. A diagonal face is sqrt(2) further away, so it carries its own critical height
// difference; leaving that out would make every diagonal read as 1.41 times its true S/Sc.
//
// It is also mass-conserving, which the clamp is not: the flux out of one cell is the flux into its
// neighbour by construction, so material shed from a divide arrives at the foot of the slope rather than
@@ -57,6 +66,14 @@ func (g *Grid) DiffuseNonlinear(h []float32, d, sc, slopeCap, dt float64, maxSub
dx := g.CellM
dx2 := dx * dx
// The Courant number the sub-stepping aims for. The worst mode is the checkerboard: on the five-point
// stencil its cardinal faces sum to -8*amp and the amplification is 1 - 8*coeff, stable to coeff 0.25;
// on the nine-point the diagonals cancel and (4/6)*(-8*amp) leaves 1 - 5.333*coeff, stable to 0.375. Both
// targets keep the same 1.25x margin under their own limit, and the extra room is most of what pays for
// the four extra faces. Raising the target without the 4/6 and 1/6 weights, or adding the faces without
// raising the target, is a scheme that checkerboards a few hundred steps in - which is the failure the
// budget note below is about, and it does not announce itself.
// The steepest ground on the grid bounds D_eff for the whole call. Uplift is not applied in here and
// diffusion only relaxes slopes, so nothing can get steeper part-way through and invalidate the bound.
u := math.Min(g.maxSlopeRatio(h, sc), slopeCap)
@@ -64,7 +81,7 @@ func (g *Grid) DiffuseNonlinear(h []float32, d, sc, slopeCap, dt float64, maxSub
// What the sub-step budget can pay for. Lowering the cap rather than truncating the sub-step count is
// what keeps this stable: a truncated count leaves alpha above 0.25 and the surface checkerboards a few
// hundred steps later, which is precisely the sort of failure that does not announce itself.
if budget := float64(maxSub) * 0.2 * dx2 / (d * dt); f > budget {
if budget := float64(maxSub) * subTargetNine * dx2 / (d * dt); f > budget {
f = budget
u = invStiffness(f)
}
@@ -75,7 +92,7 @@ func (g *Grid) DiffuseNonlinear(h []float32, d, sc, slopeCap, dt float64, maxSub
f = 1
u = 0
}
sub := int(math.Ceil(d * f * dt / dx2 / 0.2))
sub := int(math.Ceil(d * f * dt / dx2 / subTargetNine))
if sub < 1 {
sub = 1
}
@@ -87,6 +104,7 @@ func (g *Grid) DiffuseNonlinear(h []float32, d, sc, slopeCap, dt float64, maxSub
// factor of dx too large, which pins every face against the cap and quietly turns the whole law into
// linear diffusion with a constant multiplier.
dhCrit := float32(sc * dx)
dhCritDiag := float32(sc * dx * math.Sqrt2)
src := h
tmp := g.scratch[:len(h)]
@@ -100,13 +118,17 @@ func (g *Grid) DiffuseNonlinear(h []float32, d, sc, slopeCap, dt float64, maxSub
continue
}
c := src[i]
// The net inflow over the four faces. Each face is evaluated from both of its cells,
// The net inflow over all eight faces. Each face is evaluated from both of its cells,
// which costs twice and buys a gather: no two goroutines ever write the same cell.
net := flux(clampAt(src, g.W, g.H, x-1, y)-c, dhCrit, uCap) +
card := flux(clampAt(src, g.W, g.H, x-1, y)-c, dhCrit, uCap) +
flux(clampAt(src, g.W, g.H, x+1, y)-c, dhCrit, uCap) +
flux(clampAt(src, g.W, g.H, x, y-1)-c, dhCrit, uCap) +
flux(clampAt(src, g.W, g.H, x, y+1)-c, dhCrit, uCap)
tmp[i] = c + coeff*net
diag := flux(clampAt(src, g.W, g.H, x-1, y-1)-c, dhCritDiag, uCap) +
flux(clampAt(src, g.W, g.H, x+1, y-1)-c, dhCritDiag, uCap) +
flux(clampAt(src, g.W, g.H, x-1, y+1)-c, dhCritDiag, uCap) +
flux(clampAt(src, g.W, g.H, x+1, y+1)-c, dhCritDiag, uCap)
tmp[i] = c + coeff*(nineCard*card+nineDiag*diag)
}
}
})
@@ -114,6 +136,14 @@ func (g *Grid) DiffuseNonlinear(h []float32, d, sc, slopeCap, dt float64, maxSub
}
}
// The isotropic nine-point Laplacian's weights, and the Courant target its stability allows. See
// DiffuseNonlinear.
const (
nineCard = float32(4.0 / 6.0)
nineDiag = float32(1.0 / 6.0)
subTargetNine = 0.3
)
// flux is q/D for one face, in height differences rather than slopes: one factor of the cell spacing cancels
// against the divergence and is carried in coeff instead. dhCrit is the height difference that corresponds to
// Sc across one cell, so dh/dhCrit is exactly S/Sc. u is capped so the denominator cannot reach zero.
@@ -152,9 +182,19 @@ func invStiffness(f float64) float64 {
return lo
}
// maxSlopeRatio is the steepest face on the grid as a fraction of Sc. Cardinal neighbours only, because those
// are the faces the five-point stencil actually transports across.
// maxSlopeRatio is the steepest face on the grid as a fraction of Sc, over every face the stencil transports
// across - which since the stencil went to nine points means the diagonals too. A diagonal face is compared
// against its own critical height difference, sqrt(2) larger, so what comes back is a slope ratio either way.
//
// What this number is for is worth being exact about, because it looks like physics and is not. It bounds the
// stiffening for the whole call, and the flux law only caps a face when that face exceeds the bound - so on a
// grid whose steepest face is the bound, no face is capped and the value has no effect on any cell. Its one
// real job is to decide how many sub-steps the call pays for, which is a cost question. Where it does reach
// the physics is when the sub-step budget cannot buy the grid's own maximum; the cap is then lowered to what
// the budget affords, and that value is the manifest's - D, dt, the cell and MaxHillslopeSub - and not the
// grid's, so a planet decomposed two ways still answers the same.
func maxSlopeRatio(h []float32, w, hgt int, sc, cellM float64) float64 {
invDiag := float32(1 / math.Sqrt2)
var maxDiff float32
for y := 0; y < hgt; y++ {
for x := 0; x < w; x++ {
@@ -164,6 +204,16 @@ func maxSlopeRatio(h []float32, w, hgt int, sc, cellM float64) float64 {
if dv := abs32(h[i+1] - c); dv > maxDiff {
maxDiff = dv
}
if y+1 < hgt {
if dv := abs32(h[i+w+1]-c) * invDiag; dv > maxDiff {
maxDiff = dv
}
}
if y > 0 {
if dv := abs32(h[i-w+1]-c) * invDiag; dv > maxDiff {
maxDiff = dv
}
}
}
if y+1 < hgt {
if dv := abs32(h[i+w] - c); dv > maxDiff {
@@ -152,7 +152,12 @@ func TestDiffuseNonlinearIsStable(t *testing.T) {
field[i] = 100 // flat base level, the mean of the checkerboard
}
}
for i := 0; i < 500; i++ {
// Two thousand steps, not five hundred. The nine-point stencil damps the checkerboard more slowly per
// step than the five-point did - the diagonal faces of a checkerboard are flat, so only the 4/6 of the
// stencil facing the cardinals sees the mode at all - and it is run at a Courant target of 0.3 rather
// than 0.2 because its stability limit is 0.375 rather than 0.25. Both of those are arguments on paper.
// A slow instability takes hundreds of steps to show, and a solve runs a thousand.
for i := 0; i < 2000; i++ {
g.DiffuseNonlinear(field, 0.02, sc, 0.95, 1500, 24)
}
lo, hi := float32(math.Inf(1)), float32(math.Inf(-1))
@@ -170,9 +175,9 @@ func TestDiffuseNonlinearIsStable(t *testing.T) {
}
}
}
t.Logf("after 500 steps the interior spans %.3f..%.3f m, from a 200 m checkerboard", lo, hi)
t.Logf("after 2000 steps the interior spans %.3f..%.3f m, from a 200 m checkerboard", lo, hi)
if hi-lo > 1 {
t.Errorf("the checkerboard is still %.1f m after 500 steps: it is not being damped", hi-lo)
t.Errorf("the checkerboard is still %.1f m after 2000 steps: it is not being damped", hi-lo)
}
}
+61 -15
View File
@@ -1,5 +1,7 @@
package fluvial
import "salty/terrain/internal/world"
// Deterministic per-cell jitter, and why a router needs one.
//
// D8 lets a cell drain to one of eight neighbours, so every channel is a chain of 0, 45 and 90 degree
@@ -9,28 +11,72 @@ package fluvial
// flood's traversal geometry and draws it as rivers — ruler-straight diagonals, the polygonal network that
// killed the first attempt at flat plains.
//
// The fix is to stop the epsilon being uniform. A hash of the cell index scatters it by plus or minus half,
// which is far below anything that matters to the solve (a millimetre against metre-scale relief) and far
// above the difference the flood's ordering would otherwise leave, so the descent direction on a flat is
// decided by the hash rather than by scan order. The same hash breaks near-ties between two equally steep
// neighbours, which is the other place a fixed direction order leaks a grid axis into the result.
// The fix is to stop the epsilon being uniform. A hash scatters it by plus or minus half, which is far below
// anything that matters to the solve (a millimetre against metre-scale relief) and far above the difference
// the flood's ordering would otherwise leave, so the descent direction on a flat is decided by the hash
// rather than by scan order. The same hash breaks near-ties between two equally steep neighbours, which is
// the other place a fixed direction order leaks a grid axis into the result.
//
// It is a hash rather than a random source because cross-cutting rule 12 is determinism from a seed: the
// value for a cell must not depend on how many cells were visited before it, on which goroutine ran, or on
// how many steps the solve has taken.
//
// And it is a hash of a *world position* rather than of a grid index, which is rule 1 of the tiling plan in
// Docs/Terrain-Next.md 3.3. A planet is solved one landmass at a time, so the same physical cell turns up in
// grids of different widths at different offsets; keyed on the index it would jitter differently each time,
// and every place two frames met would show it. Keyed on where the cell actually is, it cannot.
// hash01 is splitmix64 finalised to the unit interval. Cheap, no state, and well enough distributed that
// neighbouring indices get unrelated values — which is the whole requirement here.
func hash01(seed uint64, i int32) float32 {
x := seed ^ (uint64(uint32(i)) * 0x9e3779b97f4a7c15)
x ^= x >> 30
x *= 0xbf58476d1ce4e5b9
x ^= x >> 27
x *= 0x94d049bb133111eb
x ^= x >> 31
return float32(x>>11) / float32(1<<53)
// The k namespace. Every caller of hashXY picks a k, and two callers that share one get perfectly correlated
// jitter - the clamp's allowance would track the router's tie-break in the same direction, which is exactly
// the kind of hidden coupling that prints a texture nobody can attribute. They are named here so a new
// caller has to pick a free one.
const (
jitterFloodEpsilon int32 = 0 // the priority-flood's per-cell fall across a flat (fluvial.go)
jitterReceiverTie int32 = 1 // .. 8, one per D8 direction: the steepest-neighbour tie-break (fluvial.go)
jitterReposeAllow int32 = 9 // .. 16, one per D8 direction: the repose clamp's allowance (repose.go)
jitterReposeOrder int32 = 17 // the repose clamp's pop order (repose.go)
)
// hashXY is splitmix64's finaliser over a weighted sum of the seed and the position. One finalising round,
// because this is called eight times per cell per step - several hundred billion times over a planet bake -
// and the requirement is only that neighbouring cells get unrelated values, not cryptographic quality. The
// three odd constants are summed rather than exclusive-ored so that swapping x and y does not collide.
func hashXY(seed uint64, x, y, k int32) float32 {
h := seed ^ (uint64(uint32(x))*0x9e3779b97f4a7c15 +
uint64(uint32(y))*0xc2b2ae3d27d4eb4f +
uint64(uint32(k))*0x165667b19e3779f9)
h ^= h >> 30
h *= 0xbf58476d1ce4e5b9
h ^= h >> 27
h *= 0x94d049bb133111eb
h ^= h >> 31
return float32(h>>11) / float32(1<<53)
}
// SetSeed ties the jitter to the run's seed, so two seeds do not share the same flat-routing geometry.
// Zero is a perfectly good seed; it is the default and nothing depends on it being set.
func (g *Grid) SetSeed(seed int64) { g.seed = uint64(seed)*0x9e3779b97f4a7c15 + 0x243f6a8885a308d3 }
// SetFrame says where on the planet this grid sits, which is what turns the jitter from an index hash into
// a position hash. Without it a grid is its own world at the origin, which is what the square canvas is and
// what every existing test expects, so it is optional and NewGrid does not require it.
func (g *Grid) SetFrame(f world.Frame) {
g.originX = int32(f.P.WrapX(f.X0))
g.originY = int32(f.Y0)
g.planetW = int32(f.P.W)
}
// worldX and worldY map a grid cell to its planet cell.
//
// The wrap is a compare and a subtract rather than a modulo on purpose: originX is already inside the
// planet and x is less than the planet's width, so the sum overshoots by at most one turn. A modulo here
// would be a division in the router's innermost loop.
func (g *Grid) worldX(x int) int32 {
v := g.originX + int32(x)
if g.planetW > 0 && v >= g.planetW {
v -= g.planetW
}
return v
}
func (g *Grid) worldY(y int) int32 { return g.originY + int32(y) }
@@ -0,0 +1,74 @@
package fluvial
import (
"testing"
"salty/terrain/internal/world"
)
// The whole point of the move from an index hash to a position hash: a planet is solved one landmass at a
// time, so the same physical cell turns up in grids of different widths at different offsets. If the jitter
// disagreed between them, every place two frames met would show a line.
func TestJitterFollowsThePositionNotTheIndex(t *testing.T) {
p, err := world.New(512, 8, 100, 50, 2, 512)
if err != nil {
t.Fatal(err)
}
// Two frames of different widths, both covering planet column 5, row 7.
a := Grid{W: 16, H: 16}
a.SetSeed(11)
a.SetFrame(world.Frame{P: p, X0: 0, Y0: 0, W: 16, H: 16})
b := Grid{W: 9, H: 12}
b.SetSeed(11)
b.SetFrame(world.Frame{P: p, X0: 3, Y0: 4, W: 9, H: 12})
for k := int32(0); k < 9; k++ {
ja := hashXY(a.seed, a.worldX(5), a.worldY(7), k)
jb := hashXY(b.seed, b.worldX(2), b.worldY(3), k)
if ja != jb {
t.Fatalf("k=%d: frame a gives %v, frame b gives %v for the same planet cell", k, ja, jb)
}
}
// And it must still be a hash: the neighbouring cell gets an unrelated value.
if hashXY(a.seed, a.worldX(5), a.worldY(7), 0) == hashXY(a.seed, a.worldX(6), a.worldY(7), 0) {
t.Error("neighbouring cells hash the same")
}
}
// A frame that straddles the seam sees the same positions as one that does not.
func TestJitterWrapsAtTheSeam(t *testing.T) {
p, err := world.New(512, 8, 100, 50, 0, 512)
if err != nil {
t.Fatal(err)
}
at := Grid{W: 8, H: 8}
at.SetSeed(3)
at.SetFrame(world.Frame{P: p, X0: 60, Y0: 0, W: 8, H: 8})
origin := Grid{W: 8, H: 8}
origin.SetSeed(3)
origin.SetFrame(world.Frame{P: p, X0: 0, Y0: 0, W: 8, H: 8})
// The seam frame's column 4 is planet column 0, which is the origin frame's column 0.
if got, want := at.worldX(4), origin.worldX(0); got != want {
t.Fatalf("world column = %d, want %d", got, want)
}
if hashXY(at.seed, at.worldX(4), at.worldY(2), 0) != hashXY(origin.seed, origin.worldX(0), origin.worldY(2), 0) {
t.Error("the same planet cell jitters differently on either side of the seam")
}
}
// Without a frame a grid is its own world at the origin, which is what the square canvas is and what every
// existing test relies on.
func TestNoFrameMeansTheGridIsTheWorld(t *testing.T) {
g := Grid{W: 8, H: 8}
g.SetSeed(1)
if got := g.worldX(7); got != 7 {
t.Errorf("worldX(7) = %d, want 7", got)
}
if got := g.worldY(3); got != 3 {
t.Errorf("worldY(3) = %d, want 3", got)
}
}
+218
View File
@@ -0,0 +1,218 @@
package fluvial
import (
"math"
"salty/terrain/internal/field"
)
// Multiple-flow-direction drainage area: Freeman, Quinn and Holmgren's partition, and the answer to the one
// thing D8 cannot do.
//
// A planar hillslope is where D8 fails, and it fails in closed form. The specific catchment area on a plane
// is the distance from the divide and it is the same at every point along a contour, because nothing about a
// plane tells one flow line from its neighbour. D8 has to disagree: every cell picks the same steepest
// neighbour, so the flow lines run exactly parallel and never converge, and a cell either sits on a line and
// carries the whole tube or sits off one and carries a single cell for ever. Measured on a ramp at an aspect
// of 22.5 degrees, the most-drained cell in a contour band carries 769 times the median and 30 % of the grid
// drains nothing at all (flow_test.go). Stream power then reads A^m off that and cuts each line in, which is
// what a bake's mountain flanks were: a comb of ruler-straight parallel grooves, one per surviving line,
// spaced by the mean distance between the merges the router's tie-break jitter happened to allow - a spacing
// in cells, which is why it measured the same eighteen cells at an 8 m cell and at a 32 m one.
//
// The partition below splits a cell's area among every downslope neighbour by (dh_k * q_k)^p, where q_k is
// the share of the cell's perimeter facing direction k divided by the centre-to-centre distance: 0.5 for a
// cardinal neighbour, 0.25 for a diagonal. The cell size cancels out of the ratio, so the weights are height
// differences times a constant - no division and no transcendental in the inner loop at p = 1.
//
// It replaces Accumulate and it replaces only that. Receiver, Length and Stack stay D8, because
// Braun-Willett's implicit update walks one receiver chain and there is no multi-receiver form of it that is
// still unconditionally stable. Stream power therefore incises along the steepest path using the area that
// actually converges there. That pairing is deliberate and it is the standard one; it is not an oversight.
// mfdPow selects how the partition quantity is raised to p, once per call rather than once per cell. p is
// almost always 1, where the whole thing is a multiply.
type mfdPow uint8
const (
mfdP1 mfdPow = iota
mfdP2
mfdP3
mfdP4
mfdGeneral
)
func mfdModeFor(p float64) (mfdPow, float64) {
switch {
case math.Abs(p-1) < 1e-9:
return mfdP1, 1
case math.Abs(p-2) < 1e-9:
return mfdP2, 2
case math.Abs(p-3) < 1e-9:
return mfdP3, 3
case math.Abs(p-4) < 1e-9:
return mfdP4, 4
default:
return mfdGeneral, p
}
}
func (g *Grid) mfdRaise(v float32) float32 {
switch g.mfdMode {
case mfdP1:
return v
case mfdP2:
return v * v
case mfdP3:
return v * v * v
case mfdP4:
v2 := v * v
return v2 * v2
default:
return float32(math.Pow(float64(v), g.mfdExp))
}
}
// mfdQ is the perimeter share facing each D8 direction divided by the distance to it, in the order of dx8
// and dy8: NW N NE W E SW S SE. Cardinal 0.5, diagonal 0.25.
var mfdQ = [8]float32{0.25, 0.5, 0.25, 0.5, 0.5, 0.25, 0.5, 0.25}
// AccumulateMFD fills Area with multiple-flow drainage area, in m².
//
// The order is Kahn's algorithm over the flow graph rather than a sort by elevation, and neither of the two
// obvious alternatives works. The D8 stack cannot be reused: BuildStack is a depth-first walk of the donor
// tree, so a deep node of one subtree precedes a shallow node of the next and the order is not descending in
// elevation - a cell would send area to a neighbour that had already been processed, and the loss would fall
// on the flanks, which is exactly where it cannot be afforded. A bucket sort cannot either: the queue
// quantises to a centimetre while the flood's epsilon ladder across a filled flat is a millimetre a cell, so
// ten cells of one descending chain share a bucket and a lake bed would leak its area.
//
// Kahn needs no elevation comparison at all. mfdPending[i] is how many strictly higher neighbours i still
// owes; a cell is ready when the count reaches zero. Because "strictly lower" is a strict order the graph is
// acyclic, so every cell is released exactly once - which is asserted, because the alternative is a drainage
// area that is quietly too small in a two-hour bake.
//
// The counting pass is inside this function and not folded into ComputeReceivers, which already reads all
// eight neighbours and could have produced it for nothing. It was, and it was wrong: the walk *consumes* the
// counts, so a second call without an intervening ComputeReceivers seeded its whole queue at once and
// returned a drainage area that was silently wrong rather than panicking. Run happens to call the two in
// lockstep, so nothing would have caught it there. A pass that owns its own preconditions cannot be misused
// that way, and this one is a pure gather, so it parallelises and costs almost nothing in wall clock.
func (g *Grid) AccumulateMFD(h []float32, p float64) {
n := g.W * g.H
g.mfdMode, g.mfdExp = mfdModeFor(p)
field.Rows(g.H, func(y0, y1 int) {
for y := y0; y < y1; y++ {
for x := 0; x < g.W; x++ {
i := y*g.W + x
pend := uint8(0)
for k := 0; k < 8; k++ {
nx, ny := x+dx8[k], y+dy8[k]
if nx < 0 || ny < 0 || nx >= g.W || ny >= g.H {
continue
}
// How many neighbours will hand this cell a share: the ones strictly above it. The
// weights below skip a neighbour when hn >= hc, so c sends to n exactly when
// h[c] > h[n] - the same predicate, and it has to stay the same one or the walk ends
// short.
if h[ny*g.W+nx] > h[i] {
pend++
}
}
g.mfdPending[i] = pend
}
}
})
cell := float32(g.CellM * g.CellM)
for i := range g.Area {
g.Area[i] = cell
}
if cap(g.mfdQueue) < n {
g.mfdQueue = make([]int32, 0, n)
}
q := g.mfdQueue[:0]
for i := 0; i < n; i++ {
if g.mfdPending[i] == 0 {
q = append(q, int32(i))
}
}
// p = 1 is the default and it is a multiply; hoisting the mode test out of the cell loop saves a call
// and a switch on every one of the eight faces of every cell of every step.
linear := g.mfdMode == mfdP1
var wgt [8]float32
for read := 0; read < len(q); read++ {
c := q[read]
cx, cy := int(c)%g.W, int(c)/g.W
hc := h[c]
var total float32
for k := 0; k < 8; k++ {
nx, ny := cx+dx8[k], cy+dy8[k]
if nx < 0 || ny < 0 || nx >= g.W || ny >= g.H {
wgt[k] = 0
continue
}
// The same predicate the counting pass above used, written the same way round, because the
// counts and these weights have to agree cell for cell or the walk ends short.
hn := h[ny*g.W+nx]
if hn >= hc {
wgt[k] = 0
continue
}
dh := hc - hn
w := dh * mfdQ[k]
if !linear {
w = g.mfdRaise(w)
}
wgt[k] = w
total += w
}
// A fixed cell is base level: it absorbs what arrives and sends nothing on. It still has to release
// the cells below it, or their counts would never reach zero and the walk would end short - which is
// why the release
// loop below is not inside the `total > 0` branch.
share := float32(0)
if total > 0 && !g.fixed[c] {
share = g.Area[c] / total
}
for k := 0; k < 8; k++ {
if wgt[k] == 0 {
continue
}
ni := int32((cy+dy8[k])*g.W + cx + dx8[k])
if share > 0 {
g.Area[ni] += share * wgt[k]
}
g.mfdPending[ni]--
if g.mfdPending[ni] == 0 {
q = append(q, ni)
}
}
}
g.mfdQueue = q
if len(q) != n {
// Unreachable unless the pending counts and the weights disagree about which neighbours are lower,
// which would mean area silently going missing. Loud is the only useful behaviour here.
panic("fluvial: MFD released " + itoa(len(q)) + " of " + itoa(n) + " cells; the pending counts and " +
"the downslope test disagree")
}
}
func itoa(v int) string {
if v == 0 {
return "0"
}
var b [20]byte
i := len(b)
for v > 0 {
i--
b[i] = byte('0' + v%10)
v /= 10
}
return string(b[i:])
}
@@ -0,0 +1,27 @@
package fluvial
import "testing"
// What the two accumulators cost per cell, which is the number a bake's wall clock is spent against. Both are
// measured on the same surface with the receivers and the stack already built, because those are shared.
func benchAccumulate(b *testing.B, mfd bool) {
const n = 1024
h := planarRamp(n, 8.0, 0.1, 22.5)
g := NewGrid(n, n, 8.0, nil)
g.SetSeed(37125)
g.FillDepressions(h, 1e-3)
g.ComputeReceivers(h)
g.BuildStack()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if mfd {
g.AccumulateMFD(h, 1)
} else {
g.Accumulate()
}
}
b.ReportMetric(float64(b.Elapsed().Nanoseconds())/float64(b.N)/float64(n*n), "ns/cell")
}
func BenchmarkAccumulateD8(b *testing.B) { benchAccumulate(b, false) }
func BenchmarkAccumulateMFD(b *testing.B) { benchAccumulate(b, true) }
+14 -2
View File
@@ -30,9 +30,16 @@ func (g *Grid) ClampToRepose(h []float32, talus float64) float64 {
for i := range g.closed {
g.closed[i] = false
}
// Pushed with a jittered bucket, not a plain one. The constraint this pass imposes is isotropic; the
// order it imposed it in was not. Every cell went in in flat-index order and the queue pops last-in
// first-out within a bucket, so on ground flat to within a centimetre - which is most of a hillside -
// cells popped bottom-right to top-left, and whichever popped first decided which of its neighbours got
// cut. That is where the grid-aligned pyramid faces came from, and it is one hash away from not being
// there. See bucketpq.go.
g.pq.reset()
for i := 0; i < n; i++ {
g.pq.push(h[i], int32(i))
x, y := i%g.W, i/g.W
g.pq.pushJittered(h[i], int32(i), (hashXY(g.seed, g.worldX(x), g.worldY(y), jitterReposeOrder)-0.5)*2*reposeOrderBuckets)
}
card := talus * g.CellM
@@ -62,11 +69,16 @@ func (g *Grid) ClampToRepose(h []float32, talus float64) float64 {
if dx8[k] != 0 && dy8[k] != 0 {
allow = diag
}
// The same tie-break ComputeReceivers uses and for the same reason: a fixed allowance resolves
// every near-tie the same way and prints its preferred axis. A tenth of a percent, keyed on the
// cell being cut, so what a cell is allowed in a direction does not depend on which neighbour
// reached it first.
allow *= 1 + 1e-3*(float64(hashXY(g.seed, g.worldX(nx), g.worldY(ny), int32(k)+jitterReposeAllow))-0.5)
limit := h[c] + float32(allow)
if h[ni] > limit {
removed += float64(h[ni] - limit)
h[ni] = limit
g.pq.push(limit, ni)
g.pq.pushJittered(limit, ni, (hashXY(g.seed, g.worldX(nx), g.worldY(ny), jitterReposeOrder)-0.5)*2*reposeOrderBuckets)
}
}
}
@@ -48,3 +48,115 @@ func TestClampToReposeCutsACone(t *testing.T) {
t.Errorf("steepest slope %.3f exceeds repose %.3f", worst, talus)
}
}
// TestClampToReposeIsIsotropic asks what shape is left, which the test above cannot: a four-sided pyramid
// satisfies "no slope exceeds repose" exactly, so the constraint check says nothing about whether the clamp
// cut a cone or cut a pyramid.
//
// Clamp a cone far above repose, then for each of 360 azimuths find by bisection the radius at which the
// surface falls through a fixed height. A cone gives a constant radius; the amplitudes of the four-fold and
// eight-fold Fourier components of that radius, as a fraction of its mean, say how far from one it is.
//
// What the numbers turn out to be, and what they are not. Measured 0.97 % four-fold and 2.39 % eight-fold -
// and *identical* with the pop-order jitter, with the allowance jitter, with both and with neither. On a cone
// no two cells share a bucket, because the surface falls twenty metres a cell against a one-centimetre
// bucket, so the ordering bias this file's jitter removes has nothing to bite on here. The residual is
// geometry: a path to a point at 22.5 degrees has to be built of cardinal and diagonal steps, and the octile
// distance it accumulates exceeds the straight line by up to 8 %, so an eight-connected clamp cuts an
// octagon out of a cone whatever order it works in. That is irreducible without a wider neighbourhood, and
// the thresholds below sit above it: this test guards against a regression to something far worse, and
// TestBucketPQDoesNotPreferRasterOrder is what actually holds the ordering honest.
func TestClampToReposeIsIsotropic(t *testing.T) {
const (
w, h = 201, 201
cellM = 10.0
talus = 0.4
level = 300.0
)
field := make([]float32, w*h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
d := math.Hypot(float64(x-w/2), float64(y-h/2)) * cellM
field[y*w+x] = float32(math.Max(0, 2000-2.0*d))
}
}
g := NewGrid(w, h, cellM, make([]bool, w*h))
g.SetSeed(37125)
g.SetElevationRange(-100, 4000)
g.ClampToRepose(field, talus)
at := func(fx, fy float64) float64 { // bilinear, in cells
x0, y0 := int(fx), int(fy)
if x0 < 0 || y0 < 0 || x0 >= w-1 || y0 >= h-1 {
return 0
}
tx, ty := fx-float64(x0), fy-float64(y0)
return (1-ty)*((1-tx)*float64(field[y0*w+x0])+tx*float64(field[y0*w+x0+1])) +
ty*((1-tx)*float64(field[(y0+1)*w+x0])+tx*float64(field[(y0+1)*w+x0+1]))
}
const rays = 360
var sum, c4r, c4i, c8r, c8i float64
for i := 0; i < rays; i++ {
th := 2 * math.Pi * float64(i) / rays
cs, sn := math.Cos(th), math.Sin(th)
lo, hi := 0.0, float64(w/2-2)
for n := 0; n < 40; n++ { // bisect on the radius where the surface crosses `level`
mid := (lo + hi) / 2
if at(float64(w/2)+mid*cs, float64(h/2)+mid*sn) > level {
lo = mid
} else {
hi = mid
}
}
r := (lo + hi) / 2
sum += r
c4r += r * math.Cos(4*th)
c4i += r * math.Sin(4*th)
c8r += r * math.Cos(8*th)
c8i += r * math.Sin(8*th)
}
a4 := 2 * math.Hypot(c4r, c4i) / sum
a8 := 2 * math.Hypot(c8r, c8i) / sum
t.Logf("clamped cone: mean radius %.2f cells, four-fold %.2f%%, eight-fold %.2f%%",
sum/rays, a4*100, a8*100)
if a4 > 0.02 || a8 > 0.04 {
t.Errorf("the clamped cone is %.2f%% four-fold and %.2f%% eight-fold against 0.97 and 2.39 measured: "+
"it is a pyramid, not an octagon", a4*100, a8*100)
}
}
// TestBucketPQDoesNotPreferRasterOrder is the unit underneath it. Pushed plain, cells at one elevation come
// back in exactly reverse insertion order, which is a Spearman correlation of -1.
func TestBucketPQDoesNotPreferRasterOrder(t *testing.T) {
const n = 4096
order := func(jitter bool) float64 {
q := newBucketPQ(0, 100)
for i := 0; i < n; i++ {
if jitter {
q.pushJittered(50, int32(i), (hashXY(1, int32(i%64), int32(i/64), jitterReposeOrder)-0.5)*2*reposeOrderBuckets)
} else {
q.push(50, int32(i))
}
}
var sum float64
for pos := 0; pos < n; pos++ {
idx := float64(q.pop())
sum += (float64(pos) - float64(n-1)/2) * (idx - float64(n-1)/2)
}
var varr float64
for i := 0; i < n; i++ {
d := float64(i) - float64(n-1)/2
varr += d * d
}
return sum / varr
}
plain, jittered := order(false), order(true)
t.Logf("pop order against flat index: plain %.3f, jittered %.3f", plain, jittered)
if plain > -0.99 {
t.Errorf("plain push no longer pops in reverse insertion order (%.3f); this test's premise is gone", plain)
}
if math.Abs(jittered) > 0.05 {
t.Errorf("jittered push still correlates with flat index at %.3f", jittered)
}
}
@@ -0,0 +1,82 @@
package manifest
import "testing"
// The shelf break is the one number that decides how deep the near-shore sea is, and for as long as it was
// not a key it was read off `continent.sea_floor_m.hi()` — a square-canvas number, -30 m, because a real
// margin does not fit on a 14.28 km canvas. A painted planet inherited it in silence.
//
// What that cost is worth writing down, because no test and no printed statistic could see it: the derived
// margin reaches `shelf_km.hi() + slope_km` = 4.6 km from every shore, and the first template has 1069 km of
// shoreline against a 3100 km² sea, so the margin covers the whole ocean. The painting said 512 m; 40 % of
// the planet came out between 0 and 30 m and the shelf halo around every landmass encoded to the same grey
// as the land, which is what "it is just a landmass and no oceans really" looks like from the outside.
//
// So these tests are about provenance rather than about arithmetic: a planet must name its own break depth,
// and must not be able to acquire the square canvas's by default.
func TestPlanetDoesNotInheritTheSquareCanvasShelfBreak(t *testing.T) {
m := Defaults()
m.Planet = &Planet{}
m.fillPlanetDefaults()
canvas := -m.Pipeline.Continent.SeaFloorM.Hi()
if got := m.ShelfBreakM(); got == canvas {
t.Fatalf("a planet's shelf break is %.0f m, the square canvas's own number; it must not be "+
"inherited from continent.sea_floor_m", got)
}
// And it is a shelf break rather than a puddle: deeper than the shallowest thing an author paints.
if got := m.ShelfBreakM(); got < 100 {
t.Errorf("a planet's default shelf break is %.0f m; a continental shelf breaks at a hundred "+
"metres and more, and anything shallower makes the painted ocean unreachable", got)
}
}
// The square canvas keeps what it had. Its sea floor is a range and the break is the shallow end of it, so
// the fallback reads that rather than inventing a key for a manifest written without one.
func TestSquareCanvasShelfBreakIsUnchanged(t *testing.T) {
m := Defaults()
if m.IsPlanet() {
t.Fatal("Defaults() should not be a planet")
}
if got, want := m.ShelfBreakM(), -m.Pipeline.Continent.SeaFloorM.Hi(); got != want {
t.Errorf("square canvas shelf break %.0f m, want %.0f m", got, want)
}
}
// An explicit key wins everywhere, which is what makes the flag override and a hand-written manifest work.
func TestExplicitShelfBreakWins(t *testing.T) {
for _, planet := range []bool{false, true} {
m := Defaults()
if planet {
m.Planet = &Planet{}
}
m.Pipeline.Coast.BreakM = 275
if planet {
m.fillPlanetDefaults()
}
if got := m.ShelfBreakM(); got != 275 {
t.Errorf("planet=%v: shelf break %.0f m, want the 275 m asked for", planet, got)
}
}
}
// The margin's reach is what makes the break depth matter: everything nearer than this to a shore is the
// derived profile and the painting is not consulted, so a sea narrower than twice it never reaches the depth
// it was painted. Pinned so that widening the shelf is a deliberate act with this arithmetic in view.
func TestDerivedMarginReachIsBoundedAndKnown(t *testing.T) {
m := Defaults()
m.Planet = &Planet{}
m.fillPlanetDefaults()
c := m.Pipeline.Coast
reach := c.ShelfKm.Hi() + c.SlopeKm
if reach > 5 {
t.Errorf("the derived margin reaches %.1f km from every shore; past about 5 km it swallows the "+
"straits of a 100 km planet and the painted depths stop meaning anything", reach)
}
if c.ShelfKm.Lo() <= 0 || c.ShelfKm.Lo() >= c.ShelfKm.Hi() {
t.Errorf("shelf width range %.1f..%.1f km is not an increasing positive range",
c.ShelfKm.Lo(), c.ShelfKm.Hi())
}
}
+584 -19
View File
@@ -11,6 +11,8 @@ import (
"math"
"os"
"path/filepath"
"salty/terrain/internal/plates"
)
// The engine maps heightmap value v to local height (v - 32768) / 128 * ZScale cm, so ZScale 100 spans 512 m.
@@ -110,6 +112,18 @@ type Fluvial struct {
CriticalSlopeDeg float64 `json:"critical_slope_deg"`
SlopeCap float64 `json:"slope_cap"`
MaxHillslopeSub int `json:"max_hillslope_substeps"`
// MFDExponent is the exponent on the multiple-flow-direction partition of drainage area. 0 goes back to
// D8's single receiver, which is what every bake before this one ran. See internal/fluvial/mfd.go.
MFDExponent float64 `json:"mfd_exponent"`
}
// Smooth is the edge-preserving pass that runs once after the solve. It is a filter, not a process, and it
// is off by default: 0 passes. See internal/field/smooth.go for why it cannot go inside the step loop, and
// Docs/Terrain-Next.md for the statistics a run with it on has to match a run with it off.
type Smooth struct {
Passes int `json:"passes"`
SlopeRef float64 `json:"slope_ref"` // rise over run; ground steeper than this is preserved
}
type Thermal struct {
@@ -127,12 +141,43 @@ type Strata struct {
type Detail struct {
Octaves int `json:"octaves"`
AmplitudeM Range `json:"amplitude_m"`
// ClassBlendM is how far a painted class's detail numbers fade into its neighbour's.
//
// The class *index* is never interpolated - a class is a name - but the numbers it stands for are
// quantities, and a boundary somebody drew with a mouse should not be a step in the ground. At 0 it is a
// step, which is what it was before: seven metres of dune amplitude to two in the width of one cell.
ClassBlendM float64 `json:"class_blend_m"`
// SeabedM is how deep the detail texture reaches below the waterline, and the reason it is not zero is
// that a coast where the land is rough and the water is glass reads as a cut-out rather than as a shore.
// The amplitude fades in from nothing at the waterline - a few metres of noise there turns the shallows
// into a scatter of one-cell islands - and back out to nothing at this depth.
SeabedM float64 `json:"seabed_m"`
// TilePx is the interior side of a detail tile, in detail cells. It must divide the planet's width in
// geology cells once divided by geology_factor, because X wraps and a tile grid that did not come out
// whole would leave the last tile overlapping the first by an arbitrary amount.
TilePx int `json:"tile_px"`
}
// Particle is the droplet block, demoted by D-47 from "carves the valleys" to detail only. Every brake in it
// was learned the hard way; see Docs/Terrain.md.
type Particle struct {
Droplets int `json:"droplets"`
Droplets int `json:"droplets"`
// DropletsPerCell is what the tiled detail pass uses instead of Droplets, because a tile does not know
// how big the world is and must not: a cell has to spawn the same droplets whichever tile it falls in.
// 0.18 is the density Droplets 9e6 at 7141 squared comes to, which is the density the numpy was tuned at.
DropletsPerCell float64 `json:"droplets_per_cell"`
// Rounds is how many passes the droplets are split into. Within a round they read the height as it was
// when it began, so this is what lets a channel deepen as more water follows it; the numpy got the same
// effect from its batch size, and this is that number expressed so it does not depend on how big a piece
// of the world is being worked on. A tile and the whole map must agree about which round a droplet is in
// or the seams would not close.
Rounds int `json:"rounds"`
Lifetime int `json:"lifetime"`
Scale float64 `json:"scale"`
MinErodeSlope float64 `json:"min_erode_slope"`
@@ -190,7 +235,18 @@ type Coast struct {
// range that this replaces was neither. ShelfKm is a range because the shelf width is not a constant:
// it is wide off a low coastal plain and narrow off a mountain range that comes down to the water, so it
// is interpolated per stretch of shore by the relief standing behind that stretch.
ShelfKm Range `json:"shelf_km"`
ShelfKm Range `json:"shelf_km"`
// BreakM is the depth at the shelf break, in positive metres: how deep the water is where the gentle
// shelf ends and the continental slope begins. It is the one number that decides how deep the near-shore
// sea *is*, and until D-64 it was not a key at all - it was read off `continent.sea_floor_m.hi()`, whose
// default is -30 because the square canvas is 14.28 km a side and a real margin does not fit on it.
// Applied unchanged to a 100 km painted planet that reads as no ocean at all: the derived margin is up to
// 3 km of shelf and 1.6 km of slope, 1069 km of shoreline carries 4900 km2 of it against a 3100 km2 sea,
// so the margin covers the whole ocean and pins it between 0 and 30 m whatever the author painted. Zero
// keeps the old behaviour, which is what the square canvas wants; a planet gets 130 m from
// fillPlanetDefaults, and the first template's own `shelf` class is 120 m, which is the same number by
// the other route. The break can never be deeper than the water it is a break in - see layShelf.
BreakM float64 `json:"break_m"`
SteepCoastM float64 `json:"steep_coast_m"`
SlopeKm float64 `json:"slope_km"`
ShelfExponent float64 `json:"shelf_exponent"`
@@ -224,19 +280,219 @@ type Coast struct {
RiverChannelKm2 float64 `json:"river_channel_km2"`
}
// CoastDetail is the shore at the detail cell: pass 11b, and the one landform the geology grid cannot hold.
//
// Every length in it is in metres and none of them scales with the canvas, which is the argument for it being
// a block of its own rather than more knobs on Coast. The geology pass decides where the shore is, how far the
// surf reaches and how sheltered each stretch is, and those are *its* numbers, read from Coast; this decides
// what the shore looks like once there are cells small enough to draw it.
type CoastDetail struct {
Enabled bool `json:"enabled"`
// Crenulation moves the whole profile in and out along the shore, which is what a crenulate coastline is.
// It is added to the distance rather than to the height and it is drawn at the nearest waterline cell, so
// it varies along the shore and not across it. This is the fine end of the same idea as the template's
// coast_jitter_px, three orders of magnitude down: that one decides which pixels are land, this one wiggles
// a waterline that is already decided.
CrenulationM float64 `json:"crenulation_m"`
CrenulationWaveM float64 `json:"crenulation_wavelength_m"`
// ShoreSmoothM is how far the signed distance to the waterline is smoothed before the profile is measured
// from it, and it is not cosmetic. On a coastal plain the ground crosses sea level at a grade of about
// one in a hundred, so the land mask there is not a line but a forty-metre band of speckle, and a profile
// measured from it builds a separate two-metre berm on every isolated cell in it. Measured on the first
// run of the pass: a string of beads down the whole coast at a spacing of twenty to thirty metres.
// Smoothing the distance rather than the mask is what keeps the profile a profile - the shoreline moves,
// the shape crossing it does not.
ShoreSmoothM float64 `json:"shore_smooth_m"`
// The beach. DeanA is the A of the equilibrium profile depth = A*x^(2/3), in metres to the one third: 0.1
// is fine sand and 0.2 is coarse. BermBackM is how far inland the berm crest is held before the profile
// hands back to whatever the droplets left.
DeanA float64 `json:"dean_a"`
BermBackM float64 `json:"berm_back_m"`
// BeachFillM is the most sediment a beach may lay on what is already there. The equilibrium profile is a
// target *depth*, so without a cap a shore with deep water close in - a drowned valley, which is an
// ordinary thing - gets tens of metres of sand invented to bring the floor up to the curve.
BeachFillM float64 `json:"beach_fill_m"`
// The cliff. A stretch of shore is a beach below CliffFromM of backshore and a cliff above CliffToM, and
// blended between. CliffGrade is the tangent of the angle the face stands at - 2.75 is 70 degrees, which
// is a sea cliff rather than a hillside. ScreeDeg is the angle its debris comes to rest at and ScreeReachM
// is how far out from the foot the apron reaches.
// CliffMaxM is how tall a face the surf is allowed to have cut. Past it the ground is a mountain coming
// down to the water rather than a wave-cut cliff, and its face is a hillslope that belongs to the solve.
// Without it a coastal range gets a seventy-degree wall carved four hundred metres inland, because the
// only thing stopping the face is the ground rising faster than it does.
CliffFromM float64 `json:"cliff_from_m"`
CliffToM float64 `json:"cliff_to_m"`
CliffMaxM float64 `json:"cliff_max_m"`
CliffGrade float64 `json:"cliff_grade"`
ScreeDeg float64 `json:"scree_repose_deg"`
ScreeReachM float64 `json:"scree_reach_m"`
// PlatformReliefM is how far the strata field is allowed to move the shore platform, which is how a
// platform gets its ledges and runnels instead of being planed flat.
PlatformReliefM float64 `json:"platform_relief_m"`
// SmoothReachM is how far past the profile the shore damps the ground's *roughness* - not its shape.
//
// The profile itself is only a few tens of metres wide, so without this the ground goes from a drawn
// beach to full dune amplitude and droplet rills in the width of the taper, and the beach reads as a
// ribbon laid on top of the terrain rather than as part of it. What this does is blend the surface
// towards a smoothed copy of itself over a wider band: the relief is untouched, the metre-scale texture
// fades, and the backshore of a beach comes out smoother than the hillside behind it - which is what a
// backshore is. 0 turns it off.
SmoothReachM float64 `json:"smooth_reach_m"`
}
type Pipeline struct {
GeologyFactor int `json:"geology_factor"`
Continent Continent `json:"continent"`
Coast Coast `json:"coast"`
Plates Plates `json:"plates"`
Faults Faults `json:"faults"`
Lithology Lithology `json:"lithology"`
Relief Relief `json:"relief"`
Fluvial Fluvial `json:"fluvial"`
Thermal Thermal `json:"thermal"`
Strata Strata `json:"strata"`
Detail Detail `json:"detail"`
Particle Particle `json:"particle"`
GeologyFactor int `json:"geology_factor"`
Continent Continent `json:"continent"`
Coast Coast `json:"coast"`
CoastDetail CoastDetail `json:"coast_detail"`
Plates Plates `json:"plates"`
Faults Faults `json:"faults"`
Lithology Lithology `json:"lithology"`
Relief Relief `json:"relief"`
Fluvial Fluvial `json:"fluvial"`
Thermal Thermal `json:"thermal"`
Smooth Smooth `json:"smooth"`
Strata Strata `json:"strata"`
Detail Detail `json:"detail"`
Particle Particle `json:"particle"`
}
// Planet turns a manifest into a planet-scale bake driven by a painted template instead of a seed.
//
// Its presence is what switches the generator from the square canvas to the cylinder; a manifest without it
// is the world the `generate` command has always built, unchanged. Paths are relative to the manifest file.
type Planet struct {
Template string `json:"template"` // the painted map
Legend string `json:"legend"` // what its colours mean
// Palette is how the preview is *drawn* - the hypsometric ramp, the water, the rivers, the ice and the
// light. Optional, and deliberately a file of its own rather than part of the legend: the legend says
// what the colours in the input mean and is about the world, while this is purely a matter of taste
// about the picture, and taste is the thing most likely to want swapping. Empty means the generator's
// own, which internal/field.DefaultPalette holds.
Palette string `json:"palette"`
// CircumferenceKm is how far it is all the way round. With the geology cell fixed at 8 m by D-48 this
// is the one number that sets how big the world is, and it must be a whole number of cells or the seam
// would fall between two columns.
CircumferenceKm float64 `json:"circumference_km"`
// OceanMarginKm is how much water each region carries around its landmass.
//
// The solve needs only one cell of it - a grid edge is an outlet, and the edge has to be water - because
// the coastal pass runs once on the whole cylinder rather than per region. What the margin actually
// decides is clustering: two landmasses within twice this distance are solved in one box, which is the
// right call when they are close enough to be one drainage problem and a waste of memory when they are
// not.
OceanMarginKm float64 `json:"ocean_margin_km"`
// MinLandCells drops specks. A stray paint pixel classified as land would otherwise cost a whole region
// for a rock; below this many cells a landmass goes back to the sea and the run says how many did.
MinLandCells int `json:"min_land_cells"`
// PadClass is the sea class filling the synthetic rows above and below the painted map, which exist so
// that a polar cap has a shore to drain to. Empty means the legend's first sea class.
PadClass string `json:"pad_class"`
// NoisePeriodKm is how far a world-coordinate noise lattice runs before repeating. It must divide the
// circumference exactly or every noise field breaks at the seam. Zero means one turn.
NoisePeriodKm float64 `json:"noise_period_km"`
// DetailNoisePeriodKm is the same thing for the detail passes, and it is short because it has to be: a
// noise lattice holds (period/wavelength)^2 floats, so an eight-metre octave on a hundred-kilometre
// period is a gigabyte and a half. What repeats at a kilometre is a few metres of surface roughness with
// no shape to it; everything with a shape comes from the solve and the paint, which do not repeat.
// It must divide the circumference too.
DetailNoisePeriodKm float64 `json:"detail_noise_period_km"`
// UpliftVariation is how much the painted uplift rate is modulated by sub-pixel noise, as a fraction.
//
// It is not decoration. D-49: uniform uplift over a wide area produces no divides, and with no divides
// the router falls back on the priority-flood's epsilon and draws its traversal order as rivers. A
// painted lowland holds one rate over tens of kilometres, so without this it would come out table-flat
// with the flood's geometry scratched across it.
UpliftVariation float64 `json:"uplift_variation"`
// MassifWavelengthKm is how big the planet's upland fabric is: the size of the blocks a class with a
// massif breaks into. One fabric for the whole world rather than one per class, deliberately, so that a
// highland belt and the hills in the lowland beside it are high and low parts of a single structure - a
// foreland and its outliers - instead of two unrelated noises that happen to meet at a painted edge.
//
// It is rounded to a whole number of lattice cells in the noise period, because noise.Lattice.Sample
// wraps modulo its cell count and anything else breaks at the seam. `terrain plan` prints what it was
// rounded to.
MassifWavelengthKm float64 `json:"massif_wavelength_km"`
// LithologyWavelengthKm is how big the planet's rock provinces are. Zero means no lithology at all, which
// is what every painted planet had before D-58: one flat erodibility inside each painted class, so
// map_erodibility.png was a recolour of map_class.png and there was nothing to make one flank of a range
// read differently from the next.
//
// The types and their multipliers are `pipeline.lithology`, shared with the procedural path. What is new
// here is the wavelength, because a province on a 100 km planet is a different size from one on a 14 km
// canvas, and the cut is a quantile of the planet rather than a percentile of whatever grid is in front
// of it - see internal/uplift's painted_rock.go for why that distinction is not optional.
LithologyWavelengthKm float64 `json:"lithology_wavelength_km"`
// FaultGrainKm is the wavelength of the fault set's orientation field: faults within one of its cells
// come out sub-parallel, and the strike swings gradually across the world.
//
// It is a field rather than one global angle because a single strike is what the procedural path has and
// it reads as corduroy across a whole map. Which classes are faulted at all, and how hard, is the
// legend's `faults` block; this is only how they are aimed.
FaultGrainKm float64 `json:"fault_grain_km"`
// CoastJitterPx perturbs the painted waterline by this many template pixels of world-coordinate noise.
//
// An upsampled painted outline is a smooth polygon, and a coastline is fractal - which is the whole
// content of the Richardson paradox and, measured, the difference between a shore with bays the shelter
// model can work with and one the fetch reports as fully open everywhere.
CoastJitterPx float64 `json:"coast_jitter_px"`
// CoastJitterWavelengthPx is the coarsest octave: the size of the biggest bay it can cut, in template
// pixels. Octaves halve from there, so the finest detail is this over 2^(octaves-1).
CoastJitterWavelengthPx float64 `json:"coast_jitter_wavelength_px"`
// CoastJitterOctaves and CoastJitterGain are the fractal structure. A gain near 0.5 makes each scale as
// prominent as the last, which is the property a real coastline has and a single wobble does not - it is
// the whole content of the Richardson paradox, and it is why one octave reads as a wobbly line rather
// than as a coast.
CoastJitterOctaves int `json:"coast_jitter_octaves"`
CoastJitterGain float64 `json:"coast_jitter_gain"`
// Overlay and OverlayLegend are the annotation layer: a second painting registered to the first, and a
// legend of marks saying what its colours stand for. Both empty means there is no overlay, which is what
// every planet had before D-57 and what one still has until an author paints one.
//
// It is a second *image* rather than more colours on the first because the two answer different
// questions. A class is geology - every colour on the template changes an uplift rate or an erodibility,
// and the solve answers for it - while a mark is a thing placed on the finished world: a forest, a
// village, a road, or a stretch of coast the author drew deliberately and does not want roughened. There
// is no uplift rate for a town, and a mark has to be able to sit on top of any class without changing it.
//
// See internal/overlay. Only one mark property is read by the generator at all (coast_jitter); the rest
// travel through to the engine as per-tile masks and as features in world metres in overlay.json.
Overlay string `json:"overlay"`
OverlayLegend string `json:"overlay_legend"`
// Plates is the tectonic model: how many rigid pieces the lithosphere is in and how fast they move.
//
// It is `planet.plates` rather than `pipeline.plates` deliberately. The two are different models of the
// same word: the procedural block below is a percentile range band over whatever grid it is handed, which
// D-53 forbids on a decomposed planet, while this one is drawn once for the whole cylinder in world
// metres and produces boundary *geometry* - the lines pass 3 was always specified to read.
//
// A count of zero switches it off, which is what every template painted before it had. It is off by
// default because nothing in the solve reads it yet: what it produces today is a diagnostic map and a
// set of lines in meta.json.
Plates plates.Config `json:"plates"`
}
type Manifest struct {
@@ -253,6 +509,9 @@ type Manifest struct {
Layers Layers `json:"layers"`
Pipeline Pipeline `json:"pipeline"`
// Planet is present only on a planet manifest. Its absence is what keeps `generate` exactly as it was.
Planet *Planet `json:"planet"`
// Erosion is the pre-D-47 block. Kept only so a manifest that still carries it can be reported rather
// than silently ignored.
Erosion map[string]any `json:"erosion"`
@@ -311,6 +570,25 @@ func Defaults() *Manifest {
// has a number to work from instead of an impression of a picture.
RiverM3PerKm2: 1.2e5, RiverExponent: 0.6, RiverChannelKm2: 0.5,
},
CoastDetail: CoastDetail{
Enabled: true,
// A bay a hundred and twenty metres across with six metres of wander in it. That is the
// scale the template's own coast_jitter cannot reach: its wavelength is 384 template px,
// which is five kilometres here, and its finest octave is still 150 m of paint.
CrenulationM: 6, CrenulationWaveM: 120, ShoreSmoothM: 12,
// Dean's A for medium sand. 0.12 puts the 2 m contour 65 m offshore and the 5 m contour
// 260 m, which is a beach you can wade out on and not a shelf.
DeanA: 0.12, BermBackM: 25, BeachFillM: 3,
// A coast with eight metres of land behind it is a beach; one with thirty is a cliff. Both
// are the backshore *mean* between one and two surf reaches inland, so a low headland in a
// bay does not turn the bay into a cliff coast.
CliffFromM: 8, CliffToM: 30, CliffMaxM: 60,
// tan 70 degrees. A heightfield cannot hold an overhang, so a wave-cut notch is the one
// piece of a cliff this pass cannot draw; what it can do is stop a thirty-metre cliff
// arriving as a four-cell ramp, which is what the upsample makes of it.
CliffGrade: 2.75, ScreeDeg: 34, ScreeReachM: 30,
PlatformReliefM: 0.6, SmoothReachM: 90,
},
Plates: Plates{
Count: 6, VelocityCmYr: Range{1, 5}, BandKm: Range{2, 4},
DivergentMmYr: Range{-2, -1}, RiftKm: Range{3, 6},
@@ -378,12 +656,31 @@ func Defaults() *Manifest {
CriticalSlopeDeg: 35,
SlopeCap: 0.9,
MaxHillslopeSub: 24,
// One, and the choice is not a tuning decision. On a planar hillslope the correct specific
// catchment area is the same at every point along a contour, and D8 cannot say so: it gives
// one cell the whole flow tube and its neighbour a single cell for ever. Measured on a ramp
// at an aspect of 22.5 degrees, the most-drained cell in a contour band carried 769 times
// the median and 30 % of the grid drained nothing; at an exponent of one it is 1.34 and
// 0.4 %. Raising it past one narrows the spread again, so it is the knob to reach for if
// map_flow reads as broad smears rather than rivers - but a real valley has its cross-valley
// neighbours *above* it, which get zero weight whatever the exponent, so MFD is already D8
// wherever convergence is real.
MFDExponent: 1,
},
Thermal: Thermal{CoarsePasses: 2, Every: 4, FinePasses: 24, TalusDeg: 35},
Strata: Strata{PeriodM: 160, Contrast: 0.6},
Detail: Detail{Octaves: 4, AmplitudeM: Range{2, 8}},
// Off. Turning it on is a decision to hide something rather than to fix it, so it is a decision
// somebody makes in a file. 0.3 is about seventeen degrees: steeper than that is a landform and
// is left alone.
Smooth: Smooth{Passes: 0, SlopeRef: 0.3},
Strata: Strata{PeriodM: 160, Contrast: 0.6},
// ClassBlendM 120 is fifteen geology cells, which is exactly half the tile margin and therefore the
// most a tile can blend without reading past its own cut: two passes of a box blur reach twice the
// radius. SeabedM 24 is twice the shore taper, so the texture is fully in by the time the water is
// deep enough to hold it and gone again before the shelf.
Detail: Detail{Octaves: 4, AmplitudeM: Range{2, 8}, TilePx: 2500, ClassBlendM: 120, SeabedM: 24},
Particle: Particle{
Droplets: 9000000, Lifetime: 40, Scale: 0.5, MinErodeSlope: 0.25, MaxChange: 0.2,
Droplets: 9000000, DropletsPerCell: 0.18, Rounds: 16,
Lifetime: 40, Scale: 0.5, MinErodeSlope: 0.25, MaxChange: 0.2,
MaxSpeed: 5, MaxLoad: 2, Inertia: 0.1, Capacity: 2, MinSlope: 0.01,
ErodeRate: 0.2, DepositRate: 0.2, Evaporation: 0.02, Gravity: 4, Batch: 200000,
},
@@ -404,9 +701,254 @@ func Load(path string) (*Manifest, error) {
return nil, fmt.Errorf("%s: %w", path, err)
}
m.Path = path
m.fillPlanetDefaults()
return m, m.Validate()
}
// fillPlanetDefaults runs after the merge rather than in Defaults(), because the block is a pointer: a
// manifest without one is not a planet at all, and json.Unmarshal would allocate a zero struct over
// anything Defaults had put there.
func (m *Manifest) fillPlanetDefaults() {
p := m.Planet
if p == nil {
return
}
if p.CircumferenceKm == 0 {
p.CircumferenceKm = 100
}
if p.OceanMarginKm == 0 {
p.OceanMarginKm = 0.5
}
if p.MinLandCells == 0 {
p.MinLandCells = 16
}
if p.UpliftVariation == 0 {
p.UpliftVariation = 0.30
}
// 12 px is about 155 m on a 100 km planet drawn at 7738 px, and the octaves run from 2.5 km down to
// 155 m. The old default was 1.5 px, which is one template pixel of wobble and would have been invisible
// - it was never read by anything, so it was never a measured number.
if p.CoastJitterPx == 0 {
p.CoastJitterPx = 12
}
if p.CoastJitterWavelengthPx == 0 {
p.CoastJitterWavelengthPx = 192
}
if p.CoastJitterOctaves == 0 {
p.CoastJitterOctaves = 5
}
if p.CoastJitterGain == 0 {
p.CoastJitterGain = 0.55
}
if p.MassifWavelengthKm == 0 {
p.MassifWavelengthKm = 12
}
if p.NoisePeriodKm == 0 {
p.NoisePeriodKm = p.CircumferenceKm
}
if p.DetailNoisePeriodKm == 0 {
p.DetailNoisePeriodKm = 1
}
// A real shelf break, because on a planet a real margin fits. The square canvas's 30 m is not a shelf
// break at all, it is the shallow end of a 14 km canvas's sea floor range, and inheriting it here is
// what made a painted 512 m ocean come out as a 20 m pond (D-64).
if m.Pipeline.Coast.BreakM == 0 {
m.Pipeline.Coast.BreakM = 130
}
}
// ShelfBreakM is how deep the water is at the shelf break, in positive metres.
//
// The planet names it outright. The square canvas never did: its sea floor is a range and the shelf break is
// the shallow end of it, so the fallback keeps that reading rather than inventing a key for a manifest that
// was written without one.
func (m *Manifest) ShelfBreakM() float64 {
if m.Pipeline.Coast.BreakM > 0 {
return m.Pipeline.Coast.BreakM
}
return -m.Pipeline.Continent.SeaFloorM.Hi()
}
// IsPlanet reports whether this manifest describes a painted planet rather than the square canvas.
func (m *Manifest) IsPlanet() bool { return m.Planet != nil }
// TemplatePath and LegendPath resolve the planet's two inputs against the manifest's own directory.
func (m *Manifest) TemplatePath() string { return m.relative(m.Planet.Template) }
func (m *Manifest) LegendPath() string { return m.relative(m.Planet.Legend) }
// OverlayPath and OverlayLegendPath resolve the annotation layer, or "" when there is none. The image may
// be named by the manifest or, failing that, by the overlay legend itself; the manifest wins, which is what
// lets the studio's versioned saves repoint without rewriting a file the author wrote.
func (m *Manifest) OverlayLegendPath() string {
if m.Planet == nil || m.Planet.OverlayLegend == "" {
return ""
}
return m.relative(m.Planet.OverlayLegend)
}
// OverlayPath is the painted overlay named by the manifest, or "" when it names none.
func (m *Manifest) OverlayPath() string {
if m.Planet == nil || m.Planet.Overlay == "" {
return ""
}
return m.relative(m.Planet.Overlay)
}
// HasOverlay reports whether an annotation layer is configured at all.
func (m *Manifest) HasOverlay() bool { return m.OverlayLegendPath() != "" }
// PlatesLayerPath and PlatesLegendPath resolve the painted tectonic layer, or "" when there is none. Same
// shape as the overlay's pair above, and for the same reason: the manifest names the image so that a
// versioned save can be repointed without rewriting the legend an author wrote.
func (m *Manifest) PlatesLayerPath() string {
if m.Planet == nil || m.Planet.Plates.Layer == "" {
return ""
}
return m.relative(m.Planet.Plates.Layer)
}
func (m *Manifest) PlatesLegendPath() string {
if m.Planet == nil || m.Planet.Plates.Legend == "" {
return ""
}
return m.relative(m.Planet.Plates.Legend)
}
// HasPaintedPlates reports whether the tectonics are drawn rather than generated.
func (m *Manifest) HasPaintedPlates() bool {
return m.PlatesLayerPath() != "" && m.PlatesLegendPath() != ""
}
// PalettePath is the preview palette, or "" when the manifest names none.
func (m *Manifest) PalettePath() string {
if m.Planet == nil || m.Planet.Palette == "" {
return ""
}
return m.relative(m.Planet.Palette)
}
func (m *Manifest) relative(p string) string {
if p == "" || filepath.IsAbs(p) {
return p
}
return filepath.Join(filepath.Dir(m.Path), p)
}
// validatePlanet checks the numbers that would otherwise fail deep inside a bake, or - worse - not fail.
func (m *Manifest) validatePlanet() error {
p := m.Planet
if p.Template == "" {
return fmt.Errorf("%s: planet.template is empty", m.Path)
}
if p.Legend == "" {
return fmt.Errorf("%s: planet.legend is empty", m.Path)
}
if p.CircumferenceKm <= 0 {
return fmt.Errorf("%s: planet.circumference_km is %v", m.Path, p.CircumferenceKm)
}
cell := m.GeologyCellM()
cols := p.CircumferenceKm * 1000 / cell
if d := cols - math.Round(cols); d > 1e-9 || d < -1e-9 {
return fmt.Errorf("%s: a %.3f km circumference is %.4f cells of %.1f m. It must be a whole number, "+
"or the seam falls between two columns; the nearest that works is %.3f km",
m.Path, p.CircumferenceKm, cols, cell, math.Round(cols)*cell/1000)
}
if p.OceanMarginKm <= 0 {
return fmt.Errorf("%s: planet.ocean_margin_km is %v; a region needs a ring of water", m.Path, p.OceanMarginKm)
}
if p.CoastJitterPx < 0 {
return fmt.Errorf("%s: planet.coast_jitter_px is %v", m.Path, p.CoastJitterPx)
}
if p.CoastJitterPx > 0 {
if p.CoastJitterWavelengthPx <= 0 {
return fmt.Errorf("%s: planet.coast_jitter_wavelength_px is %v", m.Path, p.CoastJitterWavelengthPx)
}
if p.CoastJitterOctaves < 1 || p.CoastJitterOctaves > 12 {
return fmt.Errorf("%s: planet.coast_jitter_octaves is %d, outside 1..12",
m.Path, p.CoastJitterOctaves)
}
if p.CoastJitterGain <= 0 || p.CoastJitterGain >= 1 {
return fmt.Errorf("%s: planet.coast_jitter_gain is %v, outside 0..1 exclusive",
m.Path, p.CoastJitterGain)
}
}
if p.MassifWavelengthKm <= 0 {
return fmt.Errorf("%s: planet.massif_wavelength_km is %v", m.Path, p.MassifWavelengthKm)
}
for _, w := range []struct {
key string
km float64
}{{"lithology_wavelength_km", p.LithologyWavelengthKm}, {"fault_grain_km", p.FaultGrainKm}} {
if w.km < 0 {
return fmt.Errorf("%s: planet.%s is %v; it is a wavelength in kilometres", m.Path, w.key, w.km)
}
if w.km > p.NoisePeriodKm {
return fmt.Errorf("%s: planet.%s is %v km, longer than the noise period of %v km, so the field "+
"would be one lattice cell and flat over the whole world",
m.Path, w.key, w.km, p.NoisePeriodKm)
}
}
if p.MassifWavelengthKm > p.NoisePeriodKm {
return fmt.Errorf("%s: planet.massif_wavelength_km is %v against a noise period of %v. The fabric "+
"would be a single lattice cell, so every massif on the planet would be the same one",
m.Path, p.MassifWavelengthKm, p.NoisePeriodKm)
}
for _, np := range []struct {
key string
period float64
}{{"noise_period_km", p.NoisePeriodKm}, {"detail_noise_period_km", p.DetailNoisePeriodKm}} {
if np.period <= 0 {
return fmt.Errorf("%s: planet.%s is %v", m.Path, np.key, np.period)
}
if k := p.CircumferenceKm / np.period; math.Abs(k-math.Round(k)) > 1e-9 || k < 1 {
return fmt.Errorf("%s: planet.%s %v does not divide the circumference %v (%.4f times); every "+
"noise field built on it would break at the seam", m.Path, np.key, np.period,
p.CircumferenceKm, k)
}
}
return nil
}
// LithologyCells is the rock field's wavelength in lattice cells of the noise period, or 0 when the planet
// asks for no lithology. Rounded the same way MassifCells is and for the same reason: noise.Lattice.Sample
// wraps modulo its cell count, so anything else breaks at the seam.
func (p *Planet) LithologyCells() int {
if p.LithologyWavelengthKm <= 0 {
return 0
}
n := int(p.NoisePeriodKm/p.LithologyWavelengthKm + 0.5)
if n < 1 {
n = 1
}
return n
}
// MassifCells is the upland fabric's wavelength counted in lattice cells of the noise period, which is what
// noise.Params.BaseCells takes. It has to be a whole number: noise.Lattice.Sample wraps modulo its cell count,
// so a fraction of a cell at the seam is a discontinuity down one meridian.
func (p *Planet) MassifCells() int {
n := int(p.NoisePeriodKm/p.MassifWavelengthKm + 0.5)
if n < 1 {
n = 1
}
return n
}
// MassifWavelengthRoundedKm is the wavelength MassifCells actually delivers, which is what a run should
// report rather than what was asked for.
func (p *Planet) MassifWavelengthRoundedKm() float64 {
return p.NoisePeriodKm / float64(p.MassifCells())
}
// MarginCells is the ocean margin in geology cells, at least one.
func (p *Planet) MarginCells(cellM float64) int {
n := int(p.OceanMarginKm*1000/cellM + 0.5)
if n < 1 {
n = 1
}
return n
}
func (m *Manifest) Validate() error {
if m.VerticesPerSide < 2 {
return fmt.Errorf("%s: vertices_per_side must be at least 2", m.Path)
@@ -439,6 +981,9 @@ func (m *Manifest) Validate() error {
if f := m.Pipeline.GeologyFactor; f < 1 || q%f != 0 {
return fmt.Errorf("%s: geology_factor %d must divide the quad count %d exactly", m.Path, f, q)
}
if m.IsPlanet() {
return m.validatePlanet()
}
return nil
}
@@ -521,16 +1066,36 @@ func (m *Manifest) ClipFraction(metres []float32) float64 {
if len(metres) == 0 {
return 0
}
var n int
return float64(m.ClipCells(metres)) / float64(len(metres))
}
// ClipCells is the same count before it is turned into a fraction.
//
// A fraction of one region is not a fraction of a planet and cannot be made into one without carrying the
// region's size beside it, so anything that pools across regions counts cells and divides at the end. See
// internal/stats.
func (m *Manifest) ClipCells(metres []float32) int64 {
var n int64
for _, v := range metres {
if float64(v) < m.ElevationM.Min || float64(v) > m.ElevationM.Max {
n++
}
}
return float64(n) / float64(len(metres))
return n
}
// Encode turns metres into the 16-bit values the PNG carries, clamping to the range.
// Decode is Encode's inverse: 16-bit samples back to metres. It is what lets the detail bake read a geology
// bake's heightmap off disk instead of holding it, which is what makes the two commands separable.
func (m *Manifest) Decode(values []uint16) []float32 {
span := m.ElevationM.Max - m.ElevationM.Min
out := make([]float32, len(values))
for i, v := range values {
out[i] = float32(m.ElevationM.Min + float64(v)/65535*span)
}
return out
}
func (m *Manifest) Encode(metres []float32) []uint16 {
out := make([]uint16, len(metres))
for i, v := range metres {
+165
View File
@@ -0,0 +1,165 @@
package overlay
import (
"encoding/json"
"image/png"
"os"
"path/filepath"
"salty/terrain/internal/field"
)
// What the overlay hands to whatever builds the level.
//
// Two shapes, because two different things want it. A *raster* answers "what is under this square metre" and
// is what a per-tile importer wants: one 8-bit image beside each height tile, an index per detail cell, zero
// for nothing. A *feature list* answers "where do I put the village" and "what curve does the road follow",
// and lives once at the planet root in world metres because a point is not a pixel and a spline crossing a
// tile boundary is still one spline.
//
// Nothing in the generator reads either of them back. That is the point of the layer.
// CoastScale is the per-pixel multiplier on the waterline roughening, or nil when no mark asks for one.
//
// A pixel with no instruction comes back negative rather than 1, which is the contract template.Coast.Scale
// documents: an unmarked cell takes its instruction from the far side of the waterline instead of overriding
// what the marked side said.
func (l *Legend) CoastScale(r *Raster) []float32 {
if !l.TouchesCoast() {
return nil
}
per := make([]float32, len(l.Marks)+1)
per[Blank] = -1
for i := range l.Marks {
if j, set := l.Marks[i].Jitter(); set {
per[i+1] = float32(j)
} else {
per[i+1] = -1
}
}
out := make([]float32, len(r.Mark))
field.Rows(r.H, func(y0, y1 int) {
for i := y0 * r.W; i < y1*r.W; i++ {
out[i] = per[r.Mark[i]]
}
})
return out
}
// Document is overlay.json: everything an importer needs to place what the author painted.
type Document struct {
Image string `json:"image"`
Legend string `json:"legend"`
// The frame the coordinates are in: metres east from the seam and metres south from the top painted row,
// which is the same frame world.Planet uses for a painted cell once the polar pad is taken off.
CircumferenceM float64 `json:"circumference_m"`
HeightM float64 `json:"height_m"`
PaintW int `json:"paint_w"`
PaintH int `json:"paint_h"`
MetresPerPxX float64 `json:"metres_per_px_x"`
MetresPerPxY float64 `json:"metres_per_px_y"`
Marks []MarkShare `json:"marks"`
Features []Feature `json:"features"`
}
// MarkShare is one mark and how much of the world carries it.
type MarkShare struct {
Index int `json:"index"`
Name string `json:"name"`
Kind string `json:"kind"`
RGB [3]int `json:"rgb"`
Cells int `json:"cells_px"`
AreaKm2 float64 `json:"area_km2"`
Pieces int `json:"pieces"`
WidthM float64 `json:"width_m,omitempty"`
// Jitter and HasJitter are a pair and neither is omitempty, because the interesting value of the first
// is **zero** - a pinned coastline - and omitting it would leave every consumer of this file unable to
// tell "pin it" from "said nothing", which is the one distinction the key exists to make.
Jitter float64 `json:"coast_jitter"`
HasJitter bool `json:"has_coast_jitter"`
Note string `json:"note,omitempty"`
}
// Describe builds the document from a classified overlay.
func (l *Legend) Describe(r *Raster, m Match, s Scale, image, legend string) *Document {
feats := l.Features(r, s)
pieces := make([]int, len(l.Marks)+1)
for _, f := range feats {
pieces[f.Index]++
}
doc := &Document{
Image: image, Legend: legend,
CircumferenceM: s.CircumferenceM,
HeightM: float64(r.H) * s.MetresPerPxY,
PaintW: r.W, PaintH: r.H,
MetresPerPxX: s.MetresPerPxX, MetresPerPxY: s.MetresPerPxY,
Features: feats,
}
for i := range l.Marks {
mk := &l.Marks[i]
cells := 0
if i+1 < len(m.Counts) {
cells = m.Counts[i+1]
}
share := MarkShare{
Index: i + 1, Name: mk.Name, Kind: mk.Kind, RGB: mk.RGB,
Cells: cells, AreaKm2: float64(cells) * s.MetresPerPxX * s.MetresPerPxY / 1e6,
Pieces: pieces[i+1], WidthM: mk.WidthM, Note: mk.Note,
}
if j, set := mk.Jitter(); set {
share.Jitter, share.HasJitter = j, true
}
doc.Marks = append(doc.Marks, share)
}
return doc
}
// WriteJSON writes overlay.json.
func (d *Document) WriteJSON(dir string) error {
data, err := json.MarshalIndent(d, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, "overlay.json"), append(data, '\n'), 0o644)
}
// WriteMask writes an 8-bit indexed PNG: one mark index a pixel, zero for nothing. It is the per-tile
// output, and it is indexed rather than one image a mark because marks cannot overlap - the overlay is one
// painting and a pixel is one colour - so 254 masks fit in the file one would have taken.
func WriteMask(path string, w, h int, marks []uint8) error {
return field.WriteGray8(path, w, h, marks, png.BestCompression)
}
// SampleWorld reads the overlay over a rectangle of some other grid, described in world metres.
//
// World metres rather than cell indices, because the caller is a *detail* tile: it is 2 m where the overlay
// is 12.9 and the geology is 8, and it sits at an origin that is only expressible in metres. Going through
// the common frame is the only way the three agree, and it is rule 1 of the tiling plan applied to a raster
// instead of to a noise - a cell gets the same mark whichever tile reaches it.
//
// Nearest neighbour, for the same reason the class raster is: an index is a name, and the average of
// "forest" and "road" is neither.
func (r *Raster) SampleWorld(originXM, originYM, cellM float64, w, h int, s Scale) []uint8 {
out := make([]uint8, w*h)
field.Rows(h, func(b0, b1 int) {
for y := b0; y < b1; y++ {
py := int((originYM + (float64(y)+0.5)*cellM) / s.MetresPerPxY)
if py < 0 {
py = 0
} else if py >= r.H {
py = r.H - 1
}
row := py * r.W
for x := 0; x < w; x++ {
px := int((originXM + (float64(x)+0.5)*cellM) / s.MetresPerPxX)
px = ((px % r.W) + r.W) % r.W
out[y*w+x] = r.Mark[row+px]
}
}
})
return out
}
+377
View File
@@ -0,0 +1,377 @@
package overlay
import (
"math"
"sort"
)
// Turning painted strokes into things an engine can place.
//
// A raster is enough for anything that is a mask - where the forest is, where the ground is a town - and the
// per-tile output is exactly that. It is not enough for anything that is a *position* or a *line*: "put a
// village here" wants a point and a radius, and "run a road along this" wants an ordered polyline, because
// the thing being built on the other side is a spline. So the marks are also reduced to features in world
// metres, once, over the whole cylinder.
//
// Both reductions work on connected components with X wrapped, because the world does. A component that
// straddles the seam is one thing, and reporting it as two would put half a forest at each end of the map.
// Feature is one connected piece of one mark, reduced to something placeable.
type Feature struct {
Mark string `json:"mark"`
Index int `json:"index"`
Kind string `json:"kind"`
ID int `json:"id"`
// CentreM is the centroid in world metres. X is a circular mean, so a component across the seam reports
// a centre on the component rather than on the far side of the world.
CentreM [2]float64 `json:"centre_m"`
// AreaM2 is the painted area, and RadiusM the radius of the disc with that area - the number to hand a
// placement rule that wants "how big is this village".
AreaM2 float64 `json:"area_m2"`
RadiusM float64 `json:"radius_m"`
// ExtentM is the bounding box, as width and height in metres. For a component across the seam the width
// is measured the short way round, which is the way it was painted.
ExtentM [2]float64 `json:"extent_m"`
Cells int `json:"cells_px"`
// PointsM is the centreline, in world metres, for a path. Empty for an area.
PointsM [][2]float64 `json:"points_m,omitempty"`
LengthM float64 `json:"length_m,omitempty"`
WidthM float64 `json:"width_m,omitempty"`
}
// Scale converts overlay pixels to world metres. The overlay is painted at the template's resolution, which
// is not the geology grid's, so nothing here may assume a pixel is a cell.
type Scale struct {
MetresPerPxX float64
MetresPerPxY float64
// CircumferenceM is how far X runs before it comes back to itself, for the circular mean.
CircumferenceM float64
}
// Features reduces every mark on the raster to placeable pieces, in mark order and then in a stable order
// within a mark.
//
// Stable means "does not depend on which goroutine ran", which is cross-cutting rule 12 and is why this is
// serial: it is one pass over a raster of a few tens of millions of pixels and it runs once per plan.
func (l *Legend) Features(r *Raster, s Scale) []Feature {
var out []Feature
// A visited flag and nothing more. It is a bool rather than a component id because nothing downstream
// asks which component a pixel belonged to, and at planet scale that is 29 MB against 116.
seen := make([]bool, len(r.Mark))
var stack []int32
for mi := range l.Marks {
m := &l.Marks[mi]
idx := uint8(mi + 1)
minArea := l.MinArea(m)
var found []Feature
for start := 0; start < len(r.Mark); start++ {
if r.Mark[start] != idx || seen[start] {
continue
}
cells := flood(r, idx, int32(start), seen, &stack)
if len(cells) < minArea {
continue
}
f := describe(r, m, mi+1, len(found), cells, s)
if !m.Area() {
pts := trace(r, cells)
f.PointsM, f.LengthM = project(pts, r, s)
f.WidthM = m.WidthM
}
found = append(found, f)
}
// Biggest first: a placement rule that takes the first few wants the ones that matter.
sort.SliceStable(found, func(a, b int) bool { return found[a].Cells > found[b].Cells })
for i := range found {
found[i].ID = i
}
out = append(out, found...)
}
return out
}
// flood collects one 8-connected component with X wrapped. The scratch stack is reused across components so
// a map with thousands of specks does not allocate thousands of slices.
func flood(r *Raster, idx uint8, start int32, seen []bool, stack *[]int32) []int32 {
cells := []int32{start}
seen[start] = true
*stack = (*stack)[:0]
*stack = append(*stack, start)
for len(*stack) > 0 {
i := (*stack)[len(*stack)-1]
*stack = (*stack)[:len(*stack)-1]
x, y := int(i)%r.W, int(i)/r.W
for dy := -1; dy <= 1; dy++ {
ny := y + dy
if ny < 0 || ny >= r.H {
continue
}
for dx := -1; dx <= 1; dx++ {
if dx == 0 && dy == 0 {
continue
}
nx := x + dx
if nx < 0 {
nx += r.W
} else if nx >= r.W {
nx -= r.W
}
n := int32(ny*r.W + nx)
if seen[n] || r.Mark[n] != idx {
continue
}
seen[n] = true
cells = append(cells, n)
*stack = append(*stack, n)
}
}
}
return cells
}
// describe measures a component: centroid, area, extent.
//
// X is a circular mean - the average of the unit vectors at each cell's longitude, turned back into an angle.
// A plain mean would put the centre of a component straddling the seam on the opposite side of the planet,
// which is the one failure mode a cylindrical map has and the one nobody notices until a village appears in
// the ocean.
func describe(r *Raster, m *Mark, idx, id int, cells []int32, s Scale) Feature {
var sx, sy, cx float64
for _, i := range cells {
x, y := float64(int(i)%r.W), float64(int(i)/r.W)
th := 2 * math.Pi * x / float64(r.W)
sx += math.Sin(th)
cx += math.Cos(th)
sy += y
}
n := float64(len(cells))
th := math.Atan2(sx/n, cx/n)
if th < 0 {
th += 2 * math.Pi
}
meanX := th / (2 * math.Pi) * float64(r.W)
meanY := sy / n
// The extent, measured relative to the circular centre so the seam is not a boundary.
var lo, hi, y0, y1 float64
lo, hi = math.Inf(1), math.Inf(-1)
y0, y1 = math.Inf(1), math.Inf(-1)
for _, i := range cells {
x, y := float64(int(i)%r.W), float64(int(i)/r.W)
d := x - meanX
if d > float64(r.W)/2 {
d -= float64(r.W)
} else if d < -float64(r.W)/2 {
d += float64(r.W)
}
lo = math.Min(lo, d)
hi = math.Max(hi, d)
y0 = math.Min(y0, y)
y1 = math.Max(y1, y)
}
areaM2 := n * s.MetresPerPxX * s.MetresPerPxY
return Feature{
Mark: m.Name, Index: idx, Kind: m.Kind, ID: id,
CentreM: [2]float64{meanX * s.MetresPerPxX, meanY * s.MetresPerPxY},
AreaM2: areaM2,
RadiusM: math.Sqrt(areaM2 / math.Pi),
ExtentM: [2]float64{(hi - lo + 1) * s.MetresPerPxX, (y1 - y0 + 1) * s.MetresPerPxY},
Cells: len(cells),
}
}
// trace reduces a painted stroke to its centreline, as an ordered run of pixel indices.
//
// The stroke's width is not the road; a brush eight pixels wide standing for a cart track is an author saying
// "along here", not "this is eighty metres of carriageway". What comes out is the longest line through the
// component, which for a stroke is the stroke.
//
// It is the geodesic diameter, found by two breadth-first searches: from any cell to the furthest cell A,
// then from A to the furthest cell B, keeping parents. The walk from B back to A is the path. That is the
// standard trick and it is exact on a tree; on a stroke with a loop in it, it takes the long way round, which
// is the right answer for a road that loops and the wrong one for a road that forks - a fork reports its two
// longest arms as one path and drops the third. The remedy is an author's, not the tool's: paint each run as
// its own stroke. `terrain plan` says how many components each path mark has, which is where that shows.
//
// The walk is then smoothed once and simplified, because a breadth-first search leaves a D8 staircase and a
// spline built straight from it would wobble at the pixel scale.
func trace(r *Raster, cells []int32) []int32 {
if len(cells) < 2 {
return cells
}
// A local index for the component, so the searches do not allocate over the whole map.
local := make(map[int32]int32, len(cells)*2)
for i, c := range cells {
local[c] = int32(i)
}
far := func(from int32) (int32, []int32) {
dist := make([]int32, len(cells))
parent := make([]int32, len(cells))
for i := range dist {
dist[i] = -1
parent[i] = -1
}
start := local[from]
dist[start] = 0
queue := []int32{start}
best, bestD := start, int32(0)
for head := 0; head < len(queue); head++ {
cur := queue[head]
ci := cells[cur]
x, y := int(ci)%r.W, int(ci)/r.W
for dy := -1; dy <= 1; dy++ {
ny := y + dy
if ny < 0 || ny >= r.H {
continue
}
for dx := -1; dx <= 1; dx++ {
if dx == 0 && dy == 0 {
continue
}
nx := x + dx
if nx < 0 {
nx += r.W
} else if nx >= r.W {
nx -= r.W
}
n, ok := local[int32(ny*r.W+nx)]
if !ok || dist[n] >= 0 {
continue
}
dist[n] = dist[cur] + 1
parent[n] = cur
if dist[n] > bestD {
bestD, best = dist[n], n
}
queue = append(queue, n)
}
}
}
return best, parent
}
a, _ := far(cells[0])
b, parent := far(cells[a])
var path []int32
for n := b; n >= 0; n = parent[n] {
path = append(path, cells[n])
if parent[n] < 0 {
break
}
}
// Reversed so the line runs from A to B, which is the order the search found them in and therefore the
// same order on every run.
for i, j := 0, len(path)-1; i < j; i, j = i+1, j-1 {
path[i], path[j] = path[j], path[i]
}
return path
}
// project turns a run of pixels into a simplified polyline in world metres, and measures its length.
//
// Simplification is Douglas-Peucker at half a pixel of the overlay, which is well below anything an author
// drew and well above the single-pixel staircase the walk leaves behind. The seam is handled by unrolling X:
// each point is taken to the branch nearest the last, so a road crossing the meridian comes out as one
// continuous run of coordinates rather than jumping the width of the world. A consumer that wraps it back
// does so knowing the circumference; a consumer that does not gets a spline that still looks right.
func project(path []int32, r *Raster, s Scale) ([][2]float64, float64) {
if len(path) == 0 {
return nil, 0
}
pts := make([][2]float64, len(path))
prevX := float64(int(path[0]) % r.W)
for i, p := range path {
x, y := float64(int(p)%r.W), float64(int(p)/r.W)
for x-prevX > float64(r.W)/2 {
x -= float64(r.W)
}
for prevX-x > float64(r.W)/2 {
x += float64(r.W)
}
prevX = x
pts[i] = [2]float64{x, y}
}
pts = smooth(pts)
pts = simplify(pts, 0.5)
out := make([][2]float64, len(pts))
length := 0.0
for i, p := range pts {
out[i] = [2]float64{p[0] * s.MetresPerPxX, p[1] * s.MetresPerPxY}
if i > 0 {
length += math.Hypot(out[i][0]-out[i-1][0], out[i][1]-out[i-1][1])
}
}
return out, length
}
// smooth is a three-point moving average with the ends pinned. One pass: enough to take the staircase off a
// D8 walk, not enough to pull a real corner off the line it was drawn on.
func smooth(p [][2]float64) [][2]float64 {
if len(p) < 3 {
return p
}
out := make([][2]float64, len(p))
out[0], out[len(p)-1] = p[0], p[len(p)-1]
for i := 1; i < len(p)-1; i++ {
out[i] = [2]float64{
(p[i-1][0] + p[i][0] + p[i+1][0]) / 3,
(p[i-1][1] + p[i][1] + p[i+1][1]) / 3,
}
}
return out
}
// simplify is Douglas-Peucker, iterative so a ten-thousand-point stroke cannot blow the stack.
func simplify(p [][2]float64, tol float64) [][2]float64 {
if len(p) < 3 {
return p
}
keep := make([]bool, len(p))
keep[0], keep[len(p)-1] = true, true
type span struct{ a, b int }
stack := []span{{0, len(p) - 1}}
for len(stack) > 0 {
sp := stack[len(stack)-1]
stack = stack[:len(stack)-1]
if sp.b <= sp.a+1 {
continue
}
worst, worstD := -1, tol
for i := sp.a + 1; i < sp.b; i++ {
if d := perpendicular(p[i], p[sp.a], p[sp.b]); d > worstD {
worstD, worst = d, i
}
}
if worst < 0 {
continue
}
keep[worst] = true
stack = append(stack, span{sp.a, worst}, span{worst, sp.b})
}
out := make([][2]float64, 0, len(p))
for i, k := range keep {
if k {
out = append(out, p[i])
}
}
return out
}
func perpendicular(p, a, b [2]float64) float64 {
dx, dy := b[0]-a[0], b[1]-a[1]
l := math.Hypot(dx, dy)
if l == 0 {
return math.Hypot(p[0]-a[0], p[1]-a[1])
}
return math.Abs(dy*(p[0]-a[0])-dx*(p[1]-a[1])) / l
}
+867
View File
@@ -0,0 +1,867 @@
package overlay
import (
"fmt"
"math"
"sort"
"salty/terrain/internal/field"
"salty/terrain/internal/noise"
)
// Filling an overlay in from a baked world, so an author starts from something rather than from nothing.
//
// The annotation layer is hand-painted and it starts blank, which is the right default and a bad starting
// point. Where a forest can grow, where a town would actually stand and what a road between two towns would
// follow are all *consequences of the terrain* - of slope, of where the rivers run, of how far the sea is -
// and the terrain is the one thing an author cannot see while painting, because the solve has not happened
// yet when they are painting classes and the heightmap is 29 million pixels when it has. So the generator
// reads a finished bake and proposes the marks the terrain implies. The author then moves them.
//
// Four rules, and they are the whole design:
//
// - **A painted pixel is never touched.** Generation fills blank pixels only. An author who has drawn the
// capital exactly where they want it can regenerate everything else around it as often as they like, and
// the two halves compose rather than competing. This is what makes the feature safe to re-run.
// - **It is opt-in per mark.** A mark generates only if it carries a `generate` block. A legend written
// before this existed produces exactly the blank sheet it always did, and a mark the author wants to own
// completely simply says nothing.
// - **It runs at the template's resolution**, which is the overlay's own. Generating on the 8 m geology
// grid and downsampling would smear a road across two colours, and the classifier reads exact colours -
// a blended pixel is dropped or becomes a different mark. Nothing here antialiases anything, for the
// same reason the studio's brush does not.
// - **It proposes, it does not decide.** These are starting points. The numbers below are chosen to put
// something plausible on the sheet, not to be a settlement model.
//
// What it is emphatically not: a simulation. There is no economy, no history and no climate here - the Go
// generator has no climate model at all - so "where would a city be" is answered with drainage, slope and
// distance to the sea, which is the part of the question the terrain can actually answer.
// Generate kinds. A mark's `generate.kind` picks one.
const (
// GenForest fills ground that could carry trees: shallow enough, below the treeline, and broken up by a
// noise field so it reads as woodland rather than as a contour band.
GenForest = "forest"
// GenSettlement places discs at scored sites - rivers, flat ground, the coast - with a minimum spacing,
// largest tier first. Several marks may use it; they share one spacing rule, so a village never lands
// inside a city.
GenSettlement = "settlement"
// GenRoad joins the settlements that were placed, along least-cost paths over the terrain. Water is
// impassable, so roads never swim: an island group comes out as one road network per island.
GenRoad = "road"
// GenCoast bands the waterline. It is the one kind whose mark usually carries `coast_jitter`, which is
// the only overlay property any pass reads.
GenCoast = "coast"
)
// GenSpec is a mark's `generate` block: what to put where, and the few numbers worth varying. Every zero
// field takes a default that is derived from the world being generated rather than from a constant, because
// a treeline in metres means nothing until you know how high the land got.
type GenSpec struct {
Kind string `json:"kind"`
// MaxSlopeDeg is the steepest ground this mark will be put on. Forests stop at cliffs, towns stand on
// flat ground, and roads climb but grudgingly.
MaxSlopeDeg float64 `json:"max_slope_deg"`
// MinHeightM and MaxHeightM bound the elevation band. MaxHeightM zero means "derive a treeline from the
// land's own height distribution", which is the only honest default on a world whose relief is unknown
// until it is baked.
MinHeightM float64 `json:"min_height_m"`
MaxHeightM float64 `json:"max_height_m"`
// Cover is roughly the fraction of the eligible ground this mark should take, for area kinds. It is a
// quantile of the noise field rather than a count, so it means the same thing on any size of world.
Cover float64 `json:"cover"`
// WavelengthKm is how big the patches are, for area kinds.
WavelengthKm float64 `json:"wavelength_km"`
// Count is how many of this mark to place, for settlements.
Count int `json:"count"`
// MinSpacingKm is how far apart settlements must stand. Shared across every settlement mark, taken from
// the largest that sets one.
MinSpacingKm float64 `json:"min_spacing_km"`
// RadiusM is how big the painted blob is. Zero derives one from the mark's own min_area_px, so the blob
// this writes is never one the feature reducer would then discard as a speck.
RadiusM float64 `json:"radius_m"`
// WidthM is how wide a band or a road is painted. For a road the legend's own width_m is used when this
// is zero, because that is the same number said once.
WidthM float64 `json:"width_m"`
// CoastKm is how far inland a coast band reaches, and how close to the sea a settlement wants to be for
// its coastal bonus.
CoastKm float64 `json:"coast_km"`
// OnlyClasses and NotClasses restrict a mark to, or bar it from, ground painted with named classes from
// the *class* legend.
//
// They exist because height and slope cannot tell an ice cap from a meadow. The first run of this
// generator grew woodland across both polar caps: the caps are flat, they are below the treeline, and
// nothing the terrain knows says otherwise - the only thing that does is the colour the author painted
// there. A class name that is not in the legend is an error rather than an empty filter, because a
// misspelt exclusion is a forest on an ice cap that nobody notices.
OnlyClasses []string `json:"only_classes"`
NotClasses []string `json:"not_classes"`
// Resolved forms of the two lists above, as class indices. Filled in by Generate.
onlyIdx map[int]bool
notIdx map[int]bool
}
// resolveClasses turns the class names into indices against the class legend that was actually loaded.
func (g *GenSpec) resolveClasses(markName string, names []string) error {
find := func(list []string) (map[int]bool, error) {
if len(list) == 0 {
return nil, nil
}
if len(names) == 0 {
return nil, fmt.Errorf("mark %q names classes, but no class legend was handed to the generator",
markName)
}
out := map[int]bool{}
for _, want := range list {
found := -1
for i, n := range names {
if n == want {
found = i
break
}
}
if found < 0 {
return nil, fmt.Errorf("mark %q names the class %q, which is not in the class legend",
markName, want)
}
out[found] = true
}
return out, nil
}
var err error
if g.onlyIdx, err = find(g.OnlyClasses); err != nil {
return err
}
g.notIdx, err = find(g.NotClasses)
return err
}
func (g *GenSpec) validate(markName string) error {
switch g.Kind {
case GenForest, GenSettlement, GenRoad, GenCoast:
default:
return fmt.Errorf("mark %q: generate.kind %q is not one of %q, %q, %q, %q",
markName, g.Kind, GenForest, GenSettlement, GenRoad, GenCoast)
}
if g.Cover < 0 || g.Cover > 1 {
return fmt.Errorf("mark %q: generate.cover is %v, outside 0..1", markName, g.Cover)
}
if g.Count < 0 {
return fmt.Errorf("mark %q: generate.count is %d", markName, g.Count)
}
return nil
}
// GenInputs is the baked world the marks are read off, at the overlay's own resolution.
type GenInputs struct {
W, H int
CellM float64 // metres per overlay pixel
// HeightM is the surface in metres and Sea is which cells are under water, both over the painted rows
// only - the polar pad is scaffolding and has no marks on it.
HeightM []float32
Sea []bool
// FlowM2 is drainage area in square metres. Nil is allowed: rivers then contribute nothing to a
// settlement's score, which is worth saying out loud rather than silently scoring zero everywhere.
FlowM2 []float32
// ClassAt is the class legend's index per cell, and ClassNames the names those indices mean. Both are
// optional together: without them only_classes and not_classes cannot be honoured, and asking for one is
// then an error rather than a filter that quietly does nothing.
ClassAt []uint8
ClassNames []string
Seed int64
// Existing is the overlay as it stands. Its painted pixels are preserved exactly and generation fills
// around them. Nil is a blank sheet.
Existing *Raster
}
// GenReport is what was placed, for the run summary.
type GenReport struct {
Marks []GenMarkReport
Kept int // pixels that were already painted and were left alone
Painted int // pixels this generation filled
TreelineM float64
Settlement []Placed
}
// GenMarkReport is one mark's share of a generation.
type GenMarkReport struct {
Name string
Kind string
Cells int
Pieces int // settlements placed, or roads traced
// Wanted is how many were asked for, when that is a number the legend gave. Reported separately from
// Pieces so a run that could not fit them all says so: the spacing and the amount of flat ground are
// what ration settlements, and an author who asked for forty and got eighteen needs to be told, not left
// to count the dots.
Wanted int
}
// Placed is one settlement, kept so the roads can be run between them and so the summary can say where they
// went.
type Placed struct {
Mark int // raster index
X, Y int
Score float64
RadPx int
Region int // which connected landmass, so roads never try to cross open water
}
// wantedFor is how many of a mark the legend asked for, or zero when it is not a counted kind.
func wantedFor(m *Mark) int {
if m.Generate == nil {
return 0
}
return m.Generate.Count
}
// Generate fills the blank parts of an overlay from a baked world.
func (l *Legend) Generate(in GenInputs) (*Raster, GenReport, error) {
var rep GenReport
if in.W <= 0 || in.H <= 0 {
return nil, rep, fmt.Errorf("overlay generation needs a size, got %dx%d", in.W, in.H)
}
if len(in.HeightM) != in.W*in.H || len(in.Sea) != in.W*in.H {
return nil, rep, fmt.Errorf("overlay generation: height and sea must be %d cells", in.W*in.H)
}
for i := range l.Marks {
if g := l.Marks[i].Generate; g != nil {
if err := g.validate(l.Marks[i].Name); err != nil {
return nil, rep, err
}
if err := g.resolveClasses(l.Marks[i].Name, in.ClassNames); err != nil {
return nil, rep, err
}
}
}
if in.ClassAt != nil && len(in.ClassAt) != in.W*in.H {
return nil, rep, fmt.Errorf("overlay generation: the class raster is %d cells and the grid is %d",
len(in.ClassAt), in.W*in.H)
}
out := &Raster{W: in.W, H: in.H, Mark: make([]uint8, in.W*in.H)}
// What was on the sheet before this run, kept separately from what is on it now. The distinction is the
// whole layering rule: a hand-painted pixel is never touched, while a mark this run has just put down
// may be built over by a later one - a road through generated woodland is a road, and a town on it is a
// town. Without the two being different, whichever kind painted first would block every kind after it,
// which is exactly what happened on the first run: a coastal band claimed a fifth of the world and the
// settlements and roads placed inside it painted nothing at all.
protectedPx := make([]bool, in.W*in.H)
if in.Existing != nil {
if in.Existing.W != in.W || in.Existing.H != in.H {
return nil, rep, fmt.Errorf("the overlay on disk is %dx%d and the generator is working at %dx%d",
in.Existing.W, in.Existing.H, in.W, in.H)
}
copy(out.Mark, in.Existing.Mark)
for i, m := range out.Mark {
if m != Blank {
protectedPx[i] = true
rep.Kept++
}
}
}
d := newGenData(in)
d.protected = protectedPx
rep.TreelineM = d.treelineM
// Painting order is coarse to fine: the coastal band, then woodland, then the roads across it, then the
// settlements the roads run between.
//
// Two area marks never overwrite each other - the first in legend order claims the overlap, because
// deciding that a forest beats a coastline or the reverse is an authoring judgement and not one a
// generator should make silently. Roads and settlements do overwrite generated areas, because they are
// the thing being placed and the area is the ground it stands on.
order := []string{GenCoast, GenForest, GenRoad, GenSettlement}
byKind := map[string][]int{}
for i := range l.Marks {
if g := l.Marks[i].Generate; g != nil {
byKind[g.Kind] = append(byKind[g.Kind], i)
}
}
// Settlements are placed before the roads are drawn even though they are painted after, because the
// roads are the paths between them and cannot be traced until they exist.
if len(byKind[GenSettlement]) > 0 {
rep.Settlement = l.placeSettlements(byKind[GenSettlement], d)
}
for _, kind := range order {
for _, mi := range byKind[kind] {
m := &l.Marks[mi]
idx := uint8(mi + 1)
var cells, pieces int
switch kind {
case GenCoast:
cells = l.paintCoastBand(m, d, out, idx)
case GenForest:
cells = l.paintForest(m, d, out, idx)
case GenRoad:
cells, pieces = l.paintRoads(m, d, out, idx, rep.Settlement)
case GenSettlement:
cells, pieces = paintSettlements(d, out, idx, rep.Settlement, l.MinArea(m))
}
rep.Marks = append(rep.Marks, GenMarkReport{
Name: m.Name, Kind: kind, Cells: cells, Pieces: pieces, Wanted: wantedFor(m),
})
rep.Painted += cells
}
}
return out, rep, nil
}
// genData is everything derived once and shared by the kinds: slope, distance to the sea, the treeline and
// the landmass labels.
type genData struct {
in GenInputs
// protected marks the pixels that were already painted when this run started. Nothing here may write to
// one, whatever kind it is.
protected []bool
slopeDeg []float32
coastKm []float32 // distance to the nearest sea cell, kilometres; land only
landID []int32 // connected landmass, -1 at sea
treelineM float64
landMaxM float64
flowLog []float32 // log10 of drainage area, normalised 0..1 over the land
}
func newGenData(in GenInputs) *genData {
d := &genData{in: in}
d.slopeDeg = slopeField(in.HeightM, in.W, in.H, in.CellM)
d.coastKm = coastDistanceKm(in.Sea, in.W, in.H, in.CellM)
d.landID = labelLandmasses(in.Sea, in.W, in.H)
// The treeline is a quantile of the land's own heights rather than a number in metres, because a metre
// means nothing until the world is baked: the same legend over a 47 m plain and a 2800 m range has to
// put trees on both. Two thirds of the way up leaves the summits bare on a world that has summits and
// takes almost nothing off a world that does not - which is correct, a lowland has no treeline.
var hs []float32
for i, s := range in.Sea {
if !s {
hs = append(hs, in.HeightM[i])
}
}
if len(hs) > 0 {
sort.Slice(hs, func(a, b int) bool { return hs[a] < hs[b] })
d.landMaxM = float64(hs[len(hs)-1])
d.treelineM = float64(hs[int(float64(len(hs)-1)*0.94)])
}
if in.FlowM2 != nil && len(in.FlowM2) == in.W*in.H {
d.flowLog = make([]float32, in.W*in.H)
cell := in.CellM * in.CellM
// Normalised against a trunk river's catchment rather than the map's largest, so one enormous basin
// cannot flatten every other river to nothing.
hi := math.Log10(math.Max(cell*4, 5e7))
lo := math.Log10(math.Max(cell, 1))
for i, f := range in.FlowM2 {
if in.Sea[i] || f <= 0 {
continue
}
t := (math.Log10(float64(f)) - lo) / (hi - lo)
d.flowLog[i] = float32(math.Max(0, math.Min(1, t)))
}
}
return d
}
// slopeField is the surface gradient in degrees, central differences, X wrapped because the world is a
// cylinder and Y clamped because it is not a sphere.
func slopeField(h []float32, w, hgt int, cellM float64) []float32 {
out := make([]float32, w*hgt)
field.Rows(hgt, func(y0, y1 int) {
for y := y0; y < y1; y++ {
ym := y - 1
if ym < 0 {
ym = 0
}
yp := y + 1
if yp >= hgt {
yp = hgt - 1
}
for x := 0; x < w; x++ {
xm := (x - 1 + w) % w
xp := (x + 1) % w
dzdx := float64(h[y*w+xp]-h[y*w+xm]) / (2 * cellM)
dzdy := float64(h[yp*w+x]-h[ym*w+x]) / (2 * cellM)
out[y*w+x] = float32(math.Atan(math.Hypot(dzdx, dzdy)) * 180 / math.Pi)
}
}
})
return out
}
// coastDistanceKm is how far each land cell is from the sea, by a multi-source breadth-first walk over the
// eight neighbours with X wrapped. Hop distance rather than Euclidean: it is a score input, and a BFS over
// 29 million cells costs one pass where a distance transform costs several.
func coastDistanceKm(sea []bool, w, h int, cellM float64) []float32 {
out := make([]float32, w*h)
for i := range out {
out[i] = -1
}
queue := make([]int32, 0, w*8)
for i, s := range sea {
if s {
continue
}
x, y := i%w, i/w
if touchesSea(sea, w, h, x, y) {
out[i] = 0
queue = append(queue, int32(i))
}
}
hop := float32(cellM / 1000)
for head := 0; head < len(queue); head++ {
c := int(queue[head])
cx, cy := c%w, c/w
d := out[c] + hop
for _, o := range neighbours8 {
nx := (cx + o[0] + w) % w
ny := cy + o[1]
if ny < 0 || ny >= h {
continue
}
n := ny*w + nx
if sea[n] || out[n] >= 0 {
continue
}
out[n] = d
queue = append(queue, int32(n))
}
}
return out
}
var neighbours8 = [8][2]int{{-1, -1}, {0, -1}, {1, -1}, {-1, 0}, {1, 0}, {-1, 1}, {0, 1}, {1, 1}}
func touchesSea(sea []bool, w, h, x, y int) bool {
for _, o := range neighbours8 {
nx := (x + o[0] + w) % w
ny := y + o[1]
if ny < 0 || ny >= h {
continue
}
if sea[ny*w+nx] {
return true
}
}
return false
}
// labelLandmasses numbers the connected land components, X wrapped, so a landmass across the seam is one
// landmass. Roads are built per component, which is what stops them crossing open water.
func labelLandmasses(sea []bool, w, h int) []int32 {
out := make([]int32, w*h)
for i := range out {
out[i] = -1
}
var stack []int32
next := int32(0)
for start := range sea {
if sea[start] || out[start] >= 0 {
continue
}
id := next
next++
out[start] = id
stack = append(stack[:0], int32(start))
for len(stack) > 0 {
c := int(stack[len(stack)-1])
stack = stack[:len(stack)-1]
cx, cy := c%w, c/w
for _, o := range neighbours8 {
nx := (cx + o[0] + w) % w
ny := cy + o[1]
if ny < 0 || ny >= h {
continue
}
n := ny*w + nx
if sea[n] || out[n] >= 0 {
continue
}
out[n] = id
stack = append(stack, int32(n))
}
}
}
return out
}
// eligible is the shared test every kind starts from: on land, not too steep, inside the height band.
func (d *genData) eligible(i int, g *GenSpec, maxDefault float64) bool {
if d.in.Sea[i] {
return false
}
maxSlope := g.MaxSlopeDeg
if maxSlope <= 0 {
maxSlope = maxDefault
}
if float64(d.slopeDeg[i]) > maxSlope {
return false
}
if !d.classAllows(i, g) {
return false
}
hm := float64(d.in.HeightM[i])
if hm < g.MinHeightM {
return false
}
top := g.MaxHeightM
if top <= 0 {
top = d.treelineM
}
return top <= 0 || hm <= top
}
// classAllows applies a mark's only_classes and not_classes to one cell.
func (d *genData) classAllows(i int, g *GenSpec) bool {
if d.in.ClassAt == nil || (g.onlyIdx == nil && g.notIdx == nil) {
return true
}
c := int(d.in.ClassAt[i])
if g.notIdx != nil && g.notIdx[c] {
return false
}
if g.onlyIdx != nil && !g.onlyIdx[c] {
return false
}
return true
}
// paintForest fills eligible ground where a noise field stands above a quantile, so woodland has an outline
// rather than a contour edge. The field is indexed by world position (cross-cutting rule 1), so the same
// ground gets the same trees whatever else changes.
func (l *Legend) paintForest(m *Mark, d *genData, out *Raster, idx uint8) int {
g := m.Generate
cover := g.Cover
if cover <= 0 {
cover = 0.45
}
wavelengthKm := g.WavelengthKm
if wavelengthKm <= 0 {
wavelengthKm = 6
}
in := d.in
circM := float64(in.W) * in.CellM
u, v := noise.WorldUV(in.W, in.H, in.CellM, 0, 0, math.Max(circM, 1))
cells := math.Max(1, math.Round(circM/(wavelengthKm*1000)))
f := noise.FBMAt(u, v, noise.NewSource(in.Seed, srcOverlayForest),
noise.Params{BaseCells: int(cells), Octaves: 4, Gain: 0.5})
// The threshold is a quantile of the noise *over the eligible ground*, so `cover` means what it says on a
// world whose eligible ground is a thin strip as much as on one where it is everything.
// The quantile is taken over the ground this mark can actually take - eligible and not already claimed -
// so `cover` means the same fraction whether or not another area mark got there first.
var vals []float32
for i := range out.Mark {
if out.Mark[i] == Blank && d.eligible(i, g, 25) {
vals = append(vals, f.Data[i])
}
}
if len(vals) == 0 {
return 0
}
sort.Slice(vals, func(a, b int) bool { return vals[a] < vals[b] })
cut := vals[int(float64(len(vals)-1)*(1-cover))]
n := 0
for i := range out.Mark {
if out.Mark[i] != Blank || f.Data[i] < cut || !d.eligible(i, g, 25) {
continue
}
out.Mark[i] = idx
n++
}
return n
}
// paintCoastBand marks a strip inland of the waterline. Its usual purpose is to carry `coast_jitter`, so it
// deliberately follows the shore rather than any other feature.
func (l *Legend) paintCoastBand(m *Mark, d *genData, out *Raster, idx uint8) int {
g := m.Generate
reachKm := g.CoastKm
if reachKm <= 0 {
if g.WidthM > 0 {
reachKm = g.WidthM / 1000
} else {
reachKm = 1.5
}
}
n := 0
for i := range out.Mark {
if out.Mark[i] != Blank || d.in.Sea[i] {
continue
}
if c := d.coastKm[i]; c >= 0 && float64(c) <= reachKm {
out.Mark[i] = idx
n++
}
}
return n
}
// placeSettlements scores the land and takes the best sites, largest tier first, with one spacing rule
// shared by every settlement mark so a village never lands inside a city.
//
// The score is the part of "where would a town be" that terrain can answer: fresh water, flat ground, and
// the sea. Everything else about a settlement - trade, history, who won a war - is the author's, which is
// why these are proposals in an editable sheet rather than a placement the bake bakes in.
func (l *Legend) placeSettlements(marks []int, d *genData) []Placed {
in := d.in
spacingKm := 0.0
for _, mi := range marks {
if s := l.Marks[mi].Generate.MinSpacingKm; s > spacingKm {
spacingKm = s
}
}
if spacingKm <= 0 {
spacingKm = 4
}
spacingPx := math.Max(2, spacingKm*1000/in.CellM)
// Tiers in the order the legend lists them, which is how an author already writes them: city, town,
// village. The first listed takes the best sites.
type tier struct {
mi int
g *GenSpec
radPx int
}
var tiers []tier
for _, mi := range marks {
g := l.Marks[mi].Generate
radM := g.RadiusM
if radM <= 0 {
// Big enough that the feature reducer will not drop it as a speck. The margin is generous on
// purpose: a disc loses area wherever it meets ground that is already painted, and a settlement
// that came out just under its own min_area_px would be placed, reported, and then silently
// dropped by the feature pass - which is what happened to a city on the first real run. 1.6
// linear is 2.6x the area, so it survives losing more than half of itself.
minArea := float64(l.MinArea(&l.Marks[mi]))
radM = math.Sqrt(minArea/math.Pi) * in.CellM * 1.6
}
radPx := int(math.Max(1, math.Round(radM/in.CellM)))
tiers = append(tiers, tier{mi: mi, g: g, radPx: radPx})
}
// Candidates are taken on a stride rather than from every cell: two sites a quarter of the spacing apart
// are the same site, and sorting 29 million scores to throw away all but fifty is work for nothing.
stride := int(math.Max(1, math.Floor(spacingPx/4)))
type cand struct {
i int
score float64
}
var cands []cand
for y := 0; y < in.H; y += stride {
for x := 0; x < in.W; x += stride {
i := y*in.W + x
s := d.settlementScore(i)
if s > 0 {
// The seed picks among the plausible sites; the terrain decides which sites are plausible at
// all. Without this the score is a pure function of the ground, so every press of the
// studio's generate button proposes exactly the same towns and a re-roll re-rolls nothing.
// A third either way reshuffles the ranking among comparable ground while still leaving a
// river mouth on a plain beating a hillside.
s *= 1 + settlementJitter*(hash01(uint64(i), uint64(in.Seed))-0.5)
cands = append(cands, cand{i: i, score: s})
}
}
}
// Sorted by score, ties broken by index so the result does not depend on the sort's stability.
sort.Slice(cands, func(a, b int) bool {
if cands[a].score != cands[b].score {
return cands[a].score > cands[b].score
}
return cands[a].i < cands[b].i
})
var placed []Placed
taken := make([][2]int, 0, 64)
sp2 := spacingPx * spacingPx
farEnough := func(x, y int) bool {
for _, t := range taken {
dx := float64(wrapDelta(x-t[0], in.W))
dy := float64(y - t[1])
if dx*dx+dy*dy < sp2 {
return false
}
}
return true
}
for _, t := range tiers {
want := t.g.Count
if want <= 0 {
continue
}
got := 0
maxSlope := t.g.MaxSlopeDeg
if maxSlope <= 0 {
maxSlope = 8
}
for _, c := range cands {
if got >= want {
break
}
if float64(d.slopeDeg[c.i]) > maxSlope {
continue
}
x, y := c.i%in.W, c.i/in.W
if !farEnough(x, y) {
continue
}
taken = append(taken, [2]int{x, y})
placed = append(placed, Placed{
Mark: t.mi + 1, X: x, Y: y, Score: c.score, RadPx: t.radPx,
Region: int(d.landID[c.i]),
})
got++
}
}
return placed
}
// settlementJitter is how far the seed may move a site's score, as a fraction. Large enough that the
// ranking among comparable ground genuinely reshuffles between presses, small enough that a site three times
// better than its neighbour still wins every time.
const settlementJitter = 0.65
// hash01 is a deterministic value in [0,1) from two integers: splitmix64 finalised. Not a stream, so it does
// not matter which order the cells are visited in, which is cross-cutting rule 12.
func hash01(a, b uint64) float64 {
x := a*0x9e3779b97f4a7c15 + b*0xbf58476d1ce4e5b9
x ^= x >> 30
x *= 0xbf58476d1ce4e5b9
x ^= x >> 27
x *= 0x94d049bb133111eb
x ^= x >> 31
return float64(x>>11) / float64(1<<53)
}
// settlementScore is 0 where nobody would build and rises with the three things the terrain knows.
//
// Ground that is already painted scores zero, which is not a judgement about the ground: a site there cannot
// be stamped, because nothing may overwrite a hand-painted pixel. Scoring it anyway is how a settlement gets
// placed, counted and reported and then paints nothing at all - measured on the shipped template, one city of
// three and six villages of eighteen came out as empty blobs that the feature pass then dropped, so the run
// summary and `terrain plan` disagreed with each other and neither was wrong.
func (d *genData) settlementScore(i int) float64 {
if d.in.Sea[i] || d.protected[i] {
return 0
}
slope := float64(d.slopeDeg[i])
if slope > 12 {
return 0
}
flat := 1 - slope/12
river := 0.0
if d.flowLog != nil {
river = float64(d.flowLog[i])
}
// A coast bonus that falls off over a few kilometres: a harbour is worth a great deal, being forty
// kilometres inland is worth nothing either way.
coast := 0.0
if c := d.coastKm[i]; c >= 0 {
coast = math.Max(0, 1-float64(c)/5)
}
// Flat ground is a precondition rather than an attraction, so it multiplies; water and the sea are the
// reasons to be here, so they add.
return flat * (0.15 + 1.5*river + 1.0*coast)
}
// paintSettlements stamps each placed site, growing the disc until the blob is big enough to survive the
// feature pass.
//
// The growth loop is not a flourish. A disc loses whatever part of itself falls on a coastline somebody has
// already painted, or on the sea, and settlements are scored *towards* the coast, so the loss is routine
// rather than rare. Without it the generator places a town, reports it, writes it, and the feature reducer
// then drops it as a speck - so `terrain plan` lists fewer settlements than the run said it made, with
// nothing anywhere to explain the difference. Measured on the shipped template: three cities placed and two
// reported, eighteen villages placed and twelve reported.
//
// It gives up after a few tries rather than growing without limit: a site hemmed in on every side is telling
// you it is a bad site, and a village the size of a county is worse than a missing one.
func paintSettlements(d *genData, out *Raster, idx uint8, placed []Placed, minArea int) (int, int) {
n, pieces := 0, 0
for _, p := range placed {
if uint8(p.Mark) != idx {
continue
}
pieces++
got, r := 0, p.RadPx
for try := 0; try < 4; try++ {
// Re-stamping a larger disc only adds the new ring, because the cells already taken carry this
// mark, so the area accumulates rather than being recounted.
got += stampDisc(out, d, p.X, p.Y, r, idx, true)
if got >= minArea {
break
}
r = int(math.Ceil(float64(r) * 1.5))
}
n += got
}
return n, pieces
}
// stampDisc paints a filled circle, wrapping in X.
//
// overArea says whether this mark may cover ground another generated mark has already taken. A hand-painted
// pixel is never covered either way, which is what keeps a drawn stroke intact underneath a generated town.
func stampDisc(out *Raster, d *genData, cx, cy, r int, idx uint8, overArea bool) int {
n := 0
r2 := r * r
for dy := -r; dy <= r; dy++ {
y := cy + dy
if y < 0 || y >= out.H {
continue
}
for dx := -r; dx <= r; dx++ {
if dx*dx+dy*dy > r2 {
continue
}
x := ((cx+dx)%out.W + out.W) % out.W
i := y*out.W + x
if d.in.Sea[i] || d.protected[i] || (!overArea && out.Mark[i] != Blank) {
continue
}
out.Mark[i] = idx
n++
}
}
return n
}
func wrapDelta(d, w int) int {
if d > w/2 {
d -= w
} else if d < -w/2 {
d += w
}
return d
}
// srcOverlayForest is this pass's seeded noise stream. It sits above the detail passes' 40s and the
// tectonic 50s so that adding one here cannot reshuffle any existing field.
const srcOverlayForest = 60
@@ -0,0 +1,335 @@
package overlay
import (
"math"
"sort"
)
// Roads: the least-cost paths between the settlements that were just placed.
//
// A road is the one mark whose shape is not a judgement at all. Given where two towns are, the line between
// them is whatever the ground allows - up the valley, round the spur, across the saddle - and that is a
// shortest-path problem with a cost function, not a drawing. It is also the single most tedious thing to
// paint by hand, because getting it right means reading a heightmap pixel by pixel.
//
// Three decisions worth stating:
//
// - **Water is impassable, so roads never swim.** Each landmass gets its own network. A bridge or a ferry
// is a deliberate act and belongs to the author, and a generator that guessed at them would put a
// motorway across a strait it has no idea is thirty kilometres wide.
// - **A minimum spanning tree, not every pair.** Joining all pairs gives a cobweb; the tree gives exactly
// enough road to reach everywhere, which is both what a road network minimally is and the thing an
// author can most easily add to. Edges are weighted by path *cost*, not by straight-line distance, so
// two towns either side of a range are correctly further apart than the map says.
// - **It runs on a coarsened grid.** A road at the overlay's full resolution would be a Dijkstra over
// twenty-nine million cells per settlement. The cost surface is smooth at the scale a road cares about,
// so it is pooled to a few hundred cells across, solved there, and the resulting polyline is stamped
// back at full resolution with the mark's real width.
// roadGrid is the coarsened cost surface the paths are solved on.
type roadGrid struct {
w, h int
step int // overlay pixels per coarse cell
cost []float32 // per coarse cell, +Inf where impassable
scale float64 // overlay pixels per coarse cell, as a float
}
func buildRoadGrid(d *genData, maxSlopeDeg float64) *roadGrid {
in := d.in
// About six hundred cells around the world: fine enough that a coarse cell is well under a kilometre on
// any world this tool makes, coarse enough that fifty Dijkstras are a second's work.
step := int(math.Max(1, math.Round(float64(in.W)/600)))
gw := (in.W + step - 1) / step
gh := (in.H + step - 1) / step
g := &roadGrid{w: gw, h: gh, step: step, scale: float64(step), cost: make([]float32, gw*gh)}
inf := float32(math.Inf(1))
for gy := 0; gy < gh; gy++ {
for gx := 0; gx < gw; gx++ {
// Pool the block: any sea in it makes the cell water, because a road that clips a bay is a road
// in the sea. The slope taken is the worst in the block, for the same reason.
var worst float64
wet := false
for y := gy * step; y < (gy+1)*step && y < in.H; y++ {
for x := gx * step; x < (gx+1)*step && x < in.W; x++ {
i := y*in.W + x
if in.Sea[i] {
wet = true
break
}
if s := float64(d.slopeDeg[i]); s > worst {
worst = s
}
}
if wet {
break
}
}
gi := gy*gw + gx
switch {
case wet:
g.cost[gi] = inf
case worst > maxSlopeDeg:
g.cost[gi] = inf
default:
// Slope is what a road pays for. Quadratic rather than linear so that a route prefers a long
// gentle way round to a short steep one, which is what a real road does.
t := worst / math.Max(maxSlopeDeg, 1e-6)
g.cost[gi] = float32(1 + 12*t*t)
}
}
}
return g
}
func (g *roadGrid) idx(x, y int) int { return y*g.w + x }
// dijkstra returns the cost to every reachable coarse cell from a source, and the predecessor chain to walk
// a path back. A binary heap over a few hundred thousand cells; the graph is eight-connected and X wraps.
func (g *roadGrid) dijkstra(src int) (cost []float32, pred []int32) {
n := g.w * g.h
cost = make([]float32, n)
pred = make([]int32, n)
inf := float32(math.Inf(1))
for i := range cost {
cost[i] = inf
pred[i] = -1
}
if math.IsInf(float64(g.cost[src]), 1) {
return cost, pred
}
cost[src] = 0
h := &costHeap{keys: []float32{0}, items: []int32{int32(src)}}
for h.Len() > 0 {
c := int(h.pop())
cx, cy := c%g.w, c/g.w
base := cost[c]
for _, o := range neighbours8 {
nx := (cx + o[0] + g.w) % g.w
ny := cy + o[1]
if ny < 0 || ny >= g.h {
continue
}
n := g.idx(nx, ny)
cc := g.cost[n]
if math.IsInf(float64(cc), 1) {
continue
}
// Diagonal steps cost their real length, or the network shows a bias along the axes.
step := float32(1.0)
if o[0] != 0 && o[1] != 0 {
step = float32(math.Sqrt2)
}
next := base + cc*step
if next < cost[n] {
cost[n] = next
pred[n] = int32(c)
h.push(int32(n), next)
}
}
}
return cost, pred
}
// costHeap is a binary min-heap of coarse cells. Lazy deletion is not needed because a cell is only pushed
// when its cost strictly improves, and a stale entry pops with a cost no better than the settled one.
type costHeap struct {
keys []float32
items []int32
}
func (h *costHeap) Len() int { return len(h.items) }
func (h *costHeap) push(item int32, key float32) {
h.keys = append(h.keys, key)
h.items = append(h.items, item)
i := len(h.items) - 1
for i > 0 {
p := (i - 1) / 2
if h.keys[p] <= h.keys[i] {
break
}
h.keys[p], h.keys[i] = h.keys[i], h.keys[p]
h.items[p], h.items[i] = h.items[i], h.items[p]
i = p
}
}
func (h *costHeap) pop() int32 {
top := h.items[0]
last := len(h.items) - 1
h.keys[0], h.items[0] = h.keys[last], h.items[last]
h.keys = h.keys[:last]
h.items = h.items[:last]
i := 0
for {
l := 2*i + 1
if l >= last {
break
}
if r := l + 1; r < last && h.keys[r] < h.keys[l] {
l = r
}
if h.keys[l] >= h.keys[i] {
break
}
h.keys[l], h.keys[i] = h.keys[i], h.keys[l]
h.items[l], h.items[i] = h.items[i], h.items[l]
i = l
}
return top
}
// paintRoads traces a spanning tree over the settlements of each landmass and stamps it.
func (l *Legend) paintRoads(m *Mark, d *genData, out *Raster, idx uint8, placed []Placed) (int, int) {
if len(placed) < 2 {
return 0, 0
}
g := m.Generate
maxSlope := g.MaxSlopeDeg
if maxSlope <= 0 {
maxSlope = 22
}
widthM := g.WidthM
if widthM <= 0 {
widthM = m.WidthM
}
if widthM <= 0 {
widthM = 8
}
// A road eight metres wide is less than one overlay pixel at 12.9 m, and a mark thinner than a pixel is
// not a mark. It is painted at least one pixel wide and the true width travels in the legend, which is
// exactly how `width_m` is meant to be read.
halfPx := int(math.Max(0, math.Round(widthM/d.in.CellM/2)))
rg := buildRoadGrid(d, maxSlope)
// Settlements grouped by landmass: a spanning tree per island, never between islands.
byRegion := map[int][]int{}
for i, p := range placed {
if p.Region < 0 {
continue
}
byRegion[p.Region] = append(byRegion[p.Region], i)
}
regions := make([]int, 0, len(byRegion))
for r := range byRegion {
regions = append(regions, r)
}
sort.Ints(regions) // deterministic order, cross-cutting rule 12
total, pieces := 0, 0
for _, r := range regions {
members := byRegion[r]
if len(members) < 2 {
continue
}
total += l.connectRegion(rg, d, out, idx, placed, members, halfPx, &pieces)
}
return total, pieces
}
// connectRegion solves the paths among one landmass's settlements and stamps its spanning tree.
func (l *Legend) connectRegion(rg *roadGrid, d *genData, out *Raster, idx uint8,
placed []Placed, members []int, halfPx int, pieces *int) int {
n := len(members)
src := make([]int, n)
for k, pi := range members {
p := placed[pi]
gx := (p.X / rg.step) % rg.w
gy := p.Y / rg.step
if gy >= rg.h {
gy = rg.h - 1
}
src[k] = rg.idx(gx, gy)
}
// One Dijkstra per settlement, kept: the coarse grid is a few hundred thousand cells and a landmass has
// a handful of towns, so holding the predecessor chains costs a few megabytes and saves solving twice.
costs := make([][]float32, n)
preds := make([][]int32, n)
for k := range members {
costs[k], preds[k] = rg.dijkstra(src[k])
}
// Prim's, on path cost. Unreachable pairs are skipped, so a landmass whose towns are separated by ground
// too steep for a road comes out as two networks rather than one impossible line.
inTree := make([]bool, n)
inTree[0] = true
painted := 0
for added := 1; added < n; added++ {
bestA, bestB := -1, -1
best := float32(math.Inf(1))
for a := 0; a < n; a++ {
if !inTree[a] {
continue
}
for b := 0; b < n; b++ {
if inTree[b] {
continue
}
if c := costs[a][src[b]]; c < best {
best, bestA, bestB = c, a, b
}
}
}
if bestA < 0 || math.IsInf(float64(best), 1) {
break // nothing else on this landmass is reachable by road
}
inTree[bestB] = true
painted += stampPath(rg, preds[bestA], src[bestA], src[bestB], d, out, idx, halfPx)
*pieces++
}
return painted
}
// stampPath walks the predecessor chain back from dst to src and paints it at full resolution.
func stampPath(rg *roadGrid, pred []int32, src, dst int, d *genData, out *Raster, idx uint8, halfPx int) int {
var chain []int
for c := dst; c >= 0; {
chain = append(chain, c)
if c == src {
break
}
p := pred[c]
if p < 0 {
return 0 // no route; leave the ground unpainted rather than drawing a guess
}
c = int(p)
}
painted := 0
for k := 0; k+1 < len(chain); k++ {
ax, ay := coarseCentre(rg, chain[k])
bx, by := coarseCentre(rg, chain[k+1])
painted += stampSegment(out, d, ax, ay, bx, by, halfPx, idx)
}
return painted
}
func coarseCentre(rg *roadGrid, c int) (int, int) {
gx, gy := c%rg.w, c/rg.w
return gx*rg.step + rg.step/2, gy*rg.step + rg.step/2
}
// stampSegment draws one straight run between two coarse-cell centres, wrapping in X the short way so a road
// crossing the seam is one road rather than a line back across the whole map.
func stampSegment(out *Raster, d *genData, ax, ay, bx, by, halfPx int, idx uint8) int {
dx := wrapDelta(bx-ax, out.W)
dy := by - ay
steps := int(math.Max(math.Abs(float64(dx)), math.Abs(float64(dy))))
if steps == 0 {
return stampDisc(out, d, ax, ay, halfPx, idx, true)
}
painted := 0
for s := 0; s <= steps; s++ {
t := float64(s) / float64(steps)
x := ax + int(math.Round(float64(dx)*t))
y := ay + int(math.Round(float64(dy)*t))
if y < 0 || y >= out.H {
continue
}
painted += stampDisc(out, d, x, y, halfPx, idx, true)
}
return painted
}
@@ -0,0 +1,330 @@
package overlay
import (
"math"
"testing"
)
// A small world with a sea on the left, a flat coastal plain, and a steep ridge on the right, so every
// generated kind has somewhere it should go and somewhere it should not.
func testWorld(w, h int) GenInputs {
height := make([]float32, w*h)
sea := make([]bool, w*h)
flow := make([]float32, w*h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*w + x
switch {
case x < w/5:
sea[i] = true
height[i] = -50
case x < 3*w/5:
height[i] = float32(x-w/5) * 0.2 // a gentle plain
default:
height[i] = float32(w/5)*0.2 + float32(x-3*w/5)*12 // a wall
}
// One river down the middle row of the plain.
if y == h/2 && !sea[i] {
flow[i] = 5e7
}
}
}
return GenInputs{W: w, H: h, CellM: 100, HeightM: height, Sea: sea, FlowM2: flow, Seed: 11}
}
func genLegend(specs map[string]*GenSpec) *Legend {
l := &Legend{
MatchDistance: DefaultMatchDistance,
MinAreaPx: DefaultMinAreaPx,
Marks: []Mark{
{Name: "forest", RGB: [3]int{0, 128, 0}},
{Name: "town", RGB: [3]int{220, 30, 30}},
{Name: "road", RGB: [3]int{90, 60, 30}, Kind: KindPath, WidthM: 8},
{Name: "wild_coast", RGB: [3]int{255, 128, 0}},
{Name: "hand", RGB: [3]int{10, 10, 200}},
},
}
for i := range l.Marks {
if g, ok := specs[l.Marks[i].Name]; ok {
l.Marks[i].Generate = g
}
}
if err := l.resolve(); err != nil {
panic(err)
}
return l
}
// The property the whole feature rests on: generation never touches a pixel somebody painted. Without it,
// re-running the generator would quietly destroy an author's work, and the round trip would be unusable.
func TestGenerationNeverOverwritesPaintedPixels(t *testing.T) {
in := testWorld(200, 120)
l := genLegend(map[string]*GenSpec{
"forest": {Kind: GenForest, Cover: 0.9},
"town": {Kind: GenSettlement, Count: 6, MinSpacingKm: 2},
"road": {Kind: GenRoad},
"wild_coast": {Kind: GenCoast, CoastKm: 3},
})
// A hand-painted stripe right across the plain, where the generator badly wants to put things.
handIdx := uint8(l.Index("hand"))
existing := &Raster{W: in.W, H: in.H, Mark: make([]uint8, in.W*in.H)}
handAt := map[int]bool{}
for y := 0; y < in.H; y++ {
for x := in.W / 5; x < in.W/2; x += 3 {
i := y*in.W + x
existing.Mark[i] = handIdx
handAt[i] = true
}
}
in.Existing = existing
out, rep, err := l.Generate(in)
if err != nil {
t.Fatal(err)
}
for i := range handAt {
if out.Mark[i] != handIdx {
t.Fatalf("cell %d was painted %d by hand and the generator changed it to %d", i, handIdx, out.Mark[i])
}
}
if rep.Kept != len(handAt) {
t.Errorf("kept %d painted pixels, want %d", rep.Kept, len(handAt))
}
if rep.Painted == 0 {
t.Error("the generator filled nothing at all; the test world should have room for every kind")
}
}
// A mark with no generate block is only ever painted by hand. This is what makes the feature opt-in and what
// keeps every legend written before it producing exactly the blank sheet it always did.
func TestMarksWithoutAGenerateBlockAreNeverGenerated(t *testing.T) {
in := testWorld(160, 100)
l := genLegend(map[string]*GenSpec{"forest": {Kind: GenForest, Cover: 0.8}})
out, _, err := l.Generate(in)
if err != nil {
t.Fatal(err)
}
forest := uint8(l.Index("forest"))
for i, m := range out.Mark {
if m != Blank && m != forest {
t.Fatalf("cell %d got mark %d, but only %q asked to be generated", i, m, "forest")
}
}
}
// Nothing is ever put in the sea. A forest, a town or a road on open water is the one output that is simply
// wrong rather than merely a matter of taste.
func TestNothingIsGeneratedAtSea(t *testing.T) {
in := testWorld(200, 120)
l := genLegend(map[string]*GenSpec{
"forest": {Kind: GenForest, Cover: 1},
"town": {Kind: GenSettlement, Count: 8, MinSpacingKm: 2},
"road": {Kind: GenRoad},
"wild_coast": {Kind: GenCoast, CoastKm: 4},
})
out, _, err := l.Generate(in)
if err != nil {
t.Fatal(err)
}
for i, m := range out.Mark {
if m != Blank && in.Sea[i] {
t.Fatalf("cell %d is sea and was marked %d", i, m)
}
}
}
// Settlements keep their spacing, across tiers as well as within one. A village inside a city is two marks
// for one place.
func TestSettlementsKeepTheirSpacing(t *testing.T) {
in := testWorld(300, 160)
l := &Legend{MatchDistance: DefaultMatchDistance, MinAreaPx: DefaultMinAreaPx, Marks: []Mark{
{Name: "city", RGB: [3]int{220, 30, 30}, Generate: &GenSpec{Kind: GenSettlement, Count: 3, MinSpacingKm: 5}},
{Name: "village", RGB: [3]int{150, 90, 200}, Generate: &GenSpec{Kind: GenSettlement, Count: 12}},
}}
if err := l.resolve(); err != nil {
t.Fatal(err)
}
_, rep, err := l.Generate(in)
if err != nil {
t.Fatal(err)
}
if len(rep.Settlement) < 2 {
t.Fatalf("only %d settlements placed; the test world should hold more", len(rep.Settlement))
}
minPx := 5 * 1000 / in.CellM
for a := range rep.Settlement {
for b := a + 1; b < len(rep.Settlement); b++ {
p, q := rep.Settlement[a], rep.Settlement[b]
dx := float64(wrapDelta(p.X-q.X, in.W))
dy := float64(p.Y - q.Y)
if d := math.Hypot(dx, dy); d < minPx-1e-9 {
t.Fatalf("settlements %d and %d are %.1f px apart, closer than the %.1f px spacing", a, b, d, minPx)
}
}
}
}
// Roads connect only what is on the same landmass. Water is impassable, so a two-island world gets no road
// between the islands however close they are.
func TestRoadsNeverCrossWater(t *testing.T) {
w, h := 240, 120
height := make([]float32, w*h)
sea := make([]bool, w*h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*w + x
// Two flat islands with a channel between them.
island := (x > 20 && x < 100) || (x > 140 && x < 220)
if !island {
sea[i] = true
height[i] = -30
} else {
height[i] = 10
}
}
}
in := GenInputs{W: w, H: h, CellM: 100, HeightM: height, Sea: sea, Seed: 3}
l := &Legend{MatchDistance: DefaultMatchDistance, MinAreaPx: DefaultMinAreaPx, Marks: []Mark{
{Name: "town", RGB: [3]int{220, 30, 30}, Generate: &GenSpec{Kind: GenSettlement, Count: 8, MinSpacingKm: 3}},
{Name: "road", RGB: [3]int{90, 60, 30}, Kind: KindPath, WidthM: 8, Generate: &GenSpec{Kind: GenRoad}},
}}
if err := l.resolve(); err != nil {
t.Fatal(err)
}
out, rep, err := l.Generate(in)
if err != nil {
t.Fatal(err)
}
// Towns landed on both islands, so a network that ignored water would have had a reason to cross.
regions := map[int]bool{}
for _, p := range rep.Settlement {
regions[p.Region] = true
}
if len(regions) < 2 {
t.Fatalf("settlements only landed on %d landmass(es); the test cannot show anything", len(regions))
}
road := uint8(l.Index("road"))
for i, m := range out.Mark {
if m == road && sea[i] {
t.Fatalf("a road was painted at sea, cell %d", i)
}
}
}
// The same world and seed generate the same sheet. Determinism is cross-cutting rule 12 and it is what makes
// a regenerated overlay reviewable in a diff.
func TestGenerationIsDeterministic(t *testing.T) {
l := genLegend(map[string]*GenSpec{
"forest": {Kind: GenForest, Cover: 0.5},
"town": {Kind: GenSettlement, Count: 5, MinSpacingKm: 2},
"road": {Kind: GenRoad},
"wild_coast": {Kind: GenCoast, CoastKm: 2},
})
a, repA, err := l.Generate(testWorld(200, 120))
if err != nil {
t.Fatal(err)
}
b, repB, err := l.Generate(testWorld(200, 120))
if err != nil {
t.Fatal(err)
}
for i := range a.Mark {
if a.Mark[i] != b.Mark[i] {
t.Fatalf("two runs disagree at cell %d: %d against %d", i, a.Mark[i], b.Mark[i])
}
}
if repA.Painted != repB.Painted || len(repA.Settlement) != len(repB.Settlement) {
t.Errorf("reports differ: %d/%d painted, %d/%d settlements",
repA.Painted, repB.Painted, len(repA.Settlement), len(repB.Settlement))
}
}
// A generated sheet has to survive the round trip: encoded to RGBA and classified back, it must be the same
// raster. If it did not, what the studio opened would not be what the generator wrote.
func TestGeneratedSheetSurvivesClassifyingItBack(t *testing.T) {
in := testWorld(200, 120)
l := genLegend(map[string]*GenSpec{
"forest": {Kind: GenForest, Cover: 0.5},
"town": {Kind: GenSettlement, Count: 5, MinSpacingKm: 2},
"road": {Kind: GenRoad},
"wild_coast": {Kind: GenCoast, CoastKm: 2},
})
out, _, err := l.Generate(in)
if err != nil {
t.Fatal(err)
}
px, alpha := l.Encode(out)
back, match := l.Classify(px, alpha, out.W, out.H)
if match.Far != 0 {
t.Errorf("%d pixels of a sheet this legend wrote matched no mark", match.Far)
}
for i := range out.Mark {
if out.Mark[i] != back.Mark[i] {
t.Fatalf("round trip changed cell %d from %d to %d", i, out.Mark[i], back.Mark[i])
}
}
}
func TestGenerateRejectsAnUnknownKind(t *testing.T) {
l := genLegend(map[string]*GenSpec{"forest": {Kind: "woods"}})
if _, _, err := l.Generate(testWorld(80, 40)); err == nil {
t.Fatal("a kind the generator does not know should be an error, not a silent no-op")
}
}
func TestGenerateRejectsAMismatchedExistingSheet(t *testing.T) {
in := testWorld(80, 40)
in.Existing = &Raster{W: 40, H: 20, Mark: make([]uint8, 800)}
l := genLegend(map[string]*GenSpec{"forest": {Kind: GenForest}})
if _, _, err := l.Generate(in); err == nil {
t.Fatal("an existing sheet of the wrong size should be an error; it is registered to the template")
}
}
// A re-roll must actually re-roll. The studio's Generate button hands a fresh seed every press, and if the
// placement does not move, the button does nothing an author can see: the forest count is a quantile and so
// is invariant by construction, which makes the settlements the only visible difference between two drafts.
func TestASecondSeedMovesTheSettlements(t *testing.T) {
l := &Legend{MatchDistance: DefaultMatchDistance, MinAreaPx: DefaultMinAreaPx, Marks: []Mark{
{Name: "town", RGB: [3]int{220, 30, 30},
Generate: &GenSpec{Kind: GenSettlement, Count: 8, MinSpacingKm: 3}},
}}
if err := l.resolve(); err != nil {
t.Fatal(err)
}
place := func(seed int64) []Placed {
in := testWorld(300, 160)
in.Seed = seed
_, rep, err := l.Generate(in)
if err != nil {
t.Fatal(err)
}
return rep.Settlement
}
a, b := place(11), place(20260920)
if len(a) == 0 || len(b) == 0 {
t.Fatalf("no settlements placed (%d, %d); the test world should hold some", len(a), len(b))
}
same := 0
for i := range a {
if i < len(b) && a[i].X == b[i].X && a[i].Y == b[i].Y {
same++
}
}
if same == len(a) && len(a) == len(b) {
t.Fatalf("both seeds placed the same %d settlements in the same places; the seed is not reaching "+
"the placement", len(a))
}
// And the same seed twice is still the same world, or nothing is reproducible.
c := place(11)
for i := range a {
if a[i].X != c[i].X || a[i].Y != c[i].Y {
t.Fatalf("the same seed placed settlement %d differently on two runs", i)
}
}
}
+401
View File
@@ -0,0 +1,401 @@
// Package overlay is the second painting: a layer over the same cylinder whose colours name things the
// geology does not simulate.
//
// The class legend answers "what is the rock doing here" and every colour on it changes the terrain. That is
// the wrong place to say "a forest grows here", "this is the village", "a road runs along this valley" or
// "leave this stretch of coast exactly as I drew it": three of those four are not geology at all, and the
// fourth is a constraint on a pass rather than a rate. Painting them as classes would mean inventing an
// uplift rate for a town.
//
// So there is a second image, registered to the first, painted in the same studio, with a legend of its own.
// Its marks are sparse - most of the sheet is nothing - and unlike a class a mark is allowed to mean nothing
// to the generator at all. Two rules follow from that and they are the whole design:
//
// - **A mark that no pass reads still travels.** Every mark comes out as an index in a per-tile raster and,
// where it has a shape worth naming, as a feature in world metres in overlay.json. The engine reads those;
// the generator never does. That is what makes the layer useful for content an author places by hand and
// the simulation has no opinion about.
// - **A mark that a pass does read changes one number and never the terrain's shape directly.** The one
// built is `coast_jitter`, which scales how far the waterline roughening may move the shore inside the
// mark - zero pins a hand-drawn coastline exactly as painted. The list is meant to stay short: anything
// that wants to *make* terrain belongs in the class legend, where it is an uplift rate and the solve
// answers for it.
//
// Blank is decided by alpha, not by a colour. An overlay is a transparent sheet with strokes on it, which is
// what every image editor gives you and what the studio paints; reserving a background colour instead would
// spend one of the author's colours on nothing and would break the moment they exported with a white matte.
package overlay
import (
"bytes"
"encoding/json"
"fmt"
"math"
"os"
"salty/terrain/internal/field"
)
// KindArea and KindPath are what a mark's shape is taken to mean. An area keeps its outline - a forest, a
// district, a stretch of coast to leave alone - and comes out as a region with a centre and an extent. A path
// is a stroke whose *width is not the point*: it is thinned to a centreline and comes out as an ordered
// polyline, because a road drawn eight pixels wide is a spline with a width, not a ribbon-shaped polygon.
const (
KindArea = "area"
KindPath = "path"
)
// Mark is one painted colour on the overlay and everything it means.
type Mark struct {
Name string `json:"name"`
RGB [3]int `json:"rgb"`
// Kind is "area" or "path"; empty is "area".
Kind string `json:"kind"`
// CoastJitter scales the waterline roughening inside this mark. 1 is the planet's own amplitude, 0 pins
// the shore exactly where it was painted, and above 1 chews it harder than the rest of the world.
//
// It is a pointer so that "not set" and "set to zero" are different things: zero is the whole reason the
// key exists. A mark that says nothing about the coast leaves the amplitude alone.
//
// Painting either side of the waterline is enough. The roughening already knows, for every cell it might
// move, which cell on the other side it would take its class from, so a stroke that covers only the water
// or only the land still protects the shore between them - see template.Coast.
CoastJitter *float64 `json:"coast_jitter"`
// WidthM is how wide the thing this stroke stands for really is, in metres. Paths only, and it is
// carried rather than used: the generator has no opinion about how wide a road is, the engine that builds
// the spline does. Zero means unstated.
WidthM float64 `json:"width_m"`
// MinAreaPx drops components smaller than this many painted pixels. A brush leaves specks, a save through
// a lossy codec leaves more, and a speck in overlay.json is a village the author never placed.
// Zero takes the legend's own default.
MinAreaPx int `json:"min_area_px"`
// Note is for the author and for whatever reads overlay.json. Nothing here parses it.
Note string `json:"note"`
// Generate, when set, lets `terrain overlay` propose this mark from a baked world - woodland where trees
// would grow, towns where somebody would build, the roads between them. It is a starting point an author
// then edits, and it is opt-in per mark: without this block the mark is only ever painted by hand, which
// is what every mark was before it existed. Generation never touches a pixel that is already painted.
// See generate.go.
Generate *GenSpec `json:"generate,omitempty"`
}
// Area reports whether this mark keeps its outline rather than being thinned to a line.
func (m Mark) Area() bool { return m.Kind != KindPath }
// Jitter is the coast jitter multiplier this mark asks for, and whether it asks for one at all.
func (m Mark) Jitter() (float64, bool) {
if m.CoastJitter == nil {
return 1, false
}
return *m.CoastJitter, true
}
// Legend is the overlay image and what its colours mean. It sits beside the class legend and has the same
// shape, deliberately: an author who has edited one can edit the other without learning a second file format.
type Legend struct {
// Image is the painted overlay, relative to this file unless it is absolute. The manifest's
// planet.overlay overrides it, which is how the studio's versioned saves repoint without rewriting this.
Image string `json:"image"`
// MatchDistance is how far, in RGB, an opaque pixel may sit from the nearest mark before it is treated as
// blank rather than as that mark. It is a *tolerance* and not the class legend's warn distance: there,
// every pixel must become something, so the nearest class always wins and the distance only warns. Here
// most of the sheet is nothing, so a pixel that matches nothing has an obvious right answer.
MatchDistance float64 `json:"match_distance"`
// MinAreaPx is the default for every mark that does not set its own.
MinAreaPx int `json:"min_area_px"`
Marks []Mark `json:"marks"`
}
// DefaultMatchDistance is tight compared with the class legend's 60, because an overlay painted in the studio
// is exact to the byte and one brought in from elsewhere is a flat stroke rather than a scanned wash. Wide
// tolerances here would swallow an unrelated colour into whichever mark it happened to be nearest.
const DefaultMatchDistance = 40
// DefaultMinAreaPx is about a brush tip. Below it a component is a speck.
const DefaultMinAreaPx = 24
// Blank is the raster index for a pixel with no mark on it. Marks are numbered from 1 so that the raster can
// be written straight out as an 8-bit image whose zero means "nothing here".
const Blank = 0
// Load reads an overlay legend from JSON.
func Load(path string) (*Legend, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
l, err := Parse(data)
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return l, nil
}
// Parse reads an overlay legend already in memory. Unknown fields are refused for the same reason the class
// legend refuses them: a misspelt key is a mark quietly running on the default rather than on what was
// written. Keys beginning with an underscore carry the commentary and are allowed.
func Parse(data []byte) (*Legend, error) {
clean, err := field.StripJSONComments(data)
if err != nil {
return nil, err
}
var l Legend
dec := json.NewDecoder(bytes.NewReader(clean))
dec.DisallowUnknownFields()
if err := dec.Decode(&l); err != nil {
return nil, err
}
if err := l.resolve(); err != nil {
return nil, err
}
return &l, nil
}
func (l *Legend) resolve() error {
if l.MatchDistance <= 0 {
l.MatchDistance = DefaultMatchDistance
}
if l.MinAreaPx <= 0 {
l.MinAreaPx = DefaultMinAreaPx
}
if len(l.Marks) > 254 {
return fmt.Errorf("overlay has %d marks; the raster holds 254 plus blank", len(l.Marks))
}
seen := make(map[string]int, len(l.Marks))
byRGB := make(map[[3]int]string, len(l.Marks))
for i := range l.Marks {
m := &l.Marks[i]
if m.Name == "" {
return fmt.Errorf("mark %d has no name", i)
}
if j, dup := seen[m.Name]; dup {
return fmt.Errorf("marks %d and %d are both named %q", j, i, m.Name)
}
seen[m.Name] = i
for k, v := range m.RGB {
if v < 0 || v > 255 {
return fmt.Errorf("mark %q: rgb[%d] is %d, outside 0..255", m.Name, k, v)
}
}
if other, dup := byRGB[m.RGB]; dup {
return fmt.Errorf("marks %q and %q share the colour %v; nothing could tell them apart",
other, m.Name, m.RGB)
}
byRGB[m.RGB] = m.Name
switch m.Kind {
case "", KindArea:
m.Kind = KindArea
case KindPath:
default:
return fmt.Errorf("mark %q: kind %q is neither %q nor %q", m.Name, m.Kind, KindArea, KindPath)
}
if m.CoastJitter != nil && *m.CoastJitter < 0 {
return fmt.Errorf("mark %q: coast_jitter is %v; it is a multiplier on how far the waterline "+
"may move, so it is never negative", m.Name, *m.CoastJitter)
}
if m.WidthM < 0 {
return fmt.Errorf("mark %q: width_m is %v", m.Name, m.WidthM)
}
if m.WidthM > 0 && m.Area() {
return fmt.Errorf("mark %q: width_m is for a path's spline, and this mark is an area; give it "+
"kind %q or drop the width", m.Name, KindPath)
}
if m.MinAreaPx < 0 {
return fmt.Errorf("mark %q: min_area_px is %d", m.Name, m.MinAreaPx)
}
}
return nil
}
// Index is the raster index of the mark with this name, or Blank when there is none. Marks are numbered
// from 1 in legend order.
func (l *Legend) Index(name string) int {
for i := range l.Marks {
if l.Marks[i].Name == name {
return i + 1
}
}
return Blank
}
// MinArea is how many painted pixels a component of this mark must have to be reported.
func (l *Legend) MinArea(m *Mark) int {
if m != nil && m.MinAreaPx > 0 {
return m.MinAreaPx
}
return l.MinAreaPx
}
// TouchesCoast reports whether any mark changes the waterline roughening, so a caller can skip building the
// scale field when nothing would read it.
func (l *Legend) TouchesCoast() bool {
for i := range l.Marks {
if _, set := l.Marks[i].Jitter(); set {
return true
}
}
return false
}
// Raster is one mark index per overlay pixel, row-major, at the overlay image's own resolution. X wraps;
// Y does not, the same convention as every other cylindrical raster here.
type Raster struct {
W, H int
Mark []uint8
}
// At reads a pixel, wrapping X and clamping Y.
func (r *Raster) At(x, y int) uint8 {
x = ((x % r.W) + r.W) % r.W
if y < 0 {
y = 0
} else if y >= r.H {
y = r.H - 1
}
return r.Mark[y*r.W+x]
}
// Match is what the overlay classifier saw.
type Match struct {
Total int
Blank int
Counts []int // per mark index, so Counts[0] is blank
// Far is opaque pixels that matched no mark inside the tolerance and were therefore treated as blank.
// It is the one number that catches a colour the legend forgot, and unlike the class legend's Far it is
// not merely advisory: those pixels are painted and are being thrown away.
Far int
MaxDist float64
MaxAt [2]int
}
func (m Match) String() string {
if m.Total == 0 {
return "no overlay"
}
painted := m.Total - m.Blank
s := fmt.Sprintf("%d px painted of %d (%.1f%%)", painted, m.Total,
100*float64(painted)/float64(m.Total))
if m.Far > 0 {
s += fmt.Sprintf("; %d px match no mark and were dropped (worst %.0f at %d,%d)",
m.Far, m.MaxDist, m.MaxAt[0], m.MaxAt[1])
}
return s
}
// Classify assigns every pixel to a mark, or to Blank.
//
// Two ways to be blank, and both are needed. A pixel whose alpha is below half is unpainted, which is what a
// transparent sheet gives and what the studio writes. A pixel that is opaque but sits further than the
// legend's tolerance from every mark is a colour the legend has never heard of - a flattened matte, an
// anti-aliased edge between two strokes, a JPEG artefact - and taking the nearest mark there is how a halo
// round a road becomes a road.
func (l *Legend) Classify(px []uint8, alpha []uint8, w, h int) (*Raster, Match) {
r := &Raster{W: w, H: h, Mark: make([]uint8, w*h)}
partial := make([]Match, field.BandCount(h))
for i := range partial {
partial[i].Counts = make([]int, len(l.Marks)+1)
}
tol2 := l.MatchDistance * l.MatchDistance
field.RowsIndexed(h, func(band, y0, y1 int) {
p := &partial[band]
for y := y0; y < y1; y++ {
for x := 0; x < w; x++ {
i := y*w + x
p.Total++
if alpha != nil && alpha[i] < 128 {
p.Blank++
p.Counts[Blank]++
continue
}
o := i * 3
cr, cg, cb := int(px[o]), int(px[o+1]), int(px[o+2])
best, bestD := -1, 1<<30
for mi := range l.Marks {
m := &l.Marks[mi]
dr, dg, db := cr-m.RGB[0], cg-m.RGB[1], cb-m.RGB[2]
if d := dr*dr + dg*dg + db*db; d < bestD {
bestD, best = d, mi
}
}
if best < 0 || float64(bestD) > tol2 {
p.Blank++
p.Counts[Blank]++
if alpha != nil || best >= 0 {
p.Far++
if float64(bestD) > p.MaxDist {
p.MaxDist = float64(bestD)
p.MaxAt = [2]int{x, y}
}
}
continue
}
r.Mark[i] = uint8(best + 1)
p.Counts[best+1]++
}
}
})
out := Match{Counts: make([]int, len(l.Marks)+1)}
out.MaxAt = [2]int{-1, -1}
for i := range partial {
p := &partial[i]
out.Total += p.Total
out.Blank += p.Blank
out.Far += p.Far
for c, n := range p.Counts {
out.Counts[c] += n
}
// Tie-broken by position so the report does not depend on GOMAXPROCS (cross-cutting rule 12).
if p.MaxDist > out.MaxDist || (p.MaxDist == out.MaxDist && earlier(p.MaxAt, out.MaxAt)) {
out.MaxDist = p.MaxDist
out.MaxAt = p.MaxAt
}
}
out.MaxDist = math.Sqrt(out.MaxDist)
return r, out
}
func earlier(a, b [2]int) bool {
if b[1] < 0 {
return true
}
if a[1] != b[1] {
return a[1] < b[1]
}
return a[0] < b[0]
}
// Encode turns a raster back into the RGBA sheet an author opens: each mark in its own legend colour, fully
// opaque, and blank left transparent.
//
// It is the exact inverse of Classify for anything this package wrote, and that has to stay true: a sheet
// written here is read back by Classify on the next plan, so a colour that did not survive the round trip
// would be a mark that vanished between writing the file and reading it. Nothing is blended or antialiased,
// for the reason the studio's brush is hard-edged - a pixel between two mark colours is not a blend of two
// marks, it is a pixel that classifies as whichever one it happens to sit nearer, or as nothing at all.
func (l *Legend) Encode(r *Raster) (px []uint8, alpha []uint8) {
n := r.W * r.H
px = make([]uint8, n*3)
alpha = make([]uint8, n)
for i, m := range r.Mark {
if m == Blank || int(m) > len(l.Marks) {
continue
}
rgb := l.Marks[m-1].RGB
px[i*3] = uint8(rgb[0])
px[i*3+1] = uint8(rgb[1])
px[i*3+2] = uint8(rgb[2])
alpha[i] = 255
}
return px, alpha
}
@@ -0,0 +1,321 @@
package overlay
import (
"math"
"testing"
)
// A legend with one of each kind of mark, written the way an author would.
const legendJSON = `{
"_comment": "commentary survives a parse",
"image": "sheet.png",
"marks": [
{ "name": "drawn_coast", "rgb": [255, 0, 255], "coast_jitter": 0 },
{ "name": "wild_coast", "rgb": [255, 128, 0], "coast_jitter": 2.5 },
{ "name": "forest", "rgb": [0, 128, 0] },
{ "name": "road", "rgb": [90, 60, 30], "kind": "path", "width_m": 8 }
]
}`
func mustLegend(t *testing.T) *Legend {
t.Helper()
l, err := Parse([]byte(legendJSON))
if err != nil {
t.Fatalf("parse: %v", err)
}
return l
}
func TestParseFillsDefaultsAndRefusesNonsense(t *testing.T) {
l := mustLegend(t)
if l.MatchDistance != DefaultMatchDistance || l.MinAreaPx != DefaultMinAreaPx {
t.Fatalf("defaults not filled: %v %v", l.MatchDistance, l.MinAreaPx)
}
if l.Index("forest") != 3 || l.Index("nope") != Blank {
t.Fatalf("marks are numbered from 1 in legend order, got %d", l.Index("forest"))
}
if !l.TouchesCoast() {
t.Fatal("this legend has a coast mark, so the roughening has a scale field to build")
}
if j, set := l.Marks[0].Jitter(); !set || j != 0 {
t.Fatalf("a zero coast_jitter is the whole reason the key is a pointer; got %v set=%v", j, set)
}
if j, set := l.Marks[2].Jitter(); set || j != 1 {
t.Fatalf("a mark that says nothing about the coast leaves the amplitude alone; got %v set=%v", j, set)
}
for _, bad := range []struct{ what, src string }{
{"two marks one colour", `{"marks":[{"name":"a","rgb":[1,2,3]},{"name":"b","rgb":[1,2,3]}]}`},
{"two marks one name", `{"marks":[{"name":"a","rgb":[1,2,3]},{"name":"a","rgb":[4,5,6]}]}`},
{"a width on an area", `{"marks":[{"name":"a","rgb":[1,2,3],"width_m":4}]}`},
{"a negative jitter", `{"marks":[{"name":"a","rgb":[1,2,3],"coast_jitter":-1}]}`},
{"an unknown kind", `{"marks":[{"name":"a","rgb":[1,2,3],"kind":"blob"}]}`},
{"a misspelt key", `{"marks":[{"name":"a","rgb":[1,2,3],"coastjitter":0}]}`},
} {
if _, err := Parse([]byte(bad.src)); err == nil {
t.Errorf("%s should not parse", bad.what)
}
}
}
// paint builds an RGBA sheet the size asked for, all transparent, and returns setters.
func paint(w, h int) (px, alpha []uint8, set func(x, y int, rgb [3]int)) {
px = make([]uint8, w*h*3)
alpha = make([]uint8, w*h)
return px, alpha, func(x, y int, rgb [3]int) {
i := y*w + x
px[i*3], px[i*3+1], px[i*3+2] = uint8(rgb[0]), uint8(rgb[1]), uint8(rgb[2])
alpha[i] = 255
}
}
// TestBlankIsAlphaAndTolerance is the rule the whole layer rests on: most of the sheet is nothing, and there
// are two ways to be nothing. An opaque pixel near no mark is dropped rather than snapped to the nearest,
// which is the opposite of what the class legend does and is why they are different code.
func TestBlankIsAlphaAndTolerance(t *testing.T) {
l := mustLegend(t)
const w, h = 8, 4
px, alpha, set := paint(w, h)
set(1, 1, [3]int{0, 128, 0}) // forest, exactly
set(2, 1, [3]int{6, 132, 4}) // forest, near enough
set(3, 1, [3]int{0, 0, 255}) // a colour the legend has never heard of
// A transparent pixel that happens to carry a mark's colour: alpha wins.
i := 1*w + 4
px[i*3], px[i*3+1], px[i*3+2] = 0, 128, 0
r, m := l.Classify(px, alpha, w, h)
if got := r.At(1, 1); got != 3 {
t.Fatalf("an exact colour is its mark; got %d", got)
}
if got := r.At(2, 1); got != 3 {
t.Fatalf("within the tolerance is its mark; got %d", got)
}
if got := r.At(3, 1); got != Blank {
t.Fatalf("a colour no mark is near is blank, not the nearest mark; got %d", got)
}
if got := r.At(4, 1); got != Blank {
t.Fatalf("transparent is blank whatever colour is under it; got %d", got)
}
if m.Far != 1 {
t.Fatalf("the one unmatched opaque pixel should be reported; Far=%d", m.Far)
}
if m.Total != w*h || m.Blank != w*h-2 {
t.Fatalf("counts: total %d blank %d", m.Total, m.Blank)
}
}
// TestCoastScaleLeavesUnmarkedPixelsUninstructed is the contract template.Coast.Scale depends on. An
// unmarked cell must come back negative rather than 1, or a stroke painted on the land would be overruled by
// the water beside it and the coastline would move anyway.
func TestCoastScaleLeavesUnmarkedPixelsUninstructed(t *testing.T) {
l := mustLegend(t)
const w, h = 6, 2
px, alpha, set := paint(w, h)
set(0, 0, [3]int{255, 0, 255}) // drawn_coast: pinned
set(1, 0, [3]int{255, 128, 0}) // wild_coast: chewed harder
set(2, 0, [3]int{0, 128, 0}) // forest: says nothing about the coast
r, _ := l.Classify(px, alpha, w, h)
sc := l.CoastScale(r)
if sc == nil {
t.Fatal("this legend has coast marks, so there is a scale")
}
if sc[0] != 0 {
t.Errorf("a pinned coast is exactly zero, got %v", sc[0])
}
if sc[1] != 2.5 {
t.Errorf("wild_coast is 2.5, got %v", sc[1])
}
if sc[2] >= 0 {
t.Errorf("a mark that says nothing about the coast is uninstructed, got %v", sc[2])
}
if sc[3] >= 0 {
t.Errorf("blank is uninstructed, got %v", sc[3])
}
// And a legend with no coast marks builds nothing at all, so the roughening pays nothing.
plain, err := Parse([]byte(`{"marks":[{"name":"forest","rgb":[0,128,0]}]}`))
if err != nil {
t.Fatal(err)
}
pr, _ := plain.Classify(px, alpha, w, h)
if plain.CoastScale(pr) != nil {
t.Error("no mark asks about the coast, so there should be no scale field")
}
}
func testScale(w, h int) Scale {
return Scale{MetresPerPxX: 10, MetresPerPxY: 10, CircumferenceM: float64(w) * 10}
}
// TestFeaturesMeasureAreasInWorldMetres covers the ordinary case and the speck filter.
func TestFeaturesMeasureAreasInWorldMetres(t *testing.T) {
l := mustLegend(t)
const w, h = 40, 20
px, alpha, set := paint(w, h)
// A 6x6 block of forest, and a single speck of it far away.
for y := 4; y < 10; y++ {
for x := 10; x < 16; x++ {
set(x, y, [3]int{0, 128, 0})
}
}
set(30, 15, [3]int{0, 128, 0})
r, _ := l.Classify(px, alpha, w, h)
feats := l.Features(r, testScale(w, h))
if len(feats) != 1 {
t.Fatalf("the 36 px block is a feature and the 1 px speck is below min_area_px; got %d", len(feats))
}
f := feats[0]
if f.Mark != "forest" || f.Kind != KindArea {
t.Fatalf("wrong mark: %+v", f)
}
if f.Cells != 36 || math.Abs(f.AreaM2-3600) > 1 {
t.Fatalf("36 px at 10x10 m is 3600 m2; got %d px %v m2", f.Cells, f.AreaM2)
}
if math.Abs(f.CentreM[0]-125) > 1 || math.Abs(f.CentreM[1]-65) > 1 {
t.Fatalf("centre should be the middle of the block in metres; got %v", f.CentreM)
}
if math.Abs(f.ExtentM[0]-60) > 1 || math.Abs(f.ExtentM[1]-60) > 1 {
t.Fatalf("a 6x6 block is 60x60 m; got %v", f.ExtentM)
}
}
// TestASeamCrossingFeatureIsOneThing is the failure a cylindrical map has and nobody notices: a plain mean of
// the longitudes puts the centre of a blob straddling the seam on the opposite side of the world.
func TestASeamCrossingFeatureIsOneThing(t *testing.T) {
l := mustLegend(t)
const w, h = 40, 20
px, alpha, set := paint(w, h)
for y := 6; y < 14; y++ {
for _, x := range []int{38, 39, 0, 1} {
set(x, y, [3]int{0, 128, 0})
}
}
r, _ := l.Classify(px, alpha, w, h)
feats := l.Features(r, testScale(w, h))
if len(feats) != 1 {
t.Fatalf("the blob crosses the seam and is one thing; got %d features", len(feats))
}
f := feats[0]
if f.Cells != 32 {
t.Fatalf("all 32 px belong to it; got %d", f.Cells)
}
// Columns 38, 39, 0, 1 have their circular centre at 39.5, which is 395 m.
if d := math.Abs(f.CentreM[0] - 395); d > 6 && math.Abs(f.CentreM[0]-395+400) > 6 {
t.Fatalf("the centre should sit on the blob, near 395 m; got %v", f.CentreM[0])
}
if math.Abs(f.ExtentM[0]-40) > 1 {
t.Fatalf("the extent is measured the short way round: 4 px is 40 m; got %v", f.ExtentM[0])
}
}
// TestAPathBecomesACentrelineNotAnOutline is the difference between a road and a ribbon-shaped polygon.
func TestAPathBecomesACentrelineNotAnOutline(t *testing.T) {
l := mustLegend(t)
const w, h = 60, 20
px, alpha, set := paint(w, h)
// A horizontal stroke three pixels thick from x=5 to x=50.
for x := 5; x <= 50; x++ {
for y := 9; y <= 11; y++ {
set(x, y, [3]int{90, 60, 30})
}
}
r, _ := l.Classify(px, alpha, w, h)
feats := l.Features(r, testScale(w, h))
if len(feats) != 1 {
t.Fatalf("one stroke is one path; got %d", len(feats))
}
f := feats[0]
if f.Kind != KindPath || f.WidthM != 8 {
t.Fatalf("the path's width travels with it: %+v", f)
}
if len(f.PointsM) < 2 {
t.Fatalf("a path needs at least two points; got %d", len(f.PointsM))
}
// Simplified, so a straight stroke is a handful of points and not one per pixel.
if len(f.PointsM) > 8 {
t.Errorf("a straight stroke should simplify to a few points; got %d", len(f.PointsM))
}
// It runs the length of the stroke, not round its outline: 45 px is 450 m, an outline would be ~960.
if f.LengthM < 400 || f.LengthM > 500 {
t.Errorf("a 45 px stroke at 10 m a pixel is about 450 m of centreline; got %v", f.LengthM)
}
for _, p := range f.PointsM {
if p[1] < 85 || p[1] > 115 {
t.Errorf("every point should sit on the stroke, y near 100 m; got %v", p)
}
}
// An area mark never gets points, whatever shape it is drawn in.
for _, g := range feats {
if g.Kind == KindArea && len(g.PointsM) > 0 {
t.Error("an area keeps its outline and is not thinned")
}
}
}
// TestSampleWorldIsIndependentOfTheWindow is rule 1 for a raster: a cell gets the same mark whichever tile
// reaches it, because the lookup goes through world metres rather than through a tile-local index.
func TestSampleWorldIsIndependentOfTheWindow(t *testing.T) {
l := mustLegend(t)
const w, h = 40, 20
px, alpha, set := paint(w, h)
for y := 4; y < 10; y++ {
for x := 10; x < 16; x++ {
set(x, y, [3]int{0, 128, 0})
}
}
r, _ := l.Classify(px, alpha, w, h)
s := testScale(w, h)
// Two windows of a 2 m grid overlapping the same ground: one starting at 100 m, one at 60 m.
a := r.SampleWorld(100, 40, 2, 40, 40, s)
b := r.SampleWorld(60, 40, 2, 60, 40, s)
for y := 0; y < 40; y++ {
for x := 0; x < 40; x++ {
if a[y*40+x] != b[y*60+x+20] {
t.Fatalf("the same ground read two marks at (%d,%d): %d vs %d",
x, y, a[y*40+x], b[y*60+x+20])
}
}
}
// And it wraps, rather than clamping, past the seam.
past := r.SampleWorld(s.CircumferenceM+100, 40, 2, 40, 40, s)
for i := range a {
if a[i] != past[i] {
t.Fatalf("a window a whole world to the east must read the same ground; differ at %d", i)
}
}
}
func TestDocumentReportsEveryMarkPaintedOrNot(t *testing.T) {
l := mustLegend(t)
const w, h = 40, 20
px, alpha, set := paint(w, h)
for y := 4; y < 10; y++ {
for x := 10; x < 16; x++ {
set(x, y, [3]int{0, 128, 0})
}
}
r, m := l.Classify(px, alpha, w, h)
doc := l.Describe(r, m, testScale(w, h), "sheet.png", "sheet.json")
if len(doc.Marks) != 4 {
t.Fatalf("every mark is reported, painted or not; got %d", len(doc.Marks))
}
byName := map[string]MarkShare{}
for _, mk := range doc.Marks {
byName[mk.Name] = mk
}
if f := byName["forest"]; f.Cells != 36 || f.Pieces != 1 || math.Abs(f.AreaKm2-0.0036) > 1e-6 {
t.Errorf("forest: %+v", f)
}
if c := byName["drawn_coast"]; !c.HasJitter || c.Jitter != 0 || c.Pieces != 0 {
t.Errorf("an unpainted coast mark still reports what it would ask for: %+v", c)
}
if rd := byName["road"]; rd.Kind != KindPath || rd.WidthM != 8 {
t.Errorf("road: %+v", rd)
}
if doc.CircumferenceM != 400 || doc.PaintW != w {
t.Errorf("the frame is the overlay's own: %v x %d", doc.CircumferenceM, doc.PaintW)
}
}
+539
View File
@@ -0,0 +1,539 @@
package planet
import (
"fmt"
"sort"
"sync"
"time"
"salty/terrain/internal/coast"
"salty/terrain/internal/field"
"salty/terrain/internal/fluvial"
"salty/terrain/internal/manifest"
"salty/terrain/internal/region"
"salty/terrain/internal/stats"
"salty/terrain/internal/template"
"salty/terrain/internal/thermal"
"salty/terrain/internal/uplift"
)
// Bake solves the geology of every region and composites the result into one planet.
//
// The order is the whole design: each landmass is solved in a box of its own with water all round it, which
// is exactly the same answer as solving the planet whole because no flow path crosses open water; then the
// land is written back; then the sea floor is laid once, over the finished cylinder.
// Result is a baked planet at geology resolution.
type Result struct {
In *Inputs
Height *field.Field // metres, over the whole planet including the polar pad
Flow []float32 // drainage area in m2, for the rivers on the preview
Sea []bool // after the bake: below sea level
Regions []RegionResult
Craters []CraterStats
Elapsed time.Duration
// Coast is the shelf, the surf and the sediment budget, run once over the whole cylinder after every
// region is composited. Nil only when there is no planet to run it on.
Coast *coast.Result
// Stats is the whole planet's, pooled from the regions. Nil until Write computes it.
//
// Pooled rather than averaged, which is the one thing that makes it a planet statistic at all: a region
// holds a histogram and histograms add, so merging them and taking a quantile of the sum gives exactly
// what one pass over the whole world would have. See internal/stats.
Stats *stats.Report
cancelled bool
}
// RegionResult is what one region's solve cost and produced.
type RegionResult struct {
ID int
Cells int
LandCells int
Seconds float64
MinM float64
MaxM float64
ClipFrac float64
// FaultClamped is how many cells the fault pass pushed past the angle of repose and had to bound. Not an
// error - it is the set saying the throws are large for the class they sit in - but worth seeing in the
// summary rather than discovering later in a hillshade full of polygonal facets.
FaultClamped int
// stats is this region's land statistics, unexported because it is scaffolding: it exists to be merged
// into the planet's and is not part of what a region result means.
stats *stats.Accumulator
}
// statsOptions is what a world is judged against, in one place so a region and the planet it belongs to
// cannot disagree about it - two accumulators built on different bounds do not merge.
func statsOptions(m *manifest.Manifest) stats.Options {
return stats.Options{
ElevMin: m.ElevationM.Min, ElevMax: m.ElevationM.Max,
TalusDeg: m.Pipeline.Thermal.TalusDeg, ReliefWindowM: reliefWindowM,
ChannelM2: channelKm2 * 1e6,
K: m.Pipeline.Fluvial.K, M: m.Pipeline.Fluvial.M, N: m.Pipeline.Fluvial.N,
}
}
// The two constants the statistics are taken at. They are flags on `generate` and fixed here, because a bake
// is compared against other bakes and a window that moved between them would make the relief column
// meaningless: 500 m is the usual choice, and 1 km2 is the incoming spec's channel definition.
const (
reliefWindowM = 500.0
channelKm2 = 1.0
)
// BakeOptions steer a run without editing the manifest.
type BakeOptions struct {
Only []int // region ids; empty means all
Steps int // override the fluvial step count
Jobs int // regions solved at once; 0 is the default
Log func(string, ...any)
// OnRegion is called each time a region's land has been written back, with the planet as it stands.
//
// It runs holding the composite lock, so Height and Flow can be read without racing the workers still
// running - and every one of them is stopped while it does, so it has to be quick and it must not keep a
// reference to either past the call. It exists so a caller can *watch* a bake: two hours is a long time
// to find out at the end that the numbers were wrong, and the world filling in one landmass at a time
// answers that at the first one.
OnRegion func(*Result, RegionResult)
// Cancel abandons the run when it is closed. Regions in flight stop at the end of their current step and
// their land is composited in whatever state the solve had reached, so a cancelled Result is for looking
// at and never for writing out as a bake.
Cancel <-chan struct{}
}
// Cancelled reports whether a run was abandoned before it finished.
func (r *Result) Cancelled() bool { return r.cancelled }
// Bake runs the geology solve over a prepared planet.
func Bake(in *Inputs, opt BakeOptions) (*Result, error) {
log := opt.Log
if log == nil {
log = func(string, ...any) {}
}
m := in.M
p := in.P
n := p.W * p.H
started := time.Now()
params := solveParams(m)
if opt.Steps > 0 {
params.Steps = opt.Steps
}
log("fluvial %d steps of %.0f yr (%.2f Myr), K %.1e, m %.2f, n %.2f, fill every %d",
params.Steps, params.DtYr, float64(params.Steps)*params.DtYr/1e6,
params.K, params.M, params.N, params.FillEvery)
res := &Result{In: in, Height: field.New(p.W, p.H, p.CellM), Flow: make([]float32, n)}
for i := range res.Height.Data {
res.Height.Data[i] = float32(m.SeaLevelM)
}
rates := in.Legend.Rates()
ks := in.Legend.Erodibilities()
plainM, plainFloor := in.Legend.CoastalPlains()
massifFloor, massifFraction := in.Legend.Massifs()
wanted := wantedRegions(in.Part.Regions, opt.Only)
// Regions are solved a few at a time. They are independent - each writes only the land it owns, and no
// flow path crosses the water between them - so this changes nothing about the result; what it buys is
// the parts of the solve that are sequential *within* a region. Terrain.md's profile says most of the
// runtime is the stack walk and the flood's cursor, neither of which parallelises inside one grid, so
// overlapping regions is where the cores actually go.
//
// Results land in indexed slots and are read back in region order afterwards, never drained from a
// channel: cross-cutting rule 12 means the output must not depend on which goroutine finished first.
tables := paint{rates: rates, ks: ks, plainM: plainM, plainFloor: plainFloor,
massifFloor: massifFloor, massifFraction: massifFraction, massifCells: m.Planet.MassifCells(),
rockCells: m.Planet.LithologyCells(), rockMult: m.Pipeline.Lithology.KMultipliers,
lithMix: in.Legend.LithologyMixes(), faults: in.Faults,
// The *manifest's* step count, not the overridden one. `throw_m` is a total displacement over the
// run and it becomes a rate by dividing by the run's length, so taking the override would make a
// short run raise the *rate* to build the same scarp in less time - which at `--steps 200` is five
// times the uplift and past the repose ceiling, so a tuning run would show every fault pinned
// against the clamp and tell an author nothing about the world they are tuning. `--steps` means
// "run less time"; everything else in the solve is under-done by it and faults should be too.
runYears: float64(m.Pipeline.Fluvial.Steps) * params.DtYr,
clampCeilM: faultCeilingMYr(m),
}
jobs := opt.Jobs
if jobs <= 0 {
jobs = 3
}
if jobs > len(wanted) {
jobs = len(wanted)
}
// Biggest first, so the long poles start early and the short ones fill the tail.
order := append([]region.Region(nil), wanted...)
sort.Slice(order, func(a, b int) bool { return order[a].Cells() > order[b].Cells() })
out := make([]RegionResult, len(order))
var wg sync.WaitGroup
var mu sync.Mutex
// Guards the composite - the one place workers touch shared state - so that OnRegion can read the whole
// planet without racing them.
var composite sync.Mutex
safeLog := func(format string, a ...any) {
mu.Lock()
log(format, a...)
mu.Unlock()
}
next := make(chan int)
go func() {
for i := range order {
next <- i
}
close(next)
}()
for w := 0; w < jobs; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := range next {
if cancelled(opt.Cancel) {
continue // drain the queue; the workers already running stop at their next step
}
out[i] = solveRegion(in, order[i], params, tables, res, safeLog, opt, &composite)
}
}()
}
wg.Wait()
res.Regions = out
sort.Slice(res.Regions, func(a, b int) bool { return res.Regions[a].ID < res.Regions[b].ID })
if cancelled(opt.Cancel) {
res.cancelled = true
res.Elapsed = time.Since(started)
log("cancelled after %s", res.Elapsed.Round(time.Second))
return res, nil
}
res.Craters = stampCraters(in, res, log)
runCoast(in, res, log)
res.Elapsed = time.Since(started)
return res, nil
}
func wantedRegions(all []region.Region, only []int) []region.Region {
if len(only) == 0 {
return all
}
keep := make(map[int]bool, len(only))
for _, id := range only {
keep[id] = true
}
var out []region.Region
for _, r := range all {
if keep[r.ID] {
out = append(out, r)
}
}
return out
}
// solveRegion is one landmass: cut it out, build its painted geology, run the solve, write the land back.
// progressCells is where a region becomes big enough to be worth reporting on: below a couple of million it
// is over in a minute or two and the lines are noise.
const progressCells = 2_000_000
// paint is the per-class tables a region's geology is built from, bundled so the worker signature does not
// grow a column every time the legend learns a new word.
type paint struct {
rates, ks []float32
plainM []float64
plainFloor []float32
massifFloor []float32
// massifFraction is per class, zero for a class that is one rate all over. massifCells is the planet's
// upland fabric wavelength in lattice cells, which is a property of the planet rather than of a class.
massifFraction []float64
massifCells int
// The rock field and the fault set: both are properties of the planet that every region reads the same
// way, which is the whole point of computing them once above rather than per region.
rockCells int
rockMult []float64
lithMix []float64
faults []uplift.FaultTrace
runYears float64
clampCeilM float64
}
func cancelled(ch <-chan struct{}) bool {
if ch == nil {
return false
}
select {
case <-ch:
return true
default:
return false
}
}
func solveRegion(in *Inputs, rg region.Region, params fluvial.Params, t paint,
res *Result, log func(string, ...any), opt BakeOptions, composite *sync.Mutex) RegionResult {
m := in.M
start := time.Now()
class, land := in.Part.Cut(in.Map, rg)
up := uplift.FromTemplate(uplift.Paint{
Frame: rg.Frame, Class: class, Land: land,
Rates: t.rates, Ks: t.ks, PlainM: t.plainM, PlainFloor: t.plainFloor,
MassifFloor: t.massifFloor, MassifFraction: t.massifFraction, MassifCells: t.massifCells,
RockCells: t.rockCells, RockMult: t.rockMult, LithMix: t.lithMix,
Faults: t.faults, RunYears: t.runYears, ClampCeilM: t.clampCeilM,
Variation: m.Planet.UpliftVariation,
}, m)
h := up.Height.Clone()
grid := fluvial.NewGrid(rg.Frame.W, rg.Frame.H, rg.Frame.P.CellM, up.Base)
grid.SetSeed(m.Source.Seed)
grid.SetCancel(opt.Cancel)
grid.SetFrame(rg.Frame) // the jitter is a hash of world position, not of grid index
grid.SetElevationRange(m.ElevationM.Min-200, m.ElevationM.Max+500)
log("region %2d start %.1f x %.1f km, %d cells (%d land)%s",
rg.ID, float64(rg.Frame.W)*rg.Frame.P.CellM/1000, float64(rg.Frame.H)*rg.Frame.P.CellM/1000,
rg.Cells(), rg.LandCells, seamNote(rg.Seam))
// Progress, because a big region is an hour on its own and silence for an hour is indistinguishable from
// a hang. Every tenth, and only for regions big enough to be worth waiting on: with several in flight the
// lines interleave, so each one carries its region id and none of them is per-step.
var progress func(step, total int, pct float64)
if rg.Cells() > progressCells {
next := 10.0
progress = func(step, total int, pct float64) {
if pct < next || step == 0 {
return
}
next = pct + 10
lo, hi := landExtent(h.Data, land)
elapsed := time.Since(start)
eta := time.Duration(float64(elapsed) / (pct / 100) * (1 - pct/100))
log("region %2d %3.0f%% step %d/%d, land %.0f..%.0f m, eta %s",
rg.ID, pct, step, total, lo, hi, eta.Round(time.Second))
}
}
grid.Run(h.Data, up.Rate.Data, up.K.Data, params, progress)
// The edge-preserving pass, if the manifest asked for one. Here and not after the write: the region's
// statistics are gathered a few lines below while the grid is alive, and a smooth applied after them
// would put a surface on disk that meta.json does not describe - which is the bake-against-tiles drift
// trap in a new costume.
//
// It reads a cell's eight neighbours, and that is safe against the decomposition for a reason particular
// to this pass: a region's frame edges are open ocean by construction and every landmass in it sits at
// least margin_cells from them, so a land cell's neighbours are always inside its own region. The rule
// this would otherwise break - no neighbourhood operation near a region edge - still stands for anything
// that reaches across the waterline.
if sm := m.Pipeline.Smooth; sm.Passes > 0 {
field.SmoothEdgePreserving(h.Data, h.W, h.H, h.CellM, land, sm.Passes, sm.SlopeRef, grid.Scratch())
}
lo, hi := landExtent(h.Data, land)
peakRate := 0.0
for i, isLand := range land {
if isLand && float64(up.Rate.Data[i]) > peakRate {
peakRate = float64(up.Rate.Data[i])
}
}
clip := clipFraction(h.Data, land, m.ElevationM.Min, m.ElevationM.Max)
secs := time.Since(start).Seconds()
clampNote := ""
if up.FaultClamped > 0 {
clampNote = fmt.Sprintf(", %.2f%% of it bounded at repose by faults",
100*float64(up.FaultClamped)/float64(rg.LandCells))
}
log("region %2d done %.0f..%.0f m, %.3f%% clipped, %.2f mm/yr peak%s [%.0f s]",
rg.ID, lo, hi, clip*100, peakRate*1000, clampNote, secs)
// The region's own land statistics, gathered here while its grid is still alive - it is thrown away a
// few lines below, and the composited planet has no uplift field or flow topology to recover them from.
// Only the land this region *owns*: `Cut` marks nothing else, so the pieces are disjoint and the sum is
// the planet.
acc := stats.New(statsOptions(m))
acc.Add(stats.Input{
H: h, Land: land, UpliftMYr: up.Rate.Data, KLocal: up.K.Data,
Area: grid.Area, Receiver: grid.Receiver, Length: grid.Length,
})
rr := RegionResult{ID: rg.ID, Cells: rg.Cells(), LandCells: rg.LandCells,
Seconds: secs, MinM: lo, MaxM: hi, ClipFrac: clip, FaultClamped: up.FaultClamped, stats: acc}
// The write-back and the hook under one lock. Composite is the only place a worker touches shared state,
// so holding it here is what lets OnRegion read the whole planet without racing the others.
composite.Lock()
in.Part.Composite(res.Height.Data, in.Map, rg, h.Data)
compositeFlow(in.Part, in.Map, rg, res.Flow, grid.Area)
if opt.OnRegion != nil {
opt.OnRegion(res, rr)
}
composite.Unlock()
return rr
}
func seamNote(seam bool) string {
if seam {
return ", across the seam"
}
return ""
}
// compositeFlow writes a region's drainage area back for the preview's rivers. It follows Composite's rule:
// only the land this region owns.
func compositeFlow(part *region.Partition, mp *template.Map, rg region.Region, dst, area []float32) {
for y := 0; y < rg.Frame.H; y++ {
for x := 0; x < rg.Frame.W; x++ {
pi := rg.Frame.PlanetIdx(x, y)
if part.Owner[pi] != int32(rg.ID) || mp.Sea[pi] {
continue
}
dst[pi] = area[y*rg.Frame.W+x]
}
}
}
func solveParams(m *manifest.Manifest) fluvial.Params {
f := m.Pipeline.Fluvial
return fluvial.Params{
K: f.K, M: f.M, N: f.N, DtYr: f.DtYr, Steps: f.Steps,
Diffusion: f.DiffusionM2Yr, FillEvery: f.FillEvery,
TalusSlope: thermal.TalusFromDegrees(m.Pipeline.Thermal.TalusDeg),
ThermalEvery: m.Pipeline.Thermal.Every,
ThermalPasses: m.Pipeline.Thermal.CoarsePasses,
CriticalAreaM2: f.CriticalAreaM2,
ChannelTaper: f.ChannelTaper,
CriticalSlope: thermal.TalusFromDegrees(f.CriticalSlopeDeg),
SlopeCap: f.SlopeCap,
MaxHillslopeSub: f.MaxHillslopeSub,
MFDExponent: f.MFDExponent,
}
}
// runCoast lays the sea floor and works the shoreline, once, over the whole cylinder.
//
// Once and whole rather than per region, which is D-53's rule and is not a convenience: the pass costs tens
// of nanoseconds a cell against tens of nanoseconds a cell *per step* for the solve, and cutting it up would
// truncate the fetch across every strait, split the sediment budget whose conservation is the one thing in it
// not derived from something already measured, and leave the shoreline length and the exposure percentiles as
// statistics that do not pool. It runs after every region is composited, because two of its three processes
// read the finished land: the shelf width comes off the relief standing behind each shore, and the surf cuts
// into whatever the solve built.
//
// The painted depths go in as the *abyss*, one value per cell. That is what makes the derived margin meet the
// painted ocean instead of stepping to it: an author who painted `shelf` at 120 m, `deep` at 512 and `surf`
// at 20 gets a continental slope that runs down to each of those where each of them is, and a strait painted
// shallower than the shelf break comes out as shelf all the way across rather than as a trench.
func runCoast(in *Inputs, res *Result, log func(string, ...any)) {
m := in.M
depths := in.Legend.Depths()
abyss := make([]float32, len(res.Height.Data))
for i := range abyss {
if in.Map.Sea[i] {
abyss[i] = depths[in.Map.Class[i]]
}
}
start := time.Now()
cs := coast.Build(coast.Input{
Height: res.Height, Sea: in.Map.Sea, SeaLevelM: m.SeaLevelM,
BreakM: m.ShelfBreakM(),
// AbyssM is the square canvas's key and is never read on this path: `Abyss` below is per-cell and
// always allocated, so `abyssAt` takes the painting every time. Left as the fallback it is rather
// than removed, and named here because it is the same shape as the read D-64 had to fix one line up.
AbyssM: -m.Pipeline.Continent.SeaFloorM.Lo(),
Abyss: abyss,
// The cylinder, which is the whole of what D-59's successor had to add: every march, every ray and
// every running sum in the pass crosses the seam now, and a planet measured on a flat grid would have
// had its shelf, its fetch and its sediment budget all stop dead at one meridian.
WrapX: true,
NoisePeriodM: in.P.NoisePeriodM,
Flow: res.Flow,
Seed: m.Source.Seed,
Cfg: m.Pipeline.Coast,
})
res.Coast = cs
res.Sea = cs.Sea
if !m.Pipeline.Coast.Enabled {
log("ocean the painted depths, flat: the coastal pass is switched off in the manifest")
return
}
// The margin's own numbers, said out loud, because they decide how much of the painted ocean survives
// and they were a square-canvas inheritance nobody could see until D-64. A shelf and a slope together
// reach `ShelfKm.Hi() + SlopeKm` from every shore; where the sea is narrower than twice that, the
// painting's depth is never reached anywhere in it and the author's ocean is whatever `break_m` says.
cfg := m.Pipeline.Coast
reach := cfg.ShelfKm.Hi() + cfg.SlopeKm
log(" sea floor: shelf %.1f..%.1f km to a break at %.0f m, then %.1f km of slope to the painted "+
"depth, so the painting owns the water past %.1f km offshore and nothing nearer",
cfg.ShelfKm.Lo(), cfg.ShelfKm.Hi(), m.ShelfBreakM(), cfg.SlopeKm, reach)
log("coast %s", cs.Stats.Summary())
log(" over the whole cylinder in %s, once: the pass is tens of nanoseconds a cell and cutting it",
time.Since(start).Round(time.Millisecond))
log(" up would truncate the fetch across every strait and split the sediment budget")
}
func landExtent(h []float32, land []bool) (lo, hi float64) {
lo, hi = 1e30, -1e30
any := false
for i, v := range h {
if !land[i] {
continue
}
any = true
if float64(v) < lo {
lo = float64(v)
}
if float64(v) > hi {
hi = float64(v)
}
}
if !any {
return 0, 0
}
return lo, hi
}
// clipFraction is how much of the land the 16-bit encoding would cut off. Above a fraction of a per cent it
// is a failed run rather than a rounded one, and painted uplift makes it easier to hit: an author can ask
// for more relief than the elevation range holds.
func clipFraction(h []float32, land []bool, minM, maxM float64) float64 {
n, clipped := 0, 0
for i, v := range h {
if !land[i] {
continue
}
n++
if float64(v) < minM || float64(v) > maxM {
clipped++
}
}
if n == 0 {
return 0
}
return float64(clipped) / float64(n)
}
// faultCeilingMYr is the uplift rate at which a divide stands at the angle of repose, in metres a year at
// K x1. It is the number `terrain plan` already prints as the clamp ceiling, in the unit the solve works in;
// here it bounds what a *fault* may add on top of what an author painted, and nothing else. Zero when the
// repose clamp is switched off, which switches the bound off with it.
func faultCeilingMYr(m *manifest.Manifest) float64 {
if m.Pipeline.Thermal.TalusDeg <= 0 || m.Pipeline.Thermal.TalusDeg >= 90 {
return 0
}
return clampCeiling(m.Pipeline.Fluvial.K, m.GeologyCellM(), m.Pipeline.Fluvial.M,
m.Pipeline.Thermal.TalusDeg) / 1000
}
+204
View File
@@ -0,0 +1,204 @@
package planet
import (
"math"
"salty/terrain/internal/dt"
"salty/terrain/internal/template"
)
// Impacts, stamped onto the finished terrain.
//
// A crater is not an uplift rate and it cannot be one, which is worth writing down because it is the obvious
// thing to try. A closed basin does not survive the fluvial solve: the priority-flood runs every step and
// *raises* every depression to its spill level, so a crater built out of negative uplift would be filled in
// before the run was a hundred steps old. It is also the wrong model. A crater is an event, not a rate - it
// postdates the landscape it sits in - and a pass running after the solve is what that means.
//
// The shape is derived from the painted blob rather than drawn. Distance inward from the blob's own boundary,
// normalised by its widest point, is a coordinate that runs 0 at the shore to 1 at the centre whatever size
// and shape the author painted, so one set of numbers describes every crater on the map.
// craterProfile is the height at a normalised distance t in from the shore.
//
// t = 0 the waterline: sea level, so the island keeps the outline that was painted
// t = RimAt the crest
// t = WallAt the foot of the inner wall
// t > WallAt floor
//
// Both segments are smoothstepped, so the crest is a ridge rather than a corner and the floor meets the wall
// without a crease. A corner at either would be ground the detail passes then spend their time sanding off.
func craterProfile(c template.Crater, seaLevelM, t float64) float64 {
switch {
case t <= 0:
return seaLevelM
case t < c.RimAt:
return seaLevelM + (c.RimM-seaLevelM)*smoothstep(t/c.RimAt)
case t < c.WallAt:
return c.RimM + (c.FloorM-c.RimM)*smoothstep((t-c.RimAt)/(c.WallAt-c.RimAt))
default:
return c.FloorM
}
}
func smoothstep(t float64) float64 {
if t <= 0 {
return 0
}
if t >= 1 {
return 1
}
return t * t * (3 - 2*t)
}
// CraterStats is what the pass stamped.
//
// The floor is measured over the cells that actually reached it rather than over the whole blob, and that is
// not fussiness: the profile starts at sea level on the shoreline, so the minimum over a blob is always zero
// and reporting it as the floor says nothing at all. What is worth knowing is whether the blob was wide
// enough for the profile to get there - a crater painted smaller than its own rim is a hill.
type CraterStats struct {
Class string
Blobs int
Cells int
FloorCells int // cells past wall_at, which are the ones at the floor
RadiusM float64 // the widest blob's inradius: what the profile is normalised by
RimM float64 // the highest point actually stamped
FloorM float64 // the lowest point among the floor cells
}
// stampCraters reshapes every blob of every crater class in the planet raster.
//
// It runs after the regions are composited and before the ocean is laid, so it sees finished land and writes
// only onto land the paint marked as crater.
func stampCraters(in *Inputs, res *Result, log func(string, ...any)) []CraterStats {
if !in.Legend.HasCraters() {
return nil
}
p := in.P
n := p.W * p.H
var out []CraterStats
for ci := range in.Legend.Classes {
c := in.Legend.Classes[ci]
if c.Crater == nil {
continue
}
mask := make([]bool, n)
outside := make([]bool, n)
count := 0
for i := 0; i < n; i++ {
if in.Map.Class[i] == uint8(ci) && !in.Map.Sea[i] {
mask[i] = true
count++
} else {
outside[i] = true
}
}
if count == 0 {
continue
}
// Distance inward from the blob's boundary: seed the transform with everything that is *not* this
// class, and every cell of it then knows how far it is from the nearest edge. Wrapped, because a
// crater on the seam is one crater.
d2 := dt.Distance2(outside, p.W, p.H, true)
dist := make([]float64, n)
for i := range d2 {
if mask[i] {
dist[i] = math.Sqrt(float64(d2[i]))
}
}
// Each blob is normalised by its own widest point, so a big crater and a small one get the same
// shape rather than the same depth. Components are found with the same wrap-aware flood the region
// partitioner uses; there are a handful of them and they are tiny.
comp, maxDist, blobs := craterComponents(mask, dist, p.W, p.H, p.WrapX)
st := CraterStats{Class: c.Name, Blobs: blobs, Cells: count, FloorM: math.Inf(1),
RimM: math.Inf(-1)}
for _, d := range maxDist {
if d*p.CellM > st.RadiusM {
st.RadiusM = d * p.CellM
}
}
for i := 0; i < n; i++ {
if !mask[i] || comp[i] < 0 {
continue
}
d := maxDist[comp[i]]
if d <= 0 {
continue
}
t := dist[i] / d
h := craterProfile(*c.Crater, in.M.SeaLevelM, t)
res.Height.Data[i] = float32(h)
if h > st.RimM {
st.RimM = h
}
if t >= c.Crater.WallAt {
st.FloorCells++
if h < st.FloorM {
st.FloorM = h
}
}
}
if st.FloorCells == 0 {
st.FloorM = 0
}
out = append(out, st)
log("crater %s: %d blob(s), %d cells, widest %.0f m across; rim reached %.0f m, "+
"%d cells at the floor (%.0f m)",
st.Class, st.Blobs, st.Cells, 2*st.RadiusM, st.RimM, st.FloorCells, st.FloorM)
if st.FloorCells == 0 {
log(" WARNING no cell reached the floor: every blob is narrower than wall_at asks for, " +
"so this is a hill rather than a crater. Paint it wider or lower wall_at.")
}
}
return out
}
// craterComponents labels each blob and records its widest point, which is the radius the profile is
// normalised by.
func craterComponents(mask []bool, dist []float64, w, h int, wrap func(int) int) (comp []int32, maxDist []float64, n int) {
comp = make([]int32, len(mask))
for i := range comp {
comp[i] = -1
}
var stack []int32
for start := 0; start < len(mask); start++ {
if !mask[start] || comp[start] >= 0 {
continue
}
id := int32(len(maxDist))
maxDist = append(maxDist, 0)
comp[start] = id
stack = append(stack[:0], int32(start))
for len(stack) > 0 {
c := stack[len(stack)-1]
stack = stack[:len(stack)-1]
if dist[c] > maxDist[id] {
maxDist[id] = dist[c]
}
cx, cy := int(c)%w, int(c)/w
for dy := -1; dy <= 1; dy++ {
ny := cy + dy
if ny < 0 || ny >= h {
continue
}
base := ny * w
for dx := -1; dx <= 1; dx++ {
if dx == 0 && dy == 0 {
continue
}
ni := int32(base + wrap(cx+dx))
if mask[ni] && comp[ni] < 0 {
comp[ni] = id
stack = append(stack, ni)
}
}
}
}
}
return comp, maxDist, len(maxDist)
}
@@ -0,0 +1,94 @@
package planet
import (
"math"
"testing"
"salty/terrain/internal/template"
)
// The profile is the whole of what a crater is, so it is worth pinning down: shore at sea level, a crest, an
// inner wall, a floor, and nothing anywhere that is not monotonic on its own segment.
func TestCraterProfileIsRimThenFloor(t *testing.T) {
c := template.Crater{RimM: 340, FloorM: 60, RimAt: 0.30, WallAt: 0.62}
if got := craterProfile(c, 0, 0); got != 0 {
t.Errorf("at the shore = %.1f m, want sea level: the island keeps the outline that was painted", got)
}
if got := craterProfile(c, 0, c.RimAt); math.Abs(got-c.RimM) > 1e-9 {
t.Errorf("at rim_at = %.1f m, want the rim %.1f", got, c.RimM)
}
if got := craterProfile(c, 0, c.WallAt); math.Abs(got-c.FloorM) > 1e-9 {
t.Errorf("at wall_at = %.1f m, want the floor %.1f", got, c.FloorM)
}
if got := craterProfile(c, 0, 1); got != c.FloorM {
t.Errorf("at the centre = %.1f m, want the floor %.1f", got, c.FloorM)
}
// Rising to the crest, falling to the floor, and the floor is dry.
prev := craterProfile(c, 0, 0)
for i := 1; i <= 30; i++ {
v := craterProfile(c, 0, c.RimAt*float64(i)/30)
if v < prev-1e-9 {
t.Fatalf("the outer flank dips at t = %.3f", c.RimAt*float64(i)/30)
}
prev = v
}
prev = craterProfile(c, 0, c.RimAt)
for i := 1; i <= 30; i++ {
v := craterProfile(c, 0, c.RimAt+(c.WallAt-c.RimAt)*float64(i)/30)
if v > prev+1e-9 {
t.Fatalf("the inner wall rises at t = %.3f", c.RimAt+(c.WallAt-c.RimAt)*float64(i)/30)
}
prev = v
}
if c.FloorM <= 0 {
t.Error("this crater's floor is not above sea level, which is what was asked for")
}
}
// A blob is normalised by its own widest point, so the same numbers describe a small crater and a large one.
func TestCraterComponentsMeasureEachBlobsOwnRadius(t *testing.T) {
const w, h = 40, 12
mask := make([]bool, w*h)
dist := make([]float64, w*h)
set := func(x0, y0, wid, hei int, d float64) {
for y := y0; y < y0+hei; y++ {
for x := x0; x < x0+wid; x++ {
mask[y*w+x] = true
dist[y*w+x] = d
}
}
}
set(2, 2, 6, 6, 3) // a blob whose widest point is 3
set(20, 4, 4, 4, 1.5) // and a smaller one at 1.5
wrap := func(x int) int { return ((x % w) + w) % w }
comp, maxDist, n := craterComponents(mask, dist, w, h, wrap)
if n != 2 {
t.Fatalf("found %d blobs, want 2", n)
}
a := maxDist[comp[3*w+3]]
b := maxDist[comp[5*w+21]]
if a != 3 || b != 1.5 {
t.Errorf("radii %.1f and %.1f, want 3 and 1.5", a, b)
}
}
// A crater on the seam is one crater, not two half craters with two different radii.
func TestACraterOnTheSeamIsOneBlob(t *testing.T) {
const w, h = 40, 12
mask := make([]bool, w*h)
dist := make([]float64, w*h)
for y := 4; y < 8; y++ {
for _, x := range []int{38, 39, 0, 1} {
mask[y*w+x] = true
dist[y*w+x] = 2
}
}
wrap := func(x int) int { return ((x % w) + w) % w }
_, _, n := craterComponents(mask, dist, w, h, wrap)
if n != 1 {
t.Fatalf("found %d blobs across the seam, want 1", n)
}
}
+442
View File
@@ -0,0 +1,442 @@
package planet
import (
"image/png"
"math"
"path/filepath"
"salty/terrain/internal/field"
"salty/terrain/internal/uplift"
)
// The maps the plan command writes, and why each one earns its place.
//
// They are rendered by point-sampling the planet's own arrays straight into an image rather than by building
// a full-resolution Field and handing it to field.WriteDataMap. At 78 million cells a Field is 312 MB, and
// the whole point of the plan command is that it costs a minute and nothing else.
// water is the flat blue every map uses for cells that are not land, so the land reads against it.
var water = [3]uint8{24, 44, 74}
// renderRGB point-samples the painted rows of the planet down to width pixels, keeping the aspect, and asks
// at() for a colour per sampled cell. The polar pad is not drawn: it is scaffolding, not world.
// The callback is given the image pixel as well as the planet cell, because a map may have a field of its
// own built at the image's resolution rather than the planet's - the uplift map does, since the massif fabric
// is a field and not a per-class constant.
func renderRGB(in *Inputs, width int, at func(planetIdx, imgIdx int) [3]uint8) (px []uint8, w, h int) {
p := in.P
if width <= 0 || width > p.W {
width = p.W
}
paintH := p.PaintH()
height := int(float64(width)*float64(paintH)/float64(p.W) + 0.5)
if height < 1 {
height = 1
}
px = make([]uint8, width*height*3)
field.Rows(height, func(y0, y1 int) {
for y := y0; y < y1; y++ {
sy := p.PadY + y*paintH/height
for x := 0; x < width; x++ {
sx := x * p.W / width
c := at(sy*p.W+sx, y*width+x)
o := (y*width + x) * 3
px[o], px[o+1], px[o+2] = c[0], c[1], c[2]
}
}
})
return px, width, height
}
func write(path string, px []uint8, w, h int) error {
return field.WriteRGB(path, w, h, px, png.DefaultCompression)
}
// WriteClassMap is the first thing to look at when a template comes out wrong: the legend's own colours,
// after the strokes have been dissolved and the poles rescued. If this is not the painting, nothing
// downstream can be.
func WriteClassMap(dir string, in *Inputs, width int) error {
cols := make([][3]uint8, len(in.Legend.Classes))
for i, c := range in.Legend.Classes {
cols[i] = [3]uint8{uint8(c.RGB[0]), uint8(c.RGB[1]), uint8(c.RGB[2])}
}
px, w, h := renderRGB(in, width, func(i, _ int) [3]uint8 { return cols[in.Map.Class[i]] })
return write(filepath.Join(dir, "map_class.png"), px, w, h)
}
// WriteRegionMap shows how the planet was cut up. Each region gets its own hue: its land saturated, the
// water it carries as a margin the same hue dimmed. Water owned by nobody is flat blue.
//
// What to read from it: that the seam-straddling landmass is one colour and not two, that no two landmasses
// which should be independent have been merged into one enormous box, and that the margins are not so wide
// that the regions have swallowed the ocean.
func WriteRegionMap(dir string, in *Inputs, width int) error {
hues := in.RegionHues()
px, w, h := renderRGB(in, width, func(i, _ int) [3]uint8 {
r := in.Part.Owner[i]
if r < 0 {
return water
}
c := hues[r]
if in.Map.Sea[i] {
// The margin: the same region, dimmed, so the box it will be solved in is visible.
return [3]uint8{c[0]/3 + water[0]/2, c[1]/3 + water[1]/2, c[2]/3 + water[2]/2}
}
return c
})
return write(filepath.Join(dir, "map_regions.png"), px, w, h)
}
// goldenAngle is the fraction of a turn between consecutive region hues: 1/phi, or 137.507 degrees.
const goldenAngle = 0.6180339887498949
// RegionHues is the colour each region is drawn in, indexed the same way in.Part.Regions is.
//
// Exported so that a caller drawing a key beside the map gets the colours from here rather than
// reimplementing it. A legend that is a second copy of the thing it describes is a legend that will
// eventually be wrong about it.
//
// The hue walks by the golden angle rather than coming out of a hash of the index, which is what it used to
// do. A hash gives *independent* hues, and independent hues collide: the closest pair of the hash's first
// twenty was 8.5 apart in RGB, which is two colours nobody can tell apart, on a map whose entire job is
// answering "is that one landmass or two". Stepping 137.5 degrees is the arrangement that keeps every prefix
// of the sequence as far apart as a sequence can be, and saturation and value then cycle on 3 and 2 so that
// two regions coming round to the same hue still differ in something else. Measured over the same walk: the
// closest pair is 44.0 at twenty regions, 41.9 at twenty-six and 37.7 at forty.
//
// Neither cycle is pushed far. The ocean margin is drawn as this colour thirded and mixed with water, so a
// region that starts dim dims to the same grey-blue as every other dim one.
func (in *Inputs) RegionHues() [][3]uint8 {
hues := make([][3]uint8, len(in.Part.Regions))
for i := range hues {
c := field.HSV(math.Mod(float64(i)*goldenAngle, 1), 0.48+0.17*float64(i%3), 0.96-0.16*float64(i%2))
hues[i] = [3]uint8{clamp8(c[0]), clamp8(c[1]), clamp8(c[2])}
}
return hues
}
// RegionLabels is where to write each region's id over the region map: the centroid of its land, as fractions
// of the drawn map - 0..1 across, 0..1 down the painted rows, the same frame renderRGB draws into.
//
// Exported for the reason RegionHues is. The alternative is a caller reproducing the polar row offset and the
// seam wrap in a second language, and a label half a region away from the region it names is worse than no
// label at all. Colour alone cannot carry this: even at 137.5 degrees a step, forty regions are forty hues and
// a person matching a hue to a swatch is doing work a two-digit number does for them.
//
// Two details it would be wrong to leave out. The mean across is *circular*, because a landmass over the seam
// has land at x=0 and at x=W-1 and a plain average puts its number on the opposite side of the planet. And it
// is sampled on a stride rather than walked whole: this is a place to put a number, the planet is seventy-six
// million cells, and a quarter of a cell of accuracy is not worth a sixteenth of a plan. A region too small to
// catch a sample falls back to the middle of its frame, which is the only thing left to say about it.
func (in *Inputs) RegionLabels() [][2]float64 {
p := in.P
out := make([][2]float64, len(in.Part.Regions))
paintH := p.PaintH()
if len(out) == 0 || paintH <= 0 {
return out
}
const stride = 4
cosX := make([]float64, p.W)
sinX := make([]float64, p.W)
for x := 0; x < p.W; x++ {
a := 2 * math.Pi * float64(x) / float64(p.W)
cosX[x], sinX[x] = math.Cos(a), math.Sin(a)
}
sumC := make([]float64, len(out))
sumS := make([]float64, len(out))
sumY := make([]float64, len(out))
n := make([]float64, len(out))
for y := p.PadY; y < p.PadY+paintH; y += stride {
row := y * p.W
for x := 0; x < p.W; x += stride {
i := row + x
r := in.Part.Owner[i]
if r < 0 || in.Map.Sea[i] {
continue
}
sumC[r] += cosX[x]
sumS[r] += sinX[x]
sumY[r] += float64(y - p.PadY)
n[r]++
}
}
for r := range out {
if n[r] == 0 {
f := in.Part.Regions[r].Frame
// X0 can run past W on a seam region and Y0 can reach into the polar pad, so both are brought
// back into the drawn frame rather than trusted.
u := math.Mod(float64(f.X0)+float64(f.W)/2, float64(p.W)) / float64(p.W)
v := (float64(f.Y0-p.PadY) + float64(f.H)/2) / float64(paintH)
out[r] = [2]float64{clamp01(u), clamp01(v)}
continue
}
a := math.Atan2(sumS[r]/n[r], sumC[r]/n[r])
if a < 0 {
a += 2 * math.Pi
}
out[r] = [2]float64{a / (2 * math.Pi), clamp01(sumY[r] / n[r] / float64(paintH))}
}
return out
}
func clamp01(v float64) float64 {
if v <= 0 {
return 0
}
if v >= 1 {
return 1
}
return v
}
// UpliftScale is the top of the uplift map's ramp, in mm/yr, and the colours along it. Same argument as
// RegionHues: the key comes from the code that drew the picture.
func (in *Inputs) UpliftScale(stops int) (hi float64, ramp [][3]uint8) {
for _, r := range in.Legend.Rates() {
if v := float64(r) * 1000; v > hi {
hi = v
}
}
if hi <= 0 {
hi = 1
}
return hi, sampleRamp(stops, field.Inferno)
}
// ErodibilityScale is the low and high end of the erodibility map's ramp and the colours along it.
func (in *Inputs) ErodibilityScale(stops int) (lo, hi float64, ramp [][3]uint8) {
lo, hi = in.erodibilityRange()
return lo, hi, sampleRamp(stops, field.Viridis)
}
// erodibilityRange is what the erodibility map spans: every land class's own multiplier, widened by the rock
// field's extremes wherever a class lets them through. It has to account for the lithology or the ramp would
// clip exactly the variation the field was added to show.
func (in *Inputs) erodibilityRange() (lo, hi float64) {
lo, hi = 1, 1
mult := in.M.Pipeline.Lithology.KMultipliers
rockLo, rockHi := 1.0, 1.0
if in.M.Planet.LithologyCells() > 0 && len(mult) > 1 {
rockLo, rockHi = mult[0], mult[0]
for _, v := range mult {
rockLo = math.Min(rockLo, v)
rockHi = math.Max(rockHi, v)
}
}
for i := range in.Legend.Classes {
c := in.Legend.Classes[i]
if !c.Land() {
continue
}
k, mix := c.K(), c.LithMix()
lo = math.Min(lo, k*(1+mix*(rockLo-1)))
hi = math.Max(hi, k*(1+mix*(rockHi-1)))
}
return lo, hi
}
func sampleRamp(stops int, f func(float64) [3]float64) [][3]uint8 {
if stops < 2 {
stops = 2
}
out := make([][3]uint8, stops)
for i := range out {
c := f(float64(i) / float64(stops-1))
out[i] = [3]uint8{clamp8(c[0]), clamp8(c[1]), clamp8(c[2])}
}
return out
}
// WriteUpliftMap is the field everything else is a consequence of, in mm/yr. On the square canvas this map
// would have shown, at a glance and with no arithmetic, that the plains were being raised at mountain rates.
// On a painted planet it is the direct check that the legend's numbers landed where the author painted them.
func WriteUpliftMap(dir string, in *Inputs, width int) error {
rates := in.Legend.Rates()
massifFloor, massifFraction := in.Legend.Massifs()
hi := 0.0
for _, r := range rates {
if v := float64(r) * 1000; v > hi {
hi = v
}
}
if hi <= 0 {
hi = 1
}
// The fabric, at the image's resolution rather than the planet's. It has to be drawn, not left out: with
// massifs the rate is a field and not a per-class constant, and a map that showed the class rate flat
// across a landmass would be showing the one thing that is no longer true about it. Building it here
// costs a couple of million noise samples rather than the planet's seventy-eight.
var rank *field.Field
if in.Legend.HasMassifs() {
u, v := in.renderUV(width)
rank = uplift.MassifRank(in.P, in.M.Source.Seed, in.M.Planet.MassifCells(), u, v)
}
px, w, h := renderRGB(in, width, func(i, img int) [3]uint8 {
if in.Map.Sea[i] {
return water
}
cl := in.Map.Class[i]
r := float64(rates[cl])
if rank != nil && massifFraction[cl] > 0 {
r = uplift.MassifRate(float64(massifFloor[cl]), r, float64(rank.Data[img]), massifFraction[cl])
}
c := field.Inferno(r * 1000 / hi)
return [3]uint8{clamp8(c[0]), clamp8(c[1]), clamp8(c[2])}
})
drawFaults(in, px, w, h)
return write(filepath.Join(dir, "map_uplift.png"), px, w, h)
}
// faultInk is the colour traces are drawn in: cyan, which appears nowhere in the Inferno ramp underneath, so
// a trace cannot be mistaken for a value.
var faultInk = [3]uint8{80, 240, 255}
// drawFaults strokes every fault trace over a map, as a line.
//
// The *line* rather than the rate it contributes, deliberately. A fault's escarpment is a couple of hundred
// metres wide and this image is a hundred kilometres across, so the thing it changes is a twentieth of a
// pixel and rendering the field would show nothing at all. What an author wants from this map is where the
// faults are and which way they run - the same question `map_regions` answers about the region cuts - and a
// stroked polyline answers it exactly.
func drawFaults(in *Inputs, px []uint8, w, h int) {
if len(in.Faults) == 0 {
return
}
p := in.P
sx := float64(w) / p.CircumferenceM()
sy := float64(h) / p.HeightM()
set := func(x, y int) {
if y < 0 || y >= h {
return
}
x = ((x % w) + w) % w // X wraps, because the traces do
o := (y*w + x) * 3
px[o], px[o+1], px[o+2] = faultInk[0], faultInk[1], faultInk[2]
}
for _, f := range in.Faults {
for j := 0; j+1 < len(f.PointsM); j++ {
ax, ay := f.PointsM[j][0]*sx, f.PointsM[j][1]*sy
bx, by := f.PointsM[j+1][0]*sx, f.PointsM[j+1][1]*sy
steps := int(math.Hypot(bx-ax, by-ay)) + 1
for k := 0; k <= steps; k++ {
t := float64(k) / float64(steps)
set(int(ax+(bx-ax)*t), int(ay+(by-ay)*t))
}
}
}
}
// renderUV is the world coordinates of the cells renderRGB will point-sample, in the same order it writes
// them. Exactly the sampled cells rather than an even walk across the map: a diagnostic that showed the
// fabric half a pixel from where the class was read would be a diagnostic nobody could trust to the pixel.
func (in *Inputs) renderUV(width int) (u, v *field.Field) {
p := in.P
if width <= 0 || width > p.W {
width = p.W
}
paintH := p.PaintH()
height := int(float64(width)*float64(paintH)/float64(p.W) + 0.5)
if height < 1 {
height = 1
}
cellM := p.CircumferenceM() / float64(width)
u = field.New(width, height, cellM)
v = field.New(width, height, cellM)
for y := 0; y < height; y++ {
sy := p.PadY + y*paintH/height
vy := float32(p.YM(sy) / p.NoisePeriodM)
for x := 0; x < width; x++ {
i := y*width + x
u.Data[i] = float32(p.XM(x*p.W/width) / p.NoisePeriodM)
v.Data[i] = vy
}
}
return u, v
}
// WriteErodibilityMap is where texture inside a range comes from: the multiplier on stream-power K.
func WriteErodibilityMap(dir string, in *Inputs, width int) error {
ks := in.Legend.Erodibilities()
mix := in.Legend.LithologyMixes()
lo, hi := in.erodibilityRange()
span := hi - lo
if span < 1e-9 {
span = 1
}
// The rock field at the image's resolution rather than the planet's, the same way and for the same reason
// the uplift map builds the massif fabric: with lithology the erodibility is a *field*, and a map drawing
// the class multiplier flat across a landmass would be showing the one thing that is no longer true of it.
var rock *field.Field
if cells := in.M.Planet.LithologyCells(); cells > 0 && in.Legend.HasLithology() {
u, v := in.renderUV(width)
rock = uplift.RockK(in.P, in.M.Source.Seed, cells, in.M.Pipeline.Lithology.KMultipliers, u, v)
}
px, w, h := renderRGB(in, width, func(i, img int) [3]uint8 {
if in.Map.Sea[i] {
return water
}
cl := in.Map.Class[i]
k := float64(ks[cl])
if rock != nil && mix[cl] > 0 {
k *= 1 + mix[cl]*(float64(rock.Data[img])-1)
}
c := field.Viridis((k - lo) / span)
return [3]uint8{clamp8(c[0]), clamp8(c[1]), clamp8(c[2])}
})
return write(filepath.Join(dir, "map_erodibility.png"), px, w, h)
}
func clamp8(v float64) uint8 {
if v <= 0 {
return 0
}
if v >= 255 {
return 255
}
return uint8(v + 0.5)
}
// WriteOverlayMap draws the annotation layer over a dimmed class map, which is the only way to judge it: a
// mark means nothing on its own and everything relative to the coastline or the range it was drawn against.
//
// It samples the overlay at its own resolution rather than the planet's. Everything else here reads a planet
// array; the overlay is registered to the *template*, so going through the planet grid would resample it
// twice and lose thin strokes on the way.
func WriteOverlayMap(dir string, in *Inputs, width int) error {
if in.OverlayRaster == nil {
return nil
}
cols := make([][3]uint8, len(in.Overlay.Marks)+1)
for i, m := range in.Overlay.Marks {
cols[i+1] = [3]uint8{uint8(m.RGB[0]), uint8(m.RGB[1]), uint8(m.RGB[2])}
}
class := make([][3]uint8, len(in.Legend.Classes))
for i, c := range in.Legend.Classes {
// Halved towards black, so a full-strength mark on top of it cannot be mistaken for the ground.
class[i] = [3]uint8{uint8(c.RGB[0] / 2), uint8(c.RGB[1] / 2), uint8(c.RGB[2] / 2)}
}
ov := in.OverlayRaster
p := in.P
paintH := p.PaintH()
px, w, h := renderRGB(in, width, func(i, img int) [3]uint8 {
// The planet cell this pixel came from, turned back into an overlay pixel. Both rasters cover the
// same painted rows, so the conversion is two ratios and no interpolation.
x := i % p.W
y := i/p.W - p.PadY
ox := x * ov.W / p.W
oy := y * ov.H / paintH
if m := ov.At(ox, oy); m != 0 && int(m) < len(cols) {
return cols[m]
}
return class[in.Map.Class[i]]
})
return write(filepath.Join(dir, "map_overlay.png"), px, w, h)
}
@@ -0,0 +1,139 @@
package planet
import (
"math"
"os"
"path/filepath"
"salty/terrain/internal/field"
"salty/terrain/internal/plates"
)
// The tectonic map: which plate every place belongs to, and what is happening where two of them meet.
//
// It earns its place the same way map_uplift does. Every belt, every fault and every basin this model
// produces is a consequence of one line and what is happening across it, so when a range comes out in the
// wrong place the question is always "what did the boundary there do", and this is the only picture that
// answers it. The lines are drawn by *kind* rather than by rate, for the reason drawFaults gives about
// stroking traces: a collision belt is tens of kilometres wide, a map of a whole planet is a few thousand
// pixels, and the thing an author needs from it is where the margins are and which ones are closing.
// plateInk is the colour of each kind of margin. They are picked to be distinguishable from each other and
// from the plate fills underneath, which are pastel by construction so that these read on top of them.
var plateInk = map[plates.Kind][3]uint8{
plates.Collision: {255, 64, 48}, // red: two continents, the thing that makes mountains
plates.Subduction: {255, 156, 32}, // orange: an ocean going under
plates.Rift: {96, 240, 120}, // green: a continent pulling apart
plates.Ridge: {72, 196, 255}, // blue: new ocean floor
plates.Transform: {236, 232, 128}, // yellow: sliding, neither up nor down
}
// WritePlateMap draws the tectonic model over the painted land.
func WritePlateMap(dir string, in *Inputs, width int) error {
m := in.Plates
if m == nil {
return nil
}
// A hue per plate, walked round the wheel by a step coprime-ish with the count so that neighbouring ids
// are not neighbouring hues - plates 3 and 4 are usually adjacent on the map, and two greens meeting
// would hide the very boundary this map exists to show.
n := len(m.Plates)
hue := make([]float64, n)
for i := range hue {
hue[i] = math.Mod(float64(i)*0.61803398875, 1)
}
px, w, h := renderRGB(in, width, func(i, img int) [3]uint8 {
id := m.PlateAt(in.P.XM(i%in.P.W), in.P.YM(i/in.P.W))
if id < 0 || id >= n {
return water
}
// Land is the plate's hue at full strength and sea is the same hue dimmed, so the painting stays
// legible underneath: a margin is only interesting relative to where the coasts are.
sat, val := 0.55, 0.78
if in.Map.Sea[i] {
sat, val = 0.38, 0.34
}
// A continental plate is warmer than an oceanic one at the same hue, because which of the two a
// plate is decides what every convergent margin around it does.
if !m.Plates[id].Continental {
sat *= 0.5
}
c := field.HSV(hue[id]*360, sat, val)
return [3]uint8{clamp8(c[0]), clamp8(c[1]), clamp8(c[2])}
})
drawBoundaries(m, in, px, w, h)
return write(filepath.Join(dir, "map_plates.png"), px, w, h)
}
// WritePlateProposal writes a tectonic layer and its legend for an author to open and edit.
//
// A blank canvas is the wrong place to start this. Seven plates with plausible motions is a second's work for
// the generator and an afternoon's by hand, and what an author actually wants to do is move two of them and
// change a heading - which is editing. The pair it writes is exactly what `planet.plates.layer` and
// `planet.plates.legend` take, so adopting it is two lines in the manifest.
func WritePlateProposal(dir string, in *Inputs, width int) error {
if in.Plates == nil {
return nil
}
px, w, h, lg := in.Plates.Propose(width)
const name = "plates_proposal"
if err := write(filepath.Join(dir, name+".png"), px, w, h); err != nil {
return err
}
lg.Image = name + ".png"
data, err := plates.MarshalLegend(lg)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, name+".json"), data, 0o644)
}
// boundaryWidthMYr is the closing rate, in metres a year, at which a margin is drawn at its full width. A
// margin at a tenth of it is still one pixel, so a slow boundary is visible without a fast one being a blot.
const boundaryWidthMYr = 0.08
// drawBoundaries strokes every margin, coloured by what it is doing and thickened by how fast.
func drawBoundaries(m *plates.Model, in *Inputs, px []uint8, w, h int) {
p := in.P
sx := float64(w) / p.CircumferenceM()
sy := float64(h) / p.HeightM()
set := func(x, y int, c [3]uint8) {
if y < 0 || y >= h {
return
}
x = ((x % w) + w) % w // X wraps, because the boundaries do
o := (y*w + x) * 3
px[o], px[o+1], px[o+2] = c[0], c[1], c[2]
}
disc := func(x, y, r int, c [3]uint8) {
for dy := -r; dy <= r; dy++ {
for dx := -r; dx <= r; dx++ {
if dx*dx+dy*dy <= r*r {
set(x+dx, y+dy, c)
}
}
}
}
for _, b := range m.Boundaries {
for j := 0; j+1 < len(b.V); j++ {
v := b.V[j]
ink := plateInk[v.Kind]
r := int(math.Abs(v.ClosingMYr)/boundaryWidthMYr*2 + 0.5)
if r > 2 {
r = 2
}
ax, ay := v.XM*sx, v.YM*sy
bx, by := b.V[j+1].XM*sx, b.V[j+1].YM*sy
steps := int(math.Hypot(bx-ax, by-ay)) + 1
for k := 0; k <= steps; k++ {
t := float64(k) / float64(steps)
disc(int(ax+(bx-ax)*t), int(ay+(by-ay)*t), r, ink)
}
}
}
}
@@ -0,0 +1,157 @@
package planet
import (
"math"
"testing"
"salty/terrain/internal/region"
"salty/terrain/internal/template"
"salty/terrain/internal/world"
)
// A region label is a number written on a picture, so the only thing worth asserting about it is that it lands
// on the region it names.
//
// The seam is the case that fails silently. A landmass with land at x=0 and at x=W-1 has an arithmetic mean
// halfway round the planet - on some other continent entirely - and the map would look perfectly plausible
// while being wrong about the one thing the number is for. Everything else here is a guard on the arithmetic
// around it: the polar pad is not painted rows, and a region too small to catch a sample still has to get a
// position rather than a NaN.
const (
labelW = 64
labelPadY = 4
labelPaint = 32
)
// labelFixture is a planet with three regions painted onto it: a compact island in the middle, an island over
// the seam, and a single cell small enough that the stride walks straight past it.
func labelFixture() *Inputs {
p := world.Planet{
CellM: 1, W: labelW, H: labelPaint + 2*labelPadY, PadY: labelPadY,
NoisePeriodM: float64(labelW),
}
n := p.W * p.H
m := &template.Map{P: p, Class: make([]uint8, n), Sea: make([]bool, n)}
for i := range m.Sea {
m.Sea[i] = true
}
part := &region.Partition{P: p, MarginCells: 1, Owner: make([]int32, n)}
for i := range part.Owner {
part.Owner[i] = -1
}
land := func(r int32, x0, x1, py0, py1 int) {
for py := py0; py <= py1; py++ {
for x := x0; x <= x1; x++ {
i := (labelPadY+py)*p.W + ((x%p.W)+p.W)%p.W
part.Owner[i], m.Sea[i] = r, false
}
}
}
// 0: a compact island around the middle of the map, centred on x = 31.5, painted row 15.5.
land(0, 28, 35, 12, 19)
part.Regions = append(part.Regions, region.Region{
ID: 0, Frame: world.Frame{P: p, X0: 28, Y0: labelPadY + 12, W: 8, H: 8}, LandCells: 64,
})
// 1: the same island moved onto the seam, centred on x = 63.5 - half of it at x >= 60 and half at x <= 3.
land(1, 60, 67, 12, 19)
part.Regions = append(part.Regions, region.Region{
ID: 1, Frame: world.Frame{P: p, X0: 60, Y0: labelPadY + 12, W: 8, H: 8}, LandCells: 64, Seam: true,
})
// 2: one cell, at coordinates the stride never samples, so this is the fallback path.
land(2, 1, 1, 1, 1)
part.Regions = append(part.Regions, region.Region{
ID: 2, Frame: world.Frame{P: p, X0: 1, Y0: labelPadY + 1, W: 1, H: 1}, LandCells: 1,
})
return &Inputs{P: p, Map: m, Part: part}
}
// circDist is the distance between two positions round the cylinder, in fractions of a turn.
func circDist(a, b float64) float64 {
d := math.Abs(a - b)
return math.Min(d, 1-d)
}
func TestRegionLabelsLandOnTheirRegion(t *testing.T) {
in := labelFixture()
got := in.RegionLabels()
if len(got) != 3 {
t.Fatalf("got %d labels for 3 regions", len(got))
}
// The compact island. The stride costs up to two cells of accuracy in each axis, which is 0.03 of a turn
// across and 0.06 down, and a label is allowed to be that far from dead centre.
const wantU0, wantV0 = 31.5 / labelW, 15.5 / labelPaint
if d := circDist(got[0][0], wantU0); d > 0.05 {
t.Errorf("region 0 u = %.4f, want within 0.05 of %.4f (off by %.4f)", got[0][0], wantU0, d)
}
if d := math.Abs(got[0][1] - wantV0); d > 0.08 {
t.Errorf("region 0 v = %.4f, want within 0.08 of %.4f", got[0][1], wantV0)
}
// The seam island, and the reason this file exists. Its land is centred on x = 63.5, a quarter of a cell
// short of the seam. An arithmetic mean of those columns gives 31.5, which is region 0's island: if this
// assertion ever fails by landing near 0.49, the circular mean has been lost.
const wantU1 = 63.5 / labelW
if d := circDist(got[1][0], wantU1); d > 0.06 {
t.Errorf("region 1 u = %.4f, want within 0.06 of %.4f (off by %.4f); "+
"0.49 means the mean across is no longer circular", got[1][0], wantU1, d)
}
if d := math.Abs(got[1][1] - wantV0); d > 0.08 {
t.Errorf("region 1 v = %.4f, want within 0.08 of %.4f", got[1][1], wantV0)
}
// The single cell, which no sample touches: the frame centre, and in particular a v measured from the
// painted rows rather than from the top of the polar pad.
const wantU2, wantV2 = 1.5 / labelW, 1.5 / labelPaint
if d := circDist(got[2][0], wantU2); d > 0.01 {
t.Errorf("region 2 u = %.4f, want %.4f from its frame", got[2][0], wantU2)
}
if d := math.Abs(got[2][1] - wantV2); d > 0.01 {
t.Errorf("region 2 v = %.4f, want %.4f from its frame; a v of %.4f would be measuring from the "+
"top of the pad instead of the first painted row",
got[2][1], wantV2, float64(labelPadY+1)/labelPaint)
}
for i, l := range got {
if math.IsNaN(l[0]) || math.IsNaN(l[1]) ||
l[0] < 0 || l[0] > 1 || l[1] < 0 || l[1] > 1 {
t.Errorf("region %d label %v is outside the drawn map", i, l)
}
}
}
// The hues have to be far enough apart to tell one landmass from another, which is the whole reason they
// stopped coming out of a hash of the index. See RegionHues for the argument; this is the measurement.
func TestRegionHuesStayApart(t *testing.T) {
const regions = 40
in := &Inputs{Part: &region.Partition{Regions: make([]region.Region, regions)}}
hues := in.RegionHues()
worst, wa, wb := math.MaxFloat64, 0, 0
for a := 0; a < regions; a++ {
for b := a + 1; b < regions; b++ {
dr := float64(hues[a][0]) - float64(hues[b][0])
dg := float64(hues[a][1]) - float64(hues[b][1])
db := float64(hues[a][2]) - float64(hues[b][2])
if d := math.Sqrt(dr*dr + dg*dg + db*db); d < worst {
worst, wa, wb = d, a, b
}
}
}
t.Logf("closest of %d hues: %.1f, between region %d and region %d", regions, worst, wa, wb)
// Measured: the walk gives 44.0 at twenty regions, 41.9 at twenty-six and 37.7 at forty, against 8.5 for
// the hash this replaced. The floor is set well below what the walk achieves rather than at it, so that a
// change to the saturation or value cycle is free to move the number a little and not free to collapse it.
const floor = 25
if worst < floor {
t.Errorf("closest two hues are %.1f apart (regions %d and %d), want at least %d: "+
"two landmasses that colour alike is the defect this walk exists to prevent", worst, wa, wb, floor)
}
}
+373
View File
@@ -0,0 +1,373 @@
package planet
import (
"encoding/json"
"fmt"
"image/png"
"math"
"os"
"path/filepath"
"time"
"salty/terrain/internal/field"
"salty/terrain/internal/stats"
)
// What a bake writes, and why.
//
// Three resolutions of the same 16-bit heightmap, because the three answer different questions: the geology
// grid is the thing the detail passes will be built on, the middle one is what fits in an image viewer, and
// the small one is a minimap. The preview and the data maps are for judging, not for importing.
// Painted returns a field over the painted rows only, with the polar pad dropped. The pad is scaffolding -
// synthetic ocean that exists so a cap touching the top of the map has a shore to drain to - and it is
// removed before anything leaves the generator.
func (r *Result) Painted() *field.Field {
p := r.In.P
out := field.New(p.W, p.PaintH(), p.CellM)
copy(out.Data, r.Height.Data[p.PadY*p.W:(p.H-p.PadY)*p.W])
return out
}
// PaintedSea is the sea mask over the painted rows.
func (r *Result) PaintedSea() []bool {
p := r.In.P
return r.Sea[p.PadY*p.W : (p.H-p.PadY)*p.W]
}
// PaintedFlow is the drainage area over the painted rows.
func (r *Result) PaintedFlow() *field.Field {
p := r.In.P
out := field.New(p.W, p.PaintH(), p.CellM)
copy(out.Data, r.Flow[p.PadY*p.W:(p.H-p.PadY)*p.W])
return out
}
// Write puts the bake on disk.
func (r *Result) Write(outDir string, mapWidth int, log func(string, ...any)) error {
if log == nil {
log = func(string, ...any) {}
}
if err := os.MkdirAll(outDir, 0o755); err != nil {
return err
}
m := r.In.M
h := r.Painted()
sea := r.PaintedSea()
flow := r.PaintedFlow()
// The statistics, pooled. Regions are merged in *region order* rather than in the order they finished:
// the histograms themselves are integer counts and would not care, but the running sums are floats and
// float addition is not associative, so a run's numbers would otherwise depend on which landmass came
// back first. Cross-cutting rule 12, in the one place left where it could still leak.
acc := stats.New(statsOptions(m))
for i := range r.Regions {
acc.Merge(r.Regions[i].stats)
}
// And the extent, measured once on the composited planet. A region carries an ocean margin and two
// neighbouring margins overlap, so pooling "cells" across regions counts the same water twice and reports
// a land fraction that means nothing; the finished cylinder is the only place the question has an answer.
land := make([]bool, len(sea))
for i, s := range sea {
land[i] = !s
}
acc.AddExtent(h.Data, land, m.ClipCells(h.Data))
rep := acc.Report(h.CellM)
r.Stats = &rep
// The heightmap, three ways. Compression is worth paying for on the full one, which is the thing
// anything downstream actually reads; the two overviews are rebuilt from a seed in seconds.
levels := []struct {
name string
w, h int
lvl png.CompressionLevel
}{
{"planet_height.png", h.W, h.H, png.DefaultCompression},
{"planet_height_mid.png", h.W / 4, h.H / 4, png.BestSpeed},
{"planet_height_low.png", h.W / 10, h.H / 10, png.BestSpeed},
}
for _, l := range levels {
if l.w < 2 || l.h < 2 {
continue
}
data := h.Data
if l.w != h.W || l.h != h.H {
data = boxDown(h.Data, h.W, h.H, l.w, l.h)
}
if err := field.WriteGray16(filepath.Join(outDir, l.name), l.w, l.h, m.Encode(data), l.lvl); err != nil {
return err
}
log("wrote %-24s %d x %d at %.1f m", l.name, l.w, l.h, float64(h.W)*h.CellM/float64(l.w))
}
// How much of the 16-bit ramp the world actually used, which until D-64 nothing said. The clip fraction
// is the check at the top end and it only ever catches a range too *narrow*; a range several times too
// wide clips nothing, reports nothing, and quietly spends most of its resolution and all of its contrast
// on elevations no cell on the planet has. A heightmap that uses a tenth of its ramp is a flat grey
// picture in every viewer, and the ocean and the land in it are the same grey.
span := m.ElevationM.Max - m.ElevationM.Min
used := (rep.MaxM - rep.MinM) / span
landUsed := (rep.LandMaxM - m.SeaLevelM) / span
log("range %.0f..%.0f m encoded, %.0f..%.0f m used: %.0f%% of the ramp, and land is %.1f%% of it",
m.ElevationM.Min, m.ElevationM.Max, rep.MinM, rep.MaxM, used*100, landUsed*100)
if used < 0.5 {
log(" tighten elevation_m to about %.0f..%.0f m and the same terrain arrives with %.0fx the "+
"contrast and %.0fx the vertical resolution; the range is an author's choice and nothing but "+
"this line will tell you it is wrong, because too wide never clips",
math.Floor(rep.MinM/64)*64, math.Ceil(rep.MaxM/64)*64, 1/used, 1/used)
}
topM, err := field.WritePreview(filepath.Join(outDir, "preview.png"), h, field.PreviewOptions{
Flow: flow, Sea: sea, Snow: r.In.Map.SnowMask(), Palette: r.In.Palette,
SeaLevelM: m.SeaLevelM, RiverKm2: 0.5, Size: mapWidth,
})
if err != nil {
return err
}
// Say what the colours meant. The ramp is relative by default, so bare rock and snow on a preview mean
// "the highest ground on this world", not "high ground" - and on a 47 m lowland continent those are the
// same pixels a 2800 m range would produce. A relative picture is fine; one nobody was told is relative
// is how a plain gets read as an alpine massif. `palette.land_top_m` makes it absolute.
if r.In.Palette != nil && r.In.Palette.LandTopM > 0 {
log("preview the hypsometric ramp tops out at a fixed %.0f m, so the colours mean the same thing "+
"they would on any other world", topM)
} else {
log("preview the hypsometric ramp tops out at %.0f m - the %.4g%% percentile of *this* world's land, "+
"so rock and snow mean \"the highest ground here\" and nothing about scale. Set "+
"palette.land_top_m for an absolute ramp", topM, palPercentile(r.In.Palette))
}
for _, w := range []func(string, *Inputs, int) error{
WriteClassMap, WriteRegionMap, WriteUpliftMap, WriteErodibilityMap, WriteOverlayMap, WritePlateMap,
} {
if err := w(outDir, r.In, mapWidth); err != nil {
return err
}
}
// The annotation layer travels with the bake, so a heightmap and the things the author placed on it are
// never in two directories that can drift apart.
if r.In.OverlayDoc != nil {
if err := r.In.OverlayDoc.WriteJSON(outDir); err != nil {
return err
}
}
slope := h.Slope()
for i, v := range slope.Data {
slope.Data[i] = float32(degrees(float64(v)))
}
if err := field.WriteDataMap(filepath.Join(outDir, "map_slope.png"), slope, field.DataMapOptions{
Sea: sea, Size: mapWidth, Lo: 0, Hi: 45, Palette: field.Inferno,
}); err != nil {
return err
}
if err := field.WriteDataMap(filepath.Join(outDir, "map_flow.png"), flow, field.DataMapOptions{
Sea: sea, Size: mapWidth, Log: true,
}); err != nil {
return err
}
if err := r.writeCoastMaps(outDir, mapWidth); err != nil {
return err
}
log("wrote preview.png and the data maps at %d px wide", mapWidth)
meta := map[string]any{
"when": time.Now().UTC().Truncate(time.Second),
"manifest": m.Path,
"seed": m.Source.Seed,
"plan": r.In.Report(),
"regions": r.Regions,
"craters": r.Craters,
"stats": r.Stats,
"coast": coastStats(r),
// The traces themselves, not just the count: a fault set is a property of the seed and the painting,
// and "which fault made that valley" is a question somebody will ask of a finished world. Thirty
// traces of thirty points is forty kilobytes, which is nothing against the heightmap beside it.
"faults": r.In.Faults,
// The tectonic model, when there is one, for the same reason and one level up: every belt and every
// fault this planet has is a consequence of one of these lines, so "why is there a range here" is
// answerable afterwards rather than only while the process that drew it is still running.
"plates": r.In.Plates,
"elapsed": r.Elapsed.Round(time.Second).String(),
"steps": m.Pipeline.Fluvial.Steps,
}
data, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(outDir, "meta.json"), append(data, '\n'), 0o644)
}
// Summary is the verdict line, printed per region and then for the planet.
func (r *Result) Summary() string {
lo, hi := 1e30, -1e30
clipWorst, clipWorstID := 0.0, -1
total := 0.0
for _, rr := range r.Regions {
if rr.LandCells == 0 {
continue
}
if rr.MinM < lo {
lo = rr.MinM
}
if rr.MaxM > hi {
hi = rr.MaxM
}
if rr.ClipFrac > clipWorst {
clipWorst, clipWorstID = rr.ClipFrac, rr.ID
}
total += rr.Seconds
}
s := fmt.Sprintf(" %d regions solved in %s of wall time (%.0f s of solve)\n"+
" land %.0f..%.0f m against the manifest's %.0f..%.0f m\n",
len(r.Regions), r.Elapsed.Round(time.Second), total,
lo, hi, r.In.M.ElevationM.Min, r.In.M.ElevationM.Max)
if clipWorstID >= 0 && clipWorst > 0 {
verdict := "which is a rounding"
if clipWorst > 0.001 {
verdict = "WHICH IS A FAILED RUN, not a rounded one: U/K is the relief knob"
}
s += fmt.Sprintf(" worst clip %.3f%% in region %d, %s\n", clipWorst*100, clipWorstID, verdict)
}
// The block Terrain-Next has called the one that matters most since D-53, and which a planet bake could
// not print until the statistics learned to pool: map-wide medians cannot answer "are the plains plains",
// and that is the question.
if r.Stats != nil {
s += "\n" + r.Stats.Summary() + "\n"
}
return s
}
// boxDown is an area-average downsample for any ratio, integer or not: every source cell is added to the
// bucket its centre falls in. field.Resample's mass-preserving path needs an exact integer factor on the
// quad count, and a planet's two sides rarely share one.
func boxDown(src []float32, w, h, dw, dh int) []float32 {
sum := make([]float64, dw*dh)
n := make([]int32, dw*dh)
for y := 0; y < h; y++ {
dy := y * dh / h
for x := 0; x < w; x++ {
d := dy*dw + x*dw/w
sum[d] += float64(src[y*w+x])
n[d]++
}
}
out := make([]float32, dw*dh)
for i := range out {
if n[i] > 0 {
out[i] = float32(sum[i] / float64(n[i]))
}
}
return out
}
func degrees(slope float64) float64 { return math.Atan(slope) * 180 / math.Pi }
// BakePrefix is the directory a bake is written into, numbered upwards.
const BakePrefix = "Bake_"
// NextBakeDir is the first version number not already on disk.
//
// Bakes are versioned for the same reason paintings are: an hour and a half is far too long to spend on a
// change you then cannot compare against what it replaced.
func NextBakeDir(base string) string {
for n := 1; n < 10000; n++ {
dir := filepath.Join(base, fmt.Sprintf("%s%03d", BakePrefix, n))
if _, err := os.Stat(dir); os.IsNotExist(err) {
return dir
}
}
return filepath.Join(base, BakePrefix+"overflow")
}
// palPercentile is the ramp's percentile, or the default's when the bake carries no palette of its own.
func palPercentile(p *field.Palette) float64 {
if p == nil {
p = field.DefaultPalette()
}
return p.LandTopPercentile
}
// writeCoastMaps draws the two pictures the coastal pass is judged from.
//
// **The change map** is the whole pass in one image: cool where the surf cut, warm where the sediment landed.
// The sea floor is excluded from it, because the ocean goes from sea level to five hundred metres down in one
// pass and a few hundred metres of that would swamp the few the shore processes move, which is the thing the
// map exists to show.
//
// **Exposure** is drawn only within a kilometre of the water. That is not tidiness: it is measured on the
// waterline and carried to every other cell by "the stretch of shore nearest to you", so past a few hundred
// metres it is a map of the continent's medial axis rather than of anything coastal - the first render of it
// on the square canvas was a sunburst of polygonal wedges meeting in the middle of a continent.
func (r *Result) writeCoastMaps(outDir string, mapWidth int) error {
cs := r.Coast
if cs == nil || !r.In.M.Pipeline.Coast.Enabled {
return nil
}
p := r.In.P
lo, hi := p.PadY*p.W, (p.H-p.PadY)*p.W
painted := func(src *field.Field) *field.Field {
out := field.New(p.W, p.PaintH(), p.CellM)
copy(out.Data, src.Data[lo:hi])
return out
}
band := make([]bool, p.W*p.PaintH())
for i, d := range cs.Geometry.Dist.Data[lo:hi] {
band[i] = math.Abs(float64(d)) > 1000
}
if err := field.WriteDataMap(filepath.Join(outDir, "map_exposure.png"), painted(cs.Exposure),
field.DataMapOptions{Sea: band, Size: mapWidth, Lo: 0, Hi: 1, Palette: field.Inferno}); err != nil {
return err
}
// The sea floor masked out, so the scale belongs to the shore rather than to the shelf.
deep := make([]bool, p.W*p.PaintH())
for i, d := range cs.Geometry.Dist.Data[lo:hi] {
deep[i] = float64(d) < -r.In.M.Pipeline.Coast.DepositReachM*2
}
if err := field.WriteDataMap(filepath.Join(outDir, "map_coast.png"), painted(cs.Change),
field.DataMapOptions{Sea: deep, Size: mapWidth, Lo: -30, Hi: 30, Palette: field.Divergent}); err != nil {
return err
}
return r.writeExposure(outDir)
}
// writeExposure carries the fetch field forward to the detail bake, at the geology grid and unmasked.
//
// It is data rather than a picture, which is why it is not map_exposure.png: that one is scaled to a map
// width and blanked away from the water, both of which are right for looking at and useless for reading back.
//
// The detail bake needs it because it cannot compute it. Fetch is cast fifteen hundred metres in sixteen
// directions from every waterline cell, and a tile is five kilometres across with a two hundred and fifty
// metre margin - so a tile can see neither the far side of a bay nor the open ocean beyond a headland, and
// whether the water in front of a beach is one or the other is the whole difference between a berm and a
// mudflat. It is the same rule the massif threshold and the lithology split are under: a quantity measured
// over the whole world is measured once, by the pass that has the whole world, and carried.
//
// Eight bits, so a stretch of shore is placed to a four-hundredth of the range. The field is a smoothed
// fetch ratio and its own noise floor is well above that.
func (r *Result) writeExposure(outDir string) error {
p := r.In.P
lo := p.PadY * p.W
n := p.W * p.PaintH()
px := make([]uint8, n)
for i := 0; i < n; i++ {
v := float64(r.Coast.Exposure.Data[lo+i])
if v < 0 {
v = 0
} else if v > 1 {
v = 1
}
px[i] = uint8(v*255 + 0.5)
}
return field.WriteGray8(filepath.Join(outDir, "coast_exposure.png"), p.W, p.PaintH(), px,
png.DefaultCompression)
}
// coastStats is the pass's own accounting for meta.json, or nil when it did not run.
func coastStats(r *Result) any {
if r.Coast == nil || !r.In.M.Pipeline.Coast.Enabled {
return nil
}
return r.Coast.Stats
}
@@ -0,0 +1,54 @@
package planet
import (
"math"
"testing"
)
// boxDown is what the two overview heightmaps are made with, and a planet's two sides rarely share an
// integer factor, so it has to be right at a ratio that does not divide.
func TestBoxDownAveragesAndKeepsTheMean(t *testing.T) {
const w, h = 12, 7
src := make([]float32, w*h)
sum := 0.0
for i := range src {
src[i] = float32(i%5) + float32(i/w)
sum += float64(src[i])
}
want := sum / float64(len(src))
for _, d := range [][2]int{{6, 7}, {4, 3}, {5, 3}, {12, 7}, {1, 1}} {
out := boxDown(src, w, h, d[0], d[1])
if len(out) != d[0]*d[1] {
t.Fatalf("%dx%d: got %d values", d[0], d[1], len(out))
}
got := 0.0
for _, v := range out {
got += float64(v)
}
got /= float64(len(out))
// Buckets do not all hold the same number of cells at a ratio that does not divide, so the mean of
// the means drifts a little; what must not happen is a bucket left empty or a value invented.
if math.Abs(got-want) > 0.35 {
t.Errorf("%dx%d: mean %.3f, source mean %.3f", d[0], d[1], got, want)
}
lo, hi := math.Inf(1), math.Inf(-1)
for _, v := range out {
lo = math.Min(lo, float64(v))
hi = math.Max(hi, float64(v))
}
if lo < 0 || hi > 11 {
t.Errorf("%dx%d: range %.2f..%.2f is outside the source's 0..10", d[0], d[1], lo, hi)
}
}
}
func TestBoxDownIsIdentityAtTheSameSize(t *testing.T) {
src := []float32{1, 2, 3, 4, 5, 6}
out := boxDown(src, 3, 2, 3, 2)
for i := range src {
if out[i] != src[i] {
t.Fatalf("cell %d: %v, want %v", i, out[i], src[i])
}
}
}
+354
View File
@@ -0,0 +1,354 @@
package planet
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"salty/terrain/internal/field"
"salty/terrain/internal/fluvial"
"salty/terrain/internal/manifest"
"salty/terrain/internal/overlay"
)
// Proposing an overlay from a finished bake.
//
// The annotation layer is hand-painted and starts blank, and the three things most worth putting on it -
// woodland, settlements and the roads between them - are all consequences of ground the author cannot see
// while painting. The classes are painted before the solve exists, and once it does exist it is a
// seventy-six-million-cell heightmap. So this reads the bake back and hands internal/overlay the four
// fields it needs to have an opinion: height, slope, the sea, and where the water collects.
//
// It reads the bake from disk rather than hooking into one, for the same reason `terrain tiles` does: the
// geology is two hours and this is seconds, so an author can regenerate the sheet as often as they like
// against a bake they already have.
//
// **It works at the template's resolution, not the geology grid's.** The overlay is registered to the
// template and must be exactly its size, so generating anywhere else would mean resampling the output - and
// a resampled mark is a blend of two colours, which the classifier reads as a third mark or as nothing. The
// geology is pooled down to the template once, here, and everything downstream is at that scale.
// OverlayGenOptions is what the generator is pointed at.
type OverlayGenOptions struct {
In *Inputs
BakeDir string
// Replace ignores the overlay already on disk instead of filling in around it. The default is to keep
// every painted pixel, because regenerating must never cost an author their work; this is the flag for
// "throw away the last generation and start again", and it says so at the call site.
Replace bool
// Seed overrides the manifest's, which is what a re-roll is: the painting fixes where the land is and
// the seed decides everything it does not - which patch of eligible ground becomes woodland, and which
// of the equally good sites gets the town.
Seed int64
// Existing overrides the overlay loaded from disk. The studio sets it, because the sheet an author is
// looking at includes strokes they have not saved, and generating around the file instead of around the
// screen would put marks on top of work that is visibly there.
Existing *overlay.Raster
Log func(string, ...any)
}
// GenerateOverlay reads a bake and proposes the marks whose legend asks for them.
func GenerateOverlay(opt OverlayGenOptions) (*overlay.Raster, overlay.GenReport, error) {
var none overlay.GenReport
in := opt.In
log := opt.Log
if log == nil {
log = func(string, ...any) {}
}
if in.Overlay == nil {
return nil, none, fmt.Errorf("%s has no overlay legend; set planet.overlay_legend and say which "+
"marks to generate", in.M.Path)
}
wants := false
for i := range in.Overlay.Marks {
if in.Overlay.Marks[i].Generate != nil {
wants = true
break
}
}
if !wants {
return nil, none, fmt.Errorf("no mark in %s has a `generate` block, so there is nothing to "+
"generate. Generation is opt-in per mark; see the overlay section of the templates README",
in.M.Planet.OverlayLegend)
}
ow, oh := in.PaintW, in.PaintH
cellM := in.P.CircumferenceM() / float64(ow)
if opt.BakeDir != "" {
if ok, why := BakeIsOfThisPainting(opt.BakeDir, in.M); !ok {
log("ignoring %s: %s, so its terrain is not this world's", filepath.Base(opt.BakeDir), why)
opt.BakeDir = ""
}
}
if opt.BakeDir == "" {
// No bake: the painting is all there is.
//
// Worth supporting rather than refusing, because the first thing an author wants after drawing a
// world is to see something placed on it, and the bake is two hours away. What survives without a
// solve is everything the *painting* knows - where the land is, where the sea is, and which class
// each cell was painted - so the coast still shapes where towns go and the class filters still keep
// woodland off the ice. What is lost is everything the terrain knows: there are no rivers to sit on,
// no slope to avoid, and therefore no reason for a road to bend. Say which of the two ran; a draft
// made this way is a sketch, and reading it as the other is how somebody concludes the generator
// ignores the terrain.
log("no bake: generating from the painting alone, so there are no rivers and no slope to read")
gin := flatInputs(in, ow, oh, cellM, opt.Replace)
opt.apply(&gin)
return in.Overlay.Generate(gin)
}
if err := CheckBake(opt.BakeDir, in.M, log); err != nil {
return nil, none, err
}
hpath := filepath.Join(opt.BakeDir, "planet_height.png")
values, gw, gh, err := field.ReadHeightmap(hpath, 0)
if err != nil {
return nil, none, fmt.Errorf("%s: %w (run `terrain bake` first)", hpath, err)
}
if gw != in.P.W || gh != in.P.PaintH() {
return nil, none, fmt.Errorf("%s is %dx%d but the manifest describes a %dx%d planet; the bake and "+
"the manifest have drifted apart", hpath, gw, gh, in.P.W, in.P.PaintH())
}
geology := in.M.Decode(values)
log("read %s: %d x %d at %.1f m", filepath.Base(hpath), gw, gh, in.P.CellM)
heightM := poolTo(geology, gw, gh, ow, oh)
sea := make([]bool, ow*oh)
seaCells := 0
for i, v := range heightM {
if float64(v) < in.M.SeaLevelM {
sea[i] = true
seaCells++
}
}
log("overlay grid %d x %d at %.1f m a pixel, %.0f%% sea",
ow, oh, cellM, 100*float64(seaCells)/float64(ow*oh))
flow := drainage(heightM, sea, ow, oh, cellM, in.M.Pipeline.Fluvial.MFDExponent)
// The class each overlay cell was painted, so a mark can be kept off ground its author called ice or
// desert. Nearest-sampled rather than averaged: a class is a name, and the mean of two names is not one.
classAt := make([]uint8, ow*oh)
for y := 0; y < oh; y++ {
sy := y*gh/oh + in.P.PadY
for x := 0; x < ow; x++ {
classAt[y*ow+x] = in.Map.Class[sy*in.P.W+x*gw/ow]
}
}
names := make([]string, len(in.Legend.Classes))
for i, c := range in.Legend.Classes {
names[i] = c.Name
}
gin := overlay.GenInputs{
W: ow, H: oh, CellM: cellM,
HeightM: heightM, Sea: sea, FlowM2: flow,
ClassAt: classAt, ClassNames: names,
Seed: in.M.Source.Seed,
}
if !opt.Replace {
gin.Existing = in.OverlayRaster
}
opt.apply(&gin)
return in.Overlay.Generate(gin)
}
// apply puts the caller's overrides onto the inputs, after the world has been read.
func (opt OverlayGenOptions) apply(gin *overlay.GenInputs) {
if opt.Seed != 0 {
gin.Seed = opt.Seed
}
if opt.Existing != nil {
gin.Existing = opt.Existing
}
}
// poolTo box-averages a field onto a smaller grid. The two grids cover exactly the same painted rows, so
// this is a straight ratio in each axis with no registration to work out.
//
// Averaging rather than sampling, because a single sample of an 8 m grid at 12.9 m spacing would alias every
// ridge it stepped over and put woodland in stripes.
func poolTo(src []float32, sw, sh, dw, dh int) []float32 {
out := make([]float32, dw*dh)
field.Rows(dh, func(y0, y1 int) {
for dy := y0; dy < y1; dy++ {
sy0 := dy * sh / dh
sy1 := (dy + 1) * sh / dh
if sy1 <= sy0 {
sy1 = sy0 + 1
}
for dx := 0; dx < dw; dx++ {
sx0 := dx * sw / dw
sx1 := (dx + 1) * sw / dw
if sx1 <= sx0 {
sx1 = sx0 + 1
}
sum, n := 0.0, 0
for sy := sy0; sy < sy1 && sy < sh; sy++ {
row := sy * sw
for sx := sx0; sx < sx1 && sx < sw; sx++ {
sum += float64(src[row+sx])
n++
}
}
if n > 0 {
out[dy*dw+dx] = float32(sum / float64(n))
}
}
}
})
return out
}
// drainage is the catchment area per cell at the overlay's resolution, which is what tells a settlement
// where the water is.
//
// Recomputed rather than read from the bake: `map_flow.png` is a picture a few hundred pixels wide, scaled
// for looking at, and what is wanted here is a number per overlay cell. Re-deriving it from the pooled
// height is cheap - one fill and one multiple-flow accumulation - and it is self-consistent with the slope
// and the sea mask beside it, which a resampled flow map would not be.
func drainage(heightM []float32, sea []bool, w, h int, cellM, mfdExp float64) []float32 {
g := fluvial.NewGrid(w, h, cellM, sea)
// The fill runs on a copy: it raises every pit to its spill level, which is right for routing water and
// wrong for everything else here. Slope and the treeline must see the surface the bake actually made.
filled := make([]float32, len(heightM))
copy(filled, heightM)
g.FillDepressions(filled, 1e-3)
g.AccumulateMFD(filled, mfdExp)
out := make([]float32, len(heightM))
copy(out, g.Area)
return out
}
// OverlaySummary is the run's report, as lines to print.
func OverlaySummary(rep overlay.GenReport, ow, oh int) []string {
var lines []string
total := float64(ow * oh)
if rep.Kept > 0 {
lines = append(lines, fmt.Sprintf("kept %d px already painted (%.2f%% of the sheet); "+
"generation only fills blank ground", rep.Kept, 100*float64(rep.Kept)/total))
}
if rep.TreelineM > 0 {
lines = append(lines, fmt.Sprintf("treeline %.0f m, from the land's own heights", rep.TreelineM))
}
for _, m := range rep.Marks {
switch m.Kind {
case overlay.GenSettlement:
line := fmt.Sprintf(" %-14s %d placed, %d px", m.Name, m.Pieces, m.Cells)
if m.Wanted > m.Pieces {
// The two things that ration settlements are the spacing and how much flat ground there is,
// and neither is visible in the output, so the shortfall is said here rather than left to be
// counted off the sheet.
line += fmt.Sprintf(" (asked for %d; the spacing or the flat ground ran out)", m.Wanted)
}
lines = append(lines, line)
case overlay.GenRoad:
lines = append(lines, fmt.Sprintf(" %-14s %d %s, %d px",
m.Name, m.Pieces, plural(m.Pieces, "link", "links"), m.Cells))
default:
lines = append(lines, fmt.Sprintf(" %-14s %d px (%.2f%% of the sheet)",
m.Name, m.Cells, 100*float64(m.Cells)/total))
}
}
lines = append(lines, fmt.Sprintf("painted %d px (%.2f%% of the sheet)",
rep.Painted, 100*float64(rep.Painted)/total))
return lines
}
func plural(n int, one, many string) string {
if n == 1 {
return one
}
return many
}
// flatInputs builds the generator's world from the painting alone, for a planet that has not been baked.
//
// Height is a flat plateau on land and a flat floor at sea, which makes the slope field zero everywhere and
// the treeline meaningless - both correct rather than approximate, because a world with no solve genuinely
// has no relief to read. Drainage is nil rather than zero, which the generator treats as "rivers contribute
// nothing" instead of "every cell is equally dry"; the difference matters, because a score of zero
// everywhere would still be a score and would silently reweight the coast against it.
func flatInputs(in *Inputs, ow, oh int, cellM float64, replace bool) overlay.GenInputs {
n := ow * oh
heightM := make([]float32, n)
sea := make([]bool, n)
classAt := make([]uint8, n)
// The class raster is already at the template's resolution, which is the overlay's, so this is a direct
// read with no resampling at all.
for i := 0; i < n && i < len(in.Raster.Class); i++ {
c := in.Raster.Class[i]
classAt[i] = c
if int(c) < len(in.Legend.Classes) && in.Legend.Classes[c].Sea {
sea[i] = true
heightM[i] = float32(in.M.SeaLevelM - 100)
} else {
heightM[i] = float32(in.M.SeaLevelM + 10)
}
}
names := make([]string, len(in.Legend.Classes))
for i, c := range in.Legend.Classes {
names[i] = c.Name
}
gin := overlay.GenInputs{
W: ow, H: oh, CellM: cellM,
HeightM: heightM, Sea: sea,
ClassAt: classAt, ClassNames: names,
Seed: in.M.Source.Seed,
}
if !replace {
gin.Existing = in.OverlayRaster
}
return gin
}
// bakeTemplate is the template a bake was made from, or "" when the bake does not record one.
//
// It exists because CheckBake cannot catch this. That check compares the numbers a heightmap is *encoded*
// with - the elevation range, the circumference, the cell size, the seed - and two different paintings of
// the same planet agree on every one of them. So a bake of one world passes every test and is then read as
// another, and the marks come out placed against terrain that is not there: rivers in the wrong valleys,
// towns on coasts that do not exist. Nothing in the output says so, which is what makes it worth a check of
// its own rather than a note in a README.
func bakeTemplate(dir string) string {
raw, err := os.ReadFile(filepath.Join(dir, "meta.json"))
if err != nil {
return ""
}
var meta struct {
Plan struct {
Template string `json:"template"`
} `json:"plan"`
}
if json.Unmarshal(raw, &meta) != nil {
return ""
}
return meta.Plan.Template
}
// BakeIsOfThisPainting reports whether a bake was made from the template the manifest now names, and why not
// when it was not. A bake that does not record its template is taken on trust, because it predates the
// field; that is said rather than assumed.
func BakeIsOfThisPainting(dir string, m *manifest.Manifest) (bool, string) {
was := bakeTemplate(dir)
if was == "" {
return true, ""
}
if filepath.Base(was) == filepath.Base(m.Planet.Template) {
return true, ""
}
return false, fmt.Sprintf("%s was baked from %s and this planet is painted on %s",
filepath.Base(dir), filepath.Base(was), filepath.Base(m.Planet.Template))
}
+561
View File
@@ -0,0 +1,561 @@
package planet
import (
"encoding/json"
"fmt"
"math"
"os"
"path/filepath"
"sort"
"time"
"salty/terrain/internal/manifest"
"salty/terrain/internal/overlay"
)
// Plan reads a template and works out what baking it would involve, without eroding anything.
//
// It exists because two decisions can wreck an hour-long bake and both are settled before the first erosion
// step: how the legend read the painting, and how the planet was cut into regions. Looking at them costs
// about a minute here and an hour if the bake has to be thrown away.
func Plan(m *manifest.Manifest, outDir string, mapWidth int, log func(string, ...any)) (*Inputs, error) {
return PlanPainting(m, nil, outDir, mapWidth, log)
}
// WriteMaps draws the four diagnostic maps. Separate from Plan because a caller that already has an Inputs
// may want to redraw them without preparing again: the studio does, when only the legend's numbers changed
// and so only the colouring-in can differ.
func WriteMaps(outDir string, in *Inputs, mapWidth int) error {
if err := os.MkdirAll(outDir, 0o755); err != nil {
return err
}
for _, w := range []func(string, *Inputs, int) error{
WriteClassMap, WriteRegionMap, WriteUpliftMap, WriteErodibilityMap, WriteOverlayMap, WritePlateMap,
} {
if err := w(outDir, in, mapWidth); err != nil {
return err
}
}
return nil
}
// MapNames is what WriteMaps wrote, which is what the studio lists as buttons. The overlay map is there only
// when there is an overlay, because a map of nothing is a map nobody should be offered.
func (in *Inputs) MapNames() []string {
out := []string{"map_class", "map_uplift", "map_regions", "map_erodibility"}
if in.OverlayRaster != nil {
out = append(out, "map_overlay")
}
if in.Plates != nil {
out = append(out, "map_plates")
}
return out
}
// PlanPainting is Plan over paintings already in memory. See PrepareWith.
func PlanPainting(m *manifest.Manifest, art *Painting, outDir string, mapWidth int,
log func(string, ...any)) (*Inputs, error) {
in, err := PrepareWith(m, art, log)
if err != nil {
return nil, err
}
if err := WriteMaps(outDir, in, mapWidth); err != nil {
return nil, err
}
if in.OverlayDoc != nil {
if err := in.OverlayDoc.WriteJSON(outDir); err != nil {
return nil, err
}
}
rep := in.Report()
data, err := json.MarshalIndent(rep, "", " ")
if err != nil {
return nil, err
}
if err := os.WriteFile(filepath.Join(outDir, "plan.json"), append(data, '\n'), 0o644); err != nil {
return nil, err
}
return in, nil
}
// Report is the machine-readable half of a plan, written to plan.json beside the maps.
type Report struct {
Manifest string `json:"manifest"`
Template string `json:"template"`
Legend string `json:"legend"`
When time.Time `json:"when"`
PaintW int `json:"paint_w"`
PaintH int `json:"paint_h"`
CircumferenceKm float64 `json:"circumference_km"`
HeightKm float64 `json:"height_km"`
AreaKm2 float64 `json:"area_km2"`
CellM float64 `json:"cell_m"`
GridW int `json:"grid_w"`
GridH int `json:"grid_h"`
PadRows int `json:"pad_rows"`
MarginCells int `json:"margin_cells"`
TalusDeg float64 `json:"talus_deg"`
ClampCeilMmYr float64 `json:"clamp_ceiling_mm_yr"`
ElevationMinM float64 `json:"elevation_min_m"`
ElevationMaxM float64 `json:"elevation_max_m"`
Seed int64 `json:"seed"`
// MassifWavelengthKm is the upland fabric's size after rounding to a whole number of lattice cells, and
// zero when no class asked for one.
MassifWavelengthKm float64 `json:"massif_wavelength_km,omitempty"`
// The two fields a seed re-rolls that the painting does not fix: the rock provinces and the fault set.
// Zero wavelength means the planet asks for none of that field at all.
LithologyKm float64 `json:"lithology_wavelength_km,omitempty"`
LithologyTypes int `json:"lithology_types,omitempty"`
FaultGrainKm float64 `json:"fault_grain_km,omitempty"`
FaultCount int `json:"faults,omitempty"`
MatchFar int `json:"match_far_px"`
MatchWorst float64 `json:"match_worst_distance"`
MatchWorstAt [2]int `json:"match_worst_at"`
EdgeRescued int `json:"stroke_rescued_at_poles_px"`
Dissolved int `json:"stroke_dissolved_px"`
WrapRows int `json:"wrap_rows"`
WrapDiffer int `json:"wrap_differ"`
WrapLandSea int `json:"wrap_land_against_sea"`
WrapFarEdge int `json:"wrap_far_edge_px"`
Classes []ClassShare `json:"classes"`
Regions []RegionPlan `json:"regions"`
// Overlay is the annotation layer's share of the plan, or nil when the planet has none. The full
// document - every feature in world metres - goes to overlay.json beside the maps; this is the summary.
Overlay *OverlayShare `json:"overlay,omitempty"`
LandCells int `json:"land_cells"`
SolveCells int `json:"solve_cells"`
DroppedRegions int `json:"dropped_regions"`
DroppedCells int `json:"dropped_cells"`
EstimateMin float64 `json:"estimate_minutes"`
EstimatePeakGB float64 `json:"estimate_peak_gb"`
PrepareSeconds float64 `json:"prepare_seconds"`
}
type ClassShare struct {
Name string `json:"name"`
Sea bool `json:"sea"`
Cells int `json:"cells"`
Share float64 `json:"share"`
UpliftMmYr float64 `json:"uplift_mm_yr,omitempty"`
KMult float64 `json:"k_mult,omitempty"`
DepthM float64 `json:"depth_m,omitempty"`
// DivideDeg is the hillslope angle this class's numbers imply at a divide, and Clamped says whether
// that is past the angle of repose. See the note on divideAngle.
DivideDeg float64 `json:"divide_deg,omitempty"`
Clamped bool `json:"clamped,omitempty"`
// MedianDeg and P90Deg are what the ground actually comes out as: the median slope over the class and
// the ninetieth percentile. See typicalFromDivide - a divide is the *steepest* place in a catchment and
// there are very few of them, so the divide angle is about three times the ground, and an author reading
// it as the landscape sets every rate they own two or three times too hot.
MedianDeg float64 `json:"median_deg,omitempty"`
P90Deg float64 `json:"p90_deg,omitempty"`
// ReadsAs names the ground that angle makes. It is here because an uplift rate does not look like
// anything, and reading one as terrain is the mistake the massif field exists to undo. It is taken from
// the median rather than from the divide, because "what does this read as" is a question about the
// ground somebody is standing on.
ReadsAs string `json:"reads_as,omitempty"`
// The massif block, when this class has one: the plain between the massifs, the angle *it* makes, and
// how much of the class stands above the midpoint of the two. Absent for a class that is one rate all
// over, which is what every class was before D-55.
FloorMmYr float64 `json:"floor_mm_yr,omitempty"`
FloorDeg float64 `json:"floor_divide_deg,omitempty"`
FloorMedianDeg float64 `json:"floor_median_deg,omitempty"`
FloorReadsAs string `json:"floor_reads_as,omitempty"`
MassifFraction float64 `json:"massif_fraction,omitempty"`
// Faults is how many traces landed in this class's ground, and FaultThrowM the range they were drawn
// from. Zero when the class asked for none.
Faults int `json:"faults,omitempty"`
FaultThrowM [2]float64 `json:"fault_throw_m,omitempty"`
// LithologyMix is how much of the planet's rock field this class lets through. Reported even at 1, which
// is the default, because the useful reading is the column rather than one entry in it.
LithologyMix float64 `json:"lithology_mix,omitempty"`
}
// OverlayShare is how much of the world the annotation layer covers and what it asked for.
type OverlayShare struct {
Legend string `json:"legend"`
PaintedPx int `json:"painted_px"`
FarPx int `json:"far_px"`
Features int `json:"features"`
Marks []overlay.MarkShare `json:"marks"`
}
type RegionPlan struct {
ID int `json:"id"`
X0 int `json:"x0"`
Y0 int `json:"y0"`
W int `json:"w"`
H int `json:"h"`
WidthKm float64 `json:"width_km"`
HeightKm float64 `json:"height_km"`
Cells int `json:"cells"`
LandCells int `json:"land_cells"`
Seam bool `json:"seam"`
EstimateMin float64 `json:"estimate_minutes"`
EstimateGB float64 `json:"estimate_gb"`
}
// Report gathers everything the plan knows.
func (in *Inputs) Report() *Report {
perClass, land, total := in.Map.Counts()
r := &Report{
Manifest: in.M.Path, Template: in.M.Planet.Template, Legend: in.M.Planet.Legend,
When: time.Now().UTC().Truncate(time.Second),
PaintW: in.PaintW, PaintH: in.PaintH,
CircumferenceKm: in.P.CircumferenceM() / 1000,
HeightKm: in.P.HeightM() / 1000,
AreaKm2: in.P.CircumferenceM() * in.P.HeightM() / 1e6,
CellM: in.P.CellM,
GridW: in.P.W, GridH: in.P.PaintH(),
PadRows: in.P.PadY,
MarginCells: in.MarginCells,
TalusDeg: in.M.Pipeline.Thermal.TalusDeg,
ClampCeilMmYr: clampCeiling(in.M.Pipeline.Fluvial.K, in.P.CellM,
in.M.Pipeline.Fluvial.M, in.M.Pipeline.Thermal.TalusDeg),
ElevationMinM: in.M.ElevationM.Min,
ElevationMaxM: in.M.ElevationM.Max,
Seed: in.M.Source.Seed,
MatchFar: in.Match.Far, MatchWorst: in.Match.MaxDist, MatchWorstAt: in.Match.MaxAt,
EdgeRescued: in.EdgeRewritten, Dissolved: in.Dissolved,
WrapRows: in.Match.WrapRows, WrapDiffer: in.Match.WrapDiffer,
WrapLandSea: in.Match.WrapLandSea, WrapFarEdge: in.Match.WrapFarEdge,
LandCells: land,
SolveCells: in.SolveCells(),
DroppedRegions: in.Part.DroppedRegions,
DroppedCells: in.Part.DroppedCells,
PrepareSeconds: in.Elapsed.Seconds(),
}
if in.Legend.HasMassifs() {
r.MassifWavelengthKm = in.M.Planet.MassifWavelengthRoundedKm()
}
for i, c := range in.Legend.Classes {
if perClass[i] == 0 {
continue
}
cs := ClassShare{Name: c.Name, Sea: c.Sea, Cells: perClass[i],
Share: float64(perClass[i]) / float64(total)}
if c.Land() {
cs.UpliftMmYr, cs.KMult = c.UpliftMmYr, c.K()
cs.DivideDeg = divideAngle(c.RateMYr(), in.M.Pipeline.Fluvial.K*c.K(),
in.P.CellM, in.M.Pipeline.Fluvial.M)
cs.Clamped = cs.DivideDeg >= in.M.Pipeline.Thermal.TalusDeg
cs.MedianDeg, cs.P90Deg = typicalFromDivide(cs.DivideDeg)
cs.ReadsAs = readsAs(cs.MedianDeg)
if fr := c.MassifFraction(); fr > 0 {
cs.MassifFraction = fr
cs.FloorMmYr = c.Massif.FloorMmYr
cs.FloorDeg = divideAngle(c.MassifFloorMYr(), in.M.Pipeline.Fluvial.K*c.K(),
in.P.CellM, in.M.Pipeline.Fluvial.M)
cs.FloorMedianDeg, _ = typicalFromDivide(cs.FloorDeg)
cs.FloorReadsAs = readsAs(cs.FloorMedianDeg)
}
cs.LithologyMix = c.LithMix()
if c.Faults != nil {
cs.FaultThrowM = c.ThrowM()
for _, f := range in.Faults {
if f.Class == i {
cs.Faults++
}
}
}
} else {
cs.DepthM = c.DepthM
}
r.Classes = append(r.Classes, cs)
}
r.FaultGrainKm = in.M.Planet.FaultGrainKm
r.FaultCount = len(in.Faults)
if cells := in.M.Planet.LithologyCells(); cells > 0 && in.Legend.HasLithology() {
r.LithologyKm = in.M.Planet.NoisePeriodKm / float64(cells)
r.LithologyTypes = len(in.M.Pipeline.Lithology.KMultipliers)
}
if d := in.OverlayDoc; d != nil {
r.Overlay = &OverlayShare{
Legend: in.M.Planet.OverlayLegend, PaintedPx: in.OverlayMatch.Total - in.OverlayMatch.Blank,
FarPx: in.OverlayMatch.Far, Features: len(d.Features), Marks: d.Marks,
}
}
peak := 0.0
for _, rg := range in.Part.Regions {
mins := in.EstimateSeconds(rg.Cells()) / 60
gb := float64(in.EstimateBytes(rg.Cells())) / (1 << 30)
if gb > peak {
peak = gb
}
r.EstimateMin += mins
r.Regions = append(r.Regions, RegionPlan{
ID: rg.ID, X0: rg.Frame.X0, Y0: rg.Frame.Y0, W: rg.Frame.W, H: rg.Frame.H,
WidthKm: float64(rg.Frame.W) * in.P.CellM / 1000,
HeightKm: float64(rg.Frame.H) * in.P.CellM / 1000,
Cells: rg.Cells(), LandCells: rg.LandCells, Seam: rg.Seam,
EstimateMin: mins, EstimateGB: gb,
})
}
r.EstimatePeakGB = peak
sort.Slice(r.Regions, func(a, b int) bool { return r.Regions[a].Cells > r.Regions[b].Cells })
return r
}
// Print is the human half: the two tables worth reading before spending an hour.
func (r *Report) Print(w *os.File) {
p := func(format string, a ...any) { fmt.Fprintf(w, format+"\n", a...) }
p("")
p(" planet %.1f x %.1f km, %.0f km2 - %d x %d cells of %.1f m (+%d rows of polar pad)",
r.CircumferenceKm, r.HeightKm, r.AreaKm2, r.GridW, r.GridH, r.CellM, r.PadRows)
p(" template %d x %d px, %.2f m a pixel - the paint is %s than the grid",
r.PaintW, r.PaintH, r.CircumferenceKm*1000/float64(r.PaintW),
coarserOrFiner(r.CircumferenceKm*1000/float64(r.PaintW), r.CellM))
p(" classify %d px further than the warn distance from any class (worst %.0f at %d,%d)",
r.MatchFar, r.MatchWorst, r.MatchWorstAt[0], r.MatchWorstAt[1])
p(" %d px rescued as map-edge class, %d px of stroke dissolved",
r.EdgeRescued, r.Dissolved)
if r.WrapRows > 0 {
pct := 100 * float64(r.WrapDiffer) / float64(r.WrapRows)
p(" wrap the left and right edges are the same meridian: they disagree on %d of %d rows (%.1f%%),",
r.WrapDiffer, r.WrapRows, pct)
p(" %d of those land against water, and %d px in the outermost columns match no class.",
r.WrapLandSea, r.WrapFarEdge)
if r.WrapLandSea > r.WrapRows/50 {
p(" THAT IS A VISIBLE SEAM. The generator wraps; the painting has to as well.")
}
}
p("")
p(" class share cells uplift mm/yr K depth m divide typical")
clamped := 0
for _, c := range r.Classes {
if c.Sea {
p(" %-12s %5.1f%% %10d - - %7.0f", c.Name, 100*c.Share, c.Cells, c.DepthM)
continue
}
note := ""
if c.Clamped {
note = " CLAMPED"
clamped++
}
p(" %-12s %5.1f%% %10d %8.3f %4.2f - %5.1f deg %6.1f deg %s%s",
c.Name, 100*c.Share, c.Cells, c.UpliftMmYr, c.KMult, c.DivideDeg, c.MedianDeg, c.ReadsAs, note)
if c.MassifFraction > 0 {
p(" %-12s %s", "",
fmt.Sprintf("massif over %.0f%% of it; the other %.0f%% is %.3f mm/yr, %.1f deg at a divide "+
"and %.1f typical - %s",
100*c.MassifFraction, 100*(1-c.MassifFraction), c.FloorMmYr, c.FloorDeg,
c.FloorMedianDeg, c.FloorReadsAs))
}
}
if clamped > 0 {
p("")
p(" %d class(es) sit past the %.0f degree angle of repose at a divide, so the repose clamp shapes",
clamped, r.TalusDeg)
p(" them rather than erosion does, and the ground comes out as flat polygonal facets cut along the")
p(" eight D8 directions. Steady state is S = U/(K*A^m) applied down to a single cell, so at a %.0f m",
r.CellM)
p(" cell the ceiling is U = tan(talus)*K*cell = %.3f mm/yr at K x1. Above it, relief and steepness", r.ClampCeilMmYr)
p(" are the same knob and you get talus, not mountains. See Terrain-Next 4.B1 and 4.D.3.")
}
p("")
p(" `divide` is the steepest ground a rate can make and `typical` is the median over the class, which")
p(" is about a third of it: a divide is the top of a catchment and there are very few of them. Read the")
p(" second column. Reading the first as the landscape is how a legend ends up two or three times too")
p(" hot everywhere, which is the defect the massif block was added to undo one size up.")
if r.FaultCount > 0 || r.LithologyKm > 0 {
p("")
p(" what the seed re-rolls, and the painting does not")
if r.LithologyKm > 0 {
p(" lithology %d rock types over provinces of %.1f km, cut at quantiles of the *planet* so "+
"every", r.LithologyTypes, r.LithologyKm)
p(" region agrees; it multiplies each class's own k_mult by its lithology_mix")
}
if r.FaultCount > 0 {
p(" faults %d traces, strike from a %.0f km grain field. A trace is a rate difference "+
"across a", r.FaultCount, r.FaultGrainKm)
p(" line, steep one side and gentle the other, which erosion carves into a scarp")
for _, c := range r.Classes {
if c.Faults > 0 {
word := "traces"
if c.Faults == 1 {
word = "trace"
}
p(" %-12s %4d %-7s throw %.0f..%.0f m over the run",
c.Name, c.Faults, word+",", c.FaultThrowM[0], c.FaultThrowM[1])
}
}
}
p(" change source.seed, or pass --seed, and all of it moves while the painting stays put")
}
if r.MassifWavelengthKm > 0 {
p("")
p(" the massif fabric is %.1f km and is one field for the whole planet, so a highland belt and the",
r.MassifWavelengthKm)
p(" hills in the lowland beside it are high and low parts of the same structure. A fraction is a")
p(" share of the planet's surface, so it is only the *expected* share of any one island: a small one")
p(" may get all of a massif or none, which is the point of not normalising it per landmass.")
}
if o := r.Overlay; o != nil {
p("")
p(" overlay %s: %d px painted, %d features", o.Legend, o.PaintedPx, o.Features)
if o.FarPx > 0 {
p(" %d px are painted but match no mark and were dropped", o.FarPx)
}
for _, m := range o.Marks {
line := fmt.Sprintf(" %-12s %8.2f km2 %4d %s", m.Name, m.AreaKm2, m.Pieces,
pieces(m.Pieces))
if m.HasJitter {
if m.Jitter == 0 {
line += " coastline pinned as drawn"
} else {
line += fmt.Sprintf(" coast jitter x%.2g", m.Jitter)
}
}
if m.Kind == overlay.KindPath && m.WidthM > 0 {
line += fmt.Sprintf(" %.0f m wide", m.WidthM)
}
p("%s", line)
}
}
p("")
p(" %d regions, %d cells to solve against %d cells of painted land; margin %d cells",
len(r.Regions), r.SolveCells, r.LandCells, r.MarginCells)
if r.DroppedRegions > 0 {
p(" %d specks dropped, %d land cells, below the minimum", r.DroppedRegions, r.DroppedCells)
}
p("")
p(" id rect km cells land est min est GB")
shown := r.Regions
if len(shown) > 12 {
shown = shown[:12]
}
for _, rg := range shown {
seam := " "
if rg.Seam {
seam = "*"
}
p(" %3d%s %6.1f x %6.1f %10d %10d %8.1f %6.2f",
rg.ID, seam, rg.WidthKm, rg.HeightKm, rg.Cells, rg.LandCells, rg.EstimateMin, rg.EstimateGB)
}
if len(r.Regions) > len(shown) {
p(" ... and %d smaller", len(r.Regions)-len(shown))
}
p("")
p(" estimate %.0f min of solve in total, %.2f GB at the largest region, both scaled from one measured",
r.EstimateMin, r.EstimatePeakGB)
p(" lowland region and to be read as a floor: steep ground costs about five times what a plain")
p(" does per cell, because it drives the hillslope law to its full sub-step budget every step.")
p(" prepared in %.1f s", r.PrepareSeconds)
p("")
}
func coarserOrFiner(paintM, cellM float64) string {
if paintM > cellM {
return "coarser"
}
return "finer"
}
// divideAngle is the hillslope angle a class's numbers imply at a drainage divide, in degrees.
//
// Steady state is S = U/(K*A^m), and with critical_area_m2 at 0 that law is applied down to a single cell, so
// at a divide A is one cell squared and A^m is just the cell size. For n = 1 the uplift rate alone therefore
// fixes the hillslope angle - that is D-49, and it is the most useful number in the whole legend, because it
// decides whether the ground is shaped by erosion or by landsliding.
func divideAngle(rateMYr, k, cellM, m float64) float64 {
if k <= 0 || cellM <= 0 {
return 0
}
s := rateMYr / (k * math.Pow(cellM*cellM, m))
return math.Atan(s) * 180 / math.Pi
}
// typicalMedianFrac and typicalP90Frac turn a divide angle into the ground underneath it.
//
// The divide angle is exact and it is not the landscape. S = U/(K*A^m) is largest where A is smallest, which
// is the top of a catchment; slope falls away downstream from there, and almost none of a map is divide. So
// the number the legend hands an author is the steepest place in their world and they read it as the world.
//
// Measured rather than derived, on a 600 x 600 grid of 8 m cells with one coast, the manifest's own
// constants, 1000 steps, and a uniform rate:
//
// U mm/yr divide median P90 over 3 deg
// 0.012 1.7 0.58 1.00 4 %
// 0.045 6.4 2.12 2.85 8 %
// 0.080 11.3 3.72 4.85 75 %
// 0.250 32.0 11.13 14.03 99 %
//
// In tangent the median/divide ratio is 0.34, 0.33, 0.33 and 0.32 - flat enough over a factor of twenty in
// rate to be worth quoting as one number. The P90 ratio drifts from 0.59 to 0.40 as the ground steepens,
// because the tail of the slope distribution is the part the repose clamp eventually binds; 0.45 is the
// middle of it and it is the weaker of the two.
//
// Both are fractions of the *tangent*, not of the angle, because the steady-state law is about slope.
const (
typicalMedianFrac = 0.33
typicalP90Frac = 0.45
)
// typicalFromDivide is the median and P90 slope, in degrees, for a class whose divide angle is deg.
func typicalFromDivide(deg float64) (median, p90 float64) {
t := math.Tan(deg * math.Pi / 180)
return math.Atan(t*typicalMedianFrac) * 180 / math.Pi,
math.Atan(t*typicalP90Frac) * 180 / math.Pi
}
func pieces(n int) string {
if n == 1 {
return "piece"
}
return "pieces"
}
// readsAs names the ground a divide angle makes.
//
// It exists because an uplift rate does not look like anything, and the one number an author has to hand is
// therefore the one they cannot picture. The boundaries are angles rather than rates deliberately: the mistake
// this is here to stop is reading internal/stats' "plain below 0.1 mm/yr" as a description of terrain. It is
// not - it is a reporting bucket calibrated for the procedural path's intraplate rates - and 0.1 mm/yr is a 14
// degree hillslope on every divide of the map, which is hill country wherever it is painted.
func readsAs(deg float64) string {
switch {
case deg < 3:
return "plain"
case deg < 8:
return "rolling"
case deg < 16:
return "hill country"
case deg < 28:
return "mountain"
default:
return "alpine"
}
}
// clampCeiling is the uplift rate, in mm/yr, at which a divide reaches the angle of repose at K x1. Above it
// the repose clamp does the shaping and the terrain comes out as flat polygonal facets.
func clampCeiling(k, cellM, m, talusDeg float64) float64 {
return math.Tan(talusDeg*math.Pi/180) * k * math.Pow(cellM*cellM, m) * 1000
}
+561
View File
@@ -0,0 +1,561 @@
// Package planet is the driver for a painted world: template in, terrain out.
//
// It owns the order of operations and nothing else. The image and its legend belong to internal/template,
// the cylinder to internal/world, the cutting up to internal/region, and every physical process to the
// packages that already had it. What lives here is the sequence, the reporting, and the two things that are
// only true of a whole planet: that its open ocean is painted rather than solved, and that its statistics
// pool across regions rather than being computed per region and averaged.
package planet
import (
"fmt"
"math"
"os"
"path/filepath"
"strconv"
"time"
"salty/terrain/internal/field"
"salty/terrain/internal/manifest"
"salty/terrain/internal/overlay"
"salty/terrain/internal/plates"
"salty/terrain/internal/region"
"salty/terrain/internal/template"
"salty/terrain/internal/uplift"
"salty/terrain/internal/world"
)
// Inputs is everything a bake needs before a single erosion step has run: the painted map classified and
// projected onto the cylinder, and the cylinder cut into regions.
//
// It is a type of its own because it is worth looking at on its own. Two decisions can wreck an hour-long
// bake - how the legend read the paint, and how the planet was cut up - and both are settled here, in about
// a minute. That is what the plan command exists to show.
type Inputs struct {
M *manifest.Manifest
P world.Planet
Legend *template.Legend
Raster *template.Raster // at paint resolution
Map *template.Map // at planet resolution
Part *region.Partition
// Palette is how the preview is drawn. Never nil: the generator's own when the manifest names none.
Palette *field.Palette
PaintW, PaintH int
Match template.Match
EdgeRewritten int
Dissolved int
MarginCells int
Elapsed time.Duration
// The annotation layer, or nil throughout when the manifest configures none. It is prepared here rather
// than at export time for one reason: its coast_jitter marks have to be read *before* the waterline is
// roughened, which is the first thing that happens to the painting, so by the time a plan exists the
// overlay has already had its say. Everything else it carries is inert - see internal/overlay.
Overlay *overlay.Legend
OverlayRaster *overlay.Raster
OverlayMatch overlay.Match
OverlayDoc *overlay.Document
// Faults is the planet's whole fault set, in world metres, drawn once here because placing a trace needs
// the class raster of the *whole* cylinder - and because a set drawn per region would put a different
// fault in every one of them, which is the defect that kept the procedural path's version from being
// portable at all. Every region reads the same slice and filters it to its own frame.
Faults []uplift.FaultTrace
// Plates is the tectonic model, or nil when the manifest asks for none. Drawn here for the same reason
// the fault set is: a plate is a planet-wide object, and a partition computed per region would give the
// same physical margin a different classification in every one of them.
Plates *plates.Model
}
// Painting is the two images already in memory, which is what the studio has: the pictures being edited are
// the ones in the browser, so a plan run against the files on disk would answer a question nobody asked.
//
// A nil Painting, or a nil half of one, means "read what the manifest names". The two halves are separate
// because they are edited separately: moving a road does not re-roughen the coast unless a coast_jitter mark
// moved with it, and the studio's plan cache keys on them one at a time.
type Painting struct {
Class []uint8
ClassW, ClassH int
Overlay []uint8
OverlayAlpha []uint8
OverlayW, OverlayH int
// The tectonic layer, RGB with no alpha: every pixel of it is some plate, so there is no "nothing" to
// carry. Like the other two it is the authority while the studio is open.
Plates []uint8
PlatesW, PlatesH int
}
// Prepare reads the template, classifies it, projects it onto the planet and partitions it.
//
// Nothing here erodes anything, and nothing here is expensive: the whole thing is a few image passes and
// three distance transforms.
func Prepare(m *manifest.Manifest, log func(string, ...any)) (*Inputs, error) {
return PrepareWith(m, nil, log)
}
// PrepareWith is Prepare over paintings already in memory. See Painting; nil reads what the manifest names,
// which is what Prepare does.
func PrepareWith(m *manifest.Manifest, art *Painting, log func(string, ...any)) (*Inputs, error) {
if !m.IsPlanet() {
return nil, fmt.Errorf("%s has no planet block; this is the square canvas that `generate` builds",
m.Path)
}
start := time.Now()
if log == nil {
log = func(string, ...any) {}
}
pb := m.Planet
cell := m.GeologyCellM()
lg, err := template.Load(m.LegendPath())
if err != nil {
return nil, err
}
log("legend %s: %d classes", m.Planet.Legend, len(lg.Classes))
var px []uint8
var pw, ph int
if art != nil {
px, pw, ph = art.Class, art.ClassW, art.ClassH
}
if px == nil {
var err error
px, pw, ph, err = template.DecodeRGB(m.TemplatePath())
if err != nil {
return nil, err
}
log("template %s: %d x %d px", pb.Template, pw, ph)
} else {
log("template in memory: %d x %d px", pw, ph)
}
ras, match := lg.Classify(px, pw, ph)
px = nil
edge, dissolved := ras.DissolveStrokes(lg)
speckle := ras.Despeckle()
log("classify %s; %d px rescued at the poles, %d dissolved, %d despeckled",
match, edge, dissolved, speckle)
marginCells := pb.MarginCells(cell)
p, err := world.New(pb.CircumferenceKm*1000, cell, pw, ph, marginCells, pb.NoisePeriodKm*1000)
if err != nil {
return nil, err
}
log("planet %d x %d cells of %.1f m: %.1f x %.1f km, %.0f km2 (+%d rows of polar pad)",
p.W, p.PaintH(), p.CellM, p.CircumferenceM()/1000, p.HeightM()/1000,
p.CircumferenceM()*p.HeightM()/1e6, p.PadY)
padClass := lg.FirstSea()
if pb.PadClass != "" {
padClass = lg.Index(pb.PadClass)
if padClass < 0 {
return nil, fmt.Errorf("%s: planet.pad_class %q is not a class in the legend", m.Path, pb.PadClass)
}
if !lg.Classes[padClass].Sea {
return nil, fmt.Errorf("%s: planet.pad_class %q is land; the pad is the ocean a polar cap "+
"drains into", m.Path, pb.PadClass)
}
}
if padClass < 0 {
return nil, fmt.Errorf("%s: the legend has no sea class, so there is nothing to fill the polar pad "+
"with", m.LegendPath())
}
pal := field.DefaultPalette()
if path := m.PalettePath(); path != "" {
pal, err = field.LoadPalette(path)
if err != nil {
return nil, err
}
log("palette %s", m.Planet.Palette)
}
// The annotation layer, read before the coast is roughened rather than after: its coast_jitter marks are
// the one thing on it the generator reads, and what they decide is how far the waterline may move.
ov, ovRas, ovMatch, err := loadOverlay(m, art, pw, ph, log)
if err != nil {
return nil, err
}
// The painted waterline is roughened before it is projected: a drawn shore is a smooth curve and a coast
// is not. See template/coast.go. Off by default only in the sense that an amplitude of zero is the
// painting exactly as drawn.
coast := template.Coast{
AmplitudePx: pb.CoastJitterPx,
WavelengthPx: pb.CoastJitterWavelengthPx,
Octaves: pb.CoastJitterOctaves,
Gain: pb.CoastJitterGain,
Seed: m.Source.Seed,
}
if ov != nil && ovRas != nil {
coast.Scale = ov.CoastScale(ovRas)
}
if coast.Amount() {
ras = ras.RoughenCoast(lg, p, coast)
masked := ""
if coast.Scale != nil {
masked = ", masked by the overlay"
}
log("coast the painted waterline roughened by up to %.0f px over %.0f px bays, %d octaves%s",
coast.AmplitudePx, coast.WavelengthPx, coast.Octaves, masked)
}
pm := ras.Project(p, lg, padClass)
part, err := region.Build(pm, marginCells, pb.MinLandCells)
if err != nil {
return nil, err
}
// The plates first, because the fault set is now partly a consequence of them.
tect, err := buildPlates(m, p, pm, art, log)
if err != nil {
return nil, err
}
faults := buildFaultSet(pm, lg, pb.FaultGrainKm, m.Source.Seed)
if len(faults) > 0 {
log("faults %d class traces over %d classes, grain %.0f km",
len(faults), faultClasses(lg), pb.FaultGrainKm)
}
if belt := buildBeltFaults(p, pm, tect, pb.Plates.Faults, m.Source.Seed, log); len(belt) > 0 {
faults = append(faults, belt...)
}
in := &Inputs{
M: m, P: p, Legend: lg, Raster: ras, Map: pm, Part: part, Palette: pal,
PaintW: pw, PaintH: ph, Match: match,
EdgeRewritten: edge, Dissolved: dissolved, MarginCells: marginCells,
Overlay: ov, OverlayRaster: ovRas, OverlayMatch: ovMatch,
Faults: faults,
Plates: tect,
Elapsed: time.Since(start),
}
if ov != nil && ovRas != nil {
in.OverlayDoc = ov.Describe(ovRas, ovMatch, in.OverlayScale(),
m.Planet.Overlay, m.Planet.OverlayLegend)
}
return in, nil
}
// faultCandidateTarget is roughly how many strided samples of the planet the fault placement draws from. The
// sample is only ever used for a uniform draw - the *areas* are the projection's own exact counts - so what
// it has to be is dense enough that a small class still has somewhere to put a trace, not dense enough to
// measure anything. Sixty-odd thousand over a 76-million-cell planet is a stride of about 34 cells, 270 m,
// and leaves a class covering a fifth of a per cent with over a hundred candidates.
const faultCandidateTarget = 65536
// buildFaultSet draws the planet's faults, or returns nil when no class asks for any.
func buildFaultSet(pm *template.Map, lg *template.Legend, grainKm float64, seed int64) []uplift.FaultTrace {
if !lg.HasFaults() || grainKm <= 0 {
return nil
}
specs := make([]uplift.FaultSpec, len(lg.Classes))
for i := range lg.Classes {
f := lg.Classes[i].Faults
if f == nil || !lg.Classes[i].Land() {
continue
}
specs[i] = uplift.FaultSpec{Per1000Km2: f.Per1000Km2, ThrowM: f.ThrowM, LengthKm: f.LengthKm}
}
p := pm.P
stride := int(math.Sqrt(float64(p.W)*float64(p.PaintH())/faultCandidateTarget) + 0.5)
if stride < 1 {
stride = 1
}
candidates := make([][]int32, len(lg.Classes))
// Painted rows only. The polar pad is synthetic ocean that no class was ever painted on, and a trace
// placed there would be a fault in scaffolding.
for y := p.PadY; y < p.H-p.PadY; y += stride {
for x := 0; x < p.W; x += stride {
i := y*p.W + x
c := pm.Class[i]
if specs[c].Wanted() {
candidates[c] = append(candidates[c], int32(i))
}
}
}
areaCells, _, _ := pm.Counts()
return uplift.BuildFaults(p, seed, grainKm, specs, candidates, areaCells)
}
// buildPlates draws the planet's tectonics, or returns nil when the manifest asks for none.
//
// The land mask is read through a callback at world coordinates rather than handed over as a raster, and
// that is what keeps internal/plates ignorant of templates. What it wants from the painting is one bit per
// position - continent or ocean - and that bit is what decides whether a plate is continental, and therefore
// whether a margin between two of them is a collision or a subduction zone.
func buildPlates(mf *manifest.Manifest, p world.Planet, pm *template.Map, art *Painting,
log func(string, ...any)) (*plates.Model, error) {
cfg := mf.Planet.Plates
inMemory := art != nil && art.Plates != nil
painted := inMemory || mf.HasPaintedPlates()
if !painted && cfg.Count <= 0 {
return nil, nil
}
var m *plates.Model
var err error
if painted {
m, err = paintedPlates(mf, p, pm, art, cfg, log)
} else {
m, err = plates.Build(p, mf.Source.Seed, cfg, landAt(p, pm))
}
if err != nil {
return nil, err
}
continental := 0
for i := range m.Plates {
if m.Plates[i].Continental {
continental++
}
}
byKind := plates.LengthByKind(m.Boundaries)
source := "from seed " + strconv.FormatInt(mf.Source.Seed, 10)
if painted {
source = "painted"
}
log("plates %d %s (%d continental), %d boundaries, tectonic grid %.0f m",
len(m.Plates), source, continental, len(m.Boundaries), m.GCellM)
log(" collision %.0f km, subduction %.0f km, rift %.0f km, ridge %.0f km, transform %.0f km",
byKind[plates.Collision]/1000, byKind[plates.Subduction]/1000, byKind[plates.Rift]/1000,
byKind[plates.Ridge]/1000, byKind[plates.Transform]/1000)
return m, nil
}
// landAt answers "is this world position painted land" against the projected map.
//
// A callback rather than the raster itself, because internal/plates and internal/uplift's belt placement both
// want exactly this one bit and neither should know what a template is. It is also the only thing the
// painting tells the tectonic model, which is worth being able to point at: everything else about a plate
// comes from the seed and the manifest.
func landAt(p world.Planet, pm *template.Map) func(xM, yM float64) bool {
return func(xM, yM float64) bool {
x := p.WrapX(int(math.Floor(xM / p.CellM)))
y := p.ClampY(int(math.Floor(yM/p.CellM)) + p.PadY)
return !pm.Sea[y*p.W+x]
}
}
// buildBeltFaults places the traces that belong to the plate margins rather than to a painted class.
//
// Separate from buildFaultSet and added to the same slice, because from the solve's point of view a fault is
// a fault: both end up in Inputs.Faults, both are rasterised by the same FaultDelta, and the difference is
// only in how they were placed. Where they came from survives in the log line and in the trace's own Class,
// which is -1 for a belt fault because no painted colour asked for it.
func buildBeltFaults(p world.Planet, pm *template.Map, tect *plates.Model, cfg plates.Belt,
seed int64, log func(string, ...any)) []uplift.FaultTrace {
if tect == nil || !cfg.Wanted() {
return nil
}
out := uplift.BuildBeltFaults(p, seed, cfg, tect.Boundaries, landAt(p, pm))
cfg = cfg.WithDefaults()
log(" %d belt traces, %.0f km deformation half-width at %.0f cm/yr, %.0f%% conjugate",
len(out), cfg.ZoneKm, cfg.ReferenceCmYr, cfg.Conjugate()*100)
return out
}
// paintedPlates reads the tectonic layer and turns it into a model.
//
// The decoding happens here rather than in internal/plates for the same reason the land mask does: that
// package deals in geometry and motion, and giving it a file path would give it an opinion about image
// formats, paths and the manifest. It is handed pixels.
func paintedPlates(mf *manifest.Manifest, p world.Planet, pm *template.Map, art *Painting,
cfg plates.Config, log func(string, ...any)) (*plates.Model, error) {
legendPath := mf.PlatesLegendPath()
lg, err := plates.LoadPaintLegend(legendPath)
if err != nil {
return nil, err
}
// The sheet in memory wins when there is one, for the same reason Painting exists at all: the studio is
// editing a picture in a browser, and a plan run against the file on disk would answer a question nobody
// asked.
layerPath := mf.Planet.Plates.Layer
var px []uint8
var pw, ph int
if art != nil && art.Plates != nil {
px, pw, ph = art.Plates, art.PlatesW, art.PlatesH
layerPath = "in memory"
} else {
path := mf.PlatesLayerPath()
if px, pw, ph, err = template.DecodeRGB(path); err != nil {
return nil, fmt.Errorf("tectonic layer %s: %w", path, err)
}
}
m, match, err := plates.FromPainting(p, cfg, lg, px, pw, ph, landAt(p, pm))
if err != nil {
return nil, fmt.Errorf("tectonic layer %s: %w", layerPath, err)
}
log("tectonic %s, %dx%d px, %d plates", layerPath, pw, ph, len(lg.Plates))
if match.Far > 0 {
// Reported rather than fatal, and loudly. Every pixel becomes the nearest plate whatever happens, so
// a layer whose colours have drifted still produces a model - it just produces the wrong one, with
// boundaries somewhere nobody put them.
log(" %d of %d sampled cells are over %.0f from any plate colour (worst %.0f); the layer and "+
"%s disagree", match.Far, match.Cells, lg.WarnDistance, match.MaxDistance,
filepath.Base(legendPath))
}
return m, nil
}
// RebuildFaults draws the fault set again from the legend as it stands now.
//
// It exists for the studio's plan cache, which reuses a whole prepare when only the legend's *numbers*
// changed - and a class's `faults` block is a number that changes the set without changing a pixel of the
// raster the cache is keyed on. Cheap: a strided scan and a few dozen walks, against the six seconds the
// cache is there to avoid.
func (in *Inputs) RebuildFaults() {
in.Faults = buildFaultSet(in.Map, in.Legend, in.M.Planet.FaultGrainKm, in.M.Source.Seed)
// The belt set comes back too. It is not the legend's, but it is in the same slice, and a rebuild that
// dropped it would silently unfault every margin on the planet the first time a class number changed.
quiet := func(string, ...any) {}
in.Faults = append(in.Faults,
buildBeltFaults(in.P, in.Map, in.Plates, in.M.Planet.Plates.Faults, in.M.Source.Seed, quiet)...)
}
// faultClasses is how many classes asked for traces, for the log line.
func faultClasses(lg *template.Legend) int {
n := 0
for i := range lg.Classes {
if lg.Classes[i].Faults != nil {
n++
}
}
return n
}
// OverlayScale is how an overlay pixel maps to world metres. The overlay is registered to the template and
// shares its frame, so this is the template's scale and not the geology grid's - a pixel is 12.9 m where a
// cell is 8.
func (in *Inputs) OverlayScale() overlay.Scale {
w, h := in.PaintW, in.PaintH
if in.OverlayRaster != nil {
w, h = in.OverlayRaster.W, in.OverlayRaster.H
}
circ := in.P.CircumferenceM()
return overlay.Scale{
MetresPerPxX: circ / float64(w),
MetresPerPxY: in.P.HeightM() / float64(h),
CircumferenceM: circ,
}
}
// loadOverlay reads and classifies the annotation layer, from memory when the studio has one and from disk
// otherwise. Returns nils all round when the manifest configures none, which is not an error anywhere.
//
// The overlay must be the same size as the template. It is registered to it - a mark means "here", and here
// is a place on the painting - so two different sizes is not something to resample past, it is an author who
// exported one of the two at the wrong scale and would otherwise find their villages drifting.
func loadOverlay(m *manifest.Manifest, art *Painting, paintW, paintH int, log func(string, ...any)) (
*overlay.Legend, *overlay.Raster, overlay.Match, error) {
var none overlay.Match
if !m.HasOverlay() {
return nil, nil, none, nil
}
ov, err := overlay.Load(m.OverlayLegendPath())
if err != nil {
return nil, nil, none, err
}
var px, alpha []uint8
var w, h int
if art != nil && art.Overlay != nil {
px, alpha, w, h = art.Overlay, art.OverlayAlpha, art.OverlayW, art.OverlayH
} else {
path := m.OverlayPath()
if path == "" {
if ov.Image == "" {
return nil, nil, none, fmt.Errorf("%s: planet.overlay_legend is set but neither it nor "+
"planet.overlay names an image", m.Path)
}
path = filepath.Join(filepath.Dir(m.OverlayLegendPath()), ov.Image)
}
if _, statErr := os.Stat(path); statErr != nil {
// A configured overlay whose image is not there yet is the state the studio starts an author in:
// the legend is written and the sheet is blank. Worth saying, not worth failing on.
log("overlay %s: no image yet (%s); nothing is marked",
m.Planet.OverlayLegend, filepath.Base(path))
return ov, nil, none, nil
}
px, alpha, w, h, err = template.DecodeRGBA(path)
if err != nil {
return nil, nil, none, err
}
}
if w != paintW || h != paintH {
return nil, nil, none, fmt.Errorf("the overlay is %dx%d and the template is %dx%d; they are "+
"registered to each other, so they have to be the same size", w, h, paintW, paintH)
}
ras, match := ov.Classify(px, alpha, w, h)
log("overlay %d marks: %s", len(ov.Marks), match)
return ov, ras, match, nil
}
// SolveCells is how many cells the geology solve will actually visit, summed over regions. It is the number
// the bake time is proportional to, and it is well above the land area because every region carries water.
func (in *Inputs) SolveCells() int {
n := 0
for _, r := range in.Part.Regions {
n += r.Cells()
}
return n
}
// LandCells is how many painted land cells the regions own.
func (in *Inputs) LandCells() int {
n := 0
for _, r := range in.Part.Regions {
n += r.LandCells
}
return n
}
// The measurement the estimates below are scaled from, and the thing it cannot know.
//
// Docs/Terrain.md's time budget records 256 s for 3.2 M cells over 1000 steps on the development machine's
// 16 cores, which is 8.0e-8 s a cell-step. Measured again on a lowland region of this planet - 14.0 M cells,
// 200 steps, 415 s - it is 1.48e-7, very nearly twice as slow, most likely because the square canvas is two
// thirds land while a region is two thirds water: a cheap ocean cell is not a free one.
//
// What no single constant can capture is that **the cost per cell depends on the uplift rate, and by a lot**.
// Measured on the same bake at 1000 steps with four regions in flight:
//
// lowland 0.08 mm/yr 14.0 M cells 1014 s 72 s per million cells
// highland 0.90 mm/yr 4.0 M cells 1394 s 350 s per million cells
// crater 1.60 mm/yr 1.4 M cells 1829 s 1278 s per million cells
//
// Eighteen-fold, and it is not the stream power. It is the hillslope: DiffuseNonlinear sub-steps to stay
// stable, the count rises with the steepest slope on the grid, and it saturates at max_hillslope_substeps -
// 24 by default. Steep ground pays all 24 every step; a plain pays one.
//
// So the estimate is calibrated on the plains and **badly under-predicts a mountainous template**. It is a
// floor rather than a forecast, the printed line says so, and the practical consequence for an author is
// that raising an uplift rate does not only change the terrain, it changes how long the bake takes.
const secondsPerCellStep = 415.0 / (14.02e6 * 200)
// EstimateSeconds is how long a region's solve should take at the manifest's step count.
func (in *Inputs) EstimateSeconds(cells int) float64 {
return float64(cells) * float64(in.M.Pipeline.Fluvial.Steps) * secondsPerCellStep
}
// bytesPerCell is what a region costs while it is being solved: the fluvial.Grid's eight int32/float32
// arrays and three masks, plus the height, uplift and erodibility fields the solve reads. It is an estimate
// and it is labelled as one wherever it is printed.
const bytesPerCell = 35 + 12 + 16
// EstimateBytes is roughly how much memory a region's solve holds at once.
func (in *Inputs) EstimateBytes(cells int) int64 { return int64(cells) * bytesPerCell }
+686
View File
@@ -0,0 +1,686 @@
package planet
import (
"encoding/json"
"fmt"
"image/png"
"math"
"os"
"path/filepath"
"sync"
"time"
"salty/terrain/internal/detail"
"salty/terrain/internal/field"
"salty/terrain/internal/manifest"
"salty/terrain/internal/noise"
"salty/terrain/internal/overlay"
"salty/terrain/internal/thermal"
"salty/terrain/internal/tile"
)
// The detail bake: the geology grid becomes ground somebody can stand on, one tile at a time.
//
// It reads the heightmap a geology bake left behind rather than solving anything itself, which is what makes
// it batchable. The geology is hours; a tile is seconds, and the islands somebody cares about can be baked
// first and the rest later or never.
//
// Every pass here is local, and every hash and noise lattice in them is keyed on absolute world position, so
// a tile's interior comes out the same as it would have in one impossible whole-world run. That is measured
// rather than asserted: internal/detail's TestHowFarTheCutEdgeReachesIn is where the margin comes from.
// TileOptions steer a detail bake.
type TileOptions struct {
In *Inputs
HeightM *field.Field // the geology heightmap, painted rows only, in metres
Sea []bool // painted rows
// Exposure is the coastal pass's fetch field, painted rows, 0 sheltered to 1 open water, or nil when the
// bake predates it. A tile cannot compute this - see detail.CoastalParams - so without it the coastal
// detail pass treats every shore as fully exposed and the run says so once.
Exposure *field.Field
Out string
Prefix string
Only [4]int // x0,y0,x1,y1 in tile indices; zero means all
OnlySet bool
// NoDetail writes the tile as the geology upsampled and nothing else. It is a diagnostic and it earns
// its place: when ground looks wrong at two metres, the first question is always whether the detail
// passes did it or whether they are faithfully magnifying something the solve produced, and there is no
// other way to ask.
NoDetail bool
// NoShore skips pass 11b and nothing else, for the same reason NoDetail exists one level up: when a
// coastline looks wrong the first question is whether the shore pass did it or whether it is faithfully
// magnifying what the geology handed it, and a diff between two runs is the only way to ask.
NoShore bool
Jobs int
Log func(string, ...any)
}
// TileRecord is one tile in the index.
type TileRecord struct {
IX int `json:"ix"`
IY int `json:"iy"`
File string `json:"file"`
W int `json:"w"`
H int `json:"h"`
OriginXM float64 `json:"origin_x_m"`
OriginYM float64 `json:"origin_y_m"`
MinM float64 `json:"min_m"`
MaxM float64 `json:"max_m"`
ClipFrac float64 `json:"clip_fraction"`
Droplets int `json:"droplets"`
Rounds int `json:"rounds"`
LargestCutM float64 `json:"largest_cut_m"`
LargestFillM float64 `json:"largest_fill_m"`
Seconds float64 `json:"seconds"`
// Coastal is what pass 11b moved on this tile, or nil on a tile with no shore in it.
Coastal *detail.CoastalStats `json:"coastal,omitempty"`
// OverlayFile is the annotation mask beside this tile - one mark index a detail cell, zero for nothing -
// or empty when the planet has no overlay. The key is in overlay.json in the same directory.
OverlayFile string `json:"overlay_file,omitempty"`
}
// TileIndex is tiles.json: everything a consumer needs to place the tiles back into a world.
type TileIndex struct {
When time.Time `json:"when"`
Prefix string `json:"prefix"`
CellM float64 `json:"cell_m"`
TilePx int `json:"tile_px"`
MarginPx int `json:"margin_px"`
NX int `json:"nx"`
NY int `json:"ny"`
WrapX bool `json:"wrap_x"`
WorldWM float64 `json:"world_w_m"`
WorldHM float64 `json:"world_h_m"`
ElevationM struct {
Min float64 `json:"min"`
Max float64 `json:"max"`
} `json:"elevation_m"`
Tiles []TileRecord `json:"tiles"`
}
// CheckBake refuses a detail bake whose geology was produced by a different manifest.
//
// The failure it exists for is silent and total. A heightmap is 16-bit samples over an elevation range, so a
// bake made under one range and decoded under another comes out shifted - and if the shift takes the land
// below sea level, every tile decides it is ocean, holds itself at sea level, and writes a flat zero. That
// happened on the first run of this command and there was nothing in the output to say why.
func CheckBake(dir string, m *manifest.Manifest, warn func(string, ...any)) error {
raw, err := os.ReadFile(filepath.Join(dir, "meta.json"))
if err != nil {
return fmt.Errorf("%s: %w (run `terrain bake` first)", filepath.Join(dir, "meta.json"), err)
}
// Pointers, so that a field a bake did not record is distinguishable from one it recorded as zero. An
// absent field is a bake older than this check, which is a reason to say so and carry on; a different
// field is a reason to stop. The first version conflated the two and refused a perfectly good bake.
var meta struct {
Seed *int64 `json:"seed"`
Plan struct {
CircumferenceKm *float64 `json:"circumference_km"`
CellM *float64 `json:"cell_m"`
ElevationMinM *float64 `json:"elevation_min_m"`
ElevationMaxM *float64 `json:"elevation_max_m"`
} `json:"plan"`
}
if err := json.Unmarshal(raw, &meta); err != nil {
return fmt.Errorf("%s: %w", filepath.Join(dir, "meta.json"), err)
}
if warn == nil {
warn = func(string, ...any) {}
}
unknown := 0
for _, c := range []struct {
what string
was *float64
now float64
}{
{"elevation_m.min", meta.Plan.ElevationMinM, m.ElevationM.Min},
{"elevation_m.max", meta.Plan.ElevationMaxM, m.ElevationM.Max},
{"circumference_km", meta.Plan.CircumferenceKm, m.Planet.CircumferenceKm},
{"the geology cell", meta.Plan.CellM, m.GeologyCellM()},
} {
if c.was == nil {
unknown++
continue
}
if *c.was != c.now {
return fmt.Errorf("%s was baked with %s %v and the manifest now says %v. The heightmap on disk "+
"means something different from what this run would read it as; rebake, or put the manifest "+
"back", dir, c.what, *c.was, c.now)
}
}
if meta.Seed != nil && *meta.Seed != m.Source.Seed {
return fmt.Errorf("%s was baked with seed %d and the manifest now says %d; the detail passes would "+
"be hashing a different world from the one in the heightmap", dir, *meta.Seed, m.Source.Seed)
}
if unknown > 0 {
warn("warning %s predates this check and does not record %d of the numbers it would be checked "+
"against; if the manifest has moved since it was baked, the heights will be read as something "+
"they are not", dir, unknown)
}
return nil
}
// BakeTiles runs the detail passes over a rectangle of tiles and writes them.
func BakeTiles(opt TileOptions) (*TileIndex, error) {
log := opt.Log
if log == nil {
log = func(string, ...any) {}
}
in := opt.In
m := in.M
cfg := m.Pipeline
marginPx := detail.MarginCells(cfg.Particle)
g, err := tile.NewGrid(in.P, cfg.GeologyFactor, cfg.Detail.TilePx, marginPx)
if err != nil {
return nil, err
}
detailCellM := in.P.CellM / float64(cfg.GeologyFactor)
log("tiles %d x %d of %d px at %.1f m (%.2f km), margin %d px (%.0f m)",
g.NX, g.NY, cfg.Detail.TilePx, detailCellM,
float64(cfg.Detail.TilePx)*detailCellM/1000, g.MarginGeo*g.Factor, float64(g.MarginGeo)*in.P.CellM)
if want := int(cfg.Detail.ClassBlendM/in.P.CellM + 0.5); want > g.MarginGeo/2 {
log("warning class_blend_m is %.0f m, which a tile cannot reach past its own margin; it will blend "+
"over %.0f m instead. Two passes of the blur reach twice its radius, and the margin is %.0f m",
cfg.Detail.ClassBlendM, float64(g.MarginGeo/2)*in.P.CellM, float64(g.MarginGeo)*in.P.CellM)
}
if cd := cfg.CoastDetail; cd.Enabled && !opt.NoShore {
log("shore the coastal detail pass is on: %.0f m of surf reach is %.0f cells here, a beach below "+
"%.0f m of backshore and a cliff above %.0f", cfg.Coast.SurfReachM,
cfg.Coast.SurfReachM/detailCellM, cd.CliffFromM, cd.CliffToM)
} else {
log("shore the coastal detail pass is off; the shore is the geology upsampled")
}
all := g.Tiles()
wanted := all[:0:0]
for _, t := range all {
if opt.OnlySet {
if t.IX < opt.Only[0] || t.IX > opt.Only[2] || t.IY < opt.Only[1] || t.IY > opt.Only[3] {
continue
}
}
wanted = append(wanted, t)
}
if len(wanted) == 0 {
return nil, fmt.Errorf("no tiles selected; the grid is %d x %d", g.NX, g.NY)
}
if err := os.MkdirAll(opt.Out, 0o755); err != nil {
return nil, err
}
prefix := opt.Prefix
if prefix == "" {
prefix = "Planet"
}
jobs := opt.Jobs
if jobs <= 0 {
jobs = 4
}
if jobs > len(wanted) {
jobs = len(wanted)
}
recs := make([]TileRecord, len(wanted))
errs := make([]error, len(wanted))
var wg sync.WaitGroup
var mu sync.Mutex
next := make(chan int)
go func() {
for i := range wanted {
next <- i
}
close(next)
}()
for w := 0; w < jobs; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := range next {
rec, err := bakeOneTile(g, wanted[i], opt, prefix)
recs[i], errs[i] = rec, err
mu.Lock()
if err != nil {
log("tile %s FAILED: %v", wanted[i].Name(prefix), err)
} else {
log("tile %s %d x %d %.0f..%.0f m %.3f%% clipped [%.1f s]",
rec.File, rec.W, rec.H, rec.MinM, rec.MaxM, rec.ClipFrac*100, rec.Seconds)
}
mu.Unlock()
}
}()
}
wg.Wait()
for _, err := range errs {
if err != nil {
return nil, err
}
}
idx := &TileIndex{
When: time.Now().UTC().Truncate(time.Second), Prefix: prefix,
CellM: detailCellM, TilePx: cfg.Detail.TilePx, MarginPx: g.MarginGeo * g.Factor,
NX: g.NX, NY: g.NY, WrapX: true,
WorldWM: in.P.CircumferenceM(), WorldHM: in.P.HeightM(),
Tiles: recs,
}
idx.ElevationM.Min, idx.ElevationM.Max = m.ElevationM.Min, m.ElevationM.Max
data, err := json.MarshalIndent(idx, "", " ")
if err != nil {
return nil, err
}
if err := os.WriteFile(filepath.Join(opt.Out, "tiles.json"), append(data, '\n'), 0o644); err != nil {
return nil, err
}
// The key to every *_overlay.png, plus the features in world metres, written beside them so an importer
// reads one directory rather than two. It is the same document the plan and the bake write; it is small,
// it describes the whole planet, and a tile batch that did not carry it would be a folder of masks with
// no legend.
if in.OverlayDoc != nil {
if err := in.OverlayDoc.WriteJSON(opt.Out); err != nil {
return nil, err
}
}
return idx, nil
}
// bakeOneTile is passes 8 to 12 and 14 over one tile.
func bakeOneTile(g *tile.Grid, t tile.Tile, opt TileOptions, prefix string) (TileRecord, error) {
in := opt.In
m := in.M
cfg := m.Pipeline
// Pass 8: cut with the margin and upsample. UpsampleInt is exact-factor Catmull-Rom, so every geology
// sample lands exactly on a detail sample and there is no phase error to accumulate along a tile row.
//
// The sea is flattened to sea level *before* the upsample, not after, and both halves of that matter. The
// geology raster drops from the shore to the painted ocean depth in a single cell, so a Catmull-Rom
// upsample of it rings at every coastline - hundreds of metres of overshoot in the water and a wave of it
// back into the land. And with the sea flat, the interpolated height crosses sea level on a smooth
// contour, so the detail land mask can be read off the height itself; taken up from the geology mask by
// nearest neighbour instead, the coastline comes out as a staircase of 8 m blocks and it is plainly
// visible in a hillshade.
//
// It is the same invariant the fluvial solve keeps, for the same reason: with the floor in place a cell at
// the waterline stands five hundred metres above its neighbour, and thermal weathering would find the
// whole coastline past the angle of repose and pour it into the sea.
geo, _, _ := g.Cut(t, opt.HeightM, 0)
geoSea := g.CutMask(t, opt.Sea, opt.HeightM.W, 0)
floor := geo.Clone()
for i, isSea := range geoSea {
if isSea {
geo.Data[i] = float32(m.SeaLevelM)
}
}
h := geo.UpsampleInt(cfg.GeologyFactor)
land := make([]bool, len(h.Data))
for i, v := range h.Data {
land[i] = float64(v) > m.SeaLevelM
}
f := g.Frame(t)
periodM := m.Planet.DetailNoisePeriodKm * 1000
classes := blendedClasses(g, t, opt, geo, h)
if opt.NoDetail {
restoreSeaFloor(h, land, geo, floor, m, cfg.GeologyFactor)
return finishTile(g, t, opt, prefix, h, land, nil, nil)
}
// Pass 9.
detail.RunDetailNoise(h, land, detail.DetailNoiseParams{
Cfg: cfg.Detail, Seed: m.Source.Seed, Frame: f, PeriodM: periodM, SeaLevelM: m.SeaLevelM,
Classes: classes,
})
// Pass 10 feeds pass 11 rather than standing alone: strata is hardness, and hardness is what the
// droplets scale their cutting by, which is how a hard band ends up holding a shelf on a cut face.
hard := detail.NewHardness(f, m.Source.Seed, m.Planet.NoisePeriodKm*1000,
cfg.Strata.PeriodM, cfg.Strata.Contrast, classes)
// Pass 11.
maps, _ := detail.RunParticle(h, land, detail.ParticleParams{
Cfg: cfg.Particle, Seed: m.Source.Seed, Frame: f, SeaLevelM: m.SeaLevelM, Hardness: hard,
Classes: classes,
})
// Pass 12: the same mass-conserving weathering the coarse grid gets, at the cell size where scree and a
// cliff face are actually resolved.
fixed := make([]bool, len(land))
for i := range land {
fixed[i] = !land[i]
}
thermal.Apply(h.Data, h.W, h.H, h.CellM, thermal.TalusFromDegrees(cfg.Thermal.TalusDeg),
cfg.Thermal.FinePasses, fixed, nil)
// The sea floor goes back *here*, before the shore is drawn, rather than on the way out. Pass 11b works
// on both sides of the waterline - a foreshore is below it and a berm is above it - so a shore laid onto
// water that is about to be overwritten would be half a shore.
restoreSeaFloor(h, land, geo, floor, m, cfg.GeologyFactor)
// Pass 9b: the same texture as pass 9, under water, now that there is a sea bed to put it on. It runs
// before the shore rather than after, so the beach the shore pass draws is smooth sand over it rather
// than sand with noise on top.
detail.RunSeabedNoise(h, detail.DetailNoiseParams{
Cfg: cfg.Detail, Seed: m.Source.Seed, Frame: f, PeriodM: periodM, SeaLevelM: m.SeaLevelM,
Classes: classes,
})
// Pass 11b: the shore. It runs last of the detail passes because marine processes are the last thing to
// act on a coast and they act faster than anything inland: a berm is rebuilt by every tide, while the
// hillslope creep that pass 12 stands for takes the age of the cliff behind it. Running it before the
// fine thermal would have that creep immediately relax the one face on the map that is meant to be
// steeper than the angle of repose.
var coastal *detail.CoastalStats
if cfg.CoastDetail.Enabled && !opt.NoShore {
st := detail.RunCoastal(h, land, detail.CoastalParams{
Cfg: cfg.CoastDetail, Surf: cfg.Coast, Seed: m.Source.Seed, Frame: f, PeriodM: periodM,
SeaLevelM: m.SeaLevelM, Exposure: cutExposure(g, t, opt, geo, h), Hardness: hard,
})
coastal = &st
}
return finishTile(g, t, opt, prefix, h, land, maps, coastal)
}
// cutExposure lifts the coastal pass's fetch field onto this tile's detail grid, or nil when the bake did not
// carry one. Interpolated rather than nearest: it is a smooth field and a staircase in it would put a
// staircase into the berm height along every beach.
func cutExposure(g *tile.Grid, t tile.Tile, opt TileOptions, geo, h *field.Field) []float32 {
if opt.Exposure == nil {
return nil
}
cut, _, _ := g.Cut(t, opt.Exposure, 0)
up := cut.UpsampleInt(opt.In.M.Pipeline.GeologyFactor)
if len(up.Data) != len(h.Data) {
return nil
}
return up.Data
}
// restoreSeaFloor puts the water back after the land passes, which ran with the sea flattened to sea level.
//
// It used to be nearest neighbour, unconditionally, and the comment said why: the geology raster dropped from
// the shore to the painted ocean depth in a single cell, and interpolating a five-hundred-metre step is
// exactly what the flattening exists to avoid. The cost was a four-fold staircase over the whole sea floor,
// which nobody could see while the shore was a cliff into five hundred metres of water.
//
// D-60 changed the input. There is a continental shelf now, and a surf-cut platform, and a beach, and between
// them they carry the sea floor down from the waterline to the shelf break over kilometres rather than over
// one cell. So the shallow water is interpolated - from the *unflattened* cut, which still holds the land
// heights, so the surface runs across the waterline with no seam in it - and only the drop past the break is
// still nearest. The two are blended over a depth band rather than switched between, because a hard switch
// would put back a smaller version of the step it exists to avoid.
func restoreSeaFloor(h *field.Field, land []bool, geo, floor *field.Field, m *manifest.Manifest, factor int) {
breakM := m.ShelfBreakM()
if breakM <= 0 {
breakM = 30
}
// The clamp sits at the *far* end of the blend band rather than at the break, so that everywhere the blend
// is still reading the interpolation, the interpolation is of the real sea floor. Clamped at the break
// instead, the smooth half of the blend was a flat surface at break depth while the nearest half followed
// the slope down, and the mixture lifted the floor by up to half the band - ten metres of invented shelf
// in exactly the strip the blend exists to make invisible.
const bandM = 40.0
deepest := float32(m.SeaLevelM - (breakM + bandM))
shallow := floor.Clone()
for i, v := range shallow.Data {
if v < deepest {
shallow.Data[i] = deepest
}
}
smooth := shallow.UpsampleInt(factor)
// Not smoothed, and it is worth saying why not, because the first version was.
//
// The interpolated sea floor comes out of a hillshade covered in dotted contour lines, which look exactly
// like an interpolation artefact and are not: measured, an eighty by hundred patch of open water takes
// three distinct 8-bit shade values, 94 % of them the same one. It is the hillshade's own quantisation on
// a surface that slopes at one in three hundred, it was there before and it is in the picture rather than
// in the ground. A box blur over the floor was tried against it and changed the tile by a fifth of a
// height quantum on average - its only real effect was to soften genuine one-cell steps in the geology,
// which is not what it was for.
for y := 0; y < h.H; y++ {
sy := y / factor
if sy >= geo.H {
sy = geo.H - 1
}
for x := 0; x < h.W; x++ {
i := y*h.W + x
if land[i] {
continue
}
sx := x / factor
if sx >= geo.W {
sx = geo.W - 1
}
near := float64(floor.Data[sy*geo.W+sx])
depth := m.SeaLevelM - near
t := (depth - breakM) / bandM
if t <= 0 {
h.Data[i] = smooth.Data[i]
continue
}
if t >= 1 {
h.Data[i] = float32(near)
continue
}
w := noise.Smoothstep(t)
h.Data[i] = float32((1-w)*float64(smooth.Data[i]) + w*near)
}
}
}
// finishTile restores the sea floor, crops the margin away and writes everything out.
func finishTile(g *tile.Grid, t tile.Tile, opt TileOptions, prefix string, h *field.Field, land []bool,
maps *detail.Maps, coastal *detail.CoastalStats) (TileRecord, error) {
start := time.Now()
_ = land
m := opt.In.M
cfg := m.Pipeline
// Pass 14: crop the margin away and write. Everything outside the interior was only ever there so the
// passes above had somewhere to read from.
ix, iy := g.MarginGeo*g.Factor, g.MarginGeo*g.Factor
iw, ih := g.DetailW(t), g.DetailH(t)
out := crop(h, ix, iy, iw, ih)
rec := TileRecord{
IX: t.IX, IY: t.IY, File: t.Name(prefix) + ".png", W: iw, H: ih,
OriginXM: g.OriginXM(t), OriginYM: g.OriginYM(t),
}
if coastal != nil && coastal.ShoreCells > 0 {
rec.Coastal = coastal
}
// The annotation layer, sampled onto this tile's interior. It is written before the height, because it is
// the cheap one and a failure here should not leave a heightmap with no mask beside it.
//
// Nothing in the detail passes read it and nothing here consults it: it is the author's layer travelling
// through to whatever builds the level. The values are mark indices, zero for nothing, and the key is in
// overlay.json beside tiles.json.
if ov := opt.In.OverlayRaster; ov != nil {
marks := ov.SampleWorld(rec.OriginXM, rec.OriginYM, h.CellM, iw, ih, opt.In.OverlayScale())
rec.OverlayFile = t.Name(prefix) + "_overlay.png"
if err := overlay.WriteMask(filepath.Join(opt.Out, rec.OverlayFile), iw, ih, marks); err != nil {
return rec, err
}
}
lo, hi := out.MinMax()
rec.MinM, rec.MaxM = float64(lo), float64(hi)
rec.ClipFrac = m.ClipFraction(out.Data)
if err := field.WriteGray16(filepath.Join(opt.Out, rec.File), iw, ih,
m.Encode(out.Data), png.DefaultCompression); err != nil {
return rec, err
}
// A hillshade beside the heightmap, at full resolution. A 16-bit grey PNG of a hundred metres of relief
// is a flat grey rectangle to look at, and the whole reason these passes exist is what they do to the
// surface - which cannot be judged from a number.
// Shaded with the water clamped at the shelf break rather than at sea level. Clamping at sea level was
// right while the shore was a step into five hundred metres of water and there was nothing below the
// waterline worth looking at; now there is a shore platform, a foreshore and a beach down there, and
// they are most of what pass 11b does. The break is still clamped, because a continental slope in the
// corner of a tile would otherwise set the whole hillshade's contrast.
shade := out.Clone()
shadeFloor := float32(m.SeaLevelM - m.ShelfBreakM())
for i := range shade.Data {
if shade.Data[i] < shadeFloor {
shade.Data[i] = shadeFloor
}
}
if err := field.WriteHillshade(filepath.Join(opt.Out, t.Name(prefix)+"_shade.png"), shade, iw, 1); err != nil {
return rec, err
}
// A slice, not a map: map iteration order is randomised in Go and nothing in this generator is allowed
// to depend on it (cross-cutting rule 12). Here it would only reorder two file writes, which is exactly
// the kind of "it does not matter this time" that makes the rule worth keeping without exception.
// The full-scale values are fixed constants, not percentiles of the tile.
//
// Field.ToUnit takes the 99th percentile of whatever it is given, which is exactly right for one map of
// one world and exactly wrong here: it is a statistic of the tile's own extent, so two tiles would stretch
// by different anchors and their shared valley would come out two different greys. That is the same
// mistake the coastal pass's exposure made and had withdrawn, and the same rule - no pass computes a
// statistic of the piece of the world it happens to be looking at.
//
// Flow is water-units accumulated and runs over decades, so it is log-scaled; wear and deposit are metres
// and a metre of either is a great deal at a 2 m cell.
flowFull := 40 * cfg.Particle.DropletsPerCell * float64(cfg.Particle.Lifetime)
var derived []struct {
name string
data []float32
full float64
log bool
}
if maps != nil {
derived = []struct {
name string
data []float32
full float64
log bool
}{
{"flow", maps.Flow, flowFull, true},
{"wear", maps.Wear, 1.0, false},
{"deposit", maps.Deposit, 1.0, false},
}
}
for _, d := range derived {
c := crop(&field.Field{W: h.W, H: h.H, CellM: h.CellM, Data: d.data}, ix, iy, iw, ih)
if err := field.WriteGray8(filepath.Join(opt.Out, t.Name(prefix)+"_"+d.name+".png"), iw, ih,
toBytes(normalise(c.Data, d.full, d.log)), png.BestSpeed); err != nil {
return rec, err
}
}
rec.Seconds = time.Since(start).Seconds()
return rec, nil
}
// upsampleClass takes the class raster up by an integer factor, nearest. A class index is a name and not a
// quantity: interpolating one would invent a class that is neither of its neighbours.
func crop(f *field.Field, x0, y0, w, h int) *field.Field {
out := field.New(w, h, f.CellM)
for y := 0; y < h; y++ {
copy(out.Data[y*w:(y+1)*w], f.Data[(y0+y)*f.W+x0:(y0+y)*f.W+x0+w])
}
return out
}
// normalise maps values onto 0..1 against a fixed full-scale, never a percentile of the data. See the note
// where the constants are chosen.
func normalise(data []float32, full float64, logScale bool) []float32 {
if full <= 0 {
full = 1
}
top := full
if logScale {
top = math.Log1p(full)
}
out := make([]float32, len(data))
for i, v := range data {
x := float64(v)
if x < 0 {
x = 0
}
if logScale {
x = math.Log1p(x)
}
out[i] = float32(x / top)
}
return out
}
func toBytes(data []float32) []uint8 {
out := make([]uint8, len(data))
for i, v := range data {
x := v
if x < 0 {
x = 0
} else if x > 1 {
x = 1
}
out[i] = uint8(x*255 + 0.5)
}
return out
}
// blendedClasses turns the painted class raster into the four numbers the detail passes read, per cell, with
// the boundaries between classes faded rather than stepped.
//
// **Why the fade.** A class is a name and a name is never interpolated - the mask that travels to whatever
// builds the level is still nearest neighbour, and it has to be. But the numbers a class stands for are
// quantities. Kept as a lookup on the class index, a desert meeting a lowland went from seven metres of dune
// amplitude to two, and from a fifth of the running water to all of it, in the width of one cell, along a
// line somebody drew with a mouse. It read as what it was: a boundary in a picture rather than a change in
// the ground. Faded over `class_blend_m`, the same boundary is a few hundred metres of one becoming the
// other, which is what the edge of a sand sea looks like from inside it.
//
// **Why at the geology grid.** The class raster is a geology-resolution field, so blurring it there costs a
// four-hundredth of blurring at detail resolution, and the upsample afterwards is the same exact-factor
// Catmull-Rom every other field gets - so the result is smoother than a blur at detail resolution would have
// been, not coarser.
//
// **Why the radius is clamped.** A blur reads outside the cell it writes, and a tile only has its margin to
// read from. Two passes of a box blur of radius r reach 2r, so r is capped at half the margin and the run
// says so once when the manifest asks for more. Past that cap a tile would be blending against its own cut
// edge and two tiles would disagree about the same ground, which is the one thing the tiling may not do.
func blendedClasses(g *tile.Grid, t tile.Tile, opt TileOptions, geo, h *field.Field) *detail.Classes {
in := opt.In
cfg := in.M.Pipeline
if !in.Legend.Overrides() {
return nil
}
tbl := in.Legend.DetailTables(cfg.Particle.DropletsPerCell,
cfg.Detail.AmplitudeM.Lo(), cfg.Detail.AmplitudeM.Hi(), cfg.Strata.Contrast)
cls := g.CutClass(t, in.Map.Class, in.P.W, in.P.PadY)
radius := int(cfg.Detail.ClassBlendM/in.P.CellM + 0.5)
if max := g.MarginGeo / 2; radius > max {
radius = max
}
lift := func(table []float64) []float32 {
f := field.New(geo.W, geo.H, geo.CellM)
for i, k := range cls {
f.Data[i] = float32(table[k])
}
if radius > 0 {
field.BoxSmooth(f.Data, f.W, f.H, radius, 2)
}
return f.UpsampleInt(cfg.GeologyFactor).Data
}
return &detail.Classes{
Droplets: lift(tbl.Droplets), AmpLo: lift(tbl.AmpLo),
AmpHi: lift(tbl.AmpHi), Contrast: lift(tbl.Contrast),
}
}
+104
View File
@@ -0,0 +1,104 @@
package plates
import "math"
// Belt is how faulted a margin's surroundings are: the deformation zone around a boundary, and how densely
// it is broken.
//
// It lives beside the plates rather than beside the faults because it describes a *boundary*, not a fault.
// How wide the ground is that a margin deforms is a property of what that margin is doing - a continental
// collision takes up its convergence across a belt a thousand kilometres wide and a mid-ocean ridge across an
// axis a few tens wide - and the traces are a consequence of that width, not the other way round. What
// internal/uplift's belt_faults.go does with these numbers, and the fault map they were read off, is
// documented there.
type Belt struct {
// ZoneKm is the deformation half-width of a *collision* margin closing at ReferenceCmYr, in kilometres.
// Every other kind of margin is a fraction of it, and every margin scales with its own rate.
ZoneKm float64 `json:"zone_km"`
// ReferenceCmYr is the rate ZoneKm is quoted at. Earth's big collisions run 2 to 5 cm/yr.
ReferenceCmYr float64 `json:"reference_cm_yr"`
// Per1000Km2 is the trace density over the *zone*, not over the planet: a belt is as faulted as a belt is
// wherever it happens to run, and the ground away from one is not lightly faulted, it is unfaulted.
Per1000Km2 float64 `json:"per_1000km2"`
// ThrowM is the total displacement over the whole run, low to high, before the closing rate scales it -
// the height of the scarp the fault would build if nothing eroded it.
ThrowM [2]float64 `json:"throw_m"`
// LengthKm is how long a trace is, low to high, before the local zone width scales it. A wide belt
// carries long faults and a narrow one cannot.
LengthKm [2]float64 `json:"length_km"`
// StrikeSpreadDeg is how far a trace may wander off the belt's local tangent. Small on purpose: a swarm
// being sub-parallel is the thing that makes it read as a swarm.
//
// ConjugateFraction is the share of traces drawn on the second, crossing direction, and ConjugateDeg is
// the angle between the two sets. One direction alone reads as corduroy.
//
// The first two are pointers for the reason Config.SpinFraction is: JSON cannot tell an absent number
// from a zero one, and both of these have a real meaning at zero - perfectly parallel traces, and no
// second set. Read as the same thing, a block that simply did not mention them silently turned them off,
// which is how the first painted planet came out with no conjugate set at all. Absent takes the default;
// an explicit 0 means none.
StrikeSpreadDeg *float64 `json:"strike_spread_deg"`
ConjugateFraction *float64 `json:"conjugate_fraction"`
ConjugateDeg float64 `json:"conjugate_deg"`
}
// DefaultStrikeSpreadDeg and DefaultConjugateFraction are what a belt that does not mention them gets.
const (
DefaultStrikeSpreadDeg = 11.0
DefaultConjugateFraction = 0.22
)
// Spread is the configured strike spread, or the default when the block said nothing.
func (b Belt) Spread() float64 {
if b.StrikeSpreadDeg == nil {
return DefaultStrikeSpreadDeg
}
return math.Max(0, *b.StrikeSpreadDeg)
}
// Conjugate is the configured share of crossing traces, or the default when the block said nothing.
func (b Belt) Conjugate() float64 {
if b.ConjugateFraction == nil {
return DefaultConjugateFraction
}
return math.Min(1, math.Max(0, *b.ConjugateFraction))
}
// DefaultBelt is what a planet that asks for belt faults but says nothing else gets.
func DefaultBelt() Belt {
return Belt{
ZoneKm: 6,
ReferenceCmYr: 4,
Per1000Km2: 90,
ThrowM: [2]float64{80, 420},
LengthKm: [2]float64{4, 16},
ConjugateDeg: 32,
// StrikeSpreadDeg and ConjugateFraction stay nil: their defaults live in Spread and Conjugate, so
// that an explicit zero can mean none.
}
}
// Wanted reports whether this asks for anything. A zero Belt is a planet whose margins are not faulted, which
// is what every painted planet had before this existed.
func (b Belt) Wanted() bool {
return b.ZoneKm > 0 && b.Per1000Km2 > 0 && b.LengthKm[1] > 0 && b.ThrowM[1] > 0
}
// WithDefaults fills in the fields that have a sensible value when left out. ZoneKm, Per1000Km2, ThrowM and
// LengthKm are deliberately not among them: those four are the feature, and defaulting them would turn
// leaving the block out into switching the feature on.
func (b Belt) WithDefaults() Belt {
d := DefaultBelt()
if b.ReferenceCmYr <= 0 {
b.ReferenceCmYr = d.ReferenceCmYr
}
if b.ConjugateDeg <= 0 {
b.ConjugateDeg = d.ConjugateDeg
}
return b
}
+437
View File
@@ -0,0 +1,437 @@
package plates
import (
"math"
"sort"
)
// What a boundary does, which is the whole point of the package: "two plates hit each other" is one of these
// five and the other four are what happens when they do something else.
//
// The kind is per *vertex*, not per boundary. A margin whose plates are rotating as well as translating
// closes at one end and slides at the other - that is why the pole is in the map plane at all - so a single
// label for the whole line would throw away the thing the model was built to produce.
type Kind uint8
const (
// Transform: the relative motion is along the line rather than across it. Little uplift, a strike-slip
// fault, and a restraining bend that pops a range up where the line curves into the motion.
Transform Kind = iota
// Collision: convergent, both sides continental. Neither can subduct, so the crust thickens and the
// result is a wide doubly-vergent belt - the thing an author means when they paint a mountain range.
Collision
// Subduction: convergent with at least one oceanic side. The ocean floor goes under, and the uplift is
// an arc on the *overriding* plate, set back from the trench rather than centred on the line.
Subduction
// Rift: divergent, both sides continental. The axis drops and the shoulders stand up - the East African
// pattern, and the one kind of boundary that lowers ground rather than raising it.
Rift
// Ridge: divergent with an oceanic side. A bathymetric ridge under water; on land it is a rift that has
// already opened.
Ridge
)
func (k Kind) String() string {
switch k {
case Collision:
return "collision"
case Subduction:
return "subduction"
case Rift:
return "rift"
case Ridge:
return "ridge"
default:
return "transform"
}
}
// Convergent reports whether this kind is two plates closing on each other.
func (k Kind) Convergent() bool { return k == Collision || k == Subduction }
// Divergent reports whether this kind is two plates separating.
func (k Kind) Divergent() bool { return k == Rift || k == Ridge }
// Vertex is one point on a boundary and everything a later pass reads off it.
type Vertex struct {
XM float64 `json:"x_m"`
YM float64 `json:"y_m"`
// NX, NY is the unit normal, pointing out of plate A and into plate B. Every sign in this package is
// measured against it, so "which side goes up" has one definition rather than one per consumer.
NX float64 `json:"nx"`
NY float64 `json:"ny"`
// ClosingMYr is the relative velocity's component along the normal, in metres a year: positive closing,
// negative opening. This is the number an uplift rate is a function of - "when two plates hit each other
// they create mountains" is this field and nothing else.
ClosingMYr float64 `json:"closing_m_yr"`
// SlipMYr is the component along the line, signed in the polyline's own direction.
SlipMYr float64 `json:"slip_m_yr"`
Kind Kind `json:"kind"`
// Over is the overriding plate at a subduction margin - the side the arc is built on - and -1 anywhere
// else.
Over int `json:"over"`
}
// Boundary is one continuous stretch of contact between two plates.
//
// X is **unwrapped**, exactly as uplift.FaultTrace is and for exactly the same reason: a boundary that
// crosses the seam has X running past the circumference or below zero rather than jumping, so every segment
// is a straight line between neighbouring points and no consumer has to special-case the meridian.
type Boundary struct {
A int `json:"a"`
B int `json:"b"`
V []Vertex `json:"vertices"`
}
// LengthM is how long the boundary is, following the line.
func (b Boundary) LengthM() float64 {
total := 0.0
for i := 0; i+1 < len(b.V); i++ {
total += math.Hypot(b.V[i+1].XM-b.V[i].XM, b.V[i+1].YM-b.V[i].YM)
}
return total
}
// Dominant is the kind most of this boundary's length is, which is the one word to print for it.
func (b Boundary) Dominant() Kind {
var byKind [5]float64
for i := 0; i+1 < len(b.V); i++ {
d := math.Hypot(b.V[i+1].XM-b.V[i].XM, b.V[i+1].YM-b.V[i].YM)
byKind[b.V[i].Kind] += d
}
best, bestK := -1.0, Transform
for k, d := range byKind {
if d > best {
best, bestK = d, Kind(k)
}
}
return bestK
}
// LengthByKind totals the planet's boundary length in each kind, in metres: the summary a run prints and the
// one number that says whether a seed produced a world with mountains in it.
func LengthByKind(bs []Boundary) [5]float64 {
var out [5]float64
for _, b := range bs {
for i := 0; i+1 < len(b.V); i++ {
d := math.Hypot(b.V[i+1].XM-b.V[i].XM, b.V[i+1].YM-b.V[i].YM)
out[b.V[i].Kind] += d
}
}
return out
}
// sample is one crossing of the boundary on the tectonic grid: the midpoint of two adjacent cells that
// belong to different plates.
type sample struct {
xM, yM float64
// dx, dy is the step from the plate-A cell towards the plate-B cell, which is what fixes the normal's
// sign once the chain has a tangent to make it perpendicular to.
dx, dy float64
a, b int
}
// minChainSamples is how short a chain is allowed to be before it is dropped. Triple junctions leave stubs
// of two or three cells that are a corner of the partition rather than a margin, and a stub cannot be given
// a meaningful tangent.
const minChainSamples = 6
// maxGapCells is how far apart two samples may be and still be the same line. Along a straight run they are
// one cell apart and on a staircase 0.71, so 1.6 chains both without reaching a parallel strand.
const maxGapCells = 1.6
// smoothPasses is how many times the chained polyline is averaged with its own neighbours.
//
// It is not cosmetic. A chain straight off the grid is a staircase, so its tangent alternates between two
// axis-aligned directions from vertex to vertex - and since the normal is the tangent's perpendicular and
// every classification is a dot product with the normal, an unsmoothed margin flickers between convergent
// and transform along its whole length. Two passes of a three-tap average cost a fraction of a grid cell in
// position and give a tangent that means something.
const smoothPasses = 2
// buildBoundaries finds every stretch of contact between two plates and says what each one is doing.
func (m *Model) buildBoundaries() []Boundary {
groups := m.collect()
// Sorted by pair, so the set is in the same order on every run: a planet's tectonics must not depend on
// Go's map iteration order, or two runs of the same seed would write different meta.json files.
keys := make([][2]int, 0, len(groups))
for k := range groups {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i][0] != keys[j][0] {
return keys[i][0] < keys[j][0]
}
return keys[i][1] < keys[j][1]
})
circ := m.P.CircumferenceM()
maxGap := maxGapCells * m.GCellM
var out []Boundary
for _, k := range keys {
for _, chain := range chainSamples(groups[k], circ, maxGap) {
b := m.classify(k[0], k[1], chain, circ)
if len(b.V) >= minChainSamples {
out = append(out, b)
}
}
}
return out
}
// collect walks the tectonic grid once and records every cell edge whose two sides belong to different
// plates.
//
// East and south only. Testing all four neighbours would record each edge twice, and a chain built from
// duplicated points walks on the spot.
func (m *Model) collect() map[[2]int][]sample {
half := m.GCellM / 2
out := make(map[[2]int][]sample)
add := func(a, b int, xM, yM, dx, dy float64) {
if a == b {
return
}
key := [2]int{a, b}
if a > b {
key = [2]int{b, a}
dx, dy = -dx, -dy
}
out[key] = append(out[key], sample{xM: xM, yM: yM, dx: dx, dy: dy, a: key[0], b: key[1]})
}
for gy := 0; gy < m.GH; gy++ {
row := gy * m.GW
for gx := 0; gx < m.GW; gx++ {
here := int(m.Cell[row+gx])
east := int(m.Cell[m.GridIdx(gx+1, gy)])
add(here, east, m.GridXM(gx)+half, m.GridYM(gy), 1, 0)
if gy+1 < m.GH {
south := int(m.Cell[m.GridIdx(gx, gy+1)])
add(here, south, m.GridXM(gx), m.GridYM(gy)+half, 0, 1)
}
}
}
return out
}
// chainSamples orders a pair's scattered crossings into one or more polylines.
//
// A greedy nearest-unused walk rather than a proper contour tracer. The set it is given is one cell wide by
// construction, so the nearest unused neighbour is the next point along the line in every case except a
// triple junction, where the walk takes one branch and the other becomes a chain of its own - which is the
// right answer, because two plates meeting a third meet it on two different margins.
//
// O(n squared) on purpose. n is a few hundred, because the tectonic grid is a quarter of a kilometre and a
// boundary is a few tens of kilometres; a spatial index here would be more code than the thing it indexes.
func chainSamples(ss []sample, circ, maxGap float64) [][]sample {
used := make([]bool, len(ss))
var out [][]sample
for {
seed := pickEnd(ss, used, circ, maxGap)
if seed < 0 {
break
}
used[seed] = true
fwd := walk(ss, used, seed, circ, maxGap)
back := walk(ss, used, seed, circ, maxGap)
chain := make([]sample, 0, len(fwd)+len(back)+1)
for i := len(back) - 1; i >= 0; i-- {
chain = append(chain, ss[back[i]])
}
chain = append(chain, ss[seed])
for _, i := range fwd {
chain = append(chain, ss[i])
}
if len(chain) >= minChainSamples {
out = append(out, chain)
}
}
return out
}
// pickEnd chooses where to start a chain: a sample with at most one unused neighbour, which is an end of the
// line. Starting in the middle would give two half-chains walked in opposite directions and joined at a
// point, which is the same line with a kink in the tangent at its centre.
func pickEnd(ss []sample, used []bool, circ, maxGap float64) int {
best, bestDeg := -1, 1<<30
for i := range ss {
if used[i] {
continue
}
deg := 0
for j := range ss {
if i == j || used[j] {
continue
}
if dist(ss[i], ss[j], circ) <= maxGap {
deg++
}
}
if deg <= 1 {
return i
}
if deg < bestDeg {
best, bestDeg = i, deg
}
}
return best
}
// walk steps from a sample to its nearest unused neighbour until there is none in reach.
func walk(ss []sample, used []bool, from int, circ, maxGap float64) []int {
var out []int
cur := from
for {
best, bestD := -1, maxGap
for j := range ss {
if used[j] {
continue
}
if d := dist(ss[cur], ss[j], circ); d <= bestD {
best, bestD = j, d
}
}
if best < 0 {
return out
}
used[best] = true
out = append(out, best)
cur = best
}
}
func dist(a, b sample, circ float64) float64 {
return math.Hypot(wrapDelta(a.xM-b.xM, circ), a.yM-b.yM)
}
// classify turns a chain of crossings into a boundary: unwrapped, smoothed, and with the relative motion
// resolved into a closing rate and a slip rate at every vertex.
func (m *Model) classify(a, b int, chain []sample, circ float64) Boundary {
xs := make([]float64, len(chain))
ys := make([]float64, len(chain))
xs[0], ys[0] = chain[0].xM, chain[0].yM
// Unwrap as the chain is copied: each point is put within half a circumference of the one before it, so
// a margin crossing the seam comes out as a straight run of increasing X rather than a jump.
for i := 1; i < len(chain); i++ {
xs[i] = xs[i-1] + wrapDelta(chain[i].xM-xs[i-1], circ)
ys[i] = chain[i].yM
}
smooth(xs, ys)
obliqueRad := m.Cfg.ObliqueDeg * math.Pi / 180
over := m.overriding(a, b)
out := Boundary{A: a, B: b, V: make([]Vertex, len(chain))}
for i := range chain {
tx, ty := tangent(xs, ys, i)
// The normal is the tangent's perpendicular, and the crossing itself says which of the two
// perpendiculars points into plate B.
nx, ny := -ty, tx
if nx*chain[i].dx+ny*chain[i].dy < 0 {
nx, ny = ty, -tx
}
vax, vay := m.Plates[a].VelocityAt(m.P, xs[i], ys[i])
vbx, vby := m.Plates[b].VelocityAt(m.P, xs[i], ys[i])
rx, ry := vax-vbx, vay-vby
closing := rx*nx + ry*ny
slip := rx*tx + ry*ty
out.V[i] = Vertex{
XM: xs[i], YM: ys[i], NX: nx, NY: ny,
ClosingMYr: closing, SlipMYr: slip,
Kind: kindOf(closing, slip, obliqueRad,
m.Plates[a].Continental && m.Plates[b].Continental),
Over: -1,
}
if out.V[i].Kind == Subduction {
out.V[i].Over = over
}
}
return out
}
// kindOf is the classification itself, and it is one comparison: is the relative motion more across the line
// or more along it, and if across, which way.
func kindOf(closing, slip, obliqueRad float64, bothContinental bool) Kind {
if math.Atan2(math.Abs(slip), math.Abs(closing)) > obliqueRad {
return Transform
}
if closing > 0 {
if bothContinental {
return Collision
}
return Subduction
}
if bothContinental {
return Rift
}
return Ridge
}
// overriding is which of two plates ends up on top when they converge.
//
// The continental one, when exactly one is: continental crust is too buoyant to go down, which is why the
// Andes are on South America and not on the Nazca plate. When both sides are oceanic it is the larger, as a
// stand-in for the older and therefore colder and denser slab being the one that sinks.
func (m *Model) overriding(a, b int) int {
ca, cb := m.Plates[a].Continental, m.Plates[b].Continental
switch {
case ca && !cb:
return a
case cb && !ca:
return b
case m.Plates[a].AreaCells >= m.Plates[b].AreaCells:
return a
default:
return b
}
}
// tangent is the local direction of the line, as a unit vector, from a central difference.
func tangent(xs, ys []float64, i int) (tx, ty float64) {
lo, hi := i-1, i+1
if lo < 0 {
lo = 0
}
if hi >= len(xs) {
hi = len(xs) - 1
}
tx, ty = xs[hi]-xs[lo], ys[hi]-ys[lo]
if d := math.Hypot(tx, ty); d > 0 {
return tx / d, ty / d
}
return 1, 0
}
// smooth averages the polyline with its own neighbours, in place, with the ends pinned. See smoothPasses for
// why an unsmoothed chain is unusable rather than merely ugly.
func smooth(xs, ys []float64) {
if len(xs) < 3 {
return
}
bx := make([]float64, len(xs))
by := make([]float64, len(ys))
for pass := 0; pass < smoothPasses; pass++ {
copy(bx, xs)
copy(by, ys)
for i := 1; i < len(xs)-1; i++ {
xs[i] = (bx[i-1] + 2*bx[i] + bx[i+1]) / 4
ys[i] = (by[i-1] + 2*by[i] + by[i+1]) / 4
}
}
}
+370
View File
@@ -0,0 +1,370 @@
package plates
import (
"encoding/json"
"fmt"
"math"
"os"
"regexp"
"salty/terrain/internal/world"
)
// The painted tectonic layer: a third painting beside the template and the overlay, where a colour is a
// plate and the legend says how that plate is moving.
//
// **You paint the cause, not the conclusion.** A colour does not say "there is a collision here" - it says
// "this piece of lithosphere is moving north-east at three centimetres a year", and where two of them meet,
// what happens is worked out from the two motions and the shape of the contact. That is the whole reason to
// paint plates rather than to paint boundary lines: a drawn line has to be told what it is, while a contact
// between two painted plates *becomes* a collision, a transform or a rift by itself, and changes character
// along its own length wherever it turns relative to the motion. The Alpide belt is a collision at the
// Himalaya and a strike-slip fault through Anatolia for exactly that reason, and no author should have to
// hand-annotate it.
//
// It also means the tracer needs no new code. Build's weighted Voronoi and this both produce the same thing -
// a plate id at every cell of the tectonic grid - and everything downstream reads that.
//
// **Registration is by extent, not by pixel.** The layer is stretched over the painted map's own rectangle,
// so it does not have to be the template's size. Paint plates at a quarter of it if you like: the tectonic
// grid is a few hundred metres a cell and a plate is tens of kilometres across, so detail below that is
// detail nothing will ever read. The polar pad has no painting under it and takes the nearest painted row,
// which is right - a plate does not stop at the top of the author's canvas.
// PaintLegend is what the colours on a tectonic layer mean.
type PaintLegend struct {
// Comment is the legend's own note to whoever opens it next. Propose writes the conventions into it,
// because "which way does heading 90 point" is the first thing an author needs and the last thing they
// should have to find in a source file.
Comment string `json:"_comment,omitempty"`
// Image is the layer's file name, resolved beside the legend. The manifest may name one instead.
Image string `json:"image"`
// WarnDistance is how far, in RGB, a sampled pixel may sit from the nearest plate before the run says so.
// It exists for the same reason the class template's does: a JPEG bleeds several units of each channel
// across a painted edge, and a silent mismatch is a plate boundary in the wrong place.
WarnDistance float64 `json:"warn_distance"`
Plates []PaintPlate `json:"plates"`
}
// PaintPlate is one painted plate: a colour, and how that piece of lithosphere is moving.
type PaintPlate struct {
Name string `json:"name"`
// RGB is the colour on the layer. Every sampled pixel becomes the *nearest* plate in RGB, because on a
// tectonic layer every pixel has to be some plate - the same rule the class template uses, and the
// opposite of the overlay's, where most of the image is deliberately nothing.
RGB [3]int `json:"rgb"`
// SpeedCmYr and HeadingDeg are the plate's drift. The heading is a compass bearing over the map: 0 points
// at the top of the image, 90 to the right, 180 to the bottom. Earth's plates run 1 to 10 cm/yr, and what
// matters at a margin is the *difference* between two of these, so two plates both drifting east at 4 are
// a boundary doing nothing at all.
SpeedCmYr float64 `json:"speed_cm_yr"`
HeadingDeg float64 `json:"heading_deg"`
// SpinDegMyr turns the plate about its own centre, in degrees per million years, positive clockwise on
// the map.
//
// It is worth setting on at least one plate. A planet of plates that only drift has margins that are the
// same all the way along, because the relative velocity is then one constant vector and the only thing
// that varies is where the contact happens to point. A little spin is what makes one end of a margin
// collide while the other slides - which is the Anatolia case, and the most useful thing a tectonic map
// can give a fault set.
SpinDegMyr float64 `json:"spin_deg_myr"`
// Continental overrides what the painting says. Left out - which is the usual case - a plate is
// continental when enough of its painted area is land, so the template decides and the two paintings
// cannot contradict each other. Set it when they should: an oceanic plate carrying a chain of islands, or
// a continental fragment currently underwater.
Continental *bool `json:"continental,omitempty"`
}
// rgbOneLine finds an indented colour triple so MarshalLegend can put it back on one line.
var rgbOneLine = regexp.MustCompile(`"rgb": \[\s*(\d+),\s*(\d+),\s*(\d+)\s*\]`)
// MarshalLegend writes a legend as JSON somebody will want to edit.
//
// json.MarshalIndent puts every colour on five lines, because Indent reformats every array whatever a custom
// marshaller does, and a seven-plate legend then runs to ninety lines of mostly punctuation. Putting the
// triples back on one line each is cosmetic and it is worth the ten lines: this file is meant to be opened
// and changed by hand, beside the painting, and a legend nobody can read at a glance is a legend nobody
// keeps in step with the picture.
func MarshalLegend(lg *PaintLegend) ([]byte, error) {
data, err := json.MarshalIndent(lg, "", " ")
if err != nil {
return nil, err
}
return append(rgbOneLine.ReplaceAll(data, []byte(`"rgb": [$1, $2, $3]`)), '\n'), nil
}
// LoadPaintLegend reads a tectonic layer's legend.
func LoadPaintLegend(path string) (*PaintLegend, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var lg PaintLegend
if err := json.Unmarshal(data, &lg); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
if err := lg.validate(path); err != nil {
return nil, err
}
return &lg, nil
}
func (l *PaintLegend) validate(path string) error {
if len(l.Plates) < 2 {
return fmt.Errorf("%s: %d plate(s); a planet in one plate has no boundaries", path, len(l.Plates))
}
if l.WarnDistance <= 0 {
l.WarnDistance = 60
}
seen := map[[3]int]string{}
for i := range l.Plates {
p := &l.Plates[i]
if p.Name == "" {
return fmt.Errorf("%s: plate %d has no name", path, i)
}
for c := range 3 {
if p.RGB[c] < 0 || p.RGB[c] > 255 {
return fmt.Errorf("%s: plate %q has rgb %v", path, p.Name, p.RGB)
}
}
if prev, dup := seen[p.RGB]; dup {
return fmt.Errorf("%s: plates %q and %q are both rgb %v; a colour is one plate",
path, prev, p.Name, p.RGB)
}
seen[p.RGB] = p.Name
if p.SpeedCmYr < 0 {
return fmt.Errorf("%s: plate %q moves at %v cm/yr; speed is a magnitude and the heading is "+
"where it points", path, p.Name, p.SpeedCmYr)
}
}
return nil
}
// PaintMatch is how well the painting matched the legend, reported the way the class template's match is: a
// layer whose colours have drifted is a tectonic model quietly built on the wrong plates.
type PaintMatch struct {
Cells int `json:"cells"`
Far int `json:"far"`
MaxDistance float64 `json:"max_distance"`
}
// FromPainting builds a tectonic model from a painted layer instead of from a seed.
//
// px is the layer decoded to RGB triples, pw by ph. Decoding happens in the caller so that this package keeps
// knowing nothing about files or image formats - the same reason land is a callback.
func FromPainting(p world.Planet, cfg Config, lg *PaintLegend, px []uint8, pw, ph int,
land func(xM, yM float64) bool) (*Model, PaintMatch, error) {
var match PaintMatch
if lg == nil || len(lg.Plates) < 2 {
return nil, match, fmt.Errorf("a tectonic layer needs at least two plates")
}
if pw <= 0 || ph <= 0 || len(px) < pw*ph*3 {
return nil, match, fmt.Errorf("the tectonic layer is %dx%d with %d bytes", pw, ph, len(px))
}
cfg = cfg.withDefaults()
circ := p.CircumferenceM()
gw := int(circ/cfg.ResolutionM + 0.5)
if gw < 8 {
gw = 8
}
gcell := circ / float64(gw)
gh := int(float64(p.H)*p.CellM/gcell + 0.5)
if gh < 2 {
gh = 2
}
m := &Model{P: p, Cfg: cfg, GW: gw, GH: gh, GCellM: gcell, Cell: make([]int16, gw*gh)}
m.Plates = make([]Plate, len(lg.Plates))
for i := range m.Plates {
m.Plates[i] = Plate{ID: i, Weight: 1}
}
heightM := p.HeightM()
for gy := range gh {
yM := m.GridYM(gy)
// The painted map covers 0..heightM; the polar pad above and below it takes the nearest painted row.
v := clamp01(yM / heightM)
py := int(v * float64(ph-1))
row := gy * gw
for gx := range gw {
xM := m.GridXM(gx)
pxi := int(xM / circ * float64(pw))
if pxi >= pw {
pxi = pw - 1
}
o := (py*pw + pxi) * 3
id, dist := nearestPlate(lg.Plates, px[o], px[o+1], px[o+2])
m.Cell[row+gx] = int16(id)
match.Cells++
if dist > lg.WarnDistance {
match.Far++
}
if dist > match.MaxDistance {
match.MaxDistance = dist
}
}
}
for i := range m.Plates {
pl := &m.Plates[i]
src := lg.Plates[i]
// Compass bearing over the map: 0 points at the top of the image, which is -Y, and 90 to the right.
speed := src.SpeedCmYr / 100
bearing := src.HeadingDeg * math.Pi / 180
pl.TransXM = speed * math.Sin(bearing)
pl.TransYM = -speed * math.Cos(bearing)
// Positive spin is clockwise on the map: with Y running down the image, v = T + omega x r sends the
// point east of the centre southwards. The centre itself comes from measure, below.
pl.OmegaRadYr = src.SpinDegMyr * math.Pi / 180 / 1e6
}
// measure fills in the area, the land fraction and the centre of each plate - and the centre is the pole
// every one of them turns about, so nothing has a usable velocity field until this has run.
m.measure(land)
for i := range m.Plates {
// A painted plate has no Voronoi site. Its centre of area is the only position it has, and it is what
// the map and the reports point at.
m.Plates[i].SiteXM = m.Plates[i].CentroidXM
m.Plates[i].SiteYM = m.Plates[i].CentroidYM
}
// The painting has the last word where it asks for one, after measure has read the template's land.
for i := range m.Plates {
if c := lg.Plates[i].Continental; c != nil {
m.Plates[i].Continental = *c
}
}
m.Boundaries = m.buildBoundaries()
return m, match, nil
}
// nearestPlate is the legend entry closest to a colour, and how far away it was.
//
// Nearest rather than exact, and unlike the overlay there is no "no plate" answer: every pixel of a tectonic
// layer is some piece of lithosphere, so a colour that matches nothing is a painting mistake to report rather
// than a hole to leave. WarnDistance is what reports it.
func nearestPlate(ps []PaintPlate, r, g, b uint8) (id int, dist float64) {
best, bestID := math.Inf(1), 0
for i := range ps {
dr := float64(int(r) - ps[i].RGB[0])
dg := float64(int(g) - ps[i].RGB[1])
db := float64(int(b) - ps[i].RGB[2])
if d := dr*dr + dg*dg + db*db; d < best {
best, bestID = d, i
}
}
return bestID, math.Sqrt(best)
}
func clamp01(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
// Propose turns a generated model into a painting and a legend to start from.
//
// An author should not face a blank canvas for this. Seven plates with plausible motions is a minute's work
// for the Voronoi and an afternoon's by hand, and what an author actually wants to do is move two of them and
// change a heading - which is editing, not authoring from nothing.
//
// The returned pixels are the layer at the given width, and the legend has one entry per plate carrying the
// motion the generator drew. Writing them out is the caller's job.
func (m *Model) Propose(width int) (px []uint8, w, h int, lg *PaintLegend) {
if width < 64 {
width = 64
}
h = int(float64(width) * m.P.HeightM() / m.P.CircumferenceM())
if h < 1 {
h = 1
}
w = width
lg = &PaintLegend{
Comment: "A painted tectonic layer: one colour per plate, and how that plate is moving. " +
"heading_deg is a compass bearing over the map - 0 points at the top of the image, 90 to the " +
"right, 180 to the bottom. speed_cm_yr is drift; what happens at a margin is the difference " +
"between the two plates either side of it, so two plates drifting the same way are a boundary " +
"doing nothing. spin_deg_myr turns a plate about its own centre, positive clockwise, and it is " +
"worth setting on at least one: without it every margin is the same all the way along, and " +
"with it one end collides while the other slides. Paint the plates, not the mountains - where " +
"two of these meet, the collision, the belt and its faults are worked out from the motions. " +
"Repaint the blobs freely; only the colours have to keep matching this file.",
WarnDistance: 60,
Plates: make([]PaintPlate, len(m.Plates)),
}
colours := make([][3]uint8, len(m.Plates))
for i := range m.Plates {
pl := &m.Plates[i]
// Hues walked by the golden ratio, so that neighbouring ids are not neighbouring colours and an
// author can tell two touching plates apart at a glance.
c := hsvBytes(math.Mod(float64(i)*0.61803398875, 1)*360, 0.62, 0.86)
colours[i] = c
speed := math.Hypot(pl.TransXM, pl.TransYM) * 100 // m/yr to cm/yr
// Back to a compass bearing: 0 at the top of the image, 90 to the right.
bearing := math.Atan2(pl.TransXM, -pl.TransYM) * 180 / math.Pi
if bearing < 0 {
bearing += 360
}
lg.Plates[i] = PaintPlate{
Name: fmt.Sprintf("plate_%d", i),
RGB: [3]int{int(c[0]), int(c[1]), int(c[2])},
SpeedCmYr: math.Round(speed*10) / 10,
HeadingDeg: math.Round(bearing),
SpinDegMyr: math.Round(pl.OmegaRadYr*180/math.Pi*1e6*100) / 100,
}
}
px = make([]uint8, w*h*3)
for y := range h {
yM := m.P.HeightM() * (float64(y) + 0.5) / float64(h)
for x := range w {
xM := m.P.CircumferenceM() * (float64(x) + 0.5) / float64(w)
c := colours[m.PlateAt(xM, yM)]
o := (y*w + x) * 3
px[o], px[o+1], px[o+2] = c[0], c[1], c[2]
}
}
return px, w, h, lg
}
// hsvBytes is a hue in degrees, saturation and value in 0..1, as an RGB triple.
func hsvBytes(hue, sat, val float64) [3]uint8 {
hue = math.Mod(math.Mod(hue, 360)+360, 360) / 60
i := math.Floor(hue)
f := hue - i
p := val * (1 - sat)
q := val * (1 - sat*f)
t := val * (1 - sat*(1-f))
var r, g, b float64
switch int(i) % 6 {
case 0:
r, g, b = val, t, p
case 1:
r, g, b = q, val, p
case 2:
r, g, b = p, val, t
case 3:
r, g, b = p, q, val
case 4:
r, g, b = t, p, val
default:
r, g, b = val, p, q
}
return [3]uint8{byte(r*255 + 0.5), byte(g*255 + 0.5), byte(b*255 + 0.5)}
}
+221
View File
@@ -0,0 +1,221 @@
package plates
import (
"math"
"testing"
)
// A painted layer and its legend, built by hand: two plates split at a quarter and three quarters of the way
// round, driven into each other along X with no spin.
func paintedStripes(w, h int) ([]uint8, *PaintLegend) {
lg := &PaintLegend{
WarnDistance: 60,
Plates: []PaintPlate{
{Name: "west", RGB: [3]int{200, 60, 60}, SpeedCmYr: 2, HeadingDeg: 90}, // due east
{Name: "east", RGB: [3]int{60, 60, 200}, SpeedCmYr: 2, HeadingDeg: 270}, // due west
},
}
px := make([]uint8, w*h*3)
for y := range h {
for x := range w {
id := 0
if x >= w/4 && x < 3*w/4 {
id = 1
}
o := (y*w + x) * 3
c := lg.Plates[id].RGB
px[o], px[o+1], px[o+2] = uint8(c[0]), uint8(c[1]), uint8(c[2])
}
}
return px, lg
}
func TestAPaintedLayerBecomesAPlanet(t *testing.T) {
p := testPlanet(t)
px, lg := paintedStripes(400, 200)
m, match, err := FromPainting(p, Default(), lg, px, 400, 200, allLandAt)
if err != nil {
t.Fatalf("from painting: %v", err)
}
if match.Far != 0 {
t.Errorf("%d of %d sampled cells did not match a plate colour", match.Far, match.Cells)
}
if len(m.Plates) != 2 {
t.Fatalf("%d plates from a two-colour legend", len(m.Plates))
}
if len(m.Boundaries) != 2 {
t.Fatalf("%d boundaries; two stripes on a cylinder make two contacts", len(m.Boundaries))
}
// The same invariant the generated path has: with a pure translation one margin closes and the other
// opens, by the same amount. Two plates at 2 cm/yr closing head-on give 4 cm/yr.
means := sortedMeans(m.Boundaries)
if means[0] >= 0 || means[1] <= 0 {
t.Fatalf("closing rates %.4g and %.4g; one of each is the only arrangement possible", means[0], means[1])
}
if got := math.Abs(means[1]); math.Abs(got-0.04) > 1e-3 {
t.Errorf("painted plates at 2 cm/yr each close at %.4g m/yr, want 0.04", got)
}
// Both painted as land, so both are continental and the closing margin is a collision.
if got, want := kinds(m.Boundaries), []string{"collision", "rift"}; !sameStrings(got, want) {
t.Errorf("got %v, want %v", got, want)
}
}
func allLandAt(xM, yM float64) bool { return true }
func TestAHeadingIsACompassBearing(t *testing.T) {
p := testPlanet(t)
px, lg := paintedStripes(400, 200)
// 0 points at the top of the image, which is -Y; 90 to the right, which is +X.
lg.Plates[0].HeadingDeg = 0
lg.Plates[1].HeadingDeg = 90
m, _, err := FromPainting(p, Default(), lg, px, 400, 200, allLandAt)
if err != nil {
t.Fatalf("from painting: %v", err)
}
north, east := m.Plates[0], m.Plates[1]
if math.Abs(north.TransXM) > 1e-9 || north.TransYM >= 0 {
t.Errorf("heading 0 gives (%.4g, %.4g); it should point at the top of the map",
north.TransXM, north.TransYM)
}
if math.Abs(east.TransYM) > 1e-9 || east.TransXM <= 0 {
t.Errorf("heading 90 gives (%.4g, %.4g); it should point to the right of the map",
east.TransXM, east.TransYM)
}
if got := math.Hypot(east.TransXM, east.TransYM); math.Abs(got-0.02) > 1e-9 {
t.Errorf("2 cm/yr came out as %.4g m/yr", got)
}
}
func TestThePaintingCanOverruleTheLandMask(t *testing.T) {
p := testPlanet(t)
px, lg := paintedStripes(400, 200)
// Every cell is land, so both plates are continental and the closing margin is a collision.
m, _, err := FromPainting(p, Default(), lg, px, 400, 200, allLandAt)
if err != nil {
t.Fatalf("from painting: %v", err)
}
if !m.Plates[0].Continental || !m.Plates[1].Continental {
t.Fatal("a planet of land has an oceanic plate on it")
}
// The legend says otherwise about one of them, and a legend that bothers to say so wins.
oceanic := false
lg.Plates[1].Continental = &oceanic
m, _, err = FromPainting(p, Default(), lg, px, 400, 200, allLandAt)
if err != nil {
t.Fatalf("from painting: %v", err)
}
if m.Plates[1].Continental {
t.Error("the legend called plate 1 oceanic and the land mask overruled it")
}
// And the consequence is the point of the override: the same margin is now a subduction zone.
if got, want := kinds(m.Boundaries), []string{"ridge", "subduction"}; !sameStrings(got, want) {
t.Errorf("got %v, want %v", got, want)
}
}
func TestACentroidIsMeasuredTheShortWayRound(t *testing.T) {
p := testPlanet(t)
const w, h = 400, 200
lg := &PaintLegend{
WarnDistance: 60,
Plates: []PaintPlate{
{Name: "seam", RGB: [3]int{200, 60, 60}, SpeedCmYr: 2, HeadingDeg: 90},
{Name: "rest", RGB: [3]int{60, 60, 200}, SpeedCmYr: 2, HeadingDeg: 270},
},
}
// Plate 0 is painted across the meridian: the left eighth and the right eighth of the image. Its centre
// is the seam, and an arithmetic mean of those columns would put it on the far side of the planet - and
// with it the pole it spins about.
px := make([]uint8, w*h*3)
for y := range h {
for x := range w {
id := 1
if x < w/8 || x >= 7*w/8 {
id = 0
}
o := (y*w + x) * 3
c := lg.Plates[id].RGB
px[o], px[o+1], px[o+2] = uint8(c[0]), uint8(c[1]), uint8(c[2])
}
}
m, _, err := FromPainting(p, Default(), lg, px, w, h, allLandAt)
if err != nil {
t.Fatalf("from painting: %v", err)
}
circ := p.CircumferenceM()
got := m.Plates[0].SiteXM
// Near the meridian, measured the short way round: either just above 0 or just below the circumference.
if d := math.Abs(wrapDelta(got, circ)); d > circ/16 {
t.Errorf("the seam-straddling plate's centre is at %.0f m of %.0f; it should be near the meridian, "+
"and the arithmetic mean would have put it near %.0f", got, circ, circ/2)
}
}
func TestAProposalReadsBackAsTheSamePlanet(t *testing.T) {
p := testPlanet(t)
cfg := Default()
cfg.Count = 6
land := func(xM, yM float64) bool { return yM > 4000 && yM < 14000 }
made, err := Build(p, 3630, cfg, land)
if err != nil {
t.Fatalf("build: %v", err)
}
// The round trip is what makes Propose worth having: what it writes has to be a layer that comes back as
// the planet it was written from, or an author's first edit starts from something that was never true.
px, w, h, lg := made.Propose(1600)
read, match, err := FromPainting(p, cfg, lg, px, w, h, land)
if err != nil {
t.Fatalf("read back: %v", err)
}
if match.Far != 0 {
t.Errorf("%d of %d cells of its own proposal did not match its own legend", match.Far, match.Cells)
}
if len(read.Plates) != len(made.Plates) {
t.Fatalf("%d plates written, %d read back", len(made.Plates), len(read.Plates))
}
// The motions survive the trip through the legend's cm/yr and degrees. Rounded when written - a tenth of
// a cm/yr and a whole degree - so the tolerance is the rounding, not a fudge.
for i := range made.Plates {
a, b := made.Plates[i], read.Plates[i]
if d := math.Hypot(a.TransXM-b.TransXM, a.TransYM-b.TransYM); d > 0.0006 {
t.Errorf("plate %d drifts %.5g m/yr differently after the round trip", i, d)
}
if a.Continental != b.Continental {
t.Errorf("plate %d was %v continental and reads back %v", i, a.Continental, b.Continental)
}
}
// And the tectonics: the same margins doing the same things. Not vertex-for-vertex - the proposal is a
// raster at 1600 px and the model was traced at the tectonic grid - but the same boundaries by count and
// by what each one is.
if len(read.Boundaries) != len(made.Boundaries) {
t.Errorf("%d boundaries written, %d read back", len(made.Boundaries), len(read.Boundaries))
}
if got, want := kinds(read.Boundaries), kinds(made.Boundaries); !sameStrings(got, want) {
t.Errorf("margins read back as %v, were %v", got, want)
}
}
func TestALegendThatCannotBeAPlanetIsRefused(t *testing.T) {
one := &PaintLegend{Plates: []PaintPlate{{Name: "only", RGB: [3]int{1, 2, 3}}}}
if err := one.validate("test"); err == nil {
t.Error("a planet in one plate was accepted; it has no boundaries")
}
dup := &PaintLegend{Plates: []PaintPlate{
{Name: "a", RGB: [3]int{1, 2, 3}},
{Name: "b", RGB: [3]int{1, 2, 3}},
}}
if err := dup.validate("test"); err == nil {
t.Error("two plates sharing a colour were accepted; a colour is one plate")
}
}
+543
View File
@@ -0,0 +1,543 @@
// Package plates is the tectonics a painted planet does not draw: which rigid pieces the lithosphere is in,
// how they move, and therefore where they are colliding.
//
// It exists because of one row in Docs/Terrain.md's pass table. Pass 1 writes `uplift` *and* `boundaries`,
// and pass 3 - faults - reads `boundaries`. D-53 dropped passes 1 to 4 on the painted path, because a
// painted template is already a statement about where the ranges are. What went out with them was the
// boundary set, and `uplift/painted_faults.go` substituted a noise grain field for it - a field that has
// never been told where a belt is. That substitution is visible in `map_uplift.png`: cyan traces striking
// across the bright belts at angles unrelated to them, the densest set sitting in a lowland, and several
// walking out over open ocean.
//
// The correction is not a better grain field. A range and its faults are not two things, one decorating the
// other: they are both consequences of the same convergence, and the line they are consequences of is the
// plate boundary. So the boundary is what gets built first, and the uplift and the faults are both read off
// it.
//
// **The plates live on the cylinder, not on a sphere.** A real plate moves by rotating about an Euler pole
// through the centre of the planet, and the velocity that produces varies along a boundary - which is the
// reason one margin is a head-on collision at one end and a strike-slip fault at the other. That variation
// is worth having; the sphere is not. Every other pass here measures distance in flat metres on a cylinder
// of fixed circumference with an 8 m cell that never varies (D-48), so a pass that believed in a sphere
// would be the only one whose distances disagreed with the solve's, and its velocities would converge at
// poles nothing else knows are there. The compromise keeps the property and drops the geometry: a plate's
// motion is a translation plus a rotation about a pole **in the map plane**,
//
// v(x) = T + omega x (x - pole)
//
// which is the two-dimensional analogue and varies along a boundary for the same reason.
//
// **What the painting still owns.** Whether a plate is continental is read from the land mask rather than
// drawn from the seed: a plate covering the author's continent *is* a continental plate. That is the one
// place the painting feeds the model rather than competing with it, and it is what makes an ocean-continent
// margin land where an author would expect a subduction zone.
//
// **The tectonic grid is its own, and coarse.** The partition is rasterised at a few hundred metres rather
// than at the 8 m geology cell. A plate boundary belt is tens of kilometres wide and the finest thing read
// off the line is a fault trace, so a quarter-kilometre lattice is already finer than anything downstream
// can use, and it makes the whole pass a few million operations instead of a few hundred million. What
// leaves this package is polylines in **world metres**, which is the same form `uplift.FaultTrace` already
// travels in and for the same reason: a region filters the planet's set to what reaches its own frame, so a
// boundary crossing a region edge is one boundary and two decompositions agree.
package plates
import (
"fmt"
"math"
"salty/terrain/internal/noise"
"salty/terrain/internal/world"
)
// Pass indices for this package's seeded streams. They sit above the detail passes' 40s so that adding one
// here cannot reshuffle any existing field.
const (
srcSites = 50
srcMotion = 51
srcWarp = 52
)
// Config is what the manifest asks for. Every zero field takes a default from withDefaults.
type Config struct {
// Layer and Legend are the painted tectonic layer: an image where a colour is a plate, and a legend
// saying how each one moves. Naming them is what turns the plates from something the seed invents into
// something an author draws, and it is the intended way to use this package - see paint.go.
//
// With no layer the plates come from Count and the seed, which is a Voronoi partition that knows nothing
// about where the continents are. That mode's real job is Propose: it writes a first painting, which the
// author then edits.
Layer string `json:"layer"`
Legend string `json:"legend"`
// Count is how many plates the lithosphere is in. Earth has seven or eight majors and a couple of dozen
// minors; what matters here is that a boundary has to have room to be a mountain belt, so the useful
// range on a hundred-kilometre planet is single digits.
Count int `json:"count"`
// SizeSpread is the ratio between the largest and smallest plate weight. The partition is a
// multiplicatively weighted Voronoi, so a heavier site claims ground further away: 1 makes every plate
// the same size, which is the one thing real plates never are.
SizeSpread float64 `json:"size_spread"`
// VelocityCmYr is how fast a plate moves, low to high. Earth runs 1 to 10; the number that matters
// downstream is the *relative* speed across a boundary, which is a difference of two of these.
VelocityCmYr [2]float64 `json:"velocity_cm_yr"`
// SpinFraction is how much of a plate's speed is rotation about its own centre rather than translation.
// Zero makes every margin uniform along its length, which is the defect the in-plane pole exists to
// avoid; one makes the plate a pinwheel. A third of it is enough to turn a collision into a transform
// over a few tens of kilometres.
//
// A pointer because zero is a real answer here and so is "say nothing". JSON cannot tell an absent
// number from a zero one, and a plain float64 read them as the same thing - which is how a proposal came
// out with every plate's spin at zero and every margin uniform, the one defect this field exists to
// prevent. Absent takes the default; an explicit 0 means none.
SpinFraction *float64 `json:"spin_fraction"`
// WarpFraction is how far a boundary wanders from the straight Voronoi edge, as a fraction of the mean
// plate spacing. Without it the partition is a polygon net and every margin is a ruled line.
//
// It is applied over two octaves, and that is not decoration either: one octave at the plate wavelength
// gives a margin one long shallow bend, which at planet scale is still a ruled line with a kink in it.
// The second octave at a third of the wavelength is what puts a promontory and a re-entrant into a
// margin, and those are where a collision belt gets its along-strike segmentation from.
WarpFraction float64 `json:"warp_fraction"`
// ResolutionM is the tectonic grid's cell. See the package comment: coarse on purpose.
ResolutionM float64 `json:"resolution_m"`
// ContinentalFraction is the share of a plate's painted area that has to be land before it counts as
// continental. Well below a half, because a continental plate carries a shelf and a passive margin as
// well as its continent.
ContinentalFraction float64 `json:"continental_fraction"`
// ObliqueDeg is where a margin stops being convergent or divergent and becomes transform: the angle
// between the relative velocity and the boundary normal, past which the strike-slip component is the
// one in charge. 60 degrees means a margin stays convergent until the slip is over 1.7 times the
// closing.
ObliqueDeg float64 `json:"oblique_deg"`
// Faults is the deformation zone around every margin: how wide it is and how densely it is broken. A
// zero block means the margins carry no faults of their own, which is what every painted planet had
// before it existed - the legend's per-class `faults` blocks are a separate, and now secondary, set.
Faults Belt `json:"faults"`
}
// Default is the configuration a manifest that says nothing gets.
func Default() Config {
return Config{
Count: 7,
SizeSpread: 1.7,
VelocityCmYr: [2]float64{1, 6},
SpinFraction: nil, // see Spin(); the default lives there so that an explicit 0 can mean none
WarpFraction: 0.34,
ResolutionM: 250,
ContinentalFraction: 0.18,
ObliqueDeg: 60,
}
}
// DefaultSpinFraction is what a config that does not mention spin gets. It is not zero on purpose: a planet
// of plates that only drift has margins identical along their whole length, and the along-strike change from
// collision to transform is the most useful thing the model gives a fault set.
const DefaultSpinFraction = 0.35
// Spin is the configured spin fraction, or the default when the manifest said nothing. An explicit zero is
// honoured and means no rotation at all.
func (c Config) Spin() float64 {
if c.SpinFraction == nil {
return DefaultSpinFraction
}
if *c.SpinFraction < 0 {
return 0
}
return *c.SpinFraction
}
func (c Config) withDefaults() Config {
d := Default()
if c.Count <= 0 {
c.Count = d.Count
}
if c.SizeSpread < 1 {
c.SizeSpread = d.SizeSpread
}
if c.VelocityCmYr[1] <= 0 {
c.VelocityCmYr = d.VelocityCmYr
}
if c.VelocityCmYr[0] < 0 {
c.VelocityCmYr[0] = 0
}
if c.WarpFraction < 0 {
c.WarpFraction = d.WarpFraction
}
if c.ResolutionM <= 0 {
c.ResolutionM = d.ResolutionM
}
if c.ContinentalFraction <= 0 {
c.ContinentalFraction = d.ContinentalFraction
}
if c.ObliqueDeg <= 0 || c.ObliqueDeg >= 90 {
c.ObliqueDeg = d.ObliqueDeg
}
return c
}
// Plate is one rigid piece of the lithosphere.
type Plate struct {
ID int `json:"id"`
// SiteXM, SiteYM is the Voronoi site in world metres, and Weight is what makes plates different sizes.
SiteXM float64 `json:"site_x_m"`
SiteYM float64 `json:"site_y_m"`
Weight float64 `json:"weight"`
// CentroidXM, CentroidYM is the plate's centre of area, measured the short way round the cylinder. It is
// where the plate turns about, and it is the one position a painted plate has - a painting has no site.
CentroidXM float64 `json:"centroid_x_m"`
CentroidYM float64 `json:"centroid_y_m"`
// The motion, in metres a year: a translation plus a rotation about a pole in the map plane. The pole is
// always the centroid; it is stored rather than derived so that VelocityAt needs nothing but the plate.
TransXM float64 `json:"trans_x_m_yr"`
TransYM float64 `json:"trans_y_m_yr"`
PoleXM float64 `json:"pole_x_m"`
PoleYM float64 `json:"pole_y_m"`
OmegaRadYr float64 `json:"omega_rad_yr"`
// Continental is read from the painting rather than drawn from the seed: see the package comment.
Continental bool `json:"continental"`
LandFraction float64 `json:"land_fraction"`
// AreaCells is the plate's size on the tectonic grid, which is what decides who overrides whom when two
// oceanic plates converge.
AreaCells int `json:"area_cells"`
}
// VelocityAt is the plate's motion at a world point, in metres a year.
//
// The lever arm is measured the short way round the cylinder. Without that a plate whose pole sits just east
// of the seam would spin the wrong way for every point just west of it, and the boundary running through the
// seam would be classified as convergent on one side and divergent on the other - the one bug this whole
// coordinate system exists to make impossible.
func (pl Plate) VelocityAt(p world.Planet, xM, yM float64) (vx, vy float64) {
rx := wrapDelta(xM-pl.PoleXM, p.CircumferenceM())
ry := yM - pl.PoleYM
return pl.TransXM - pl.OmegaRadYr*ry, pl.TransYM + pl.OmegaRadYr*rx
}
// SpeedMYr is how fast the plate is going at its own site, which is the number worth printing.
func (pl Plate) SpeedMYr(p world.Planet) float64 {
vx, vy := pl.VelocityAt(p, pl.SiteXM, pl.SiteYM)
return math.Hypot(vx, vy)
}
// Model is a planet's tectonics: the plates, the grid they were rasterised on, and the boundaries between
// them.
type Model struct {
P world.Planet `json:"-"`
Cfg Config `json:"config"`
Plates []Plate `json:"plates"`
// The tectonic grid. GCellM is derived rather than taken: it is the circumference divided by a whole
// number of columns, so the grid wraps exactly and a boundary crossing the seam is an ordinary one.
GW int `json:"-"`
GH int `json:"-"`
GCellM float64 `json:"grid_cell_m"`
// Cell is the plate id at every tectonic cell, row-major, X cyclic.
Cell []int16 `json:"-"`
// Boundaries is the whole planet's set, in world metres.
Boundaries []Boundary `json:"boundaries"`
}
// GridXM and GridYM are the world position of a tectonic cell's centre. Y runs from the top of the polar
// pad, so a grid row and a planet row mean the same place.
func (m *Model) GridXM(gx int) float64 { return (float64(gx) + 0.5) * m.GCellM }
func (m *Model) GridYM(gy int) float64 { return m.P.YM(0) + (float64(gy)+0.5)*m.GCellM }
// GridIdx wraps X and clamps Y, the same way world.Planet.Idx does.
func (m *Model) GridIdx(gx, gy int) int {
gx = ((gx % m.GW) + m.GW) % m.GW
if gy < 0 {
gy = 0
} else if gy >= m.GH {
gy = m.GH - 1
}
return gy*m.GW + gx
}
// PlateAt is which plate owns a world position.
func (m *Model) PlateAt(xM, yM float64) int {
gx := int(math.Floor(xM / m.GCellM))
gy := int(math.Floor((yM - m.P.YM(0)) / m.GCellM))
return int(m.Cell[m.GridIdx(gx, gy)])
}
// Build draws a planet's plates and the boundaries between them, once, deterministically from the seed.
//
// land reports whether a world position is painted land. It is a callback rather than a raster so that this
// package knows nothing about templates: what it needs from the painting is one bit, and asking for it this
// way also means the caller decides how the land mask is sampled.
func Build(p world.Planet, seed int64, cfg Config, land func(xM, yM float64) bool) (*Model, error) {
cfg = cfg.withDefaults()
if cfg.Count < 2 {
return nil, fmt.Errorf("a planet in %d plate(s) has no boundaries", cfg.Count)
}
circ := p.CircumferenceM()
gw := int(circ/cfg.ResolutionM + 0.5)
if gw < cfg.Count*4 {
gw = cfg.Count * 4
}
gcell := circ / float64(gw)
gh := int(float64(p.H)*p.CellM/gcell + 0.5)
if gh < 2 {
gh = 2
}
m := &Model{P: p, Cfg: cfg, GW: gw, GH: gh, GCellM: gcell}
m.Plates = placeSites(p, seed, cfg, gh, gcell)
giveMotion(p, seed, cfg, m.Plates)
m.Cell = partition(m, seed)
m.measure(land)
m.Boundaries = m.buildBoundaries()
return m, nil
}
// warpFineGain is how strong the second warp octave is against the first. Held well below a half: past that
// the displacement folds back on itself and the partition grows islands of one plate inside another, which
// is a nonsense the boundary tracer would faithfully chain into a ring.
const warpFineGain = 0.4
// spacingM is the mean distance between neighbouring sites: the length every other length in this package is
// a fraction of.
func spacingM(circ, heightM float64, count int) float64 {
return math.Sqrt(circ * heightM / float64(count))
}
// placeSites scatters the plate centres, refusing any that lands on top of another.
//
// Rejection rather than relaxation. Lloyd's algorithm would give an even, hexagonal net, which is a worse
// answer than this one: plates are not even, and the interesting boundary geometry - a small plate wedged
// between two large ones, a long thin one - comes from exactly the irregularity relaxation removes. The
// minimum separation is only there to stop two sites coinciding, which produces a sliver no boundary tracer
// can chain.
func placeSites(p world.Planet, seed int64, cfg Config, gh int, gcell float64) []Plate {
s := noise.NewSource(seed, srcSites)
circ := p.CircumferenceM()
heightM := float64(gh) * gcell
top := p.YM(0)
minSep := 0.5 * spacingM(circ, heightM, cfg.Count)
out := make([]Plate, 0, cfg.Count)
for len(out) < cfg.Count {
for try := 0; ; try++ {
x := s.Float() * circ
y := top + s.Float()*heightM
// After enough refusals the separation is the thing that is wrong, not the draw, so it is given
// up rather than looped on for ever - a count near the area's limit can have no valid position
// left at all.
if try < 64 && tooClose(out, p, x, y, minSep) {
continue
}
out = append(out, Plate{
ID: len(out),
SiteXM: x,
SiteYM: y,
Weight: 1 + (cfg.SizeSpread-1)*s.Float(),
})
break
}
}
return out
}
func tooClose(out []Plate, p world.Planet, x, y, minSep float64) bool {
circ := p.CircumferenceM()
for i := range out {
dx := wrapDelta(x-out[i].SiteXM, circ)
dy := y - out[i].SiteYM
if math.Hypot(dx, dy) < minSep {
return true
}
}
return false
}
// giveMotion draws each plate's translation and its spin.
//
// **Every plate turns about its own centre of area**, which measure fills in once the partition exists. An
// earlier version put the pole a plate-width off to one side, on the reasoning that a pole at the middle
// would cancel symmetrically and leave the margins as uniform as a pure translation does. That reasoning is
// wrong: the relative velocity at a contact is
//
// (T_a - T_b) + omega_a x (x - c_a) - omega_b x (x - c_b)
//
// which varies along the contact for any pole at all, and a pole at the centre puts the *largest* rotational
// contribution out at the margins, where it is wanted. The offset pole bought nothing and cost something
// real: it cannot be written into a painted legend, where an author says "this plate is also turning
// clockwise" and means about its own middle. A proposal therefore did not read back as the planet it was
// proposed from, which is what TestAProposalReadsBackAsTheSamePlanet caught.
func giveMotion(p world.Planet, seed int64, cfg Config, ps []Plate) {
s := noise.NewSource(seed, srcMotion)
circ := p.CircumferenceM()
spacing := spacingM(circ, p.HeightM(), cfg.Count)
for i := range ps {
speed := s.Range(cfg.VelocityCmYr[0], cfg.VelocityCmYr[1]) / 100 // cm/yr to m/yr
dir := s.Float() * 2 * math.Pi
ps[i].TransXM = math.Cos(dir) * speed
ps[i].TransYM = math.Sin(dir) * speed
// The spin is set so that the rotational speed one spacing from the pole is SpinFraction of the
// translation speed: the fraction means the same thing whatever the planet's size.
sign := 1.0
if s.Float() < 0.5 {
sign = -1
}
if spacing > 0 {
ps[i].OmegaRadYr = sign * cfg.Spin() * speed / spacing
}
}
}
// partition rasterises the plates onto the tectonic grid.
//
// A multiplicatively weighted Voronoi - nearest site by distance/weight - through a warped query point. The
// warp is what stops the result being a polygon net: it is sampled from a lattice in world coordinates, so
// two decompositions of the same planet warp the same point the same way, and its wavelength is deliberately
// long compared with the plate spacing, because a boundary that wiggled at a ten-kilometre wavelength would
// be a coastline rather than a plate margin.
func partition(m *Model, seed int64) []int16 {
p := m.P
circ := p.CircumferenceM()
spacing := spacingM(circ, p.HeightM(), m.Cfg.Count)
ws := noise.NewSource(seed, srcWarp)
// Two octaves. Both cell counts are whole numbers of the noise period, because noise.Lattice.Sample wraps
// modulo its own count and anything else is a discontinuity down one meridian.
coarse := int(p.NoisePeriodM/spacing + 0.5)
if coarse < 1 {
coarse = 1
}
fine := coarse * 3
wx := noise.NewLattice(coarse, ws)
wy := noise.NewLattice(coarse, ws)
fx := noise.NewLattice(fine, ws)
fy := noise.NewLattice(fine, ws)
amp := m.Cfg.WarpFraction * spacing
out := make([]int16, m.GW*m.GH)
for gy := 0; gy < m.GH; gy++ {
yM := m.GridYM(gy)
v := yM / p.NoisePeriodM * float64(coarse)
fv := yM / p.NoisePeriodM * float64(fine)
row := gy * m.GW
for gx := 0; gx < m.GW; gx++ {
xM := m.GridXM(gx)
u := xM / p.NoisePeriodM * float64(coarse)
fu := xM / p.NoisePeriodM * float64(fine)
qx := xM + ((float64(wx.Sample(u, v))*2-1)+(float64(fx.Sample(fu, fv))*2-1)*warpFineGain)*amp
qy := yM + ((float64(wy.Sample(u, v))*2-1)+(float64(fy.Sample(fu, fv))*2-1)*warpFineGain)*amp
best, bestID := math.Inf(1), 0
for i := range m.Plates {
pl := &m.Plates[i]
dx := wrapDelta(qx-pl.SiteXM, circ)
dy := qy - pl.SiteYM
d := math.Hypot(dx, dy) / pl.Weight
if d < best {
best, bestID = d, pl.ID
}
}
out[row+gx] = int16(bestID)
}
}
return out
}
// measure walks the partition once and fills in everything that can only be known after it exists: each
// plate's area, how much of it the author painted as land, and where its centre is.
//
// Painted rows only, for the land fraction. The polar pad is synthetic ocean that no class was ever painted
// on, so counting it would drag every plate that reaches a pole towards oceanic for a reason that is
// scaffolding rather than geography. The area and the centre do count the pad, because a plate really does
// extend over it.
//
// **The centre is a circular mean in X.** A plate painted or drawn across the meridian has cells at both ends
// of the raster, and an arithmetic mean of those columns puts its centre on the far side of the planet - and
// with it the pole the whole plate rotates about, which would make its velocity field nonsense and every
// margin around it wrong. This is the same rule as world.WrapX and Plate.VelocityAt's lever arm: the short
// way round is the only way round.
func (m *Model) measure(land func(xM, yM float64) bool) {
n := len(m.Plates)
landCells := make([]int, n)
paintedCells := make([]int, n)
sumSin := make([]float64, n)
sumCos := make([]float64, n)
sumY := make([]float64, n)
circ := m.P.CircumferenceM()
for gy := range m.GH {
yM := m.GridYM(gy)
painted := yM >= 0 && yM < m.P.HeightM()
row := gy * m.GW
for gx := range m.GW {
id := int(m.Cell[row+gx])
pl := &m.Plates[id]
pl.AreaCells++
xM := m.GridXM(gx)
ang := 2 * math.Pi * xM / circ
sumSin[id] += math.Sin(ang)
sumCos[id] += math.Cos(ang)
sumY[id] += yM
if !painted {
continue
}
paintedCells[id]++
if land != nil && land(xM, yM) {
landCells[id]++
}
}
}
for i := range m.Plates {
pl := &m.Plates[i]
if paintedCells[i] > 0 {
pl.LandFraction = float64(landCells[i]) / float64(paintedCells[i])
}
pl.Continental = pl.LandFraction >= m.Cfg.ContinentalFraction
if pl.AreaCells > 0 {
pl.CentroidXM, pl.CentroidYM = centroid(sumSin[i], sumCos[i], sumY[i], pl.AreaCells, circ)
}
// Every plate turns about its own centre of area. See giveMotion for why it is not somewhere else.
pl.PoleXM, pl.PoleYM = pl.CentroidXM, pl.CentroidYM
}
}
// centroid turns the accumulated sums into a position, taking X the short way round the cylinder.
func centroid(sumSin, sumCos, sumY float64, cells int, circ float64) (xM, yM float64) {
ang := math.Atan2(sumSin, sumCos)
if ang < 0 {
ang += 2 * math.Pi
}
return ang / (2 * math.Pi) * circ, sumY / float64(cells)
}
// wrapDelta brings a difference in X into -circ/2 .. +circ/2: the short way round the cylinder.
func wrapDelta(d, circ float64) float64 {
if circ <= 0 {
return d
}
d = math.Mod(d, circ)
if d > circ/2 {
d -= circ
} else if d < -circ/2 {
d += circ
}
return d
}
@@ -0,0 +1,489 @@
package plates
import (
"math"
"sort"
"testing"
"salty/terrain/internal/world"
)
// testPlanet is a small cylinder with the same shape of arithmetic as a real one: a whole number of columns
// and a noise period that divides the circumference.
func testPlanet(t *testing.T) world.Planet {
t.Helper()
p, err := world.New(40000, 8, 100, 50, 0, 40000)
if err != nil {
t.Fatalf("planet: %v", err)
}
return p
}
// handModel builds a Model with a raster set by the caller, so a test can ask about a boundary in a known
// place. Nothing here goes through Build: the point is to control the partition rather than the seed.
func handModel(t *testing.T, p world.Planet, gw, gh int, cell []int16, ps []Plate) *Model {
t.Helper()
return &Model{
P: p, Cfg: Default(), Plates: ps,
GW: gw, GH: gh, GCellM: p.CircumferenceM() / float64(gw),
Cell: cell,
}
}
// stripes paints two vertical bands: plate 1 from column lo up to hi, plate 0 everywhere else.
//
// That is **two** contacts, not one, and it is worth saying why every test here is written in pairs. A
// cylinder cut into two strips has a margin at each end of each strip, and under a pure translation the
// plates are closing at one of them and opening at the other by exactly the same amount. There is no way to
// arrange two plates on a cylinder that only collide. The invariant is the test.
func stripes(gw, gh, lo, hi int) []int16 {
cell := make([]int16, gw*gh)
for gy := range gh {
for gx := range gw {
if gx >= lo && gx < hi {
cell[gy*gw+gx] = 1
}
}
}
return cell
}
// rollX moves every column east by n, wrapping. Rolling the map is the whole seam test: the cylinder has no
// preferred meridian, so a partition and the same partition rolled must produce the same tectonics.
func rollX(cell []int16, gw, gh, n int) []int16 {
out := make([]int16, len(cell))
for gy := range gh {
for gx := range gw {
out[gy*gw+((gx+n)%gw)] = cell[gy*gw+gx]
}
}
return out
}
// closingPair drives plate 0 east and plate 1 west, with no spin.
func closingPair() []Plate {
return []Plate{
{ID: 0, TransXM: 0.02, Continental: true, AreaCells: 1},
{ID: 1, TransXM: -0.02, Continental: true, AreaCells: 1},
}
}
// meanClosing is a boundary's average closing rate, in metres a year.
func meanClosing(b Boundary) float64 {
if len(b.V) == 0 {
return 0
}
total := 0.0
for _, v := range b.V {
total += v.ClosingMYr
}
return total / float64(len(b.V))
}
// sortedMeans is every boundary's mean closing rate, in order: the signature of a whole planet's tectonics,
// independent of which order the boundaries happened to be found in.
func sortedMeans(bs []Boundary) []float64 {
out := make([]float64, len(bs))
for i, b := range bs {
out[i] = meanClosing(b)
}
sort.Float64s(out)
return out
}
func kinds(bs []Boundary) []string {
out := make([]string, len(bs))
for i, b := range bs {
out[i] = b.Dominant().String()
}
sort.Strings(out)
return out
}
func TestABoundaryAcrossTheSeamIsOneBoundary(t *testing.T) {
p := testPlanet(t)
const gw, gh = 200, 100
m := handModel(t, p, gw, gh, stripes(gw, gh, 50, 150), closingPair())
bs := m.buildBoundaries()
if len(bs) != 2 {
t.Fatalf("two vertical contacts on a cylinder, got %d boundaries", len(bs))
}
// Roll the partition so one contact sits exactly on the meridian. If the seam were special the boundary
// through it would come back cut in half - two chains of half the length - or with a whole circumference
// of jump in the middle of it.
rolled := handModel(t, p, gw, gh, rollX(m.Cell, gw, gh, 50), closingPair())
rbs := rolled.buildBoundaries()
if len(rbs) != 2 {
t.Fatalf("after rolling the map onto the seam: %d boundaries, want 2", len(rbs))
}
for _, b := range rbs {
for i := 0; i+1 < len(b.V); i++ {
d := math.Hypot(b.V[i+1].XM-b.V[i].XM, b.V[i+1].YM-b.V[i].YM)
if d > maxGapCells*m.GCellM*1.01 {
t.Fatalf("a %.0f m step between neighbouring vertices: X was wrapped, not unwrapped", d)
}
}
if got, want := b.LengthM(), bs[0].LengthM(); math.Abs(got-want) > m.GCellM {
t.Errorf("rolled boundary is %.0f m, unrolled %.0f m", got, want)
}
}
// The tectonics have to be the same planet, not just the same shape: the rolled map's margins close at
// the same rates as the unrolled map's.
before, after := sortedMeans(bs), sortedMeans(rbs)
for i := range before {
if math.Abs(before[i]-after[i]) > 1e-9 {
t.Errorf("closing rate %d is %.6g before the roll and %.6g after", i, before[i], after[i])
}
}
circ := p.CircumferenceM()
crossed := false
for _, b := range rbs {
for _, v := range b.V {
if v.XM < 0 || v.XM >= circ {
crossed = true
}
}
}
if !crossed {
t.Error("no vertex outside 0..circumference, so nothing was unwrapped and the roll tested nothing")
}
}
func TestClosingIsTheSameWhicheverPlateIsCalledA(t *testing.T) {
p := testPlanet(t)
const gw, gh = 200, 100
forward := handModel(t, p, gw, gh, stripes(gw, gh, 50, 150), closingPair())
// Swap which stripe belongs to which plate, and swap the motions with it. Physically nothing has moved:
// the same two materials are being driven together at the same margin. Every closing rate must therefore
// come back identical, because the normal flips and the relative velocity flips with it.
swapped := stripes(gw, gh, 50, 150)
for i := range swapped {
swapped[i] = 1 - swapped[i]
}
reverse := handModel(t, p, gw, gh, swapped, []Plate{
{ID: 0, TransXM: -0.02, Continental: true, AreaCells: 1},
{ID: 1, TransXM: 0.02, Continental: true, AreaCells: 1},
})
fm, rm := sortedMeans(forward.buildBoundaries()), sortedMeans(reverse.buildBoundaries())
if len(fm) != len(rm) {
t.Fatalf("%d boundaries one way round and %d the other", len(fm), len(rm))
}
for i := range fm {
if math.Abs(fm[i]-rm[i]) > 1e-9 {
t.Errorf("closing rate %d is %.6g one way round and %.6g the other; the sign convention is not "+
"symmetric", i, fm[i], rm[i])
}
}
}
func TestOneMarginClosesAndTheOtherOpens(t *testing.T) {
p := testPlanet(t)
const gw, gh = 200, 100
bs := handModel(t, p, gw, gh, stripes(gw, gh, 50, 150), closingPair()).buildBoundaries()
if len(bs) != 2 {
t.Fatalf("got %d boundaries, want 2", len(bs))
}
means := sortedMeans(bs)
if means[0] >= 0 || means[1] <= 0 {
t.Fatalf("closing rates %.4g and %.4g; two strips on a cylinder give one of each", means[0], means[1])
}
// Equal and opposite, because a pure translation is the same relative velocity at both margins and the
// only thing that differs is which way the normal points.
if math.Abs(means[0]+means[1]) > 1e-9 {
t.Errorf("closing rates %.6g and %.6g are not equal and opposite", means[0], means[1])
}
if got := math.Abs(means[1]); math.Abs(got-0.04) > 1e-9 {
t.Errorf("plates at 2 cm/yr each close at %.4g m/yr; 0.04 is the sum of the two speeds", got)
}
}
func TestContinentsCollideAndOceansSubduct(t *testing.T) {
p := testPlanet(t)
const gw, gh = 200, 100
cell := stripes(gw, gh, 50, 150)
both := handModel(t, p, gw, gh, cell, closingPair()).buildBoundaries()
if got, want := kinds(both), []string{"collision", "rift"}; !sameStrings(got, want) {
t.Errorf("two continental plates give %v, want %v", got, want)
}
// The same geometry with one side oceanic: the closing margin is a subduction zone and the arc belongs
// to the continent, because continental crust is too buoyant to go down.
ps := closingPair()
ps[1].Continental = false
oceanic := handModel(t, p, gw, gh, cell, ps).buildBoundaries()
if got, want := kinds(oceanic), []string{"ridge", "subduction"}; !sameStrings(got, want) {
t.Errorf("continent against ocean gives %v, want %v", got, want)
}
for _, b := range oceanic {
for _, v := range b.V {
if v.Kind == Subduction && v.Over != 0 {
t.Fatalf("the overriding plate is %d, but plate 1 is the oceanic one", v.Over)
}
if v.Kind != Subduction && v.Over != -1 {
t.Fatalf("a %q vertex carries an overriding plate", v.Kind)
}
}
}
}
func sameStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func TestMotionAlongTheLineIsATransform(t *testing.T) {
p := testPlanet(t)
const gw, gh = 200, 100
// Vertical contacts, both plates sliding north and south: the relative motion is entirely along the line,
// so neither margin closes or opens.
m := handModel(t, p, gw, gh, stripes(gw, gh, 50, 150), []Plate{
{ID: 0, TransYM: 0.02, Continental: true, AreaCells: 1},
{ID: 1, TransYM: -0.02, Continental: true, AreaCells: 1},
})
for _, b := range m.buildBoundaries() {
if got := b.Dominant(); got != Transform {
t.Errorf("plates sliding past each other give %q, want %q", got, Transform)
}
if got := math.Abs(meanClosing(b)); got > 1e-9 {
t.Errorf("a transform margin closes at %.3g m/yr", got)
}
}
}
func TestKindOf(t *testing.T) {
oblique := 60 * math.Pi / 180
cases := []struct {
name string
closing, slip float64
bothContinental bool
want Kind
}{
{"head-on continental", 1, 0, true, Collision},
{"head-on with an ocean", 1, 0, false, Subduction},
{"opening continental", -1, 0, true, Rift},
{"opening with an ocean", -1, 0, false, Ridge},
{"pure slip", 0, 1, true, Transform},
{"oblique but still closing", 1, 1.5, true, Collision},
{"slip has taken over", 1, 2, true, Transform},
}
for _, c := range cases {
if got := kindOf(c.closing, c.slip, oblique, c.bothContinental); got != c.want {
t.Errorf("%s: got %q, want %q", c.name, got, c.want)
}
}
}
func TestSpinMakesAMarginChangeAlongItsLength(t *testing.T) {
p := testPlanet(t)
const gw, gh = 200, 100
cell := stripes(gw, gh, 50, 150)
// Pure translation: the relative velocity is the same everywhere, so a straight margin closes at one rate
// from end to end. That uniformity is exactly what the in-plane pole exists to break.
flat := handModel(t, p, gw, gh, cell, closingPair()).buildBoundaries()
if spread := worstSpread(flat); spread > 1e-9 {
t.Errorf("without spin a margin varies by %.3g m/yr along its own length; it should not", spread)
}
// The same plates with one rotating about a pole off to the side close at one end and slide at the other.
spun := closingPair()
spun[0].PoleXM, spun[0].PoleYM = 0, 0
spun[0].OmegaRadYr = 2e-6
spinning := handModel(t, p, gw, gh, cell, spun).buildBoundaries()
if spread := worstSpread(spinning); spread < 1e-3 {
t.Errorf("with spin a margin varies by only %.3g m/yr; the rotation is not reaching the boundary",
spread)
}
}
// worstSpread is the largest range of closing rates found *within* a single boundary. Within, not across:
// two margins of the same pair legitimately differ, and measuring across them would report that difference
// as variation along a line.
func worstSpread(bs []Boundary) float64 {
worst := 0.0
for _, b := range bs {
lo, hi := math.Inf(1), math.Inf(-1)
for _, v := range b.V {
lo = math.Min(lo, v.ClosingMYr)
hi = math.Max(hi, v.ClosingMYr)
}
if !math.IsInf(lo, 1) && hi-lo > worst {
worst = hi - lo
}
}
return worst
}
func TestVelocityIsContinuousAcrossTheSeam(t *testing.T) {
p := testPlanet(t)
circ := p.CircumferenceM()
// A plate whose pole sits just east of the meridian. Measured without wrapping, the lever arm a metre
// west of the seam would be a whole circumference long, and the plate would spin the wrong way there.
pl := Plate{ID: 0, PoleXM: 10, PoleYM: 0, OmegaRadYr: 1e-6, TransXM: 0.01}
ax, ay := pl.VelocityAt(p, circ-1, 0)
bx, by := pl.VelocityAt(p, 1, 0)
acrossSeam := math.Hypot(ax-bx, ay-by)
// The same two-metre gap in open map, away from the meridian: the seam must cost nothing extra.
cx, cy := pl.VelocityAt(p, circ/2-1, 0)
dx, dy := pl.VelocityAt(p, circ/2+1, 0)
elsewhere := math.Hypot(cx-dx, cy-dy)
if math.Abs(acrossSeam-elsewhere) > 1e-12 {
t.Errorf("velocity changes by %.3g m/yr over two metres at the meridian and %.3g m/yr over two "+
"metres anywhere else", acrossSeam, elsewhere)
}
// And the failure this guards against is enormous, not subtle: an unwrapped lever arm would be a whole
// circumference and give a jump of omega*circ.
if acrossSeam > pl.OmegaRadYr*circ/100 {
t.Errorf("velocity jumps by %.3g m/yr at the meridian; the lever arm was not wrapped", acrossSeam)
}
}
func TestBuildCoversThePlanetAndReadsTheLandMask(t *testing.T) {
p := testPlanet(t)
cfg := Default()
cfg.Count = 6
allSea, err := Build(p, 7, cfg, func(xM, yM float64) bool { return false })
if err != nil {
t.Fatalf("build: %v", err)
}
total := 0
for _, pl := range allSea.Plates {
total += pl.AreaCells
if pl.Continental {
t.Errorf("plate %d is continental on a planet with no land", pl.ID)
}
}
if total != allSea.GW*allSea.GH {
t.Errorf("plates cover %d cells of %d; the partition has holes", total, allSea.GW*allSea.GH)
}
if len(allSea.Boundaries) == 0 {
t.Fatal("six plates and no boundaries between them")
}
for _, b := range allSea.Boundaries {
if b.A >= b.B {
t.Errorf("boundary pair (%d, %d) is not ordered", b.A, b.B)
}
for _, v := range b.V {
if v.Kind == Collision || v.Kind == Rift {
t.Errorf("a %q on a planet with no continental plate at all", v.Kind)
}
}
}
allLand, err := Build(p, 7, cfg, func(xM, yM float64) bool { return true })
if err != nil {
t.Fatalf("build: %v", err)
}
for _, pl := range allLand.Plates {
if !pl.Continental {
t.Errorf("plate %d is oceanic on a planet that is all land", pl.ID)
}
}
for _, b := range allLand.Boundaries {
for _, v := range b.V {
if v.Kind == Subduction || v.Kind == Ridge {
t.Errorf("a %q with no oceanic plate to make it", v.Kind)
}
}
}
}
func TestTheSameSeedGivesTheSamePlanet(t *testing.T) {
p := testPlanet(t)
cfg := Default()
land := func(xM, yM float64) bool { return yM > 5000 && yM < 12000 }
a, err := Build(p, 9342, cfg, land)
if err != nil {
t.Fatalf("build: %v", err)
}
b, err := Build(p, 9342, cfg, land)
if err != nil {
t.Fatalf("build: %v", err)
}
if len(a.Boundaries) != len(b.Boundaries) {
t.Fatalf("%d boundaries then %d; the set is not deterministic", len(a.Boundaries), len(b.Boundaries))
}
for i := range a.Boundaries {
if a.Boundaries[i].A != b.Boundaries[i].A || a.Boundaries[i].B != b.Boundaries[i].B {
t.Fatalf("boundary %d is a different pair on the second run", i)
}
if math.Abs(a.Boundaries[i].LengthM()-b.Boundaries[i].LengthM()) > 1e-9 {
t.Fatalf("boundary %d is a different length on the second run", i)
}
}
}
func TestTheWarpBendsMarginsAndTooMuchOfItBreaksThem(t *testing.T) {
p := testPlanet(t)
// Sinuosity - the line's own length over the distance between its ends - is what "a margin is not a
// ruled line" means as a number. A weighted Voronoi edge is a circular arc even at no warp, so the
// baseline is a little over 1 rather than exactly 1.
measure := func(warp float64) (sinuosity float64, boundaries int) {
cfg := Default()
cfg.Count = 7
cfg.WarpFraction = warp
m, err := Build(p, 3630, cfg, func(xM, yM float64) bool { return false })
if err != nil {
t.Fatalf("warp %.2f: %v", warp, err)
}
total, n := 0.0, 0
for _, b := range m.Boundaries {
if len(b.V) < 10 {
continue
}
last := b.V[len(b.V)-1]
if straight := math.Hypot(last.XM-b.V[0].XM, last.YM-b.V[0].YM); straight > 0 {
total += b.LengthM() / straight
n++
}
}
if n == 0 {
return 0, len(m.Boundaries)
}
return total / float64(n), len(m.Boundaries)
}
straight, straightCount := measure(0)
warped, warpedCount := measure(Default().WarpFraction)
if straight > 1.05 {
t.Errorf("an unwarped partition already has a sinuosity of %.3f; it should be close to a polygon net",
straight)
}
if warped <= straight*1.03 {
t.Errorf("the warp takes sinuosity from %.3f to %.3f, which is no bend at all", straight, warped)
}
// Past about half a plate spacing the displacement folds back on itself and the partition grows islands
// of one plate inside another, which the tracer faithfully chains into extra rings. The count is the
// symptom, and this is the bound warpFineGain and the default are set under.
_, tooMuch := measure(0.5)
if tooMuch <= warpedCount {
t.Skipf("no fragmentation at warp 0.5 on this seed (%d boundaries against %d); the bound still holds "+
"but this seed does not show it", tooMuch, warpedCount)
}
if warpedCount != straightCount {
t.Errorf("the default warp changed the boundary count from %d to %d; it should bend margins, not "+
"create them", straightCount, warpedCount)
}
}
+297
View File
@@ -0,0 +1,297 @@
// Package region cuts a planet into the pieces the geology solve runs on.
//
// Docs/Terrain-Next.md 3.3 says the fluvial solve cannot be tiled, and that is right: drainage area is an
// integral over the whole upstream catchment and the priority-flood needs global connectivity, so a river
// crossing a tile boundary would need the next tile's catchment to know how big it is.
//
// It can be decomposed per landmass, though, and that is a different statement. Ocean cells are held fixed
// at sea level for the entire solve - fluvial.ComputeReceivers makes every outlet its own receiver, so a
// receiver chain starting on land terminates the moment it steps into water, and StreamPower, both
// diffusions, the repose clamp and thermal all skip a fixed cell. No flow path crosses open water, and every
// basin is contained in one eight-connected land component. So solving a landmass in a box of its own is not
// an approximation of solving the planet whole: on land it is the same answer.
//
// What that buys is memory. The whole planet at once is a fluvial.Grid of about 35 bytes a cell plus the
// dozen full-size fields uplift builds, which at 78 million cells is several gigabytes before anything has
// been eroded. Landmasses plus a thin margin are a fraction of that area and are solved one at a time.
//
// What it costs is that the decomposition becomes part of the world's identity: the priority-flood's epsilon
// ladder across a flat depends on the flood's traversal order, which depends on the box it is flooding. The
// seed alone no longer names a world - the seed and the margin do - so the margin lives in the manifest and
// is recorded in meta.json.
//
// Note what is NOT decomposed. The coastal pass runs once on the whole cylinder, because it is cheap (tens
// of nanoseconds a cell, against tens of nanoseconds a cell *per step* for the solve) and because cutting it
// up would truncate the fetch across every strait, split the sediment budget whose conservation is the one
// thing in that pass not derived from something already measured, and leave the shoreline length and the
// exposure percentiles as statistics that do not pool. Decompose the solve, not the map.
package region
import (
"fmt"
"salty/terrain/internal/dt"
"salty/terrain/internal/template"
"salty/terrain/internal/world"
)
// Region is one piece of the planet: a landmass, or a cluster of landmasses close enough that they shelter
// each other, plus a margin of ocean on every side.
type Region struct {
ID int
Frame world.Frame
LandCells int // painted land cells this region owns
SetCells int // cells in the dilated set, which the frame is the bounding box of
Seam bool // the frame straddles x = 0
}
// Cells is the size of the grid the solve will run on, margin included.
func (r Region) Cells() int { return r.Frame.Cells() }
// Partition is a planet cut into regions, and the map from planet cell to owning region.
type Partition struct {
P world.Planet
MarginCells int
// Owner is the region id for every planet cell, or -1 for water that belongs to no region. A cell
// inside one region's frame may be owned by another region or by nobody, which is what keeps two
// regions from both solving the same island.
Owner []int32
Regions []Region
// Dropped counts the specks: components with less painted land than the minimum, returned to the sea.
DroppedRegions, DroppedCells int
}
// Build partitions a classified planet.
//
// The land mask is dilated by the margin with one exact distance transform, and the connected components of
// the dilated mask are the regions. That is the whole rule, and it is deliberately not a bounding-box
// overlap test: dilated boxes are transitively closed and one long thin landmass has an enormous box, so on
// a real template box clustering collapses most of the map into a single region. Dilating the mask itself
// groups exactly those landmasses that come within a margin of each other.
//
// The bounding box of a dilated component is the region's frame, and its edges are ocean by construction: a
// land cell dilates to reach margin cells further out, so the outermost column and row of the dilated set
// are at least margin cells from any land in that component. That is the invariant TestBorderIsAlwaysOcean
// asserts about the square canvas, and the solve depends on it - a border cell is an outlet, and land
// sitting on one would freeze at its initial relief while the interior eroded out from under it.
func Build(m *template.Map, marginCells, minLandCells int) (*Partition, error) {
p := m.P
n := p.W * p.H
if marginCells < 1 {
return nil, fmt.Errorf("margin is %d cells; a region needs at least one ring of ocean", marginCells)
}
if p.PadY < marginCells {
return nil, fmt.Errorf("the polar pad is %d rows against a %d cell margin; a cap touching the top "+
"of the painted map would not get a full margin of ocean", p.PadY, marginCells)
}
part := &Partition{P: p, MarginCells: marginCells, Owner: minusOne(n)}
land := make([]bool, n)
anyLand := false
for i := range m.Sea {
land[i] = !m.Sea[i]
anyLand = anyLand || land[i]
}
if !anyLand {
return part, nil
}
near := dilate(land, p, marginCells)
comp := make([]int32, n)
for i := range comp {
comp[i] = -1
}
var regionOfComp []int32 // one entry per component: the region index, or -1 when it was dropped
var stack []int32
cols := make([]bool, p.W)
for start := 0; start < n; start++ {
if !near[start] || comp[start] >= 0 {
continue
}
id := int32(len(regionOfComp))
regionOfComp = append(regionOfComp, -1)
comp[start] = id
stack = append(stack[:0], int32(start))
for i := range cols {
cols[i] = false
}
minY, maxY := p.H, -1
setCells, landCells := 0, 0
for len(stack) > 0 {
c := stack[len(stack)-1]
stack = stack[:len(stack)-1]
cx, cy := int(c)%p.W, int(c)/p.W
setCells++
cols[cx] = true
if cy < minY {
minY = cy
}
if cy > maxY {
maxY = cy
}
if land[c] {
landCells++
}
for dy := -1; dy <= 1; dy++ {
ny := cy + dy
if ny < 0 || ny >= p.H {
continue
}
base := ny * p.W
for dx := -1; dx <= 1; dx++ {
if dx == 0 && dy == 0 {
continue
}
ni := int32(base + p.WrapX(cx+dx))
if near[ni] && comp[ni] < 0 {
comp[ni] = id
stack = append(stack, ni)
}
}
}
}
if landCells < minLandCells {
// A speck: a stray paint pixel, or a lone cell the classifier left behind. Solving it would
// spend a whole region on a rock, so it goes back to the sea and is counted.
part.DroppedRegions++
part.DroppedCells += landCells
continue
}
x0, width := span(cols, p.W)
if width >= p.W {
return nil, fmt.Errorf("a landmass reaches all the way round the planet: %d of %d columns once "+
"the %d cell margin is added. It cannot be flattened into a rectangle with ocean on both "+
"sides, and the solve needs that, because a grid edge is an outlet. Break it with a strait, "+
"or reduce the margin", width, p.W, marginCells)
}
regionOfComp[id] = int32(len(part.Regions))
part.Regions = append(part.Regions, Region{
ID: len(part.Regions),
Frame: world.Frame{P: p, X0: x0, Y0: minY, W: width, H: maxY - minY + 1},
LandCells: landCells,
SetCells: setCells,
Seam: x0+width > p.W,
})
}
for i, c := range comp {
if c >= 0 {
part.Owner[i] = regionOfComp[c]
}
}
return part, nil
}
// dilate marks every cell within margin cells of a seed, on the cylinder.
func dilate(seed []bool, p world.Planet, margin int) []bool {
d2 := dt.Distance2(seed, p.W, p.H, true)
reach := float32(margin * margin)
out := make([]bool, len(d2))
for i, d := range d2 {
out[i] = d <= reach
}
return out
}
// Cut is the region's own view of the world: the class raster and the land mask for its frame, with every
// cell belonging to another region - or to no region - forced to sea.
//
// Forcing them is right rather than convenient. A neighbouring island inside this frame is a separate
// landmass with its own basins, and no flow path connects the two, so leaving it as land would solve it
// twice and let its relief leak into this region's statistics. As water it is exactly what it is to this
// region's rivers: base level.
func (p *Partition) Cut(m *template.Map, r Region) (class []uint8, land []bool) {
sea := uint8(0)
if i := m.L.FirstSea(); i >= 0 {
sea = uint8(i)
}
class = make([]uint8, r.Frame.Cells())
land = make([]bool, r.Frame.Cells())
for y := 0; y < r.Frame.H; y++ {
for x := 0; x < r.Frame.W; x++ {
pi := r.Frame.PlanetIdx(x, y)
o := y*r.Frame.W + x
mine := p.Owner[pi] == int32(r.ID)
if mine && !m.Sea[pi] {
class[o] = m.Class[pi]
land[o] = true
continue
}
if m.Sea[pi] {
class[o] = m.Class[pi] // keep the painted water class: its depth is read later
} else {
class[o] = sea // somebody else's land, which to this region is open water
}
}
}
return class, land
}
// Composite writes a region's solved land back into the planet raster.
//
// Only cells the region owns and that are painted land are written. Everything else in the frame is water,
// and the sea floor is the planetary coastal pass's to lay afterwards - a region must not write it, or two
// overlapping frames would disagree about the same stretch of shelf.
func (p *Partition) Composite(dst []float32, m *template.Map, r Region, src []float32) int {
written := 0
for y := 0; y < r.Frame.H; y++ {
for x := 0; x < r.Frame.W; x++ {
pi := r.Frame.PlanetIdx(x, y)
if p.Owner[pi] != int32(r.ID) || m.Sea[pi] {
continue
}
dst[pi] = src[y*r.Frame.W+x]
written++
}
}
return written
}
// span finds the shortest run of columns covering every occupied one, going round the cylinder. The largest
// gap decides: the run starts just after it.
func span(cols []bool, w int) (x0, width int) {
occupied := make([]int, 0, w)
for x, on := range cols {
if on {
occupied = append(occupied, x)
}
}
if len(occupied) == 0 {
return 0, 0
}
if len(occupied) == w {
return 0, w
}
bestGap, bestAt := -1, 0
for i := range occupied {
var gap int
if i == len(occupied)-1 {
gap = occupied[0] + w - occupied[i]
} else {
gap = occupied[i+1] - occupied[i]
}
if gap > bestGap {
bestGap, bestAt = gap, (i+1)%len(occupied)
}
}
return occupied[bestAt], w - bestGap + 1
}
func minusOne(n int) []int32 {
out := make([]int32, n)
for i := range out {
out[i] = -1
}
return out
}
@@ -0,0 +1,325 @@
package region
import (
"strings"
"testing"
"salty/terrain/internal/template"
"salty/terrain/internal/world"
)
const legendJSON = `{"classes":[
{"name":"sea","rgb":[0,0,255],"sea":true,"depth_m":100},
{"name":"land","rgb":[0,255,0],"uplift_mm_yr":0.5}
]}`
// testMap builds a planet from a picture of its painted rows. '#' is land, '.' is sea; the polar pad of
// synthetic ocean is added above and below.
func testMap(t *testing.T, rows []string, pad int) *template.Map {
t.Helper()
l, err := template.Parse([]byte(legendJSON))
if err != nil {
t.Fatal(err)
}
w := len(rows[0])
p := world.Planet{CellM: 1, W: w, H: len(rows) + 2*pad, PadY: pad, NoisePeriodM: float64(w)}
if err := p.Validate(); err != nil {
t.Fatal(err)
}
m := &template.Map{P: p, L: l, Class: make([]uint8, p.W*p.H), Sea: make([]bool, p.W*p.H)}
for i := range m.Class {
m.Class[i], m.Sea[i] = 0, true
}
for y, row := range rows {
if len(row) != w {
t.Fatalf("row %d is %d wide, want %d", y, len(row), w)
}
for x, r := range row {
if r == '#' {
i := (y+pad)*p.W + x
m.Class[i], m.Sea[i] = 1, false
}
}
}
return m
}
// assertBordersAreWater is the invariant the whole solve depends on, and the direct analogue of
// TestBorderIsAlwaysOcean: a grid edge is an outlet, so land on one would freeze at its initial relief while
// the interior eroded out from under it.
func assertBordersAreWater(t *testing.T, part *Partition, m *template.Map) {
t.Helper()
for _, r := range part.Regions {
_, land := part.Cut(m, r)
for x := 0; x < r.Frame.W; x++ {
if land[x] {
t.Errorf("region %d: land on the top edge at column %d", r.ID, x)
}
if land[(r.Frame.H-1)*r.Frame.W+x] {
t.Errorf("region %d: land on the bottom edge at column %d", r.ID, x)
}
}
for y := 0; y < r.Frame.H; y++ {
if land[y*r.Frame.W] {
t.Errorf("region %d: land on the left edge at row %d", r.ID, y)
}
if land[y*r.Frame.W+r.Frame.W-1] {
t.Errorf("region %d: land on the right edge at row %d", r.ID, y)
}
}
}
}
// The crater island in the template this was written for straddles x = 0. If the partitioner split it in
// two, half of it would be solved against a shore that does not exist.
func TestSeamStraddlingLandmassIsOneRegion(t *testing.T) {
m := testMap(t, []string{
"#.........#",
"#.........#",
"...........",
}, 2)
part, err := Build(m, 2, 1)
if err != nil {
t.Fatal(err)
}
if len(part.Regions) != 1 {
t.Fatalf("got %d regions, want 1: the landmass wraps", len(part.Regions))
}
r := part.Regions[0]
if !r.Seam {
t.Error("the region does not report that it straddles the seam")
}
if r.LandCells != 4 {
t.Errorf("LandCells = %d, want 4", r.LandCells)
}
// Land occupies columns 10 and 0, which are neighbours on an 11-column cylinder; a 2-cell margin on
// each side makes the frame six columns wide starting at column 8.
if r.Frame.W != 6 {
t.Errorf("frame width = %d, want 6", r.Frame.W)
}
if r.Frame.X0 != 8 {
t.Errorf("frame X0 = %d, want 8", r.Frame.X0)
}
assertBordersAreWater(t, part, m)
}
// Landmasses close enough to shelter each other are solved together; further apart they are not. The
// alternative that was rejected - overlapping dilated bounding boxes - is transitively closed and one long
// landmass has an enormous box, so on a real template it collapses most of the map into a single region.
func TestClusteringFollowsDistanceNotBoundingBoxes(t *testing.T) {
near := testMap(t, []string{
"..............................",
".####....#....................",
".####....#....................",
"..............................",
}, 3)
part, err := Build(near, 3, 1)
if err != nil {
t.Fatal(err)
}
if len(part.Regions) != 1 {
t.Fatalf("got %d regions, want 1: a four-cell gap closes under a three-cell margin on each side",
len(part.Regions))
}
far := testMap(t, []string{
"..............................",
".####.........#...............",
".####.........#...............",
"..............................",
}, 3)
part, err = Build(far, 3, 1)
if err != nil {
t.Fatal(err)
}
if len(part.Regions) != 2 {
t.Fatalf("got %d regions, want 2: a nine-cell gap does not", len(part.Regions))
}
assertBordersAreWater(t, part, far)
}
func TestEveryRegionBorderIsWater(t *testing.T) {
m := testMap(t, []string{
"..###...........................................................",
"..###.........####.............####.............##..............",
"..............####.............####.............##..............",
"..............####.............####.............................",
"................................................................",
"................................................................",
}, 3)
part, err := Build(m, 3, 1)
if err != nil {
t.Fatal(err)
}
if len(part.Regions) < 2 {
t.Fatalf("got %d regions; the picture has several separate landmasses", len(part.Regions))
}
assertBordersAreWater(t, part, m)
}
// A polar cap touches the top row of the painted map. The synthetic ocean pad is what gives it a shore, so
// that fluvial.isOutlet - which treats every top-row cell as an outlet - is answering about water.
func TestAPolarCapGetsAMarginOfOcean(t *testing.T) {
m := testMap(t, []string{
"###############...............",
"########......................",
"..............................",
"..............................",
}, 3)
part, err := Build(m, 3, 1)
if err != nil {
t.Fatal(err)
}
if len(part.Regions) != 1 {
t.Fatalf("got %d regions, want 1", len(part.Regions))
}
r := part.Regions[0]
if r.Frame.Y0 != 0 {
t.Errorf("frame Y0 = %d, want 0: the cap reaches into the pad", r.Frame.Y0)
}
assertBordersAreWater(t, part, m)
}
// Every painted land cell belongs to exactly one region, and a round trip through Cut and Composite
// reproduces it. If two regions owned the same cell, one would silently overwrite the other.
func TestCutAndCompositeCoverEveryLandCellOnce(t *testing.T) {
m := testMap(t, []string{
"#..####...................#",
"#..####...................#",
"...........................",
"..........##.....##........",
"..........##.....##........",
"...........................",
}, 3)
part, err := Build(m, 2, 1)
if err != nil {
t.Fatal(err)
}
if len(part.Regions) < 2 {
t.Fatalf("got %d regions; the picture has several separate landmasses", len(part.Regions))
}
dst := make([]float32, m.P.W*m.P.H)
hits := make([]int, len(dst))
total := 0
for _, r := range part.Regions {
_, land := part.Cut(m, r)
src := make([]float32, r.Frame.Cells())
for i := range src {
if land[i] {
src[i] = float32(r.ID + 1)
}
}
total += part.Composite(dst, m, r, src)
for y := 0; y < r.Frame.H; y++ {
for x := 0; x < r.Frame.W; x++ {
pi := r.Frame.PlanetIdx(x, y)
if part.Owner[pi] == int32(r.ID) && !m.Sea[pi] {
hits[pi]++
}
}
}
}
painted := 0
for i := range m.Sea {
if !m.Sea[i] {
painted++
}
}
if total != painted {
t.Errorf("composited %d land cells, but %d are painted", total, painted)
}
for i, n := range hits {
if m.Sea[i] {
if n != 0 {
t.Fatalf("water cell %d was written %d times", i, n)
}
continue
}
if n != 1 {
t.Fatalf("land cell %d was written %d times, want exactly 1", i, n)
}
if dst[i] == 0 {
t.Fatalf("land cell %d came back zero", i)
}
}
}
// A landmass that rings the planet cannot be flattened into a rectangle with water on both sides, and the
// solve needs that. Better a clear refusal than a silently frozen coastline.
func TestALandmassRingingThePlanetIsRefused(t *testing.T) {
m := testMap(t, []string{
"##########",
"..........",
"..........",
"..........",
}, 2)
_, err := Build(m, 2, 1)
if err == nil {
t.Fatal("accepted a landmass that goes all the way round")
}
if !strings.Contains(err.Error(), "all the way round") {
t.Errorf("error %q does not say why", err)
}
}
// A stray paint pixel should not cost a whole region.
func TestSpecksAreDroppedAndCounted(t *testing.T) {
m := testMap(t, []string{
"####......................",
"####...............#......",
"####......................",
"..........................",
}, 2)
part, err := Build(m, 2, 4)
if err != nil {
t.Fatal(err)
}
if len(part.Regions) != 1 {
t.Fatalf("got %d regions, want 1", len(part.Regions))
}
if part.DroppedRegions != 1 || part.DroppedCells != 1 {
t.Errorf("dropped %d regions / %d cells, want 1 and 1", part.DroppedRegions, part.DroppedCells)
}
// And the speck is not owned by anything, so nothing solves it.
for i := range m.Sea {
if !m.Sea[i] && part.Owner[i] < 0 {
return
}
}
t.Error("the speck is still owned by a region")
}
func TestTheMarginMustFitInsideThePad(t *testing.T) {
m := testMap(t, []string{"####......", ".........."}, 1)
if _, err := Build(m, 4, 1); err == nil {
t.Fatal("accepted a margin wider than the polar pad")
}
}
func TestSpanWrapsTheShortWay(t *testing.T) {
mark := func(w int, on ...int) []bool {
c := make([]bool, w)
for _, x := range on {
c[x] = true
}
return c
}
cases := []struct {
cols []bool
w int
x0, wanted int
}{
{mark(10, 0, 1, 2), 10, 0, 3},
{mark(10, 8, 9, 0, 1), 10, 8, 4},
{mark(10, 5), 10, 5, 1},
{mark(10, 0, 5), 10, 5, 6},
}
for _, c := range cases {
x0, w := span(c.cols, c.w)
if x0 != c.x0 || w != c.wanted {
t.Errorf("span = %d+%d, want %d+%d", x0, w, c.x0, c.wanted)
}
}
}
+448
View File
@@ -0,0 +1,448 @@
package stats
import (
"math"
"sort"
"salty/terrain/internal/field"
)
// Gathering a world's statistics one piece at a time.
//
// The geology is solved one landmass at a time (D-53) and a planet's regions never exist together, so a
// planet-wide statistic has to be assembled rather than computed. Terrain.md's rule for that is "statistics
// pool across regions rather than being computed per region and averaged", and until now it was a rule with
// no implementation: the whole package took a grid and sorted it, so a planet bake printed its elevation
// range and nothing else - no slope distribution, no per-uplift-class breakdown, no drainage density. The
// block the documentation calls the one that matters most was the one that could not be afforded.
//
// An Accumulator is what makes the rule true. Every quantity in it is either a counter, an exact running
// extreme, or a Histogram, and all three are **additive**: merging two regions and reading the result gives
// exactly what one pass over both would have. See histogram.go for why that is the whole design and not an
// implementation detail.
//
// Add takes a grid. It does not care whether that grid is one region of a planet or the whole square canvas,
// which is the other half of the point: `generate` and `bake` now compute their statistics with the same
// code, so a number measured on one is comparable with the same number measured on the other.
// Options are the constants a world is judged against. They have to be the same for every region of a planet,
// which is why they live on the accumulator rather than being passed to each Add.
type Options struct {
// ElevMin and ElevMax bound the elevation histogram, and they are the manifest's encoding range on
// purpose rather than the data's own extremes. A histogram's bounds have to be known before the first
// value arrives, or two regions would bin against different scales and could not be merged - and the
// encoding range is the one bound that is a property of the world rather than of whatever happens to be
// in front of it. Anything outside is counted as out of range, which is also what the clip fraction is
// about.
ElevMin, ElevMax float64
TalusDeg float64 // the angle of repose, for the "pinned against the clamp" share
ReliefWindowM float64 // the side of the square local relief is taken over
ChannelM2 float64 // drainage area at which a cell counts as a channel
K, M, N float64 // the stream-power constants, for the slope-area normalisation
}
// slopeBins and elevBins are the resolutions. A twentieth of a degree and a metre or two of elevation are far
// finer than any verdict in Summary turns on, and the whole structure is a few tens of kilobytes either way.
const (
slopeBins = 2048
elevBins = 4096
logSABins = 1024
)
// bucketAcc is one uplift class's share of the accumulator.
type bucketAcc struct {
slope, relief, elev *Histogram
near, total int64
}
// saBin is one decade-fraction of drainage area in the slope-area plot.
type saBin struct{ norm, raw *Histogram }
// Accumulator gathers one world's statistics, a grid at a time.
type Accumulator struct {
opt Options
Cells, Land, Clip int64
MinM, MaxM float64 // the whole field, sea floor included: what the 16-bit encoding has to hold
elev *Histogram // land only
slope *Histogram // land only, degrees
buckets []bucketAcc
sa map[int]*saBin
saChannels int64
channelCells int64
leafCells int64
}
// New returns an empty accumulator.
func New(opt Options) *Accumulator {
if opt.ElevMax <= opt.ElevMin {
opt.ElevMin, opt.ElevMax = -1024, 2048
}
a := &Accumulator{
opt: opt,
MinM: math.Inf(1), MaxM: math.Inf(-1),
elev: NewHistogram(opt.ElevMin, opt.ElevMax, elevBins),
slope: NewHistogram(0, 90, slopeBins),
sa: map[int]*saBin{},
}
span := opt.ElevMax - opt.ElevMin
a.buckets = make([]bucketAcc, len(bucketDefs))
for i := range a.buckets {
a.buckets[i] = bucketAcc{
slope: NewHistogram(0, 90, slopeBins),
relief: NewHistogram(0, span, elevBins),
elev: NewHistogram(opt.ElevMin, opt.ElevMax, elevBins),
}
}
return a
}
// Input is one grid and everything known about it. Everything but H and Land is optional; a caller with no
// flow topology gets the statistics that do not need one.
type Input struct {
H *field.Field
Land []bool // nil means every cell is land
// WrapX says whether this grid's left and right edges are the same meridian. A region of a planet is a
// rectangle cut out of the cylinder with water all round it, so it does *not* wrap; the whole square
// canvas does not either. It is here because the local relief window is the one thing that reads
// neighbours, and being wrong about it would put a seam in one column of the relief map.
WrapX bool
UpliftMYr []float32 // per cell; without it there is no per-class breakdown
KLocal []float32 // the lithology multiplier, for the slope-area normalisation
// The flow topology, for slope-area and drainage density. All three or none.
Area []float32
Receiver []int32
Length []float32
}
// AddExtent records what a *finished* grid covers: how many cells, how many of them are land, how many fall
// outside the encoding range, and the extremes over everything including the sea floor.
//
// It is separate from Add because on a planet the two are measured in different places, and measuring them in
// the wrong one is silently wrong rather than obviously so. A region is a rectangle cut out of the cylinder
// with an ocean margin round it, and neighbouring regions' margins overlap - so pooling "cells" across regions
// counts the same water more than once and reports a land fraction that means nothing. The extent is a
// property of the composited planet and is measured once, on it. Land statistics are the opposite: they are
// per landmass, disjoint by construction, and never see the finished cylinder at all.
func (a *Accumulator) AddExtent(data []float32, land []bool, clipCells int64) {
a.Clip += clipCells
for i, v := range data {
a.Cells++
f := float64(v)
if f < a.MinM {
a.MinM = f
}
if f > a.MaxM {
a.MaxM = f
}
if land == nil || land[i] {
a.Land++
}
}
}
// Add folds one grid's land statistics in. It reads only the cells the mask calls land, and it deliberately
// records nothing about the grid's extent - see AddExtent.
func (a *Accumulator) Add(in Input) {
h := in.H
if h == nil || len(h.Data) == 0 {
return
}
// Local relief first, because it is the one quantity that needs a neighbourhood and therefore a whole
// field of its own. Two sliding passes, O(1) a cell whatever the window: the loop this replaces was
// 1.1e11 comparisons on a planet, which is why no planet bake has ever printed this block.
var relief *field.Field
if a.opt.ReliefWindowM > 0 && in.UpliftMYr != nil {
r := int(math.Round(a.opt.ReliefWindowM / h.CellM / 2))
if r < 1 {
r = 1
}
relief = field.LocalRelief(h, r, in.WrapX)
}
inv := 1.0 / (2.0 * h.CellM)
cellArea := h.CellM * h.CellM
for y := 0; y < h.H; y++ {
for x := 0; x < h.W; x++ {
i := y*h.W + x
if in.Land != nil && !in.Land[i] {
continue
}
v := float64(h.Data[i])
a.elev.Add(v)
// The slope inline rather than through Field.Slope: that allocates a whole field, which at
// planet scale is 300 MB per call and there would be two of them.
gx := float64(h.AtClamped(x+1, y)-h.AtClamped(x-1, y)) * inv
gy := float64(h.AtClamped(x, y+1)-h.AtClamped(x, y-1)) * inv
deg := math.Atan(math.Hypot(gx, gy)) * 180 / math.Pi
a.slope.Add(deg)
if in.UpliftMYr != nil {
if b := bucketOf(float64(in.UpliftMYr[i]) * 1000); b >= 0 {
acc := &a.buckets[b]
acc.total++
acc.slope.Add(deg)
acc.elev.Add(v)
if relief != nil {
acc.relief.Add(float64(relief.Data[i]))
}
if deg >= a.opt.TalusDeg-2 { // pinned against the clamp rather than shaped by erosion
acc.near++
}
}
}
if in.Area == nil {
continue
}
if float64(in.Area[i]) >= a.opt.ChannelM2 {
a.channelCells++
}
// A leaf is a cell that drains nothing but itself, and what it measures is the router when the
// ground is smooth - not the landscape when it is finished. On a planar ramp with no erosion at
// all, D8 leaves 29.5 % of the grid draining nothing, because a cell either sits on one of its
// parallel flow lines or it does not; multiple-flow leaves 0.4 %, which is the strict local
// maxima. After three hundred steps of solving the same ramp both come back near 8 %: the
// terrain has dissected itself by then and its own divides dominate the count. So read this on
// young ground, on a stage dump, or against another run of the same age, and do not read it as a
// verdict on a mature one. It is a count, so it pools across regions exactly.
if float64(in.Area[i]) <= cellArea*1.001 {
a.leafCells++
}
a.addSlopeArea(in, i, h)
}
}
}
// addSlopeArea records one channel cell in the slope-area plot.
//
// S is the gradient *along the flow path*, not the magnitude of the topographic gradient: on a valley floor
// the central difference is dominated by the walls across the channel, which reads far steeper than the water
// actually runs and bends the fitted exponent well past -m/n. And the slope is normalised by (U/K)^(1/n) with
// the *local* K, because erodibility correlates with drainage area by construction - soft rock is cut down,
// sits low and collects flow - so one global K mis-corrects the large-A end systematically.
func (a *Accumulator) addSlopeArea(in Input, i int, h *field.Field) {
if in.Receiver == nil || in.Length == nil || in.UpliftMYr == nil {
return
}
r := in.Receiver[i]
if int(r) == i { // a root drains to itself and has no gradient to measure
return
}
area := float64(in.Area[i])
s := float64(h.Data[i]-h.Data[r]) / float64(in.Length[i])
if area < a.opt.ChannelM2 || s <= 1e-6 {
return
}
u := float64(in.UpliftMYr[i])
kk := a.opt.K
if in.KLocal != nil {
kk *= float64(in.KLocal[i])
}
if u <= 0 || kk <= 0 || a.opt.N <= 0 {
return // no steady state to normalise against
}
a.saChannels++
key := int(math.Floor(math.Log10(area) * binsPerDecade))
b := a.sa[key]
if b == nil {
b = &saBin{norm: NewHistogram(-8, 4, logSABins), raw: NewHistogram(-8, 4, logSABins)}
a.sa[key] = b
}
b.norm.Add(math.Log10(s / math.Pow(u/kk, 1/a.opt.N)))
b.raw.Add(math.Log10(s))
}
const binsPerDecade = 4
// Merge folds another accumulator in. Every field is additive by construction; see histogram.go.
func (a *Accumulator) Merge(o *Accumulator) {
if o == nil {
return
}
a.Cells += o.Cells
a.Land += o.Land
a.Clip += o.Clip
a.saChannels += o.saChannels
a.channelCells += o.channelCells
a.leafCells += o.leafCells
a.MinM = math.Min(a.MinM, o.MinM)
a.MaxM = math.Max(a.MaxM, o.MaxM)
a.elev.Merge(o.elev)
a.slope.Merge(o.slope)
for i := range a.buckets {
if i >= len(o.buckets) {
break
}
a.buckets[i].slope.Merge(o.buckets[i].slope)
a.buckets[i].relief.Merge(o.buckets[i].relief)
a.buckets[i].elev.Merge(o.buckets[i].elev)
a.buckets[i].near += o.buckets[i].near
a.buckets[i].total += o.buckets[i].total
}
// Sorted, because Go randomises map iteration and cross-cutting rule 12 says the answer must not depend
// on it. Here it would only change the order two float sums happen in, which is exactly the sort of "it
// does not matter this time" the rule exists to refuse.
keys := make([]int, 0, len(o.sa))
for k := range o.sa {
keys = append(keys, k)
}
sort.Ints(keys)
for _, k := range keys {
b := a.sa[k]
if b == nil {
b = &saBin{norm: NewHistogram(-8, 4, logSABins), raw: NewHistogram(-8, 4, logSABins)}
a.sa[k] = b
}
b.norm.Merge(o.sa[k].norm)
b.raw.Merge(o.sa[k].raw)
}
}
// bucketDefs are the uplift classes the breakdown splits on.
//
// Absolute rather than percentiles of this map's own field: the point is to compare one run against the next,
// and a percentile split would redefine "plain" every time the uplift field was retuned. They are reporting
// buckets and not a description of terrain - 0.1 mm/yr is a fourteen-degree hillslope at an 8 m cell, which
// is hill country wherever it is painted, and reading this axis as guidance is how a legend once ended up ten
// times too hot (D-55).
var bucketDefs = []struct {
name string
lo, hi float64
}{
{"plain", 0, 0.1},
{"rolling", 0.1, 0.5},
// The top bound is finite rather than +Inf only because the report is marshalled to meta.json and
// encoding/json refuses an infinity. 100 mm/yr is an order of magnitude above anything on Earth.
{"mountain", 0.5, 100},
}
func bucketOf(mmYr float64) int {
for i, d := range bucketDefs {
if mmYr >= d.lo && mmYr < d.hi {
return i
}
}
return -1
}
// Report turns everything gathered into the numbers a run is judged by.
func (a *Accumulator) Report(cellM float64) Report {
r := Report{
MinM: a.MinM, MaxM: a.MaxM, ReliefM: a.MaxM - a.MinM,
}
if a.Cells > 0 {
r.LandFraction = float64(a.Land) / float64(a.Cells)
r.ClipFraction = float64(a.Clip) / float64(a.Cells)
}
r.LandCells = a.Land
r.MeasuredLandCells = a.elev.Count
if a.elev.Count == 0 {
return r
}
r.LandMinM, r.LandMaxM = a.elev.MinV, a.elev.MaxV
r.LandReliefM = r.LandMaxM - r.LandMinM
r.Slopes = Slopes{
Under15Deg: a.slope.FracBelow(15),
Under30Deg: a.slope.FracBelow(30),
Over50Deg: 1 - a.slope.FracBelow(50),
MedianDeg: a.slope.Quantile(0.5),
}
// The hypsometric integral is a *mean* of the normalised elevation, so it comes off the exact running sum
// rather than out of the bins: (sum - n*lo) / (n*span). The curve is the binned part, which is what it
// should be - it is eleven fractions and nobody reads the third decimal of one.
if span := r.LandMaxM - r.LandMinM; span > 1e-6 {
r.Hypsometry.Integral = (a.elev.Sum - float64(a.elev.Count)*r.LandMinM) /
(float64(a.elev.Count) * span)
curve := make([]float64, 11)
for i := 0; i <= 10; i++ {
curve[i] = 1 - a.elev.FracBelow(r.LandMinM+span*float64(i)/10)
}
r.Hypsometry.Curve = curve
}
// Channel length over the area the channels were *counted* in, which is the land Add walked and not the
// land the planet has. On a full bake the two are the same number. On a partial one - `bake --only` - the
// extent is still the whole cylinder while the land statistics cover three islands, and dividing one by
// the other would report a drainage density an order of magnitude low with nothing to say it had.
if measured := a.elev.Count; measured > 0 && (a.channelCells > 0 || a.saChannels > 0) {
lengthKm := float64(a.channelCells) * cellM / 1000
areaKm2 := float64(measured) * cellM * cellM / 1e6
if areaKm2 > 0 {
r.DrainageDensity = lengthKm / areaKm2
}
}
if measured := a.elev.Count; measured > 0 {
r.LeafFraction = float64(a.leafCells) / float64(measured)
}
r.SlopeArea = a.slopeArea()
r.Buckets = a.bucketReport(cellM)
return r
}
func (a *Accumulator) slopeArea() SlopeArea {
out := SlopeArea{Expected: expectedGradient, Channels: int(a.saChannels),
ThreshKm2: a.opt.ChannelM2 / 1e6}
keys := make([]int, 0, len(a.sa))
for k := range a.sa {
keys = append(keys, k)
}
sort.Ints(keys)
var xs, normYs, rawYs []float64
for _, key := range keys {
b := a.sa[key]
if b.norm.Count < 8 { // a bin with a handful of cells is noise, not a data point
continue
}
logA := (float64(key) + 0.5) / binsPerDecade
med := b.norm.Quantile(0.5)
out.Bins = append(out.Bins, Bin{LogA: logA, LogS: med, N: int(b.norm.Count)})
xs = append(xs, logA)
normYs = append(normYs, med)
rawYs = append(rawYs, b.raw.Quantile(0.5))
}
out.Exponent, out.R2 = fitLine(xs, normYs)
out.RawExponent, out.RawR2 = fitLine(xs, rawYs)
return out
}
func (a *Accumulator) bucketReport(cellM float64) []UpliftBucket {
total := int64(0)
for i := range a.buckets {
total += a.buckets[i].total
}
if total == 0 {
return nil
}
out := make([]UpliftBucket, 0, len(bucketDefs))
for i, d := range bucketDefs {
b := &a.buckets[i]
if b.total == 0 {
continue
}
out = append(out, UpliftBucket{
Name: d.name, LoMmYr: d.lo, HiMmYr: d.hi,
LandFrac: float64(b.total) / float64(total),
MedianDeg: b.slope.Quantile(0.5),
P90Deg: b.slope.Quantile(0.9),
MedianRelM: b.relief.Quantile(0.5),
WindowM: a.opt.ReliefWindowM,
NearTalus: float64(b.near) / float64(b.total),
MedianElevM: b.elev.Quantile(0.5),
Cells: int(b.total),
})
}
return out
}
@@ -0,0 +1,248 @@
package stats
import (
"math"
"math/rand/v2"
"sort"
"testing"
"salty/terrain/internal/field"
)
// A small world with real structure in it: a coast, a range, a plain, and a sea the statistics have to leave
// out. Deterministic, so both halves of every comparison see the same ground.
func testWorld(t *testing.T, w, h int, cellM float64) (*field.Field, []bool, []float32) {
t.Helper()
r := rand.New(rand.NewPCG(11, 13))
f := field.New(w, h, cellM)
land := make([]bool, w*h)
up := make([]float32, w*h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*w + x
if x < w/8 {
f.Data[i] = -40 // the sea, which must not appear in any land statistic
continue
}
land[i] = true
t := float64(x) / float64(w)
// A range towards the east, a plain in the middle, and enough noise to give the slopes a spread.
// The 40 m base keeps every land cell above sea level, so "the land minimum is positive" is a
// statement about the mask rather than about this formula.
f.Data[i] = float32(40 + 300*t*t + 18*math.Sin(float64(x)/9)*math.Cos(float64(y)/7) +
r.NormFloat64()*3)
up[i] = float32((0.02 + 0.9*t*t*t) / 1000)
}
}
return f, land, up
}
func testOptions() Options {
return Options{ElevMin: -1024, ElevMax: 2048, TalusDeg: 35, ReliefWindowM: 500,
ChannelM2: 1e6, K: 5e-5, M: 0.5, N: 1}
}
// The histograms replaced sorts, and the whole point is that nothing a run is judged by moved. This is the
// same data through both: the old implementation is reproduced here as the reference, so that a future change
// to the fast path has something to be wrong against.
func TestTheHistogramsAgreeWithSorting(t *testing.T) {
const w, h, cellM = 220, 160, 8.0
f, land, up := testWorld(t, w, h, cellM)
acc := New(testOptions())
acc.Add(Input{H: f, Land: land, UpliftMYr: up})
acc.AddExtent(f.Data, land, 0)
got := acc.Report(cellM)
// --- the reference, by sorting, exactly as the package used to do it -----------------------------
slope := f.Slope()
var degs, elevs []float64
for i := range f.Data {
if !land[i] {
continue
}
degs = append(degs, math.Atan(float64(slope.Data[i]))*180/math.Pi)
elevs = append(elevs, float64(f.Data[i]))
}
sort.Float64s(degs)
sort.Float64s(elevs)
frac := func(v []float64, limit float64) float64 {
return float64(sort.SearchFloat64s(v, limit)) / float64(len(v))
}
const slopeTol = 90.0 / slopeBins // one bin: the whole error budget of a histogram quantile
if d := math.Abs(got.Slopes.MedianDeg - degs[len(degs)/2]); d > slopeTol {
t.Errorf("median slope %.4f against %.4f", got.Slopes.MedianDeg, degs[len(degs)/2])
}
for _, c := range []struct {
name string
got float64
want float64
}{
{"under 15", got.Slopes.Under15Deg, frac(degs, 15)},
{"under 30", got.Slopes.Under30Deg, frac(degs, 30)},
{"over 50", got.Slopes.Over50Deg, 1 - frac(degs, 50)},
} {
if math.Abs(c.got-c.want) > 0.002 {
t.Errorf("slopes %s: %.4f against %.4f", c.name, c.got, c.want)
}
}
// The hypsometric integral is a mean and is carried exactly, so it has to match to the bit of a float sum.
lo, hi := elevs[0], elevs[len(elevs)-1]
var sum float64
for _, v := range elevs {
sum += (v - lo) / (hi - lo)
}
if d := math.Abs(got.Hypsometry.Integral - sum/float64(len(elevs))); d > 1e-9 {
t.Errorf("hypsometric integral %.6f against %.6f", got.Hypsometry.Integral, sum/float64(len(elevs)))
}
if got.LandMinM != lo || got.LandMaxM != hi {
t.Errorf("land range %.3f..%.3f against %.3f..%.3f", got.LandMinM, got.LandMaxM, lo, hi)
}
// And the per-class breakdown, which is the block that matters most.
for _, b := range got.Buckets {
var bdeg []float64
for i := range f.Data {
if !land[i] {
continue
}
mm := float64(up[i]) * 1000
if mm < b.LoMmYr || mm >= b.HiMmYr {
continue
}
bdeg = append(bdeg, math.Atan(float64(slope.Data[i]))*180/math.Pi)
}
if len(bdeg) != b.Cells {
t.Errorf("bucket %s holds %d cells, the reference found %d", b.Name, b.Cells, len(bdeg))
}
sort.Float64s(bdeg)
if d := math.Abs(b.MedianDeg - bdeg[len(bdeg)/2]); d > slopeTol {
t.Errorf("bucket %s median %.4f against %.4f", b.Name, b.MedianDeg, bdeg[len(bdeg)/2])
}
p90 := bdeg[min(len(bdeg)*9/10, len(bdeg)-1)]
if d := math.Abs(b.P90Deg - p90); d > slopeTol {
t.Errorf("bucket %s P90 %.4f against %.4f", b.Name, b.P90Deg, p90)
}
}
if len(got.Buckets) < 2 {
t.Fatalf("only %d buckets came out; this test measured almost nothing", len(got.Buckets))
}
}
// The property the planet depends on: a world cut into pieces and accumulated piece by piece has to report
// what one pass over the whole thing would. Everything here is additive by construction, and this is the
// assertion that says so end to end rather than one histogram at a time.
func TestPoolingPiecesMatchesOnePass(t *testing.T) {
const w, h, cellM = 240, 120, 8.0
f, land, up := testWorld(t, w, h, cellM)
whole := New(testOptions())
whole.Add(Input{H: f, Land: land, UpliftMYr: up})
whole.AddExtent(f.Data, land, 0)
// The same ground in three horizontal strips. Slope and relief read neighbours, so a strip's own edge
// rows differ from the whole - which is exactly the seam a region has, and the reason the comparison
// below is on the *distributions* rather than cell by cell.
pooled := New(testOptions())
for _, band := range [][2]int{{0, 40}, {40, 80}, {80, 120}} {
sub := field.New(w, band[1]-band[0], cellM)
subLand := make([]bool, w*(band[1]-band[0]))
subUp := make([]float32, len(subLand))
copy(sub.Data, f.Data[band[0]*w:band[1]*w])
copy(subLand, land[band[0]*w:band[1]*w])
copy(subUp, up[band[0]*w:band[1]*w])
pooled.Add(Input{H: sub, Land: subLand, UpliftMYr: subUp})
pooled.AddExtent(sub.Data, subLand, 0)
}
a, b := whole.Report(cellM), pooled.Report(cellM)
if a.LandFraction != b.LandFraction {
t.Errorf("land fraction %.6f pooled against %.6f whole", b.LandFraction, a.LandFraction)
}
if a.LandMinM != b.LandMinM || a.LandMaxM != b.LandMaxM {
t.Errorf("land range %.3f..%.3f pooled against %.3f..%.3f",
b.LandMinM, b.LandMaxM, a.LandMinM, a.LandMaxM)
}
// Elevation does not read neighbours at all, so it has to pool to the bit.
if math.Abs(a.Hypsometry.Integral-b.Hypsometry.Integral) > 1e-12 {
t.Errorf("hypsometric integral %.9f pooled against %.9f", b.Hypsometry.Integral, a.Hypsometry.Integral)
}
// Slope reads one cell either side, so six rows of a 120-row world are clamped differently. The
// distribution has to survive that; a tenth of a degree is far inside anything Summary turns on.
if d := math.Abs(a.Slopes.MedianDeg - b.Slopes.MedianDeg); d > 0.1 {
t.Errorf("median slope %.3f pooled against %.3f", b.Slopes.MedianDeg, a.Slopes.MedianDeg)
}
for i := range a.Buckets {
if i >= len(b.Buckets) {
t.Fatalf("pooling lost a bucket: %d against %d", len(b.Buckets), len(a.Buckets))
}
if a.Buckets[i].Cells != b.Buckets[i].Cells {
t.Errorf("bucket %s: %d cells pooled against %d", a.Buckets[i].Name,
b.Buckets[i].Cells, a.Buckets[i].Cells)
}
if d := math.Abs(a.Buckets[i].MedianDeg - b.Buckets[i].MedianDeg); d > 0.2 {
t.Errorf("bucket %s median %.3f pooled against %.3f", a.Buckets[i].Name,
b.Buckets[i].MedianDeg, a.Buckets[i].MedianDeg)
}
}
}
// Merge is the other way pieces arrive - a planet's regions are accumulated separately and folded together -
// and it has to be the same as adding them to one accumulator.
func TestMergeMatchesAddingToOne(t *testing.T) {
const w, h, cellM = 160, 60, 8.0
f, land, up := testWorld(t, w, h, cellM)
one := New(testOptions())
one.Add(Input{H: f, Land: land, UpliftMYr: up})
one.AddExtent(f.Data, land, 0)
one.Add(Input{H: f, Land: land, UpliftMYr: up})
one.AddExtent(f.Data, land, 0)
a, b := New(testOptions()), New(testOptions())
a.Add(Input{H: f, Land: land, UpliftMYr: up})
a.AddExtent(f.Data, land, 0)
b.Add(Input{H: f, Land: land, UpliftMYr: up})
b.AddExtent(f.Data, land, 0)
a.Merge(b)
x, y := one.Report(cellM), a.Report(cellM)
if x.LandFraction != y.LandFraction || x.Slopes.MedianDeg != y.Slopes.MedianDeg ||
x.LandMinM != y.LandMinM || x.LandMaxM != y.LandMaxM {
t.Errorf("merged report differs from one built by adding twice:\n %+v\n %+v", x.Slopes, y.Slopes)
}
for i := range x.Buckets {
if x.Buckets[i].Cells != y.Buckets[i].Cells || x.Buckets[i].MedianDeg != y.Buckets[i].MedianDeg {
t.Errorf("bucket %s differs after a merge", x.Buckets[i].Name)
}
}
}
// Sea cells are counted for the land fraction and for the encoding range, and are in nothing else. A single
// -40 m sea floor in a land statistic would flatter every relief number by forty metres for free.
func TestTheSeaIsNotLand(t *testing.T) {
const w, h, cellM = 120, 80, 8.0
f, land, up := testWorld(t, w, h, cellM)
acc := New(testOptions())
acc.Add(Input{H: f, Land: land, UpliftMYr: up})
acc.AddExtent(f.Data, land, 0)
r := acc.Report(cellM)
if r.LandMinM < 0 {
t.Errorf("land minimum is %.1f m; the sea got into the land statistics", r.LandMinM)
}
if r.MinM > -39 {
t.Errorf("whole-field minimum is %.1f m; the sea should still bound the encoding range", r.MinM)
}
wantLand := 0
for _, v := range land {
if v {
wantLand++
}
}
if got := int(r.LandFraction*float64(w*h) + 0.5); got != wantLand {
t.Errorf("land fraction says %d cells, the mask has %d", got, wantLand)
}
}
@@ -0,0 +1,40 @@
package stats
import (
"math"
"testing"
"salty/terrain/internal/field"
)
// The whole reason this package was rewritten: the sorts could not be afforded at planet scale. 3000 x 3000
// is about what region 12 of the 100 km template is - 9 M cells, the largest single landmass - so this is the
// cost of the biggest piece a bake ever hands over, and the planet is the sum of twenty of them.
//
// A benchmark rather than a test: it measures rather than asserts, and nothing here should fail a build.
//
// go test ./internal/stats/ -bench Region -benchtime 1x
func BenchmarkRegionSizedAccumulate(b *testing.B) {
const w, h, cellM = 3000, 3000, 8.0
f := field.New(w, h, cellM)
land := make([]bool, w*h)
up := make([]float32, w*h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*w + x
land[i] = true
t := float64(x) / float64(w)
f.Data[i] = float32(40 + 300*t*t + 18*math.Sin(float64(x)/9)*math.Cos(float64(y)/7))
up[i] = float32((0.02 + 0.9*t*t*t) / 1000)
}
}
in := Input{H: f, Land: land, UpliftMYr: up}
b.ResetTimer()
for i := 0; i < b.N; i++ {
a := New(testOptions())
a.Add(in)
a.AddExtent(f.Data, land, 0)
_ = a.Report(cellM)
}
b.ReportMetric(float64(w*h)/1e6, "Mcells")
}
+162
View File
@@ -0,0 +1,162 @@
package stats
import "math"
// A fixed-bin histogram, which is what lets a planet be judged at all.
//
// Every statistic in this package used to be a sort: `ComputeHypsometry` copies every land cell into a
// `[]float64` and sorts it, `ComputeSlopes` does the same with slopes, and `UpliftBuckets` does it three
// times per bucket. On the square canvas that is a few megabytes and nobody noticed. On a planet it is
// **28 million land cells**, so the copies alone are several gigabytes before a single number comes out, and
// that is why a planet bake has never printed anything but its elevation range - the block that matters most
// was the block that could not be afforded.
//
// A histogram replaces all of it. One pass, no allocation per cell, a quantile out of a running sum, and the
// error is bounded by the bin width rather than by anything to do with the data.
//
// **And it pools, which is the property that actually matters here.** The geology is solved one landmass at a
// time (D-53), so a planet-wide statistic has to be assembled from per-region pieces - and a histogram is
// *additive*: summing two regions' bins and taking the quantile of the sum gives exactly the number a single
// pass over both would have given. A median of medians would not; a mean of means weighted by area would be
// right for a mean and wrong for everything else. This is the one structure that makes "statistics pool
// across regions rather than being computed per region and averaged" true rather than aspirational.
type Histogram struct {
Lo, Hi float64 `json:"-"`
Bins []int64 `json:"-"`
// Count, Sum, Min and Max are exact rather than binned. The mean and the extremes cost nothing to carry
// and they are the numbers a bin width would spoil - the hypsometric integral is a mean, and reading it
// off bin centres would make it a property of the bin count.
Count int64 `json:"count"`
Sum float64 `json:"sum"`
MinV float64 `json:"min"`
MaxV float64 `json:"max"`
Under int64 `json:"under"` // values below Lo
Over int64 `json:"over"` // values at or above Hi
}
// NewHistogram covers lo..hi in n bins. Values outside are counted rather than clamped: a quantile that
// silently piled everything on the end bin would be a quantile that lied about a field whose range had
// been set wrong.
func NewHistogram(lo, hi float64, n int) *Histogram {
if n < 1 {
n = 1
}
if hi <= lo {
hi = lo + 1
}
return &Histogram{Lo: lo, Hi: hi, Bins: make([]int64, n),
MinV: math.Inf(1), MaxV: math.Inf(-1)}
}
// Add records one value.
func (h *Histogram) Add(v float64) {
h.Count++
h.Sum += v
if v < h.MinV {
h.MinV = v
}
if v > h.MaxV {
h.MaxV = v
}
b := int((v - h.Lo) / (h.Hi - h.Lo) * float64(len(h.Bins)))
switch {
case b < 0:
h.Under++
case b >= len(h.Bins):
h.Over++
default:
h.Bins[b]++
}
}
// Merge folds another histogram of the same shape into this one. Two histograms with different bounds cannot
// be merged and the caller is the one place that knows it, so this refuses silently rather than inventing an
// answer: every merge in this package is between histograms built by the same constructor.
func (h *Histogram) Merge(o *Histogram) {
if o == nil || o.Count == 0 || len(o.Bins) != len(h.Bins) || o.Lo != h.Lo || o.Hi != h.Hi {
return
}
for i, n := range o.Bins {
h.Bins[i] += n
}
h.Count += o.Count
h.Sum += o.Sum
h.Under += o.Under
h.Over += o.Over
if o.MinV < h.MinV {
h.MinV = o.MinV
}
if o.MaxV > h.MaxV {
h.MaxV = o.MaxV
}
}
// Mean is exact, not binned.
func (h *Histogram) Mean() float64 {
if h.Count == 0 {
return 0
}
return h.Sum / float64(h.Count)
}
// Quantile is the value below which p of the distribution sits, interpolated within the bin it lands in.
//
// The out-of-range counts are part of the walk rather than ignored: a quantile that fell among values below
// Lo returns Lo, which is honest, where skipping them would shift every quantile above by however many there
// were.
func (h *Histogram) Quantile(p float64) float64 {
if h.Count == 0 {
return 0
}
if p <= 0 {
return h.MinV
}
if p >= 1 {
return h.MaxV
}
want := p * float64(h.Count)
run := float64(h.Under)
if run >= want {
return h.Lo
}
width := (h.Hi - h.Lo) / float64(len(h.Bins))
for i, n := range h.Bins {
if run+float64(n) >= want {
frac := 0.0
if n > 0 {
frac = (want - run) / float64(n)
}
return h.Lo + (float64(i)+frac)*width
}
run += float64(n)
}
return h.Hi
}
// FracBelow is the share of the distribution strictly below x, which is what every "how much of the land is
// under fifteen degrees" question is asking.
func (h *Histogram) FracBelow(x float64) float64 {
if h.Count == 0 {
return 0
}
if x <= h.Lo {
return float64(h.Under) / float64(h.Count)
}
if x >= h.Hi {
return float64(h.Count-h.Over) / float64(h.Count)
}
width := (h.Hi - h.Lo) / float64(len(h.Bins))
full := int((x - h.Lo) / width)
run := h.Under
for i := 0; i < full && i < len(h.Bins); i++ {
run += h.Bins[i]
}
// The part-bin, spread evenly across its own width. Without it every threshold would snap to a bin edge,
// which at a bin width of a twentieth of a degree does not matter and at a coarse one would.
if full < len(h.Bins) {
frac := (x - h.Lo - float64(full)*width) / width
run += int64(float64(h.Bins[full]) * frac)
}
return float64(run) / float64(h.Count)
}
@@ -0,0 +1,136 @@
package stats
import (
"math"
"math/rand/v2"
"sort"
"testing"
)
// A histogram has to answer the same questions a sort did, closely enough that no verdict changes. The bound
// is the bin width, so the test is against a real sort of the same data.
func TestHistogramMatchesASort(t *testing.T) {
r := rand.New(rand.NewPCG(7, 11))
vals := make([]float64, 200000)
h := NewHistogram(0, 90, 2048)
for i := range vals {
// A slope-like distribution: mostly gentle, with a tail.
v := math.Abs(r.NormFloat64()) * 7
if v > 89.9 {
v = 89.9
}
vals[i] = v
h.Add(v)
}
sort.Float64s(vals)
q := func(p float64) float64 { return vals[int(p*float64(len(vals)-1))] }
width := 90.0 / 2048
for _, p := range []float64{0.1, 0.25, 0.5, 0.75, 0.9, 0.99} {
got, want := h.Quantile(p), q(p)
if math.Abs(got-want) > width {
t.Errorf("quantile %.2f: histogram %.4f, sort %.4f, wider than one %.4f bin", p, got, want, width)
}
}
for _, x := range []float64{1, 5, 15, 30, 50} {
want := float64(sort.SearchFloat64s(vals, x)) / float64(len(vals))
if got := h.FracBelow(x); math.Abs(got-want) > 0.002 {
t.Errorf("fraction below %.0f: histogram %.4f, sort %.4f", x, got, want)
}
}
// The mean and the extremes are carried exactly, not read off bins.
var sum float64
for _, v := range vals {
sum += v
}
if math.Abs(h.Mean()-sum/float64(len(vals))) > 1e-9 {
t.Errorf("mean %v against %v", h.Mean(), sum/float64(len(vals)))
}
if h.MinV != vals[0] || h.MaxV != vals[len(vals)-1] {
t.Errorf("extremes %v..%v against %v..%v", h.MinV, h.MaxV, vals[0], vals[len(vals)-1])
}
}
// The property the whole per-region design rests on: summing two regions' bins and taking the quantile of the
// sum is exactly the quantile of the two together. A median of medians would not be, which is why the
// histogram is here and not a smaller summary.
func TestMergingIsExactlyPooling(t *testing.T) {
r := rand.New(rand.NewPCG(3, 5))
a, b, both := NewHistogram(0, 90, 512), NewHistogram(0, 90, 512), NewHistogram(0, 90, 512)
for i := 0; i < 30000; i++ {
v := r.Float64() * 40
a.Add(v)
both.Add(v)
}
for i := 0; i < 70000; i++ {
// A different distribution, so a mean of the two would not do.
v := 50 + r.Float64()*30
b.Add(v)
both.Add(v)
}
a.Merge(b)
if a.Count != both.Count {
t.Fatalf("merged count %d against %d", a.Count, both.Count)
}
for i := range a.Bins {
if a.Bins[i] != both.Bins[i] {
t.Fatalf("bin %d: merged %d against %d", i, a.Bins[i], both.Bins[i])
}
}
for _, p := range []float64{0.1, 0.5, 0.9} {
if got, want := a.Quantile(p), both.Quantile(p); got != want {
t.Errorf("quantile %.1f: merged %v, together %v", p, got, want)
}
}
// The extremes pool exactly; the mean is a float sum and so is associativity-bound, which is a
// 1e-16 effect and not a property worth asserting to the bit.
if a.MinV != both.MinV || a.MaxV != both.MaxV {
t.Errorf("the extremes did not pool: %v..%v against %v..%v", a.MinV, a.MaxV, both.MinV, both.MaxV)
}
if rel := math.Abs(a.Mean()-both.Mean()) / both.Mean(); rel > 1e-12 {
t.Errorf("the mean did not pool: %v against %v", a.Mean(), both.Mean())
}
}
// Out of range is counted, not clamped: a range set wrong has to be visible rather than piling up on an end
// bin and quietly moving every quantile.
func TestOutOfRangeIsCountedRatherThanClamped(t *testing.T) {
h := NewHistogram(0, 10, 10)
for _, v := range []float64{-5, -1, 3, 3, 3, 12, 20} {
h.Add(v)
}
if h.Under != 2 || h.Over != 2 {
t.Fatalf("under %d over %d, want 2 and 2", h.Under, h.Over)
}
if h.Count != 7 {
t.Fatalf("count %d", h.Count)
}
if h.MinV != -5 || h.MaxV != 20 {
t.Errorf("extremes %v..%v", h.MinV, h.MaxV)
}
// Three of seven are below 4, plus the two under the bottom: five sevenths.
if got := h.FracBelow(4); math.Abs(got-5.0/7) > 1e-9 {
t.Errorf("FracBelow(4) = %v, want %v", got, 5.0/7)
}
// An empty histogram answers zero rather than dividing by nothing.
e := NewHistogram(0, 1, 4)
if e.Quantile(0.5) != 0 || e.Mean() != 0 || e.FracBelow(0.5) != 0 {
t.Error("an empty histogram should answer zero everywhere")
}
}
// Merging refuses a mismatch rather than inventing an answer, because every real merge here is between
// histograms one constructor made.
func TestMergeRefusesADifferentShape(t *testing.T) {
a := NewHistogram(0, 10, 10)
a.Add(5)
for _, b := range []*Histogram{NewHistogram(0, 10, 20), NewHistogram(0, 20, 10), nil} {
if b != nil {
b.Add(5)
}
a.Merge(b)
}
if a.Count != 1 {
t.Errorf("a mismatched merge changed the histogram: count %d", a.Count)
}
}
+26 -271
View File
@@ -10,9 +10,6 @@ package stats
import (
"fmt"
"math"
"sort"
"salty/terrain/internal/field"
)
type Bin struct {
@@ -74,93 +71,22 @@ type Report struct {
Hypsometry Hypsometry `json:"hypsometry"`
DrainageDensity float64 `json:"drainage_density_per_km"`
// Buckets is the whole-map aggregates split by the uplift class that caused them; see UpliftBuckets.
// LeafFraction is the share of land cells that drain nothing but themselves. See accumulate.go: it is
// the one number that separates a drainage network from a comb of parallel non-converging flow lines.
LeafFraction float64 `json:"leaf_fraction"`
// LandCells is how much land the world has and MeasuredLandCells how much of it the land statistics
// below actually walked. They differ only on a partial run - `bake --only` leaves most of a planet at sea
// level - and when they do, every distribution here describes the part that was solved while the extent
// above describes the whole cylinder. Summary says so rather than leaving the two to be compared.
LandCells int64 `json:"land_cells"`
MeasuredLandCells int64 `json:"measured_land_cells"`
// Buckets is the whole-map aggregates split by the uplift class that caused them.
// The map-wide median above cannot tell a mountain belt from a plain, and that is the question.
Buckets []UpliftBucket `json:"uplift_buckets"`
}
// ComputeSlopeArea bins channel cells by log10 drainage area and takes the median slope in each bin, which
// is far more robust than the mean: one cliff cell in a bin drags a mean and leaves a median alone.
//
// S is the gradient *along the flow path*, (h - h_receiver) / L, not the magnitude of the topographic
// gradient. The difference is not pedantic: for a cell on a valley floor the central difference is dominated
// by the valley walls across the channel, which reads as a far steeper slope than the water actually runs
// down, and it bends the fitted exponent well past -m/n. The receiver gradient is the quantity the
// stream-power law is written in, so it is the quantity the plot has to use.
// kLocal is the per-cell erodibility multiplier from the lithology pass, and passing it matters as much as
// passing the uplift. Erodibility correlates with drainage area by construction: soft rock is cut down, so it
// sits low and collects flow, while hard rock stands up as ridges and drains little. Normalising every cell by
// one global K therefore mis-corrects the large-A end systematically and bends the fitted exponent — it read
// -1.23 against a true -0.50 on a landscape the solver had built correctly. Steady state is written in the
// local K, so the normalisation has to be too.
func ComputeSlopeArea(h *field.Field, area []float32, receiver []int32, length []float32, land []bool,
upliftMYr, kLocal []float32, k, n float64, thresholdM2 float64) SlopeArea {
const binsPerDecade = 4
type acc struct{ norm, raw []float64 }
bins := map[int]*acc{}
count := 0
for i := range h.Data {
if land != nil && !land[i] {
continue
}
r := receiver[i]
if int(r) == i { // a root drains to itself and has no gradient to measure
continue
}
a := float64(area[i])
s := float64(h.Data[i]-h.Data[r]) / float64(length[i])
if a < thresholdM2 || s <= 1e-6 {
continue
}
u := 0.0
if upliftMYr != nil {
u = float64(upliftMYr[i])
}
kk := k
if kLocal != nil {
kk *= float64(kLocal[i])
}
if u <= 0 || kk <= 0 || n <= 0 {
continue // no steady state to normalise against
}
count++
key := int(math.Floor(math.Log10(a) * binsPerDecade))
b := bins[key]
if b == nil {
b = &acc{}
bins[key] = b
}
b.norm = append(b.norm, math.Log10(s/math.Pow(u/kk, 1/n)))
b.raw = append(b.raw, math.Log10(s))
}
// Map iteration is randomised in Go, so the keys are sorted before anything reads them. Determinism is
// cross-cutting rule 12 and this is exactly where it would leak.
keys := make([]int, 0, len(bins))
for k := range bins {
keys = append(keys, k)
}
sort.Ints(keys)
out := SlopeArea{Expected: expectedGradient, Channels: count, ThreshKm2: thresholdM2 / 1e6}
var xs, normYs, rawYs []float64
for _, key := range keys {
b := bins[key]
if len(b.norm) < 8 { // a bin with a handful of cells is noise, not a data point
continue
}
sort.Float64s(b.norm)
sort.Float64s(b.raw)
logA := (float64(key) + 0.5) / binsPerDecade
out.Bins = append(out.Bins, Bin{LogA: logA, LogS: b.norm[len(b.norm)/2], N: len(b.norm)})
xs = append(xs, logA)
normYs = append(normYs, b.norm[len(b.norm)/2])
rawYs = append(rawYs, b.raw[len(b.raw)/2])
}
out.Exponent, out.R2 = fitLine(xs, normYs)
out.RawExponent, out.RawR2 = fitLine(xs, rawYs)
return out
}
// expectedGradient is the -m/n the theory predicts, kept in one place so the verdict compares the fit against
// the exponents the run was actually configured with rather than against the defaults.
var expectedGradient = -0.5
@@ -204,85 +130,6 @@ func fitLine(x, y []float64) (float64, float64) {
return grad, 1 - ssRes/ssTot
}
func ComputeHypsometry(h *field.Field, land []bool) Hypsometry {
vals := make([]float64, 0, len(h.Data))
for i, v := range h.Data {
if land != nil && !land[i] {
continue
}
vals = append(vals, float64(v))
}
if len(vals) == 0 {
return Hypsometry{}
}
sort.Float64s(vals)
lo, hi := vals[0], vals[len(vals)-1]
span := hi - lo
if span < 1e-6 {
return Hypsometry{Integral: 0}
}
var sum float64
for _, v := range vals {
sum += (v - lo) / span
}
curve := make([]float64, 11)
for i := 0; i <= 10; i++ {
target := lo + span*float64(i)/10
// Fraction of land standing above this elevation.
idx := sort.SearchFloat64s(vals, target)
curve[i] = 1 - float64(idx)/float64(len(vals))
}
return Hypsometry{Integral: sum / float64(len(vals)), Curve: curve}
}
func ComputeSlopes(h *field.Field, land []bool) Slopes {
slope := h.Slope()
degs := make([]float64, 0, len(slope.Data))
for i, s := range slope.Data {
if land != nil && !land[i] {
continue
}
degs = append(degs, math.Atan(float64(s))*180/math.Pi)
}
if len(degs) == 0 {
return Slopes{}
}
sort.Float64s(degs)
frac := func(limit float64) float64 {
return float64(sort.SearchFloat64s(degs, limit)) / float64(len(degs))
}
return Slopes{
Under15Deg: frac(15),
Under30Deg: frac(30),
Over50Deg: 1 - frac(50),
MedianDeg: degs[len(degs)/2],
}
}
// DrainageDensity is channel length over basin area, per kilometre. Real landscapes sit around 1 to 10 /km;
// a value near zero means the solve never organised into channels at all.
func DrainageDensity(area []float32, land []bool, cellM float64, thresholdM2 float64) float64 {
var channels, total int
for i, a := range area {
if land != nil && !land[i] {
continue
}
total++
if float64(a) >= thresholdM2 {
channels++
}
}
if total == 0 {
return 0
}
lengthKm := float64(channels) * cellM / 1000
areaKm2 := float64(total) * cellM * cellM / 1e6
if areaKm2 == 0 {
return 0
}
return lengthKm / areaKm2
}
// Summary is the one block a run prints. Written so the numbers that decide whether the run was any good are
// the ones you see without asking.
func (r Report) Summary() string {
@@ -305,19 +152,31 @@ func (r Report) Summary() string {
case r.Hypsometry.Integral < 0.35:
hyp = "concave: over-eroded"
}
// A partial run measures the whole cylinder's extent and only the solved landmasses' ground, and the two
// sitting next to each other invite exactly the wrong comparison. Say so, rather than leave somebody to
// work out afterwards why the drainage density looked impossible.
partial := ""
if r.LandCells > 0 && r.MeasuredLandCells > 0 && r.MeasuredLandCells < r.LandCells {
partial = fmt.Sprintf(
" PARTIAL: the line above is the whole world; everything below is the %.0f%% of its land that\n"+
" was actually solved (%d of %d cells). The two are not comparable.\n",
100*float64(r.MeasuredLandCells)/float64(r.LandCells), r.MeasuredLandCells, r.LandCells)
}
return fmt.Sprintf(
" field %.0f..%.0f m; land %.0f..%.0f m (relief %.0f m), %.0f%% land, %.2f%% clipped\n"+
"%s"+
" slopes: %.0f%% under 15 deg, %.0f%% under 30, %.1f%% over 50, median %.1f deg\n"+
" slope-area: exponent %.3f (expect %.3f), R2 %.3f over %d bins, %d channel cells above %.2f km2\n"+
" unnormalised %.3f, R2 %.3f (heterogeneous uplift, so this one is expected to be worse)\n"+
" %s\n"+
" hypsometric integral %.3f (%s); drainage density %.2f /km\n"+
" hypsometric integral %.3f (%s); drainage density %.2f /km; %.1f%% of land drains nothing\n"+
"%s",
r.MinM, r.MaxM, r.LandMinM, r.LandMaxM, r.LandReliefM, r.LandFraction*100, r.ClipFraction*100,
partial,
r.Slopes.Under15Deg*100, r.Slopes.Under30Deg*100, r.Slopes.Over50Deg*100, r.Slopes.MedianDeg,
sa.Exponent, sa.Expected, sa.R2, len(sa.Bins), sa.Channels, sa.ThreshKm2,
sa.RawExponent, sa.RawR2,
verdict, r.Hypsometry.Integral, hyp, r.DrainageDensity,
verdict, r.Hypsometry.Integral, hyp, r.DrainageDensity, r.LeafFraction*100,
BucketSummary(r.Buckets))
}
@@ -349,110 +208,6 @@ type UpliftBucket struct {
Cells int `json:"cells"`
}
// UpliftBuckets splits the land by rock uplift rate and reports slope, local relief and how much of each
// bucket is pinned against the repose clamp. The last of those is the diagnostic: a bucket where most cells
// sit within two degrees of talus is not being shaped by erosion at all, it is being shaped by the clamp,
// and no amount of tuning downstream of that will change what it looks like.
//
// reliefWindowM is the side of the square the local relief is taken over; 500 m is the usual choice and is
// what the caller passes.
func UpliftBuckets(h *field.Field, upliftMYr []float32, land []bool, talusDeg, reliefWindowM float64) []UpliftBucket {
// The class boundaries are in mm/yr and are deliberately absolute rather than percentiles of this map's
// own field: the point is to compare one run against the next, and a percentile split would redefine
// "plain" every time the uplift field was retuned.
defs := []struct {
name string
lo, hi float64
}{
{"plain", 0, 0.1},
{"rolling", 0.1, 0.5},
// The top bound is finite rather than +Inf only because the report is marshalled to meta.json and
// encoding/json refuses an infinity. 100 mm/yr is an order of magnitude above anything on Earth.
{"mountain", 0.5, 100},
}
if upliftMYr == nil {
return nil
}
slope := h.Slope()
radius := int(math.Round(reliefWindowM / h.CellM / 2))
if radius < 1 {
radius = 1
}
type acc struct {
deg, rel, elev []float64
near, total int
}
accs := make([]acc, len(defs))
landCells := 0
for i := range h.Data {
if land != nil && !land[i] {
continue
}
landCells++
u := float64(upliftMYr[i]) * 1000 // mm/yr
b := -1
for j, d := range defs {
if u >= d.lo && u < d.hi {
b = j
break
}
}
if b < 0 {
continue
}
a := &accs[b]
deg := math.Atan(float64(slope.Data[i])) * 180 / math.Pi
a.deg = append(a.deg, deg)
a.elev = append(a.elev, float64(h.Data[i]))
a.rel = append(a.rel, localRelief(h, i%h.W, i/h.W, radius))
a.total++
if deg >= talusDeg-2 { // pinned against the clamp rather than shaped by erosion
a.near++
}
}
out := make([]UpliftBucket, 0, len(defs))
for j, d := range defs {
a := &accs[j]
if a.total == 0 {
continue
}
sort.Float64s(a.deg)
sort.Float64s(a.rel)
sort.Float64s(a.elev)
out = append(out, UpliftBucket{
Name: d.name, LoMmYr: d.lo, HiMmYr: d.hi,
LandFrac: float64(a.total) / float64(max(landCells, 1)),
MedianDeg: a.deg[len(a.deg)/2],
P90Deg: a.deg[min(len(a.deg)*9/10, len(a.deg)-1)],
MedianRelM: a.rel[len(a.rel)/2],
WindowM: float64(radius*2) * h.CellM,
NearTalus: float64(a.near) / float64(a.total),
MedianElevM: a.elev[len(a.elev)/2],
Cells: a.total,
})
}
return out
}
// localRelief is max minus min over a square window, the standard field measure of how rugged a place is.
// Slope alone cannot tell a 5 m hummock from a 500 m mountainside, because both can stand at 30 degrees.
func localRelief(h *field.Field, cx, cy, radius int) float64 {
lo, hi := math.Inf(1), math.Inf(-1)
for y := cy - radius; y <= cy+radius; y++ {
for x := cx - radius; x <= cx+radius; x++ {
v := float64(h.AtClamped(x, y))
if v < lo {
lo = v
}
if v > hi {
hi = v
}
}
}
return hi - lo
}
// BucketSummary is the block the buckets print. Kept separate from Summary so a run that has no uplift field
// to hand still prints the rest.
func BucketSummary(bs []UpliftBucket) string {
+311
View File
@@ -0,0 +1,311 @@
package studio
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"sync"
"time"
"salty/terrain/internal/field"
"salty/terrain/internal/manifest"
"salty/terrain/internal/planet"
)
// Watching a bake, which is the part `terrain bake` on a terminal cannot do.
//
// A bake is two hours, and for most of that the only thing on screen is a percentage. The question an author
// actually has - *is this the world I meant* - is answerable long before the end, because the solve is
// decomposed per landmass (D-53) and each one comes out whole. So the studio hangs a hook on the composite:
// every time a region's land is written back, the planet as it stands is rendered to a preview, and the world
// fills in one landmass at a time while the rest of it is still running. If the first continent out is wrong,
// the other seventeen do not need to finish.
//
// Two consequences worth stating. The hook holds the composite lock, so every worker is stopped while it
// draws - about a second a region against a run measured in hours, which is the right trade for being able to
// see it at all. And a bake can be **cancelled**, because a two-hour job with no way out is not a button
// anybody should press: the solve checks once a step, so the longest wait is one step of the biggest region,
// and a cancelled result is for looking at rather than for writing out.
// bakeRun is the one bake a studio will run at a time.
type bakeRun struct {
mu sync.Mutex
running bool
finished bool
cancelCh chan struct{}
started time.Time
steps int
total int // regions this run will solve
done []planet.RegionResult
lines []string
stamp int64 // bumped every time a new preview lands, so the browser knows to re-fetch
outDir string
err string
note string
}
// maxBakeLines caps the log the browser is shown. A thousand-step bake over eighteen regions prints a couple
// of hundred lines; the cap is only so that a pathological run cannot grow without bound.
const maxBakeLines = 400
func (b *bakeRun) logf(format string, a ...any) {
line := fmt.Sprintf(format, a...)
b.mu.Lock()
b.lines = append(b.lines, line)
if len(b.lines) > maxBakeLines {
b.lines = b.lines[len(b.lines)-maxBakeLines:]
}
b.mu.Unlock()
}
type bakeStatus struct {
Running bool `json:"running"`
Finished bool `json:"finished"`
Seconds float64 `json:"seconds"`
Steps int `json:"steps"`
Total int `json:"total"`
Done []planet.RegionResult `json:"done"`
Lines []string `json:"lines"`
Stamp int64 `json:"stamp"`
OutDir string `json:"out_dir"`
Err string `json:"err"`
Note string `json:"note"`
}
func (s *Server) handleBake(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s.bake.mu.Lock()
// Both slices start empty rather than nil, because a nil slice marshals to `null` and the page does
// `j.done.length` on it - which is fine for every poll after the first region lands and throws on
// every poll before it, which is exactly the window a person watches most closely.
st := bakeStatus{
Running: s.bake.running, Finished: s.bake.finished,
Steps: s.bake.steps, Total: s.bake.total,
Done: append(make([]planet.RegionResult, 0, len(s.bake.done)), s.bake.done...),
Lines: append(make([]string, 0, len(s.bake.lines)), s.bake.lines...),
Stamp: s.bake.stamp,
OutDir: s.bake.outDir, Err: s.bake.err, Note: s.bake.note,
}
if !s.bake.started.IsZero() {
st.Seconds = time.Since(s.bake.started).Seconds()
}
s.bake.mu.Unlock()
writeJSON(w, st)
case http.MethodPost:
var req struct {
Only []int `json:"only"`
Steps int `json:"steps"`
Jobs int `json:"jobs"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if err := s.startBake(req.Only, req.Steps, req.Jobs); err != nil {
http.Error(w, err.Error(), http.StatusConflict)
return
}
writeJSON(w, map[string]any{"ok": true})
default:
http.Error(w, "GET or POST", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleBakeCancel(w http.ResponseWriter, r *http.Request) {
s.bake.mu.Lock()
if s.bake.running && s.bake.cancelCh != nil {
select {
case <-s.bake.cancelCh: // already asked
default:
close(s.bake.cancelCh)
s.bake.note = "cancelling: regions in flight stop at the end of their current step"
}
}
s.bake.mu.Unlock()
writeJSON(w, map[string]any{"ok": true})
}
func (s *Server) handleBakePreview(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
http.ServeFile(w, r, filepath.Join(s.planDir, bakePreviewName))
}
const bakePreviewName = "bake_preview.png"
// bakePreviewWidth is what the live preview is drawn at. Small on purpose: it is redrawn with every worker
// stopped, so it is charged against the bake's wall time, and 1400 px is enough to answer "is this the world
// I meant" while costing well under a second.
const bakePreviewWidth = 1400
// startBake takes a snapshot of everything the run needs and hands it to a goroutine.
//
// A snapshot rather than a reference, because the whole point of the studio is that the painting keeps being
// edited: a bake is of the world as it was when the button was pressed, and it says so.
func (s *Server) startBake(only []int, steps, jobs int) error {
s.bake.mu.Lock()
if s.bake.running {
s.bake.mu.Unlock()
return fmt.Errorf("a bake is already running; cancel it first")
}
s.bake.running, s.bake.finished = true, false
s.bake.cancelCh = make(chan struct{})
s.bake.started = time.Now()
s.bake.done, s.bake.lines, s.bake.err, s.bake.outDir, s.bake.note = nil, nil, "", "", ""
s.bake.total, s.bake.steps = 0, steps
cancel := s.bake.cancelCh
s.bake.mu.Unlock()
s.mu.Lock()
// Copied rather than shared: a bake is hours and the author keeps painting through it, so what it solves
// has to be the world as it was when they pressed the button.
art := &planet.Painting{
Class: append([]uint8(nil), s.paint...),
ClassW: s.paintW, ClassH: s.paintH,
}
if s.ov != nil {
art.Overlay = append([]uint8(nil), s.ovPaint...)
art.OverlayAlpha = append([]uint8(nil), s.ovAlpha...)
art.OverlayW, art.OverlayH = s.paintW, s.paintH
}
mPath := s.manifestPath
s.mu.Unlock()
go s.runBake(art, mPath, only, steps, jobs, cancel)
return nil
}
func (s *Server) runBake(art *planet.Painting, mPath string, only []int, steps, jobs int,
cancel chan struct{}) {
fail := func(err error) {
s.bake.mu.Lock()
s.bake.err = err.Error()
s.bake.running, s.bake.finished = false, true
s.bake.mu.Unlock()
}
// Loaded fresh rather than reusing the server's copy: a bake is long enough that the manifest may be
// edited while it runs, and it should be of the numbers that were in force when it started.
m, err := manifest.Load(mPath)
if err != nil {
fail(err)
return
}
s.bake.logf("preparing")
in, err := planet.PrepareWith(m, art, s.bake.logf)
if err != nil {
fail(err)
return
}
wanted := len(in.Part.Regions)
if len(only) > 0 {
wanted = len(only)
}
s.bake.mu.Lock()
s.bake.total = wanted
s.bake.mu.Unlock()
res, err := planet.Bake(in, planet.BakeOptions{
Only: only, Steps: steps, Jobs: jobs, Log: s.bake.logf, Cancel: cancel,
OnRegion: func(res *planet.Result, rr planet.RegionResult) {
s.writeBakePreview(res)
s.bake.mu.Lock()
s.bake.done = append(s.bake.done, rr)
s.bake.stamp = time.Now().UnixNano()
s.bake.mu.Unlock()
},
})
if err != nil {
fail(err)
return
}
note := ""
out := ""
if res.Cancelled() {
// A cancelled run is not worthless, and the first version of this threw it away, which was wrong: the
// solve is per landmass, so a region that *finished* is finished - only the one or two still in
// flight stopped mid-step. Cancelling an eighteen-region bake after fifteen of them had landed and
// getting nothing for it is exactly the outcome a cancel button should not have.
//
// So it is written, under a name of its own. Not Bake_NNN, because a directory that looked like
// every other bake while holding sea level where three continents should be is a trap for whatever
// reads it next, and `tiles --bake` picks the newest Bake_NNN by default.
done := 0
for _, rr := range res.Regions {
if rr.Seconds > 0 {
done++
}
}
if done == 0 {
note = "cancelled before any region finished; nothing to write"
} else {
out = nextPartialDir(filepath.Dir(mPath))
if err := res.Write(out, 3000, s.bake.logf); err != nil {
fail(err)
return
}
note = fmt.Sprintf("cancelled after %d region(s); those are complete and written to %s. "+
"Everything else in it is still at sea level, which is why it is not a Bake_NNN", done, out)
}
} else {
out = planet.NextBakeDir(filepath.Dir(mPath))
if err := res.Write(out, 3000, s.bake.logf); err != nil {
fail(err)
return
}
s.writeBakePreview(res)
note = "wrote " + out
}
s.bake.mu.Lock()
s.bake.running, s.bake.finished = false, true
s.bake.outDir, s.bake.note = out, note
s.bake.stamp = time.Now().UnixNano()
s.bake.mu.Unlock()
}
// writeBakePreview draws the planet as it currently stands.
//
// The height and flow fields are *views* over the result's own arrays rather than copies: Painted() allocates
// three hundred megabytes, and doing that once a region while every worker is stopped is a cost with nothing
// to show for it. Safe because this only ever runs holding the composite lock.
func (s *Server) writeBakePreview(res *planet.Result) {
p := res.In.P
lo, hi := p.PadY*p.W, (p.H-p.PadY)*p.W
h := &field.Field{W: p.W, H: p.PaintH(), CellM: p.CellM, Data: res.Height.Data[lo:hi]}
flow := &field.Field{W: p.W, H: p.PaintH(), CellM: p.CellM, Data: res.Flow[lo:hi]}
// The painted sea, not the baked one: res.Sea is only computed once the whole run is over, and the
// painting already knows which cells are water.
sea := res.In.Map.Sea[lo:hi]
_, err := field.WritePreview(filepath.Join(s.planDir, bakePreviewName), h, field.PreviewOptions{
Flow: flow, Sea: sea, Snow: res.In.Map.SnowMask(), Palette: res.In.Palette,
SeaLevelM: res.In.M.SeaLevelM, RiverKm2: 0.5, Size: bakePreviewWidth,
})
if err != nil {
s.bake.logf("preview failed: %v", err)
}
}
// partialPrefix is deliberately outside the Bake_NNN namespace: latestBakeDir parses the suffix after
// "Bake_" as an integer, so this could never be mistaken for a finished bake even by accident, and a person
// reading the directory listing can see which is which without opening anything.
const partialPrefix = "Partial_"
func nextPartialDir(base string) string {
for n := 1; n < 10000; n++ {
dir := filepath.Join(base, fmt.Sprintf("%s%03d", partialPrefix, n))
if _, err := os.Stat(dir); os.IsNotExist(err) {
return dir
}
}
return filepath.Join(base, partialPrefix+"overflow")
}
+199
View File
@@ -0,0 +1,199 @@
package studio
import (
"encoding/json"
"fmt"
"math/rand/v2"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"salty/terrain/internal/overlay"
"salty/terrain/internal/planet"
)
// Generating the annotation layer from inside the studio.
//
// `terrain overlay` already does this from the command line, and the reason to have it here as well is that
// generation is not a step in a pipeline - it is a *draft*. An author presses it, looks at where the towns
// landed, presses it again, and keeps the third one. That loop only works where the sheet is already on
// screen and editable, which is here.
//
// Two things it does that the command does not, and both exist because a button is pressed repeatedly:
//
// - **Every press is a new seed.** The painting fixes where the land is; the seed decides everything it
// does not. So the button is a re-roll by construction, which is what makes looking at three drafts
// cheap.
// - **It replaces the last draft rather than piling on top of it.** The generator never overwrites a
// painted pixel, and after one press its own output *is* painted pixels - so a second press would
// generate around the first and the sheet would silt up. The server remembers exactly which pixels the
// last generation put down and clears those, and only those, before generating again. Hand-painted work
// is never in that set and so is never touched.
//
// It uses the newest bake when there is one and the painting alone when there is not, and says which. The
// difference is not cosmetic: without a solve there are no rivers to sit on and no slope to avoid, so the
// draft is a sketch.
// bakePrefix matches the command's. A directory is a bake if it is this plus an integer.
const bakePrefix = "Bake_"
// latestBake is the newest Bake_NNN beside the manifest, or "" when the planet has never been baked.
//
// Newest by *number* rather than by modification time: the numbers are the order the bakes were made, and a
// directory touched by a backup tool is not a newer bake.
func latestBake(base string) string {
entries, err := os.ReadDir(base)
if err != nil {
return ""
}
best, bestN := "", -1
for _, e := range entries {
if !e.IsDir() || !strings.HasPrefix(e.Name(), bakePrefix) {
continue
}
n, err := strconv.Atoi(strings.TrimPrefix(e.Name(), bakePrefix))
if err != nil || n <= bestN {
continue
}
// A directory that has no heightmap in it is a bake that was interrupted, and reading one would fail
// later with a worse message than simply not choosing it.
if _, err := os.Stat(filepath.Join(base, e.Name(), "planet_height.png")); err != nil {
continue
}
best, bestN = filepath.Join(base, e.Name()), n
}
return best
}
type genReply struct {
OK bool `json:"ok"`
Seed int64 `json:"seed"`
Bake string `json:"bake"`
FromBake bool `json:"from_bake"`
Lines []string `json:"lines"`
Marks []string `json:"marks"`
}
// handleOverlayGenerate fills the annotation sheet in from the world, and hands the page back a summary.
func (s *Server) handleOverlayGenerate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST", http.StatusMethodNotAllowed)
return
}
var req struct {
Seed int64 `json:"seed"`
}
// An empty body is allowed: it means "pick a seed for me", which is what the button sends.
_ = json.NewDecoder(r.Body).Decode(&req)
s.mu.Lock()
defer s.mu.Unlock()
if s.ov == nil {
http.Error(w, "this planet has no overlay legend; set planet.overlay_legend first",
http.StatusNotFound)
return
}
wants := false
for i := range s.ov.Marks {
if s.ov.Marks[i].Generate != nil {
wants = true
break
}
}
if !wants {
http.Error(w, "no mark in the overlay legend has a `generate` block, so there is nothing to "+
"generate. Generation is opt-in per mark; see the overlay section of the templates README",
http.StatusBadRequest)
return
}
seed := req.Seed
if seed == 0 {
seed = int64(rand.Uint64()>>16) + 1
}
// The plan's prepare, which is where the class raster and the projected map come from. Reused when it is
// warm - the usual case, because an author plans before they look at anything - and built when it is not.
in := s.cache
if in == nil || s.cacheKey != s.planKey() {
var err error
in, err = planet.Prepare(s.m, func(string, ...any) {})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.cache, s.cacheKey = in, s.planKey()
}
// The sheet as it is on screen, with the last generation taken back out of it. Everything an author
// painted stays; everything the previous press put down goes, which is what makes this a re-roll rather
// than an accumulation.
existing := s.overlayRasterLocked()
cleared := 0
for i, m := range s.ovGen {
if m != overlay.Blank && i < len(existing.Mark) && existing.Mark[i] == m {
existing.Mark[i] = overlay.Blank
cleared++
}
}
// The newest bake, but only if it is a bake of *this* painting: two paintings of the same planet encode
// their heightmaps identically, so nothing else would catch it and the draft would be placed against
// terrain from another world. Decided here as well as inside the generator so that the line the page
// prints says which path actually ran.
bake := latestBake(filepath.Dir(s.manifestPath))
if bake != "" {
if ok, _ := planet.BakeIsOfThisPainting(bake, s.m); !ok {
bake = ""
}
}
var lines []string
log := func(format string, a ...any) { lines = append(lines, fmt.Sprintf(format, a...)) }
ras, rep, err := planet.GenerateOverlay(planet.OverlayGenOptions{
In: in, BakeDir: bake, Seed: seed, Existing: existing, Log: log,
})
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Back onto the sheet the author is looking at. The raster is the whole truth here - it already contains
// the pixels that were kept - so this is a straight encode rather than a merge.
px, alpha := s.ov.Encode(ras)
copy(s.ovPaint, px)
copy(s.ovAlpha, alpha)
s.ovDirty = true
// What this generation put down, so the next press can take it back out again. Only the cells that were
// blank before it ran: a mark sitting where the author painted one is theirs, not ours.
if s.ovGen == nil {
s.ovGen = make([]uint8, s.paintW*s.paintH)
}
for i := range s.ovGen {
if i < len(existing.Mark) && existing.Mark[i] == overlay.Blank && ras.Mark[i] != overlay.Blank {
s.ovGen[i] = ras.Mark[i]
} else {
s.ovGen[i] = overlay.Blank
}
}
reply := genReply{OK: true, Seed: seed, FromBake: bake != "", Lines: lines}
if bake != "" {
reply.Bake = filepath.Base(bake)
}
if cleared > 0 {
reply.Lines = append(reply.Lines, fmt.Sprintf("re-rolled: %d px of the last draft cleared first", cleared))
}
reply.Marks = planet.OverlaySummary(rep, ras.W, ras.H)
writeJSON(w, reply)
}
// overlayRasterLocked classifies the live sheet into marks. s.mu must be held.
func (s *Server) overlayRasterLocked() *overlay.Raster {
ras, _ := s.ov.Classify(s.ovPaint, s.ovAlpha, s.paintW, s.paintH)
return ras
}
File diff suppressed because it is too large Load Diff
+370
View File
@@ -0,0 +1,370 @@
package studio
import (
"fmt"
"strconv"
"strings"
)
// Editing a legend in place, without reformatting it.
//
// The obvious way to save a legend the studio has changed is to unmarshal it, set the fields and marshal it
// back. That destroys the file. A legend is mostly *commentary* - the `_comment_massif` block on `lowland` is
// four lines explaining why its floor is a tenth of its rate - and unmarshalling into the Class struct drops
// every underscore key on the floor. Unmarshalling into map[string]any keeps them and loses the order
// instead, because encoding/json sorts map keys, so the hand-laid table comes back alphabetised with every
// comment moved away from the thing it was commenting on.
//
// The same argument the palette writer already makes, one file over: this repository does not let
// MarshalIndent near a file a person wrote. So the studio patches the *text*. It finds the object for a named
// class and replaces one key's value inside it, or inserts the key if it is not there, and every byte it did
// not deliberately change comes out identical. That also means a legend edited here still diffs usefully,
// which for a file under review is most of the point.
// patchClassNumber sets one numeric key on one class, adding it if it is absent. The returned text is the
// input with exactly that value changed.
func patchClassNumber(src, class, key string, value float64) (string, error) {
start, end, err := classObject(src, class)
if err != nil {
return "", err
}
body := src[start:end]
num := formatNumber(value)
if ks, ke, ok := keyValue(body, key); ok {
return src[:start] + body[:ks] + fmt.Sprintf("%q: %s", key, num) + body[ke:] + src[end:], nil
}
// Not present: put it after the class's name, which is where a reader looks for it and which every class
// is guaranteed to have.
ns, ne, ok := keyValue(body, "name")
if !ok {
return "", fmt.Errorf("class %q has no name key to insert %q after", class, key)
}
_ = ns
ins := fmt.Sprintf(", %q: %s", key, num)
return src[:start] + body[:ne] + ins + body[ne:] + src[end:], nil
}
// patchClassRemove deletes one key from one class, taking its separating comma with it. Absent is not an
// error: the studio sends "this mark no longer says anything about the coast" whether or not it ever did.
func patchClassRemove(src, class, key string) (string, error) {
start, end, err := classObject(src, class)
if err != nil {
return "", err
}
body := src[start:end]
ks, ke, ok := keyValue(body, key)
if !ok {
return src, nil
}
// A key goes with exactly one of the two separators around it, and which one depends on where it sits.
// The test is a round trip: adding a key and taking it away again has to give the file back byte for
// byte, or every later diff carries the scar of a setting somebody tried once.
s, e := ks, ke
j := e
for j < len(body) && isJSONSpace(body[j]) {
j++
}
if j < len(body) && body[j] == ',' {
// Not the last key: take the comma after it, and the space that followed that comma in place of the
// one that preceded this key.
e = j + 1
if e < len(body) && body[e] == ' ' && s > 0 && body[s-1] == ' ' {
e++
}
} else {
// The last key in the object: there is no comma after it, so take the one before - and nothing
// forward, or the space in front of the closing brace goes with it.
for s > 0 && isJSONSpace(body[s-1]) {
s--
}
if s > 0 && body[s-1] == ',' {
s--
}
}
return src[:start] + body[:s] + body[e:] + src[end:], nil
}
func isJSONSpace(c byte) bool { return c == ' ' || c == '\n' || c == '\r' || c == '\t' }
// patchClassObject sets one object-valued key on one class - the massif block - or removes it when nil.
func patchClassObject(src, class, key string, fields map[string]float64, order []string) (string, error) {
start, end, err := classObject(src, class)
if err != nil {
return "", err
}
body := src[start:end]
var lit string
if fields != nil {
parts := make([]string, 0, len(order))
for _, k := range order {
v, ok := fields[k]
if !ok {
continue
}
parts = append(parts, fmt.Sprintf("%q: %s", k, formatNumber(v)))
}
lit = fmt.Sprintf("%q: { %s }", key, strings.Join(parts, ", "))
}
if ks, ke, ok := keyValue(body, key); ok {
if lit == "" {
// Removing it: take the separating comma with it, whichever side it is on.
s, e := ks, ke
for e < len(body) && (body[e] == ' ' || body[e] == '\n' || body[e] == '\r' || body[e] == '\t') {
e++
}
if e < len(body) && body[e] == ',' {
e++
} else {
for s > 0 && (body[s-1] == ' ' || body[s-1] == '\n' || body[s-1] == '\r' || body[s-1] == '\t') {
s--
}
if s > 0 && body[s-1] == ',' {
s--
}
}
return src[:start] + body[:s] + body[e:] + src[end:], nil
}
return src[:start] + body[:ks] + lit + body[ke:] + src[end:], nil
}
if lit == "" {
return src, nil // asked to remove something that is not there
}
// Inserted at the end of the class object, on a line of its own. Straight after the name would read
// better in a one-line class and reads badly in exactly the ones that matter: a class carrying
// commentary is written over several lines, and splicing into the middle of the first one leaves the
// rest of that line dangling behind the insertion.
brace := len(body) - 1
for brace > 0 && body[brace] != '}' {
brace--
}
head := strings.TrimRight(body[:brace], " \t\r\n")
return src[:start] + head + ",\n " + lit + "\n " + body[brace:] + src[end:], nil
}
// patchTopNumber sets a numeric key inside a named top-level object, such as the manifest's planet block.
func patchTopNumber(src, object, key string, value float64) (string, error) {
os, oe, err := objectAfterKey(src, object, 0)
if err != nil {
return "", err
}
body := src[os:oe]
num := formatNumber(value)
if ks, ke, ok := keyValue(body, key); ok {
return src[:os] + body[:ks] + fmt.Sprintf("%q: %s", key, num) + body[ke:] + src[oe:], nil
}
// Insert just inside the opening brace, on its own line.
return src[:os+1] + fmt.Sprintf("\n %q: %s,", key, num) + src[os+1:], nil
}
// patchTopString is patchTopNumber for a string value.
func patchTopString(src, object, key, value string) (string, error) {
os, oe, err := objectAfterKey(src, object, 0)
if err != nil {
return "", err
}
body := src[os:oe]
if ks, ke, ok := keyValue(body, key); ok {
return src[:os] + body[:ks] + fmt.Sprintf("%q: %q", key, value) + body[ke:] + src[oe:], nil
}
return src[:os+1] + fmt.Sprintf("\n %q: %q,", key, value) + src[os+1:], nil
}
// classObject is the byte range of the object in the classes array whose "name" is the one asked for,
// from its opening brace to just past its closing one.
func classObject(src, class string) (start, end int, err error) {
want := fmt.Sprintf("%q", class)
from := 0
for {
i := indexKeyValue(src, "name", want, from)
if i < 0 {
return 0, 0, fmt.Errorf("no class named %q in the legend", class)
}
// Walk back to the opening brace of the object this key sits in.
depth := 0
j := i
for ; j >= 0; j-- {
switch src[j] {
case '}':
depth++
case '{':
if depth == 0 {
s, e, ok := matchBrace(src, j)
if ok {
return s, e, nil
}
return 0, 0, fmt.Errorf("class %q: unbalanced braces", class)
}
depth--
}
}
from = i + 1
}
}
// objectAfterKey is the byte range of the object that is the value of the given key.
func objectAfterKey(src, key string, from int) (start, end int, err error) {
i := indexKey(src, key, from)
if i < 0 {
return 0, 0, fmt.Errorf("no %q object", key)
}
j := i
for j < len(src) && src[j] != '{' {
if src[j] == ',' || src[j] == '}' {
return 0, 0, fmt.Errorf("%q is not an object", key)
}
j++
}
if j >= len(src) {
return 0, 0, fmt.Errorf("%q is not an object", key)
}
s, e, ok := matchBrace(src, j)
if !ok {
return 0, 0, fmt.Errorf("%q: unbalanced braces", key)
}
return s, e, nil
}
// keyValue finds "key": value inside a body and returns the range covering both, value included.
func keyValue(body, key string) (start, end int, ok bool) {
i := indexKey(body, key, 0)
if i < 0 {
return 0, 0, false
}
// Past the colon, then over the value.
j := i
for j < len(body) && body[j] != ':' {
j++
}
j++
for j < len(body) && (body[j] == ' ' || body[j] == '\t' || body[j] == '\n' || body[j] == '\r') {
j++
}
if j >= len(body) {
return 0, 0, false
}
switch body[j] {
case '{':
_, e, ok := matchBrace(body, j)
if !ok {
return 0, 0, false
}
return i, e, true
case '[':
depth, k := 0, j
for ; k < len(body); k++ {
if body[k] == '[' {
depth++
} else if body[k] == ']' {
depth--
if depth == 0 {
return i, k + 1, true
}
}
}
return 0, 0, false
case '"':
k := j + 1
for ; k < len(body); k++ {
if body[k] == '\\' {
k++
continue
}
if body[k] == '"' {
return i, k + 1, true
}
}
return 0, 0, false
default:
k := j
for k < len(body) && body[k] != ',' && body[k] != '}' && body[k] != '\n' {
k++
}
// A bare number or keyword ends where the scan stopped, but the scan does not stop on a space, so
// `"width_m": 8 }` would otherwise hand back the space in front of the brace as part of the value -
// and every edit of a last-in-object key would quietly close it up to `8}`.
for k > j && isJSONSpace(body[k-1]) {
k--
}
return i, k, true
}
}
// indexKey finds the offset of a "key" token at object level, skipping any inside a string value.
func indexKey(s, key string, from int) int {
needle := fmt.Sprintf("%q", key)
for i := from; ; {
j := strings.Index(s[i:], needle)
if j < 0 {
return -1
}
at := i + j
// It is a key only if the next non-space character is a colon.
k := at + len(needle)
for k < len(s) && (s[k] == ' ' || s[k] == '\t') {
k++
}
if k < len(s) && s[k] == ':' {
return at
}
i = at + len(needle)
}
}
// indexKeyValue finds a "key": "value" pair and returns the offset of the key.
func indexKeyValue(s, key, quotedValue string, from int) int {
for i := from; ; {
at := indexKey(s, key, i)
if at < 0 {
return -1
}
_, e, ok := keyValue(s[at:], key)
if ok {
seg := strings.TrimSpace(s[at : at+e])
if strings.HasSuffix(seg, quotedValue) {
return at
}
}
i = at + 1
}
}
// matchBrace returns the range of the object opening at i.
func matchBrace(s string, i int) (start, end int, ok bool) {
depth := 0
inStr := false
for j := i; j < len(s); j++ {
c := s[j]
if inStr {
if c == '\\' {
j++
} else if c == '"' {
inStr = false
}
continue
}
switch c {
case '"':
inStr = true
case '{':
depth++
case '}':
depth--
if depth == 0 {
return i, j + 1, true
}
}
}
return 0, 0, false
}
// formatNumber writes a number the way a person would: no exponent, no trailing zeros, and never bare "0."
func formatNumber(v float64) string {
s := strconv.FormatFloat(v, 'f', -1, 64)
if s == "-0" {
return "0"
}
return s
}
+231
View File
@@ -0,0 +1,231 @@
package studio
import (
"os"
"path/filepath"
"strings"
"testing"
)
const legendSrc = `{
"_comment": "what the colours mean",
"image": "Map3.jpg",
"classes": [
{ "name": "ocean", "rgb": [91, 175, 185], "sea": true, "depth_m": 512 },
{ "_comment_plain": "why the floor is a tenth of the rate, at length",
"name": "lowland", "rgb": [153, 204, 102], "uplift_mm_yr": 0.08, "k_mult": 1.0,
"massif": { "floor_mm_yr": 0.012, "fraction": 0.16 },
"coastal_plain_km": 1.0 },
{ "name": "highland", "rgb": [68, 170, 102], "uplift_mm_yr": 0.25, "k_mult": 1.0 }
]
}`
// The whole reason this is text surgery and not MarshalIndent: a legend is mostly commentary, and the
// commentary has to survive a save byte for byte.
func TestPatchingKeepsEverythingItDidNotChange(t *testing.T) {
out, err := patchClassNumber(legendSrc, "lowland", "uplift_mm_yr", 0.12)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, `"uplift_mm_yr": 0.12`) {
t.Error("the new value is not there")
}
if strings.Contains(out, `"uplift_mm_yr": 0.08`) {
t.Error("the old value is still there")
}
if !strings.Contains(out, `"_comment_plain": "why the floor is a tenth of the rate, at length"`) {
t.Error("the comment was dropped")
}
if !strings.Contains(out, `"uplift_mm_yr": 0.25`) {
t.Error("the other class's rate was touched")
}
// And nothing else moved: the only difference from the original is those four characters.
if a, b := strings.Replace(out, "0.12", "0.08", 1), legendSrc; a != b {
t.Errorf("the file changed somewhere else:\n--- got\n%s\n--- want\n%s", a, b)
}
}
func TestPatchingAddsAKeyThatIsNotThere(t *testing.T) {
out, err := patchClassNumber(legendSrc, "highland", "coastal_plain_km", 4)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, `"coastal_plain_km": 4`) {
t.Fatalf("the key was not added:\n%s", out)
}
if !strings.Contains(out, `"name": "highland", "coastal_plain_km": 4`) {
t.Errorf("it did not go in after the name:\n%s", out)
}
}
func TestPatchingAMassifBlock(t *testing.T) {
order := []string{"floor_mm_yr", "fraction"}
out, err := patchClassObject(legendSrc, "lowland", "massif",
map[string]float64{"floor_mm_yr": 0.02, "fraction": 0.25}, order)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, `"massif": { "floor_mm_yr": 0.02, "fraction": 0.25 }`) {
t.Fatalf("the massif block was not rewritten:\n%s", out)
}
// Adding one to a class that has none.
out, err = patchClassObject(legendSrc, "highland", "massif",
map[string]float64{"floor_mm_yr": 0.045, "fraction": 0.3}, order)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, `"massif": { "floor_mm_yr": 0.045, "fraction": 0.3 }`) {
t.Fatalf("the massif block was not added:\n%s", out)
}
}
func TestRemovingAMassifBlock(t *testing.T) {
out, err := patchClassObject(legendSrc, "lowland", "massif", nil, nil)
if err != nil {
t.Fatal(err)
}
if strings.Contains(out, "massif") {
t.Fatalf("the massif block is still there:\n%s", out)
}
// The class has to still parse: no doubled or dangling comma where it was.
if strings.Contains(out, ",,") || strings.Contains(out, ", }") && !strings.Contains(legendSrc, ", }") {
t.Errorf("the comma was left in a bad state:\n%s", out)
}
}
func TestPatchingARefusesAClassThatIsNotThere(t *testing.T) {
if _, err := patchClassNumber(legendSrc, "tundra", "uplift_mm_yr", 0.1); err == nil {
t.Fatal("it accepted a class the legend does not have")
}
}
const manifestSrc = `{
"level": "/Game/Maps/L_Planet",
"planet": {
"_comment_scale": "why the cell is eight metres",
"template": "Templates/Map3.jpg",
"circumference_km": 100,
"coast_jitter_px": 48
}
}`
func TestPatchingTheManifestPlanetBlock(t *testing.T) {
out, err := patchTopNumber(manifestSrc, "planet", "coast_jitter_px", 64)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, `"coast_jitter_px": 64`) {
t.Fatalf("not patched:\n%s", out)
}
if !strings.Contains(out, `"_comment_scale"`) {
t.Error("the comment was dropped")
}
out, err = patchTopNumber(manifestSrc, "planet", "massif_wavelength_km", 7)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, `"massif_wavelength_km": 7`) {
t.Fatalf("a missing key was not added:\n%s", out)
}
out, err = patchTopString(manifestSrc, "planet", "template", "Templates/Map3.png")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, `"template": "Templates/Map3.png"`) {
t.Fatalf("the template path was not repointed:\n%s", out)
}
}
// The base map is an input a person made by hand and there is no undo for it outside this process. Saving
// versions rather than overwriting is the whole contract, and the second half of it is that saving twice
// gives _001 and _002 rather than _001 and _001_002.
func TestSavingNeverOverwritesAndNumbersUpwards(t *testing.T) {
dir := t.TempDir()
base := filepath.Join(dir, "Map3.jpg")
if err := os.WriteFile(base, []byte("the base map"), 0o644); err != nil {
t.Fatal(err)
}
first, err := nextVersion(base)
if err != nil {
t.Fatal(err)
}
if filepath.Base(first) != "Map3_001.png" {
t.Errorf("first save went to %q, want Map3_001.png", filepath.Base(first))
}
if err := os.WriteFile(first, []byte("v1"), 0o644); err != nil {
t.Fatal(err)
}
// Asked again from the *versioned* path, which is what the manifest now points at.
second, err := nextVersion(first)
if err != nil {
t.Fatal(err)
}
if filepath.Base(second) != "Map3_002.png" {
t.Errorf("second save went to %q, want Map3_002.png", filepath.Base(second))
}
// And the base map is still exactly what it was.
if b, err := os.ReadFile(base); err != nil || string(b) != "the base map" {
t.Errorf("the base map was touched: %q %v", b, err)
}
}
// The overlay legend is patched by the same three functions - a mark is an object with a "name" like a class
// is - so the thing worth testing separately is the one that is new: removing a key.
const overlaySrc = `{
"image": "Map3.overlay.png",
"marks": [
{ "_comment": "why this shore is pinned, at length",
"name": "drawn_coast", "rgb": [255, 0, 255], "coast_jitter": 0 },
{ "name": "forest", "rgb": [0, 128, 0] },
{ "name": "road", "rgb": [90, 60, 30], "kind": "path", "width_m": 8 }
]
}`
func TestRemovingAKeyLeavesNoTrace(t *testing.T) {
// Add one, then take it away: the file has to come back exactly as it started, or every later diff
// carries the scar of a setting somebody tried once.
with, err := patchClassNumber(overlaySrc, "forest", "coast_jitter", 0.5)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(with, `"coast_jitter": 0.5`) {
t.Fatalf("the key was not added:\n%s", with)
}
back, err := patchClassRemove(with, "forest", "coast_jitter")
if err != nil {
t.Fatal(err)
}
if back != overlaySrc {
t.Errorf("a round trip changed the file:\n--- want ---\n%s\n--- got ---\n%s", overlaySrc, back)
}
// Removing one that is not there is not an error: the studio sends "this mark says nothing about the
// coast" whether or not it ever did.
same, err := patchClassRemove(overlaySrc, "road", "coast_jitter")
if err != nil || same != overlaySrc {
t.Errorf("removing an absent key should be a no-op; err=%v changed=%v", err, same != overlaySrc)
}
// And the one that is there, on a mark carrying commentary.
out, err := patchClassRemove(overlaySrc, "drawn_coast", "coast_jitter")
if err != nil {
t.Fatal(err)
}
if strings.Contains(out, "coast_jitter") {
t.Error("the key is still there")
}
if !strings.Contains(out, `"_comment": "why this shore is pinned, at length"`) {
t.Error("the comment went with it")
}
if !strings.Contains(out, `"name": "drawn_coast", "rgb": [255, 0, 255] }`) {
t.Errorf("the trailing comma and its space were not cleaned up:\n%s", out)
}
}
+303
View File
@@ -0,0 +1,303 @@
package studio
import (
"bytes"
"encoding/json"
"fmt"
"image"
"image/png"
"io"
"net/http"
"os"
"path/filepath"
"salty/terrain/internal/plates"
"salty/terrain/internal/template"
)
// The tectonic layer in the studio: a third sheet beside the geology and the annotation.
//
// It is held, served and saved exactly as the other two are, and the one thing worth writing down is why it
// is resampled on the way in.
//
// A tectonic layer does not have to be the template's size. plates.FromPainting registers it by *extent*, and
// the layer `terrain plan --propose-plates` writes is a few thousand pixels wide because a plate is tens of
// kilometres across and nothing downstream reads finer than the 250 m tectonic grid. The studio's canvas, its
// brush, its undo and its tile upload all assume every sheet is the template's size, though - D-61's whole
// design rests on one geometry shared by every layer - and generalising them to three resolutions would be a
// great deal of code for a picture of seven blobs. So the layer is upsampled to the template's size on the
// way in and saved at that size. It costs nothing on disk: it is a handful of flat colours, and PNG stores
// that in a few kilobytes however large the canvas is.
//
// **A blank tectonic layer is not empty, it is one plate.** The overlay starts transparent because most of an
// annotation is nothing; here every pixel is some piece of lithosphere, so a sheet that has never been
// painted starts as the legend's first plate all over. That is the class template's rule rather than the
// overlay's, and it is the same rule plates.nearestPlate follows when it refuses to leave a pixel unassigned.
// loadPlates reads the tectonic layer, or starts a blank one the right size.
func (s *Server) loadPlates() error {
if s.m.PlatesLegendPath() == "" {
return nil
}
lg, err := plates.LoadPaintLegend(s.m.PlatesLegendPath())
if err != nil {
return err
}
s.pl = lg
if path := s.platesImagePath(); path != "" {
if _, statErr := os.Stat(path); statErr == nil {
px, w, h, err := template.DecodeRGB(path)
if err != nil {
return err
}
s.plPaint = resampleRGB(px, w, h, s.paintW, s.paintH)
s.plOnDisk = true
return nil
}
}
s.plPaint = blankPlates(lg, s.paintW*s.paintH)
return nil
}
// blankPlates is a sheet of the legend's first plate: see the note above about a blank layer being one plate
// rather than nothing.
func blankPlates(lg *plates.PaintLegend, cells int) []uint8 {
out := make([]uint8, cells*3)
if len(lg.Plates) == 0 {
return out
}
c := lg.Plates[0].RGB
for i := 0; i < cells; i++ {
out[i*3], out[i*3+1], out[i*3+2] = uint8(c[0]), uint8(c[1]), uint8(c[2])
}
return out
}
// resampleRGB scales a sheet to a new size by nearest neighbour.
//
// Nearest, never interpolated. Every pixel of this layer is a plate id wearing a colour, and a blend of two
// plate colours is a third plate as far as nearestPlate is concerned - so a bilinear resample would paint a
// one-pixel ribbon of some unrelated plate down every margin on the planet.
func resampleRGB(src []uint8, sw, sh, dw, dh int) []uint8 {
if sw == dw && sh == dh {
out := make([]uint8, len(src))
copy(out, src)
return out
}
out := make([]uint8, dw*dh*3)
for y := 0; y < dh; y++ {
sy := y * sh / dh
for x := 0; x < dw; x++ {
sx := x * sw / dw
s := (sy*sw + sx) * 3
d := (y*dw + x) * 3
out[d], out[d+1], out[d+2] = src[s], src[s+1], src[s+2]
}
}
return out
}
// platesImagePath is the layer the manifest names, or the one its legend names beside itself.
func (s *Server) platesImagePath() string {
if p := s.m.PlatesLayerPath(); p != "" {
return p
}
if s.pl != nil && s.pl.Image != "" {
return filepath.Join(filepath.Dir(s.m.PlatesLegendPath()), s.pl.Image)
}
return ""
}
func (s *Server) handlePlatesPNG(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
if s.pl == nil {
s.mu.Unlock()
http.Error(w, "no tectonic layer is configured", http.StatusNotFound)
return
}
img := rgbaFrom(s.plPaint, s.paintW, s.paintH)
s.mu.Unlock()
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "no-store")
_ = (&png.Encoder{CompressionLevel: png.BestSpeed}).Encode(w, img)
}
// rgbaFrom turns an RGB sheet into an opaque image ready to encode.
func rgbaFrom(px []uint8, w, h int) *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, w, h))
for i, n := 0, w*h; i < n; i++ {
img.Pix[i*4] = px[i*3]
img.Pix[i*4+1] = px[i*3+1]
img.Pix[i*4+2] = px[i*3+2]
img.Pix[i*4+3] = 255
}
return img
}
// handlePlatesPost takes the browser's tectonic canvas back, and saves it when asked.
func (s *Server) handlePlatesPost(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 256<<20))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
img, err := png.Decode(bytes.NewReader(body))
if err != nil {
http.Error(w, "the body is not a PNG: "+err.Error(), http.StatusBadRequest)
return
}
b := img.Bounds()
s.mu.Lock()
defer s.mu.Unlock()
if s.pl == nil {
http.Error(w, "no tectonic layer is configured", http.StatusNotFound)
return
}
if b.Dx() != s.paintW || b.Dy() != s.paintH {
http.Error(w, fmt.Sprintf("the tectonic canvas is %dx%d and the template is %dx%d",
b.Dx(), b.Dy(), s.paintW, s.paintH), http.StatusBadRequest)
return
}
changed := false
for y := 0; y < s.paintH; y++ {
for x := 0; x < s.paintW; x++ {
cr, cg, cb, _ := img.At(b.Min.X+x, b.Min.Y+y).RGBA()
o := (y*s.paintW + x) * 3
r8, g8, b8 := uint8(cr>>8), uint8(cg>>8), uint8(cb>>8)
if s.plPaint[o] != r8 || s.plPaint[o+1] != g8 || s.plPaint[o+2] != b8 {
changed = true
}
s.plPaint[o], s.plPaint[o+1], s.plPaint[o+2] = r8, g8, b8
}
}
if changed {
s.plSeq++
s.plDirty = true
}
if r.URL.Query().Get("save") == "1" {
path, repointed, err := s.savePlates()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.plDirty, s.plOnDisk = false, true
writeJSON(w, map[string]any{"saved": path, "repointed": repointed})
return
}
writeJSON(w, map[string]any{"ok": true})
}
// savePlates writes the layer to the next free numbered PNG and points the manifest at it. Same rule as the
// other two sheets and for the same reason: it never overwrites, because there is no undo for a painting
// outside this process. A layer that has never existed is written at the name already asked for, which is not
// an overwrite because nothing is there.
func (s *Server) savePlates() (path string, repointed bool, err error) {
src := s.platesImagePath()
if src == "" {
return "", false, fmt.Errorf("%s names no tectonic layer and its legend names none either; set "+
"planet.plates.layer or the legend's \"image\"", s.manifestPath)
}
if _, statErr := os.Stat(src); os.IsNotExist(statErr) {
path = src
} else {
if path, err = nextVersion(src); err != nil {
return "", false, err
}
}
var buf bytes.Buffer
enc := png.Encoder{CompressionLevel: png.BestCompression}
if err := enc.Encode(&buf, rgbaFrom(s.plPaint, s.paintW, s.paintH)); err != nil {
return "", false, err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return "", false, err
}
if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil {
return "", false, err
}
rel := filepath.ToSlash(filepath.Join(filepath.Dir(s.m.Planet.Plates.Legend), filepath.Base(path)))
if rel != s.m.Planet.Plates.Layer {
if err := s.patchManifest(func(text string) (string, error) {
return patchTopString(text, "plates", "layer", rel)
}); err != nil {
return path, false, err
}
repointed = true
}
return path, repointed, nil
}
// plateEdit is one plate's motion as the page sends it back.
type plateEdit struct {
Plate string `json:"plate"`
SpeedCmYr *float64 `json:"speed_cm_yr"`
HeadingDeg *float64 `json:"heading_deg"`
SpinDegMyr *float64 `json:"spin_deg_myr"`
}
// handlePlatesLegend writes the tectonic legend by patching its text, the same way the class and overlay
// legends are written: the commentary at the top of that file is the only place the heading convention is
// written down, and marshalling the struct back would delete it.
func (s *Server) handlePlatesLegend(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
serveJSONFile(w, s.m.PlatesLegendPath())
return
}
if r.Method != http.MethodPost {
http.Error(w, "POST", http.StatusMethodNotAllowed)
return
}
var edits []plateEdit
if err := json.NewDecoder(r.Body).Decode(&edits); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.mu.Lock()
defer s.mu.Unlock()
path := s.m.PlatesLegendPath()
if path == "" || s.pl == nil {
http.Error(w, "no tectonic layer is configured", http.StatusNotFound)
return
}
text, err := os.ReadFile(path)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
out := string(text)
for _, e := range edits {
for key, v := range map[string]*float64{
"speed_cm_yr": e.SpeedCmYr,
"heading_deg": e.HeadingDeg,
"spin_deg_myr": e.SpinDegMyr,
} {
if v == nil {
continue
}
if out, err = patchClassNumber(out, e.Plate, key, *v); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
}
if err := os.WriteFile(path, []byte(out), 0o644); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := s.reload(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{"saved": path})
}
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
package studio
import (
"net/http"
"os"
)
// Sharing the studio's files with another tool on the same machine.
//
// World Orogen (Tools/Orogen, D-66) reads the same painting and the same legends this studio edits, and until
// now the only way to get them there was a file picker: choose the PNG, choose the legend, choose the overlay,
// choose its legend, retype the manifest's numbers. The studio already holds every one of those - the painting
// in memory, exactly as the next plan will read it - so it serves them, and Orogen loads a planet with one
// button.
//
// Two rules keep this from turning the studio into something a web page can drive:
//
// - **Only GET is shared.** The CORS header goes on GET responses and nothing else, and no preflight is ever
// answered. A cross-origin POST with a JSON body needs a preflight, so every endpoint that paints, saves,
// plans or bakes stays reachable from this page and from nothing else. The studio listens on loopback, but
// a browser on the same machine visits other origins all day, and "any tab can start a two-hour bake" is
// not a property to give away for a convenience.
// - **The files are served as they are on disk.** The legend and the manifest are text somebody wrote, with
// commentary; Orogen reads the same keys the plan does and ignores the rest. Nothing is re-marshalled, so
// what Orogen sees is byte for byte what `terrain plan` will see.
// readOnlyCORS lets any origin *read* the API and touches nothing else.
func readOnlyCORS(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet || r.Method == http.MethodHead {
w.Header().Set("Access-Control-Allow-Origin", "*")
}
h.ServeHTTP(w, r)
})
}
// serveJSONFile answers a GET for one of the planet's JSON files, or says there is none. The POST handlers
// call it first and return, so an endpoint that edits a file also hands the file out.
func serveJSONFile(w http.ResponseWriter, path string) {
if path == "" {
http.Error(w, "the manifest names no such file", http.StatusNotFound)
return
}
data, err := os.ReadFile(path)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(data)
}
@@ -0,0 +1,73 @@
package studio
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
// The one property share.go promises: another origin can read, and cannot do anything else.
func TestCORSIsReadOnly(t *testing.T) {
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
h := readOnlyCORS(inner)
for _, tc := range []struct {
method string
want string
}{
{http.MethodGet, "*"},
{http.MethodHead, "*"},
{http.MethodPost, ""},
{http.MethodOptions, ""},
{http.MethodDelete, ""},
} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(tc.method, "/api/legend", nil))
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != tc.want {
t.Errorf("%s: Access-Control-Allow-Origin = %q, want %q", tc.method, got, tc.want)
}
// No preflight is answered: a browser needs Allow-Methods to send a cross-origin POST, and it never
// gets one.
if got := rec.Header().Get("Access-Control-Allow-Methods"); got != "" {
t.Errorf("%s: Access-Control-Allow-Methods = %q, want none", tc.method, got)
}
}
}
func TestServeJSONFileIsTheFileOnDisk(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "legend.json")
// Commentary and formatting are the point: what Orogen reads is the text the author wrote.
text := "{\n \"_comment\": \"kept\",\n \"classes\": []\n}\n"
if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
serveJSONFile(rec, path)
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
if got := rec.Body.String(); got != text {
t.Errorf("body changed:\n%s\nwant\n%s", got, text)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type %q", ct)
}
// A planet with no overlay legend has "" for its path, and that is a 404 rather than a read of "".
rec = httptest.NewRecorder()
serveJSONFile(rec, "")
if rec.Code != http.StatusNotFound {
t.Errorf("empty path: status %d, want 404", rec.Code)
}
rec = httptest.NewRecorder()
serveJSONFile(rec, filepath.Join(dir, "missing.json"))
if rec.Code != http.StatusNotFound {
t.Errorf("missing file: status %d, want 404", rec.Code)
}
}
+330
View File
@@ -0,0 +1,330 @@
package template
import (
"fmt"
"math"
"salty/terrain/internal/field"
)
// Raster is a class index per pixel, row-major. X wraps; Y does not.
type Raster struct {
W, H int
Class []uint8
}
// At reads a pixel, wrapping X and clamping Y, which is the convention every cylindrical map in this
// tree follows: the left and right edges are the same meridian, the top and bottom are the poles.
func (r *Raster) At(x, y int) uint8 {
x = ((x % r.W) + r.W) % r.W
if y < 0 {
y = 0
} else if y >= r.H {
y = r.H - 1
}
return r.Class[y*r.W+x]
}
// Match is what the classifier saw, and it is the first thing to read when a template comes out wrong.
//
// Every pixel is assigned to its nearest class, so a colour the legend has never heard of does not fail
// the run - it quietly becomes whatever it happens to be closest to. Far and MaxDist are what make that
// visible.
type Match struct {
Total int
Counts []int // per class
Far int // pixels further than the legend's WarnDistance from every class
MaxDist float64
MaxAt [2]int // where the worst one was
// The wrap: how well the painting's left and right edges agree. They are the same meridian, so a
// template that does not wrap produces a real discontinuity down one line of the world and there is no
// way to see it by looking at the picture - the two edges are as far apart on screen as they can be.
WrapRows int // rows compared
WrapDiffer int // rows where the two edges classify differently
WrapLandSea int // rows where one edge is land and the other water: the visible kind
WrapFarEdge int // pixels in the first or last two columns that no class is near
}
func (m Match) String() string {
return fmt.Sprintf("%d px, %d further than the warn distance from any class (worst %.0f at %d,%d)",
m.Total, m.Far, m.MaxDist, m.MaxAt[0], m.MaxAt[1])
}
// WrapReport is the one-line verdict on whether the painting is a cylinder.
func (m Match) WrapReport() string {
if m.WrapRows == 0 {
return "wrap not measured"
}
return fmt.Sprintf("the edges disagree on %d of %d rows (%.1f%%), %d of them land against water; "+
"%d px in the outermost columns match no class",
m.WrapDiffer, m.WrapRows, 100*float64(m.WrapDiffer)/float64(m.WrapRows), m.WrapLandSea, m.WrapFarEdge)
}
// measureWrap compares the first and last columns, which are the same meridian.
func (l *Legend) measureWrap(px []uint8, w, h int, r *Raster, m *Match) {
if w < 2 {
return
}
m.WrapRows = h
warn2 := l.WarnDistance * l.WarnDistance
for y := 0; y < h; y++ {
a := r.Class[y*w]
b := r.Class[y*w+w-1]
if a != b {
m.WrapDiffer++
if l.Classes[a].Sea != l.Classes[b].Sea {
m.WrapLandSea++
}
}
// The outermost columns are where a lossy encoder leaves its halo, and a halo on the seam is a
// stripe of the wrong class down the one line of the world where it cannot be hidden.
for _, x := range [4]int{0, 1, w - 2, w - 1} {
o := (y*w + x) * 3
if float64(l.nearestDist2(int(px[o]), int(px[o+1]), int(px[o+2]))) > warn2 {
m.WrapFarEdge++
}
}
}
}
// nearestDist2 is the squared RGB distance to the closest painted class.
func (l *Legend) nearestDist2(r, g, b int) int {
best := 1 << 30
for ci := range l.Classes {
c := &l.Classes[ci]
if c.Derived {
continue
}
dr, dg, db := r-c.RGB[0], g-c.RGB[1], b-c.RGB[2]
if d := dr*dr + dg*dg + db*db; d < best {
best = d
}
}
return best
}
// Classify assigns every pixel to the nearest class in RGB.
//
// Nearest rather than within-a-tolerance, so the result is total: there is no unclassified pixel to
// decide what to do with later, and a stray artefact - a JPEG ringing overshoot, the one black pixel in
// the left column of the template this was written for - lands on something sensible instead of
// punching a hole in the world. The Match report is what says it happened.
func (l *Legend) Classify(px []uint8, w, h int) (*Raster, Match) {
r := &Raster{W: w, H: h, Class: make([]uint8, w*h)}
// Reduction into pre-allocated indexed slots, never a channel drain: the result must not depend on
// which goroutine finished first (cross-cutting rule 12).
partial := make([]Match, field.BandCount(h))
for i := range partial {
partial[i].Counts = make([]int, len(l.Classes))
}
warn2 := l.WarnDistance * l.WarnDistance
field.RowsIndexed(h, func(band, y0, y1 int) {
p := &partial[band]
for y := y0; y < y1; y++ {
for x := 0; x < w; x++ {
o := (y*w + x) * 3
cr, cg, cb := int(px[o]), int(px[o+1]), int(px[o+2])
best, bestD := -1, 1<<30
for ci := range l.Classes {
c := &l.Classes[ci]
if c.Derived {
continue // never painted, so never matched
}
dr := cr - c.RGB[0]
dg := cg - c.RGB[1]
db := cb - c.RGB[2]
d := dr*dr + dg*dg + db*db
if d < bestD {
bestD, best = d, ci
}
}
r.Class[y*w+x] = uint8(best)
p.Total++
p.Counts[best]++
if float64(bestD) > warn2 {
p.Far++
}
if float64(bestD) > p.MaxDist {
p.MaxDist = float64(bestD)
p.MaxAt = [2]int{x, y}
}
}
}
})
out := Match{Counts: make([]int, len(l.Classes))}
out.MaxAt = [2]int{-1, -1}
for i := range partial {
p := &partial[i]
out.Total += p.Total
out.Far += p.Far
for c, n := range p.Counts {
out.Counts[c] += n
}
// The tie-break keeps the report itself independent of GOMAXPROCS, which changes how many bands
// there are: without it two pixels at the same distance could be reported in either order.
if p.Total > 0 && (p.MaxDist > out.MaxDist ||
(p.MaxDist == out.MaxDist && earlier(p.MaxAt, out.MaxAt))) {
out.MaxDist = p.MaxDist
out.MaxAt = p.MaxAt
}
}
out.MaxDist = math.Sqrt(out.MaxDist) // kept squared through the loops; reported as a distance
l.measureWrap(px, w, h, r, &out)
return r, out
}
func earlier(a, b [2]int) bool {
if b[1] < 0 {
return true
}
if a[1] != b[1] {
return a[1] < b[1]
}
return a[0] < b[0]
}
// DissolveStrokes removes the decoration an artist drew and leaves only classes that mean something.
//
// Two rules, in this order:
//
// 1. A stroke region that touches the top or bottom row of the map is not a stroke. It becomes the
// class its edge_class names. This is what tells a polar ice cap from the white outline drawn
// around every island when both are painted the same white, and it is the whole reason the rule
// exists.
// 2. Every remaining stroke pixel takes the class of the nearest pixel that is not a stroke, measured
// outwards from all of them at once. A ring sitting between land and water is therefore split down
// its middle rather than given wholly to one side, which is the only answer that does not move the
// coastline by the width of the artist's brush.
//
// Returns how many pixels each rule rewrote.
func (r *Raster) DissolveStrokes(l *Legend) (edge, dissolved int) {
stroke := make([]bool, len(l.Classes))
any := false
for i := range l.Classes {
stroke[i] = l.Classes[i].Stroke
any = any || stroke[i]
}
if !any {
return 0, 0
}
edge = r.rewriteEdgeStrokes(l, stroke)
// Rule 2. Seed from every non-stroke pixel that touches a stroke pixel, then walk outwards through
// stroke pixels only. Seeds are pushed in raster order and the queue is FIFO, so the result does not
// depend on anything but the image.
queue := make([]int32, 0, 1<<16)
filled := make([]bool, len(r.Class))
for y := 0; y < r.H; y++ {
for x := 0; x < r.W; x++ {
i := y*r.W + x
if stroke[r.Class[i]] {
continue
}
if r.hasStrokeNeighbour(x, y, stroke) {
queue = append(queue, int32(i))
filled[i] = true
}
}
}
for head := 0; head < len(queue); head++ {
i := int(queue[head])
c := r.Class[i]
x, y := i%r.W, i/r.W
for _, n := range r.neighbours(x, y) {
if n < 0 || filled[n] || !stroke[r.Class[n]] {
continue
}
r.Class[n] = c
filled[n] = true
dissolved++
queue = append(queue, int32(n))
}
}
return edge, dissolved
}
// rewriteEdgeStrokes applies rule 1: flood each stroke class inwards from the poles.
func (r *Raster) rewriteEdgeStrokes(l *Legend, stroke []bool) int {
n := 0
stack := make([]int32, 0, 1<<16)
for ci := range l.Classes {
if !stroke[ci] {
continue
}
to := l.EdgeIndex(ci)
if to < 0 {
continue
}
want := uint8(ci)
become := uint8(to)
stack = stack[:0]
push := func(x, y int) {
i := y*r.W + x
if r.Class[i] == want {
r.Class[i] = become
n++
stack = append(stack, int32(i))
}
}
for x := 0; x < r.W; x++ {
push(x, 0)
push(x, r.H-1)
}
for len(stack) > 0 {
i := int(stack[len(stack)-1])
stack = stack[:len(stack)-1]
x, y := i%r.W, i/r.W
for _, m := range r.neighbours(x, y) {
if m >= 0 && r.Class[m] == want {
r.Class[m] = become
n++
stack = append(stack, int32(m))
}
}
}
}
return n
}
// neighbours is the eight-connected neighbourhood with X wrapped and Y bounded. -1 means off the map,
// which only ever happens past a pole.
func (r *Raster) neighbours(x, y int) [8]int {
var out [8]int
k := 0
for dy := -1; dy <= 1; dy++ {
ny := y + dy
for dx := -1; dx <= 1; dx++ {
if dx == 0 && dy == 0 {
continue
}
if ny < 0 || ny >= r.H {
out[k] = -1
k++
continue
}
nx := x + dx
if nx < 0 {
nx += r.W
} else if nx >= r.W {
nx -= r.W
}
out[k] = ny*r.W + nx
k++
}
}
return out
}
func (r *Raster) hasStrokeNeighbour(x, y int, stroke []bool) bool {
for _, n := range r.neighbours(x, y) {
if n >= 0 && stroke[r.Class[n]] {
return true
}
}
return false
}
+244
View File
@@ -0,0 +1,244 @@
package template
import (
"math"
"salty/terrain/internal/dt"
"salty/terrain/internal/field"
"salty/terrain/internal/noise"
"salty/terrain/internal/world"
)
// A painted coastline is a drawn line, and a coastline is not a drawn line.
//
// This is the Richardson paradox with a brush in it. An author draws a shore as a smooth curve, because that
// is what a hand and a bezier tool produce; a real coast has bays inside bays inside bays and the length you
// measure depends on the ruler you measure it with. Projected straight, the painting's own smoothness
// survives all the way to the heightmap, and the result reads as exactly what it is - a shape somebody drew -
// however good the erosion downstream is. `internal/coast` does this job on the square canvas, where the
// outline is noise to begin with; the painted planet had a manifest key for it, `coast_jitter_px`, which
// until now nothing anywhere read.
//
// **It is a mask on the waterline, not a warp of the painting.** That distinction was measured rather than
// reasoned. Displacing the point each cell asks the painting about - a domain warp - was tried first and it
// cannot cut a bay: a smooth warp of a smooth boundary is another smooth boundary, just wigglier, and at an
// amplitude large enough to fold it back on itself it drags every inland class boundary the same distance.
// What produces bays and headlands is thresholding a *signed distance field*: how far is this cell from the
// waterline, add fractal noise to that distance in metres, and ask again which side of zero it is on. Land
// juts out where the noise is positive and the sea reaches in where it is negative, at every scale the
// octaves cover, and nothing away from the shore moves at all.
//
// Two things follow from doing it this way, and both are the reason to:
//
// - A cell that changes sides needs a class, and the distance transform already knows which one: it
// returns the nearest seed cell as well as the distance to it, so new land takes the class of the land
// it grew from and new sea takes the class of the water that came in. Sea eaten out of a shore becomes
// the surf that was lying against it rather than deep ocean.
// - Small islands have to survive. An islet thirty pixels across, under a noise field whose wavelength is
// four hundred, sees very nearly a constant - so it either sits still or vanishes whole, and vanishing
// whole is how an archipelago disappears between two runs. The amplitude is therefore capped per cell at
// a fraction of the widest land within reach of it, which is a sliding maximum of the land distance.
// A continent sees the full amplitude; an islet gets nibbled instead of deleted.
// Pass indices for the coast mask's noise, above the painted uplift path's 20..25 so neither can reshuffle
// the other.
const (
srcCoastMask = 30
)
// islandGuard is how much of the widest land within reach the mask may eat. Two thirds leaves an islet
// recognisably itself while still giving it a ragged edge; at 1 it can take the whole thing.
const islandGuard = 0.66
// Coast is how the painted waterline is roughened before the painting is projected.
type Coast struct {
// AmplitudePx is the furthest, in template pixels, that the shoreline may move. Zero switches the whole
// thing off and the painting is used exactly as drawn.
AmplitudePx float64
// WavelengthPx is the coarsest octave: the width of the biggest bay it can cut. Octaves halve from
// there, so the finest detail is this over 2^(Octaves-1). Bays come out about this wide and up to
// AmplitudePx deep, so the ratio of the two is what decides whether the coast reads as a rough line or
// as a fjord coast.
WavelengthPx float64
// Octaves and Gain are the fractal structure. A gain near 0.5 makes each scale about as prominent as the
// last, which is the property a real coastline has and a single wobble does not.
Octaves int
Gain float64
// Scale is a per-pixel multiplier on AmplitudePx at the raster's own resolution, from the annotation
// layer's coast_jitter marks. Nil is one everywhere, which is every world before D-57.
//
// It is what makes a hand-drawn coastline hold. The roughening exists because a drawn shore is smooth and
// a real one is not, which is true of a shore nobody thought about and false of one somebody traced off a
// map on purpose; a zero here pins that stretch exactly as painted while the rest of the world is still
// roughened. Above one chews harder, which is the same knob pointed the other way - a fjord coast wants
// more than the planet's own amplitude, not less.
//
// **A negative entry means the pixel carries no instruction**, which is not the same as one. A mark is a
// stroke an author drew along a coastline and it lands on whichever side of the waterline their hand was
// on; if an unmarked cell took the default, a stroke painted on the land would leave the water beside it
// free to march inland anyway and the coast would move regardless. So an uninstructed cell takes the
// instruction from the nearest cell on the other side of the waterline, which the distance transform
// below has already found for a different reason. Painting either side is then enough, and painting over
// the line - which is what a brush does - is enough twice over.
Scale []float32
Seed int64
}
// Amount reports whether this mask does anything.
func (c Coast) Amount() bool {
return c.AmplitudePx > 0 && c.Octaves > 0 && c.WavelengthPx > 0 && c.Gain > 0
}
// maxScale is the largest multiplier any mark asks for, and at least 1. Uninstructed entries are negative and
// do not count; an unmarked world has no Scale at all and gets 1.
func (c Coast) maxScale() float64 {
m := 1.0
for _, v := range c.Scale {
if float64(v) > m {
m = float64(v)
}
}
return m
}
// RoughenCoast returns the painting with its waterline displaced by fractal noise. The receiver is not
// modified: a caller that wants both keeps both, which is what the studio's preview does.
//
// It runs at the paint's own resolution rather than the planet's. That is a third of the cells, the mask's
// scales are quoted in template pixels anyway, and the thing being roughened is the painting - so a template
// re-exported at a different size is the one case where the coast moves, and that is already true of every
// other thing the painting decides.
func (r *Raster) RoughenCoast(l *Legend, p world.Planet, c Coast) *Raster {
if !c.Amount() || r.W == 0 || r.H == 0 {
return r
}
sea := make([]bool, len(l.Classes))
for i := range l.Classes {
sea[i] = l.Classes[i].Sea
}
isSea := make([]bool, len(r.Class))
anySea, anyLand := false, false
for i, cl := range r.Class {
isSea[i] = sea[cl]
if isSea[i] {
anySea = true
} else {
anyLand = true
}
}
if !anySea || !anyLand {
return r // nothing to move: the painting is all one or all the other
}
// Signed distance to the waterline, positive on land, in template pixels, plus the index of the nearest
// cell on the other side - which is where a cell that changes sides gets its class from.
signed := make([]float32, len(r.Class))
other := make([]int32, len(r.Class))
// Seeded on land: for every sea cell, how far to land and which land cell.
d2, near := dt.Transform(invert(isSea), r.W, r.H, true)
for i := range signed {
if isSea[i] {
signed[i] = -float32(math.Sqrt(float64(d2[i])))
other[i] = near[i]
}
}
// Seeded on sea: for every land cell, how far to water and which water cell. Released in turn so the
// two transforms are never both alive - at 29 million pixels each one is a quarter of a gigabyte.
d2, near = dt.Transform(isSea, r.W, r.H, true)
for i := range signed {
if !isSea[i] {
signed[i] = float32(math.Sqrt(float64(d2[i])))
other[i] = near[i]
}
}
d2, near = nil, nil
// How wide the land is near each cell, so an islet cannot be eaten whole. Only land contributes, so a
// lone islet reports its own half-width and not the open water around it.
landOnly := field.New(r.W, r.H, 1)
for i := range signed {
if signed[i] > 0 {
landOnly.Data[i] = signed[i]
}
}
// The window is the furthest the shore could move *anywhere*, which is no longer the plain amplitude: a
// mark asking for more than the planet's own can reach past it, and a guard measured over too small a
// window would under-report how wide the land is and let an islet inside such a mark be eaten whole -
// the one failure this guard exists to stop.
reach := int(c.AmplitudePx*c.maxScale() + 0.5)
widest := field.SlidingMax(landOnly, reach, true)
// The noise, on world coordinates so it wraps at the seam and two runs of the same world agree.
// noise.Lattice wraps modulo its cell count, so the lattice has to be a whole number of cells in the
// noise period; the wavelength is quoted in template pixels and converts through the paint's own scale.
metresPerPx := p.CircumferenceM() / float64(r.W)
cells := int(p.NoisePeriodM/(c.WavelengthPx*metresPerPx) + 0.5)
if cells < 1 {
cells = 1
}
u, v := noise.WorldUV(r.W, r.H, metresPerPx, 0, 0, p.NoisePeriodM)
n := noise.FBMAt(u, v, noise.NewSource(c.Seed, srcCoastMask),
noise.Params{BaseCells: cells, Octaves: c.Octaves, Gain: c.Gain})
// Stretched to its own full range before it is used, so the amplitude means what it says. An fBm stack
// is normalised by the sum of its octave amplitudes, which is the value it would take if every octave
// agreed at once - they never do, so the realised spread is far narrower than 0..1 and a nominal 48 px
// was moving the shore about ten. The same trap as the massif fabric's threshold, and the same fix:
// measure the distribution rather than assume it. Here it is one pass of min/max over the whole painting
// - legitimate because the mask runs once on the whole map and not per region, so there is no second
// caller to disagree with.
n.Normalise()
out := &Raster{W: r.W, H: r.H, Class: make([]uint8, len(r.Class))}
copy(out.Class, r.Class)
field.Rows(r.H, func(y0, y1 int) {
for i := y0 * r.W; i < y1*r.W; i++ {
amp := c.AmplitudePx
if c.Scale != nil {
sc := c.Scale[i]
if sc < 0 {
// Uninstructed: take the instruction from the far side of the waterline. See Coast.Scale.
if j := other[i]; j >= 0 {
sc = c.Scale[j]
}
}
if sc >= 0 {
amp *= float64(sc)
}
}
if g := islandGuard * float64(widest.Data[i]); g < amp {
amp = g
}
if amp <= 0 {
continue
}
d := float64(signed[i]) + amp*(float64(n.Data[i])*2-1)
nowLand := d > 0
if nowLand == !isSea[i] {
continue // this cell did not change sides
}
// It did. Take the class of the nearest cell on the side it has joined, which the transform
// already found: land grows out of the land beside it, and water comes in as the water that
// was lying against the shore.
if j := other[i]; j >= 0 {
out.Class[i] = r.Class[j]
}
}
})
return out
}
func invert(b []bool) []bool {
out := make([]bool, len(b))
for i, v := range b {
out[i] = !v
}
return out
}
@@ -0,0 +1,88 @@
package template
import "salty/terrain/internal/field"
// Despeckle removes the hairline of a class nobody painted that appears along a boundary between two others.
//
// It exists because of a measured failure, and the arithmetic is worth keeping because it will happen to
// anybody who exports a lossy image. Classification gives every pixel its nearest class in RGB, and a codec
// blends across every boundary it finds. On the first template the antialiased edge between `surf`
// (221,238,238) and `lowland` (153,204,102) comes out at about (186,219,174), whose distance to `desert`
// (238,221,153) is **53.8** against **77.9** to either of the colours it was actually mixed from. So every
// temperate coast on the map gained a one-pixel ribbon of desert - 1607 pixels of it nowhere near the real
// desert - and it was invisible until the coast mask made those strays the nearest *land* to a stretch of
// open water and handed their class to every cell it turned into shore. An eleven-pixel band of desert
// appeared along a green continent, and the mask was blamed for it first.
//
// **The test is spatial, and it has to be.** The obvious fix is colorimetric - notice that the pixel lies on
// the line between two class colours and give it to the nearer one - and it was built, measured and thrown
// away, because it cannot work in general and this legend is the proof: `shelf` (153,204,221) sits 10 units
// from the line between `ocean` and `surf`, so a real shelf pixel with a little codec noise on it and a
// genuine ocean/surf blend are the same point in colour space. That rule reclassified 943 000 painted shelf
// pixels. What actually distinguishes a stray is *where* it is: a class nobody painted here occupies a line
// one pixel wide with two other classes on either side of it, and no painted feature at 12.9 m a pixel is
// one pixel wide - the one thing that was, the decorative stroke, has a pass of its own.
//
// Hence a five by five window rather than three by three. A one-pixel ribbon running through the middle of a
// 3x3 holds three of its nine cells and the two classes it divides hold three each, so nothing has a
// majority and the rule cannot fire; over 5x5 the ribbon holds five of twenty-five against ten and ten, which
// is the signature being looked for. A feature two pixels wide already holds ten and is left alone.
func (r *Raster) Despeckle() int {
if r.W < despeckleWindow || r.H < despeckleWindow {
return 0
}
out := make([]uint8, len(r.Class))
copy(out, r.Class)
const rad = despeckleWindow / 2
counts := make([]int32, field.BandCount(r.H)*256)
changed := make([]int, field.BandCount(r.H))
field.RowsIndexed(r.H, func(band, y0, y1 int) {
c := counts[band*256 : band*256+256]
for y := y0; y < y1; y++ {
for x := 0; x < r.W; x++ {
self := r.Class[y*r.W+x]
for dy := -rad; dy <= rad; dy++ {
for dx := -rad; dx <= rad; dx++ {
c[r.At(x+dx, y+dy)]++
}
}
best, bestN := self, int32(0)
for i := range c {
if c[i] > bestN || (c[i] == bestN && uint8(i) < best) {
best, bestN = uint8(i), c[i]
}
}
selfN := c[self]
for i := range c {
c[i] = 0
}
if selfN <= despeckleThin && bestN >= despeckleMajority && best != self {
out[y*r.W+x] = best
changed[band]++
}
}
}
})
total := 0
for _, n := range changed {
total += n
}
r.Class = out
return total
}
const (
// despeckleWindow is five: see the note above on why three is too small to see a ribbon at all.
despeckleWindow = 5
// despeckleThin is how little of its own window a class may hold and still be called a stray. Five of
// twenty-five is a line one pixel wide straight through the middle; a feature two pixels wide holds ten.
despeckleThin = 5
// despeckleMajority is how much of the window the replacement has to hold. Eight of twenty-five means
// there is something clearly there to join, so a pixel in genuinely mixed country is left as it is.
despeckleMajority = 8
)
+154
View File
@@ -0,0 +1,154 @@
// Package template reads a painted world map and turns it into the fields the geology solve needs.
//
// The map is an image; the legend beside it says what each colour means. The image is cylindrical: X
// wraps, Y does not, so the left and right edges are the same meridian and the top and bottom rows are
// the poles. Nothing here knows how big the world is - that is the planet package's job. This package
// only answers "what did the author paint here".
//
// The one rule that governs the whole design is Docs/Terrain-Next.md 3.2: paint the uplift, never the
// height. A painted heightmap is handed to a solver that erodes it into something else and throws away
// the drainage network, which is the reason the generator exists. So a class carries an uplift rate and
// an erodibility, and the solve makes the terrain.
package template
import (
"bufio"
"fmt"
"image"
"os"
// Registered for image.Decode. JPEG is here because the first template anyone painted was a JPEG;
// PNG is what a template should be, because JPEG bleeds colour across every class boundary and the
// classifier then has to clean up after it.
_ "image/jpeg"
_ "image/png"
)
// DecodeRGB reads an image and returns tightly packed 8-bit RGB, three bytes a pixel, row-major.
//
// field.ReadHeightmap cannot be used for this and deliberately is not extended: it decodes PNG only, and
// its fallback branch collapses colour to luma, which is right for a DEM and destroys a painted map -
// two different classes can share a luma and here several nearly do.
func DecodeRGB(path string) (px []uint8, w, h int, err error) {
px, _, w, h, err = decode(path, false)
return px, w, h, err
}
// DecodeRGBA is DecodeRGB with the alpha channel kept alongside.
//
// It exists for the annotation layer and only for it. A class template is opaque by definition - every pixel
// is some class - so throwing alpha away there costs nothing. An overlay is the opposite: it is a transparent
// sheet with strokes on it, most of it is nothing, and "nothing" is exactly what alpha records. An image with
// no alpha comes back fully opaque, which is the right reading of a flattened export.
func DecodeRGBA(path string) (px, alpha []uint8, w, h int, err error) {
return decode(path, true)
}
func decode(path string, wantAlpha bool) (px, alpha []uint8, w, h int, err error) {
f, err := os.Open(path)
if err != nil {
return nil, nil, 0, 0, err
}
defer f.Close()
img, _, err := image.Decode(bufio.NewReaderSize(f, 1<<20))
if err != nil {
return nil, nil, 0, 0, fmt.Errorf("%s: %w", path, err)
}
b := img.Bounds()
w, h = b.Dx(), b.Dy()
if w <= 0 || h <= 0 {
return nil, nil, 0, 0, fmt.Errorf("%s: empty image", path)
}
px = make([]uint8, w*h*3)
if wantAlpha {
alpha = make([]uint8, w*h)
for i := range alpha {
alpha[i] = 255
}
}
// The fast paths matter: a 7738x3761 template is 29 million pixels, and going through the At()
// interface for every one of them costs seconds rather than milliseconds.
switch src := img.(type) {
case *image.RGBA:
// Premultiplied: the RGB bytes are already scaled by alpha, so a half-transparent red reads as a
// darker red. Nothing here un-multiplies it, because every consumer that cares about alpha treats a
// non-opaque pixel as blank and never looks at its colour.
for y := 0; y < h; y++ {
row := src.Pix[(y+b.Min.Y-src.Rect.Min.Y)*src.Stride:]
off := (b.Min.X - src.Rect.Min.X) * 4
for x := 0; x < w; x++ {
o := (y*w + x) * 3
px[o], px[o+1], px[o+2] = row[off+x*4], row[off+x*4+1], row[off+x*4+2]
if alpha != nil {
alpha[y*w+x] = row[off+x*4+3]
}
}
}
case *image.NRGBA:
for y := 0; y < h; y++ {
row := src.Pix[(y+b.Min.Y-src.Rect.Min.Y)*src.Stride:]
off := (b.Min.X - src.Rect.Min.X) * 4
for x := 0; x < w; x++ {
o := (y*w + x) * 3
px[o], px[o+1], px[o+2] = row[off+x*4], row[off+x*4+1], row[off+x*4+2]
if alpha != nil {
alpha[y*w+x] = row[off+x*4+3]
}
}
}
case *image.YCbCr:
// What image/jpeg returns.
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
yi := src.YOffset(b.Min.X+x, b.Min.Y+y)
ci := src.COffset(b.Min.X+x, b.Min.Y+y)
r, g, bl := ycbcrToRGB(src.Y[yi], src.Cb[ci], src.Cr[ci])
o := (y*w + x) * 3
px[o], px[o+1], px[o+2] = r, g, bl
}
}
default:
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
r, g, bl, a := img.At(b.Min.X+x, b.Min.Y+y).RGBA()
o := (y*w + x) * 3
px[o], px[o+1], px[o+2] = uint8(r>>8), uint8(g>>8), uint8(bl>>8)
if alpha != nil {
alpha[y*w+x] = uint8(a >> 8)
}
}
}
}
return px, alpha, w, h, nil
}
// ycbcrToRGB is image/color's conversion, inlined so the YCbCr path does not allocate a color.Color per
// pixel. Same arithmetic, same rounding.
func ycbcrToRGB(y, cb, cr uint8) (uint8, uint8, uint8) {
yy := int32(y) * 0x10101
cb1 := int32(cb) - 128
cr1 := int32(cr) - 128
r := yy + 91881*cr1
if uint32(r)&0xff000000 == 0 {
r >>= 16
} else {
r = ^(r >> 31) & 0xffff >> 8
}
g := yy - 22554*cb1 - 46802*cr1
if uint32(g)&0xff000000 == 0 {
g >>= 16
} else {
g = ^(g >> 31) & 0xffff >> 8
}
b := yy + 116130*cb1
if uint32(b)&0xff000000 == 0 {
b >>= 16
} else {
b = ^(b >> 31) & 0xffff >> 8
}
return uint8(r), uint8(g), uint8(b)
}
+568
View File
@@ -0,0 +1,568 @@
package template
import (
"bytes"
"encoding/json"
"fmt"
"os"
"salty/terrain/internal/field"
)
// Class is one painted colour and everything it means.
//
// A class says whether the author painted sea or land, and for land it carries the two numbers the
// solve actually reads: the rock uplift rate, which is what produces relief, and a multiplier on the
// stream-power erodibility, which is what makes one range read differently from the next. Neither is a
// height. See the package comment.
type Class struct {
Name string `json:"name"`
RGB [3]int `json:"rgb"`
Sea bool `json:"sea"`
// DepthM is how deep this water is, in metres below sea level, positive. It is scenery: the coastal
// pass owns the sea floor within its reach of a shore and lays a derived shelf there, so this only
// decides the open ocean beyond it. Sea only.
DepthM float64 `json:"depth_m"`
// UpliftMmYr is rock uplift in millimetres a year, which is the field everything else is a
// consequence of. The reporting buckets in internal/stats read plain below 0.1, rolling to 0.5 and
// mountain above, so those are the numbers to think in. Land only.
UpliftMmYr float64 `json:"uplift_mm_yr"`
// KMult multiplies the stream-power erodibility K. Soft rock above 1, hard rock below. Land only;
// zero is read as 1, because an erodibility of zero is never what anyone means.
KMult float64 `json:"k_mult"`
// Stroke marks a colour that is decoration rather than data - the white outline an artist draws
// around every island. A stroke is dissolved into whichever real class is nearest, so it never
// becomes a ring of land or a moat of water.
Stroke bool `json:"stroke"`
// Snow marks land that is permanently under ice or snow. It is a *display and material* hint and nothing
// else - it changes no height and enters no pass - but the preview needs it, because the hypsometric ramp
// tops out at snow by elevation and a polar cap at fifty metres therefore comes out the same green as a
// meadow. An ice sheet that reads as a meadow is a map that lies about the one thing it is for.
Snow bool `json:"snow"`
// CoastalPlainKm puts the range inland.
//
// For n = 1 the uplift rate alone fixes the hillslope angle (D-49), so a uniformly painted island sits at
// the angle of repose everywhere, the shore included: the rivers cut down to sea level but the ground
// between them does not care how far from the coast it is. Real coasts have a plain in front of the
// range. This ramps the rate from CoastalFloorMmYr at the waterline up to the class rate over this
// distance inland, so the first few kilometres are plain and the range stands behind them.
//
// It is deliberately opt-in and deliberately not a taper to zero, which is the distinction from D-52:
// that was a *hidden* taper - a side effect of multiplying by the continent mask - and it flattened the
// hundred-metre strip the surf works in, moving every cliff inland. This is an author saying where their
// range starts, and the waterline keeps a real rate.
CoastalPlainKm float64 `json:"coastal_plain_km"`
// CoastalFloorMmYr is the rate at the waterline. Zero means the default, and it is never raised above the
// class rate - a plain in front of a plain is still a plain.
CoastalFloorMmYr float64 `json:"coastal_floor_mm_yr"`
// Massif breaks this class into plain and upland instead of one rate over every cell of it.
Massif *Massif `json:"massif"`
// LithologyMix is how much of the planet's rock field shows through on this class's ground, 0 to 1.
//
// The rock field is one low-frequency pattern over the whole planet, cut into the manifest's
// `pipeline.lithology` types, and it multiplies K on top of this class's own `k_mult`. At 1 the class
// takes all of it; at 0 it is one uniform rock, which is what every painted class was before D-58 and
// what a polar cap or a crater floor should stay - there is no bedrock province showing through an ice
// sheet. A pointer, so "not set" is 1 and "set to zero" is uniform; those are different answers.
LithologyMix *float64 `json:"lithology_mix"`
// Faults places traces in the ground this class was painted on. Absent means none, which is right for a
// plain: faults belong to orogens, and an author saying which classes are faulted is saying where the
// orogens are. See internal/uplift's painted_faults.go for what a trace then does.
Faults *ClassFaults `json:"faults"`
// Crater reshapes this class's painted blobs into rim and floor, *after* the solve.
Crater *Crater `json:"crater"`
// Detail overrides what the detail passes do on this class's ground. Optional; every field left out
// keeps the manifest's pipeline value.
//
// It exists because at the geology grid a class is only an uplift rate and an erodibility, and those two
// numbers cannot tell a desert from a wet lowland - both are "low ground". The difference is at two
// metres: a desert has sparse sharp wadis instead of a dendritic gully network, it holds its mesas and
// ledges because there is no soil creep to round them off, and a good deal of it is dunes.
Detail *ClassDetail `json:"detail"`
// Derived marks a class that is never painted: it takes part in no colour matching and exists only as
// something another class turns into. Its rgb, if it has one, is for the diagnostic maps alone.
//
// The case it exists for is the one every hand-painted world map has. White is drawn twice - as the
// polar caps and as the outline stroke around every island - so exactly one class can own that colour,
// and it has to be the stroke, because the stroke is the one that needs to be recognised everywhere it
// appears. What the caps become is then a class with no colour of its own.
Derived bool `json:"derived"`
// EdgeClass rescues the ambiguous case, which in practice is always white: the same colour is the
// polar ice cap and the outline stroke. A stroke region that touches the top or bottom row of the
// map is not a stroke at all; it becomes the class named here. Everything else dissolves.
EdgeClass string `json:"edge_class"`
}
// ClassDetail is what the detail passes do differently on one class's ground.
type ClassDetail struct {
// DropletsPerCell is how much running water this ground sees. The single most useful number here: drop it
// and the dendritic gully network thins out to isolated channels, which is the difference between a
// rain-fed landscape and an arid one. Zero keeps the pipeline value.
DropletsPerCell float64 `json:"droplets_per_cell"`
// AmplitudeM is the detail noise, low end to high end by slope. Raise it for dune fields - flat desert
// ground with tens of metres of relief on it is a sand sea, and flat ground with two metres is a plain.
AmplitudeM *[2]float64 `json:"amplitude_m"`
// StrataContrast is how hard the hard bands are. Ledges and mesas come from here, and they survive in a
// desert because there is nothing wearing them round.
StrataContrast float64 `json:"strata_contrast"`
}
// Massif breaks one painted colour into plain and upland, which is the difference between a landmass and a
// landscape.
//
// The reason it has to exist is arithmetic. For n = 1 the steady-state divide slope is U/(K*cell^2m), so a
// class's uplift rate *is* its hillslope angle - 0.08 mm/yr is 11.3 degrees at an 8 m cell and K 5e-5 - and a
// class is one rate over every cell an author painted with it. A uniformly painted landmass therefore comes
// out uniformly dissected from the waterline to the summit at whatever angle its rate names, with no flat
// ground anywhere on it, and that is what the first painted planet looked like. Europe away from the Alps is
// not that. It is a plain at a fraction of a degree with isolated massifs standing out of it, and what
// separates the two is not the rate, it is that the rate is not the same everywhere.
//
// So the class rate is re-read as the rate a *massif* reaches, FloorMmYr is the plain between them, and
// Fraction is how much of the ground rises above the halfway point. The cut is made in one field - the
// planet's upland fabric, whose size is planet.massif_wavelength_km - so a highland belt and the hills in the
// lowland next door are outliers of one structure rather than two unrelated noises, which is how a foreland
// works on Earth.
//
// Fraction is a share of the *planet's surface*, and because the fabric knows nothing about the painting it
// is also, in expectation, the share of any one class. The difference is the variance, and the variance is
// the point: a small island may get all of a massif or none of it, exactly as it would if it were a real
// island that happened to sit on or off an orogen. Normalising per landmass would hand every island its
// quota of hills, which is the thing this exists to stop.
type Massif struct {
// FloorMmYr is the plain: the rate everywhere the fabric is low. It is the number that decides whether
// this class has flat ground at all, and it wants to be about a tenth of the class rate - 0.012 mm/yr is
// a 1.7 degree hillslope, which is a plain a player can build on, where 0.08 is continuous hill country.
FloorMmYr float64 `json:"floor_mm_yr"`
// Fraction is how much of this class stands above the midpoint between floor and class rate. Half that
// again reaches the class rate outright and half again above that is off the plain at all, so 0.15 means
// roughly a seventh upland, a quarter touched, and the rest plain.
Fraction float64 `json:"fraction"`
}
// ClassFaults is a class's fault set: how many, how long, and how much they throw.
//
// A density rather than a count, because a class covers whatever an author painted it over and a count would
// mean something different on every template. The throw is the *total displacement over the whole run*, which
// the solve turns into a rate - so it is the height of the scarp the fault would build if nothing eroded it,
// which is a number an author can picture, unlike millimetres a year.
type ClassFaults struct {
Per1000Km2 float64 `json:"per_1000km2"`
// ThrowM and LengthKm are low-to-high ranges the seed picks between, so one class's faults are not all
// the same size.
ThrowM [2]float64 `json:"throw_m"`
LengthKm [2]float64 `json:"length_km"`
}
// Crater is an impact, stamped onto the finished terrain rather than solved.
//
// It is not an uplift rate and it cannot be one, for a reason worth writing down: a closed basin does not
// survive the fluvial solve. The priority-flood runs every step and *raises* every depression to its spill
// level, so a crater built out of negative uplift would be filled in before the run was a hundred steps old.
// It is also the wrong model. A crater is an event, not a rate - it postdates the landscape it sits in, which
// is exactly what a pass running after the solve expresses.
//
// The shape is derived from the painted blob rather than drawn: distance inward from the blob's own boundary,
// normalised by its widest point, gives a coordinate that is 0 at the shore and 1 at the centre whatever size
// and shape the author painted.
type Crater struct {
// RimM is the crest height above sea level and FloorM the basin floor, also above sea level. The
// difference is the depth; real simple craters run about a fifth of their diameter deep.
RimM float64 `json:"rim_m"`
FloorM float64 `json:"floor_m"`
// RimAt is where the crest sits as a fraction of the way in from the shore, and WallAt where the inner
// wall has finished falling to the floor. Everything past WallAt is floor.
RimAt float64 `json:"rim_at"`
WallAt float64 `json:"wall_at"`
}
// Land is the complement of Sea, spelled out because it is read far more often than it is written.
func (c Class) Land() bool { return !c.Sea }
// RateMYr is the uplift rate in metres a year, which is the unit the solve works in.
func (c Class) RateMYr() float64 { return c.UpliftMmYr / 1000 }
// PlainFloorMYr is the uplift rate at the waterline in metres a year, never above the class's own rate.
func (c Class) PlainFloorMYr() float64 {
floor := c.CoastalFloorMmYr
if floor <= 0 {
floor = defaultCoastalFloorMmYr
}
if floor > c.UpliftMmYr {
floor = c.UpliftMmYr
}
return floor / 1000
}
// MassifFloorMYr is the plain's uplift rate in metres a year, or the class rate when this class has no
// massif and is therefore one rate all over.
func (c Class) MassifFloorMYr() float64 {
if c.Massif == nil {
return c.RateMYr()
}
return c.Massif.FloorMmYr / 1000
}
// MassifFraction is how much of this class stands above the midpoint between its floor and its rate. Zero
// means no massif field is built for it at all.
func (c Class) MassifFraction() float64 {
if c.Massif == nil {
return 0
}
return c.Massif.Fraction
}
// LithMix is how much of the planet's rock field this class takes, with "not set" read as all of it.
func (c Class) LithMix() float64 {
if c.LithologyMix == nil {
return 1
}
return *c.LithologyMix
}
// ThrowM is this class's fault throw range, or zeroes when it has no faults.
func (c Class) ThrowM() [2]float64 {
if c.Faults == nil {
return [2]float64{}
}
return c.Faults.ThrowM
}
// K is KMult with the zero value read as 1.
func (c Class) K() float64 {
if c.KMult == 0 {
return 1
}
return c.KMult
}
// Legend is a template's colours and their meanings. It lives beside the image as JSON so that tuning a
// world is a text edit and a rerun rather than a repaint.
type Legend struct {
// Image is the painted map, relative to the legend file unless it is absolute.
Image string `json:"image"`
// WarnDistance is how far, in RGB, a pixel may sit from the nearest class before the run says so.
// Every pixel is always assigned to its nearest class - there is no unclassified - so this is the
// only thing that catches a colour the legend forgot. Zero means the default.
WarnDistance float64 `json:"warn_distance"`
Classes []Class `json:"classes"`
edge []int // per class: the resolved EdgeClass index, or -1
}
// defaultCoastalFloorMmYr is a plain, and it is 0.02 rather than the 0.06 it was first written as because
// 0.06 is not one. The old number came from reading internal/stats' "plain below 0.1 mm/yr" as a description
// of terrain; it is not, it is a reporting bucket calibrated for the procedural path's intraplate rates. What
// decides how ground reads is the divide angle, and at an 8 m cell and K 5e-5 that is tan(angle) = 2.5 * the
// rate in mm/yr: 0.06 is an 8.5 degree hillslope on every divide, which is hill country, and 0.02 is 2.9
// degrees, which is a coastal plain. See `terrain plan`, which prints the angle and what it reads as.
const defaultCoastalFloorMmYr = 0.02
// MaxMassifFraction is the largest share of a class that may stand above the midpoint. See the ramp in
// internal/uplift: it opens at 1 - 1.5*fraction in probability, so above two thirds it would run off the
// bottom of the distribution and the number would stop meaning what it says.
const MaxMassifFraction = 0.6
// DefaultWarnDistance is generous on purpose. A JPEG bleeds several units of each channel across a
// boundary and a hand-mixed colour is rarely the one in the legend to the unit; a class the legend has
// never heard of is usually tens of units away from everything.
const DefaultWarnDistance = 60
// Load reads a legend from JSON.
func Load(path string) (*Legend, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
l, err := Parse(data)
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return l, nil
}
// Parse reads a legend from JSON already in memory.
//
// Unknown fields are refused, which is unusual for this project and deliberate here: a legend is a table of
// numbers an author edits by hand, and a misspelt key that is silently ignored is a class quietly running on
// the default rather than on what they wrote. Keys beginning with an underscore are the exception, because
// that is how every manifest in this repository carries its commentary.
func Parse(data []byte) (*Legend, error) {
clean, err := field.StripJSONComments(data)
if err != nil {
return nil, err
}
var l Legend
dec := json.NewDecoder(bytes.NewReader(clean))
dec.DisallowUnknownFields()
if err := dec.Decode(&l); err != nil {
return nil, err
}
if err := l.resolve(); err != nil {
return nil, err
}
return &l, nil
}
// Index is the class with this name, or -1.
func (l *Legend) Index(name string) int {
for i := range l.Classes {
if l.Classes[i].Name == name {
return i
}
}
return -1
}
// EdgeIndex is the class a stroke at the map edge becomes, or -1 when it has none.
func (l *Legend) EdgeIndex(i int) int { return l.edge[i] }
// FirstSea is the index of the first sea class, or -1. It is the default fill for the polar pad: those rows
// are synthetic ocean that exists only so a cap touching the top of the painted map has a shore to drain to,
// and they are discarded before anything is written out.
func (l *Legend) FirstSea() int {
for i := range l.Classes {
if l.Classes[i].Sea {
return i
}
}
return -1
}
// HasCraters reports whether any class is stamped as an impact.
func (l *Legend) HasCraters() bool {
for i := range l.Classes {
if l.Classes[i].Crater != nil {
return true
}
}
return false
}
// HasCoastalPlains reports whether any class puts its range inland.
func (l *Legend) HasCoastalPlains() bool {
for i := range l.Classes {
if l.Classes[i].CoastalPlainKm > 0 {
return true
}
}
return false
}
// HasFaults reports whether any class asks for fault traces. When none does, the planet's grain field is
// never built and no trace is ever drawn, so a legend that does not ask for them pays nothing.
func (l *Legend) HasFaults() bool {
for i := range l.Classes {
if l.Classes[i].Faults != nil {
return true
}
}
return false
}
// HasLithology reports whether any class lets the rock field through.
func (l *Legend) HasLithology() bool {
for i := range l.Classes {
if l.Classes[i].Land() && l.Classes[i].LithMix() > 0 {
return true
}
}
return false
}
// HasMassifs reports whether any class breaks into plain and upland. When none does, the planet's upland
// fabric is never built and never sampled, so a legend that does not ask for it pays nothing.
func (l *Legend) HasMassifs() bool {
for i := range l.Classes {
if l.Classes[i].MassifFraction() > 0 {
return true
}
}
return false
}
// resolve fills the derived fields and refuses a legend that cannot mean anything.
func (l *Legend) resolve() error {
if len(l.Classes) == 0 {
return fmt.Errorf("legend has no classes")
}
if len(l.Classes) > 255 {
return fmt.Errorf("legend has %d classes; the raster holds 255", len(l.Classes))
}
if l.WarnDistance <= 0 {
l.WarnDistance = DefaultWarnDistance
}
seen := make(map[string]int, len(l.Classes))
byRGB := make(map[[3]int]string, len(l.Classes))
nonStroke, painted := 0, 0
for i := range l.Classes {
c := &l.Classes[i]
if c.Name == "" {
return fmt.Errorf("class %d has no name", i)
}
if c.Derived && c.Stroke {
return fmt.Errorf("class %q is both derived and a stroke; a derived class is never painted, "+
"so there is nothing of it to dissolve", c.Name)
}
if j, dup := seen[c.Name]; dup {
return fmt.Errorf("classes %d and %d are both named %q", j, i, c.Name)
}
seen[c.Name] = i
for k, v := range c.RGB {
if v < 0 || v > 255 {
return fmt.Errorf("class %q: rgb[%d] is %d, outside 0..255", c.Name, k, v)
}
}
if !c.Derived {
if other, dup := byRGB[c.RGB]; dup {
return fmt.Errorf("classes %q and %q share the colour %v; nothing could tell them apart",
other, c.Name, c.RGB)
}
byRGB[c.RGB] = c.Name
painted++
}
if c.Sea {
if c.DepthM < 0 {
return fmt.Errorf("class %q: depth_m is %.1f; it is metres below sea level, so positive",
c.Name, c.DepthM)
}
if c.UpliftMmYr != 0 || c.KMult != 0 {
return fmt.Errorf("class %q is sea but carries uplift or erodibility; the solve holds "+
"every sea cell at base level and would never read them", c.Name)
}
if c.Crater != nil || c.CoastalPlainKm != 0 || c.Snow || c.Detail != nil || c.Massif != nil ||
c.Faults != nil || c.LithologyMix != nil {
return fmt.Errorf("class %q is sea but carries a land property (crater, coastal plain, snow, "+
"massif, faults, lithology or detail); the solve holds every sea cell at base level and "+
"would never read them", c.Name)
}
} else {
if c.UpliftMmYr < 0 {
return fmt.Errorf("class %q: uplift_mm_yr is %.3f; subsidence is not modelled",
c.Name, c.UpliftMmYr)
}
if c.KMult < 0 {
return fmt.Errorf("class %q: k_mult is %.3f", c.Name, c.KMult)
}
if c.DepthM != 0 {
return fmt.Errorf("class %q is land but carries depth_m", c.Name)
}
if c.CoastalPlainKm < 0 {
return fmt.Errorf("class %q: coastal_plain_km is %v", c.Name, c.CoastalPlainKm)
}
if d := c.Detail; d != nil {
if d.DropletsPerCell < 0 {
return fmt.Errorf("class %q: detail.droplets_per_cell is %v", c.Name, d.DropletsPerCell)
}
if d.StrataContrast < 0 || d.StrataContrast > 1 {
return fmt.Errorf("class %q: detail.strata_contrast is %v, outside 0..1",
c.Name, d.StrataContrast)
}
if a := d.AmplitudeM; a != nil && (a[0] < 0 || a[1] < a[0]) {
return fmt.Errorf("class %q: detail.amplitude_m is %v", c.Name, *a)
}
}
if ms := c.Massif; ms != nil {
if ms.FloorMmYr < 0 {
return fmt.Errorf("class %q: massif.floor_mm_yr is %.4f; subsidence is not modelled",
c.Name, ms.FloorMmYr)
}
if ms.FloorMmYr >= c.UpliftMmYr {
return fmt.Errorf("class %q: massif.floor_mm_yr is %.4f and uplift_mm_yr is %.4f; the "+
"floor is the plain between the massifs, so it has to be below the rate they reach",
c.Name, ms.FloorMmYr, c.UpliftMmYr)
}
// Above two thirds the ramp would start below the bottom of the distribution and the
// fraction would stop meaning what it says. A class that is two thirds upland is not a
// plain with hills in it anyway; paint it as its own colour.
if ms.Fraction <= 0 || ms.Fraction > MaxMassifFraction {
return fmt.Errorf("class %q: massif.fraction is %.3f; it is the share of this class "+
"standing above the midpoint and must be over 0 and at most %.2f",
c.Name, ms.Fraction, MaxMassifFraction)
}
}
if c.LithologyMix != nil && (*c.LithologyMix < 0 || *c.LithologyMix > 1) {
return fmt.Errorf("class %q: lithology_mix is %v, outside 0..1; it is the share of the "+
"planet's rock field this class takes", c.Name, *c.LithologyMix)
}
if fa := c.Faults; fa != nil {
if fa.Per1000Km2 <= 0 {
return fmt.Errorf("class %q: faults.per_1000km2 is %v; leave the block out to have no "+
"faults rather than asking for none", c.Name, fa.Per1000Km2)
}
if fa.LengthKm[0] <= 0 || fa.LengthKm[1] < fa.LengthKm[0] {
return fmt.Errorf("class %q: faults.length_km is %v; it is a low-to-high range in "+
"kilometres", c.Name, fa.LengthKm)
}
if fa.ThrowM[0] <= 0 || fa.ThrowM[1] < fa.ThrowM[0] {
return fmt.Errorf("class %q: faults.throw_m is %v; it is a low-to-high range of total "+
"displacement over the run, in metres", c.Name, fa.ThrowM)
}
}
if cr := c.Crater; cr != nil {
if cr.FloorM >= cr.RimM {
return fmt.Errorf("class %q: a crater's floor (%.0f m) must be below its rim (%.0f m)",
c.Name, cr.FloorM, cr.RimM)
}
if cr.RimAt <= 0 || cr.RimAt >= cr.WallAt || cr.WallAt > 1 {
return fmt.Errorf("class %q: a crater needs 0 < rim_at < wall_at <= 1, got %.2f and %.2f",
c.Name, cr.RimAt, cr.WallAt)
}
}
}
if !c.Stroke {
nonStroke++
}
}
if nonStroke == 0 {
return fmt.Errorf("every class is a stroke; there is nothing for them to dissolve into")
}
if painted == 0 {
return fmt.Errorf("every class is derived; nothing in the legend can match a painted pixel")
}
l.edge = make([]int, len(l.Classes))
for i := range l.Classes {
l.edge[i] = -1
c := &l.Classes[i]
if c.EdgeClass == "" {
continue
}
if !c.Stroke {
return fmt.Errorf("class %q sets edge_class but is not a stroke; only a stroke is rewritten "+
"at the map edge", c.Name)
}
j := l.Index(c.EdgeClass)
if j < 0 {
return fmt.Errorf("class %q: edge_class %q is not a class", c.Name, c.EdgeClass)
}
if l.Classes[j].Stroke {
return fmt.Errorf("class %q: edge_class %q is itself a stroke", c.Name, c.EdgeClass)
}
l.edge[i] = j
}
return nil
}
+236
View File
@@ -0,0 +1,236 @@
package template
import (
"salty/terrain/internal/field"
"salty/terrain/internal/world"
)
// Map is a classified template projected onto a planet grid: one legend index per planet cell, including
// the polar pad.
type Map struct {
P world.Planet
L *Legend
Class []uint8
Sea []bool
}
// Project resamples a paint-resolution raster onto the planet grid by nearest neighbour, and fills the
// polar pad with padClass.
//
// Nearest neighbour is not a shortcut, it is the only correct choice: a class index is a name, not a
// quantity, and interpolating between "desert" and "ocean" would invent a class that is neither. The blend
// rule in Docs/Terrain-Next.md 3.2 - the painted map owns the wavelengths above its pixel size and noise
// owns those below - is honoured downstream, where the continuous fields the classes stand for are smoothed
// and then given sub-pixel variation. Doing it here instead would smear the coastline, which is the one
// thing in the whole template an author draws deliberately.
func (r *Raster) Project(p world.Planet, l *Legend, padClass int) *Map {
m := &Map{P: p, L: l, Class: make([]uint8, p.W*p.H), Sea: make([]bool, p.W*p.H)}
sea := make([]bool, len(l.Classes))
for i := range l.Classes {
sea[i] = l.Classes[i].Sea
}
pad := uint8(padClass)
paintH := p.PaintH()
field.Rows(p.H, func(y0, y1 int) {
for y := y0; y < y1; y++ {
if p.InPad(y) {
for x := 0; x < p.W; x++ {
i := y*p.W + x
m.Class[i] = pad
m.Sea[i] = sea[pad]
}
continue
}
// Sample at the cell's centre, so a run of planet cells maps evenly across the paint rather
// than favouring its left edge.
py := (2*(y-p.PadY) + 1) * r.H / (2 * paintH)
if py >= r.H {
py = r.H - 1
}
for x := 0; x < p.W; x++ {
px := (2*x + 1) * r.W / (2 * p.W)
if px >= r.W {
px = r.W - 1
}
i := y*p.W + x
c := r.Class[py*r.W+px]
m.Class[i] = c
m.Sea[i] = sea[c]
}
}
})
return m
}
// Counts is how many planet cells each class covers, and how many of them are land. The pad is excluded,
// because it is not part of anybody's world.
func (m *Map) Counts() (perClass []int, land, total int) {
perClass = make([]int, len(m.L.Classes))
for y := m.P.PadY; y < m.P.H-m.P.PadY; y++ {
for x := 0; x < m.P.W; x++ {
i := y*m.P.W + x
perClass[m.Class[i]]++
total++
if !m.Sea[i] {
land++
}
}
}
return perClass, land, total
}
// Rates is the uplift rate in metres a year for every class, indexed by class. Sea classes are zero: the
// solve holds an ocean cell at base level for its whole run and never reads the rate there.
func (l *Legend) Rates() []float32 {
out := make([]float32, len(l.Classes))
for i := range l.Classes {
if l.Classes[i].Land() {
out[i] = float32(l.Classes[i].RateMYr())
}
}
return out
}
// Erodibilities is the multiplier on stream-power K for every class. Sea classes get 1 rather than 0, so
// that a field built from this never carries a zero into a division.
func (l *Legend) Erodibilities() []float32 {
out := make([]float32, len(l.Classes))
for i := range l.Classes {
out[i] = 1
if l.Classes[i].Land() {
out[i] = float32(l.Classes[i].K())
}
}
return out
}
// CoastalPlains is, per class, how far inland the rate ramps up to its full value, in metres, and the rate
// it starts from at the waterline.
func (l *Legend) CoastalPlains() (plainM []float64, floor []float32) {
plainM = make([]float64, len(l.Classes))
floor = make([]float32, len(l.Classes))
for i := range l.Classes {
c := l.Classes[i]
if c.Land() && c.CoastalPlainKm > 0 {
plainM[i] = c.CoastalPlainKm * 1000
floor[i] = float32(c.PlainFloorMYr())
}
}
return plainM, floor
}
// Massifs is, per class, the plain's uplift rate in metres a year and the share of the class that stands
// above the midpoint between that floor and the class rate. A class with no massif reports a zero fraction,
// which is what internal/uplift reads as "one rate all over", and its floor is then its own rate.
func (l *Legend) Massifs() (floor []float32, fraction []float64) {
floor = make([]float32, len(l.Classes))
fraction = make([]float64, len(l.Classes))
for i := range l.Classes {
c := l.Classes[i]
if !c.Land() {
continue
}
floor[i] = float32(c.MassifFloorMYr())
fraction[i] = c.MassifFraction()
}
return floor, fraction
}
// LithologyMixes is, per class, how much of the planet's rock field shows through. Sea is zero: the solve
// holds every sea cell at base level and never reads K there, and leaving it at 1 would put rock provinces on
// the diagnostic map out in the open ocean.
func (l *Legend) LithologyMixes() []float64 {
out := make([]float64, len(l.Classes))
for i := range l.Classes {
if l.Classes[i].Land() {
out[i] = l.Classes[i].LithMix()
}
}
return out
}
// Snow is, per class, whether it is permanently under ice. A display and material hint; no pass reads it.
func (l *Legend) Snow() []bool {
out := make([]bool, len(l.Classes))
for i := range l.Classes {
out[i] = l.Classes[i].Snow
}
return out
}
// SnowMask marks every planet cell whose class is permanently under ice, painted rows only.
func (m *Map) SnowMask() []bool {
snow := m.L.Snow()
any := false
for _, s := range snow {
any = any || s
}
if !any {
return nil
}
p := m.P
out := make([]bool, p.W*p.PaintH())
for i := range out {
out[i] = snow[m.Class[p.PadY*p.W+i]]
}
return out
}
// Depths is how far below sea level the open water of each class sits, in metres, positive. Land is zero.
func (l *Legend) Depths() []float32 {
out := make([]float32, len(l.Classes))
for i := range l.Classes {
if l.Classes[i].Sea {
out[i] = float32(l.Classes[i].DepthM)
}
}
return out
}
// ClassDetailTables are the per-class detail overrides, resolved against the pipeline's own numbers so a pass
// can index them without asking whether a class overrode anything.
type ClassDetailTables struct {
Droplets []float64
AmpLo []float64
AmpHi []float64
Contrast []float64
}
// DetailTables resolves every class against the pipeline defaults it is given.
func (l *Legend) DetailTables(droplets, ampLo, ampHi, contrast float64) ClassDetailTables {
n := len(l.Classes)
t := ClassDetailTables{
Droplets: make([]float64, n), AmpLo: make([]float64, n),
AmpHi: make([]float64, n), Contrast: make([]float64, n),
}
for i := range l.Classes {
t.Droplets[i], t.AmpLo[i], t.AmpHi[i], t.Contrast[i] = droplets, ampLo, ampHi, contrast
d := l.Classes[i].Detail
if d == nil {
continue
}
if d.DropletsPerCell > 0 {
t.Droplets[i] = d.DropletsPerCell
}
if d.AmplitudeM != nil {
t.AmpLo[i], t.AmpHi[i] = d.AmplitudeM[0], d.AmplitudeM[1]
}
if d.StrataContrast > 0 {
t.Contrast[i] = d.StrataContrast
}
}
return t
}
// Overrides reports whether any class asks the detail passes for anything different, so a caller can skip
// carrying a class raster through them when nothing would read it.
func (l *Legend) Overrides() bool {
for i := range l.Classes {
if l.Classes[i].Detail != nil {
return true
}
}
return false
}
@@ -0,0 +1,818 @@
package template
import (
"math"
"strings"
"testing"
"salty/terrain/internal/world"
)
const goodLegend = `{
"image": "x.png",
"classes": [
{ "name": "ocean", "rgb": [0, 0, 255], "sea": true, "depth_m": 500 },
{ "name": "land", "rgb": [0, 255, 0], "uplift_mm_yr": 0.5, "k_mult": 2 },
{ "name": "ice", "rgb": [200, 200, 200], "uplift_mm_yr": 0.05 },
{ "name": "white", "rgb": [255, 255, 255], "stroke": true, "edge_class": "ice" },
{ "name": "outline", "rgb": [255, 0, 255], "stroke": true }
]
}`
func mustLegend(t *testing.T, src string) *Legend {
t.Helper()
l, err := Parse([]byte(src))
if err != nil {
t.Fatalf("Parse: %v", err)
}
return l
}
func TestLegendResolves(t *testing.T) {
l := mustLegend(t, goodLegend)
if l.WarnDistance != DefaultWarnDistance {
t.Errorf("WarnDistance = %v, want the default %v", l.WarnDistance, DefaultWarnDistance)
}
if got := l.Index("land"); got != 1 {
t.Errorf("Index(land) = %d, want 1", got)
}
if got := l.EdgeIndex(3); got != 2 {
t.Errorf("EdgeIndex(white) = %d, want 2 (ice)", got)
}
if got := l.EdgeIndex(1); got != -1 {
t.Errorf("EdgeIndex(land) = %d, want -1", got)
}
if got := l.Classes[1].K(); got != 2 {
t.Errorf("land K = %v, want 2", got)
}
if got := l.Classes[2].K(); got != 1 {
t.Errorf("ice K = %v, want 1 (zero reads as one)", got)
}
if got := l.Classes[1].RateMYr(); got != 0.0005 {
t.Errorf("land rate = %v m/yr, want 0.0005", got)
}
}
func TestLegendRefusesTheImpossible(t *testing.T) {
cases := []struct{ name, src, want string }{
{"no classes", `{"classes":[]}`, "no classes"},
{"duplicate colour", `{"classes":[
{"name":"a","rgb":[1,2,3]},{"name":"b","rgb":[1,2,3]}]}`, "share the colour"},
{"duplicate name", `{"classes":[
{"name":"a","rgb":[1,2,3]},{"name":"a","rgb":[4,5,6]}]}`, "both named"},
{"sea with uplift", `{"classes":[
{"name":"a","rgb":[1,2,3],"sea":true,"uplift_mm_yr":1}]}`, "would never read them"},
{"land with depth", `{"classes":[
{"name":"a","rgb":[1,2,3],"depth_m":10}]}`, "carries depth_m"},
{"negative depth", `{"classes":[
{"name":"a","rgb":[1,2,3],"sea":true,"depth_m":-10}]}`, "so positive"},
{"edge on a non-stroke", `{"classes":[
{"name":"a","rgb":[1,2,3]},{"name":"b","rgb":[4,5,6],"edge_class":"a"}]}`, "is not a stroke"},
{"edge names nothing", `{"classes":[
{"name":"a","rgb":[1,2,3]},{"name":"b","rgb":[4,5,6],"stroke":true,"edge_class":"z"}]}`,
"is not a class"},
{"everything is a stroke", `{"classes":[
{"name":"a","rgb":[1,2,3],"stroke":true}]}`, "nothing for them to dissolve into"},
{"rgb out of range", `{"classes":[{"name":"a","rgb":[1,2,300]}]}`, "outside 0..255"},
{"sea with a massif", `{"classes":[
{"name":"a","rgb":[1,2,3],"sea":true,"massif":{"floor_mm_yr":0.01,"fraction":0.2}}]}`,
"carries a land property"},
{"massif floor at or above the rate", `{"classes":[
{"name":"a","rgb":[1,2,3],"uplift_mm_yr":0.08,"massif":{"floor_mm_yr":0.08,"fraction":0.2}}]}`,
"below the rate they reach"},
{"negative massif floor", `{"classes":[
{"name":"a","rgb":[1,2,3],"uplift_mm_yr":0.08,"massif":{"floor_mm_yr":-0.01,"fraction":0.2}}]}`,
"subsidence is not modelled"},
{"massif fraction of nothing", `{"classes":[
{"name":"a","rgb":[1,2,3],"uplift_mm_yr":0.08,"massif":{"floor_mm_yr":0.01,"fraction":0}}]}`,
"must be over 0"},
{"massif fraction past the ramp", `{"classes":[
{"name":"a","rgb":[1,2,3],"uplift_mm_yr":0.08,"massif":{"floor_mm_yr":0.01,"fraction":0.8}}]}`,
"must be over 0"},
{"misspelt massif key", `{"classes":[
{"name":"a","rgb":[1,2,3],"uplift_mm_yr":0.08,"massif":{"floor":0.01,"fraction":0.2}}]}`,
"unknown field"},
{"sea with faults", `{"classes":[
{"name":"a","rgb":[1,2,3],"sea":true,"faults":{"per_1000km2":5,"throw_m":[100,200],
"length_km":[4,8]}}]}`, "carries a land property"},
{"sea with a lithology mix", `{"classes":[
{"name":"a","rgb":[1,2,3],"sea":true,"lithology_mix":0.5}]}`, "carries a land property"},
{"a fault block asking for nothing", `{"classes":[
{"name":"a","rgb":[1,2,3],"faults":{"per_1000km2":0,"throw_m":[100,200],
"length_km":[4,8]}}]}`, "leave the block out"},
{"fault length the wrong way round", `{"classes":[
{"name":"a","rgb":[1,2,3],"faults":{"per_1000km2":5,"throw_m":[100,200],
"length_km":[8,4]}}]}`, "low-to-high range in kilometres"},
{"fault throw the wrong way round", `{"classes":[
{"name":"a","rgb":[1,2,3],"faults":{"per_1000km2":5,"throw_m":[200,100],
"length_km":[4,8]}}]}`, "low-to-high range of total"},
{"lithology mix past one", `{"classes":[
{"name":"a","rgb":[1,2,3],"lithology_mix":1.5}]}`, "outside 0..1"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := Parse([]byte(c.src))
if err == nil {
t.Fatalf("accepted %s", c.name)
}
if !strings.Contains(err.Error(), c.want) {
t.Errorf("error %q does not mention %q", err, c.want)
}
})
}
}
// build an RGB buffer from a small picture written as one rune per pixel.
func picture(t *testing.T, l *Legend, rows []string) ([]uint8, int, int) {
t.Helper()
h := len(rows)
w := len(rows[0])
px := make([]uint8, w*h*3)
for y, row := range rows {
if len(row) != w {
t.Fatalf("row %d is %d wide, want %d", y, len(row), w)
}
for x, r := range row {
var ci int
switch r {
case 'o':
ci = l.Index("ocean")
case 'L':
ci = l.Index("land")
case 'i':
ci = l.Index("ice")
case 'W':
ci = l.Index("white")
case 'X':
ci = l.Index("outline")
case '?':
ci = -1
default:
t.Fatalf("unknown pixel %q", r)
}
o := (y*w + x) * 3
if ci < 0 {
px[o], px[o+1], px[o+2] = 0, 0, 0 // the stray black pixel a real template had
continue
}
c := l.Classes[ci]
px[o], px[o+1], px[o+2] = uint8(c.RGB[0]), uint8(c.RGB[1]), uint8(c.RGB[2])
}
}
return px, w, h
}
func render(l *Legend, r *Raster) []string {
sym := map[string]rune{"ocean": 'o', "land": 'L', "ice": 'i', "white": 'W', "outline": 'X'}
out := make([]string, r.H)
for y := 0; y < r.H; y++ {
var b strings.Builder
for x := 0; x < r.W; x++ {
b.WriteRune(sym[l.Classes[r.Class[y*r.W+x]].Name])
}
out[y] = b.String()
}
return out
}
func TestClassifyIsTotalAndReportsTheStrays(t *testing.T) {
l := mustLegend(t, goodLegend)
px, w, h := picture(t, l, []string{
"ooLL",
"oo?L",
})
r, m := l.Classify(px, w, h)
if m.Total != 8 {
t.Errorf("Total = %d, want 8", m.Total)
}
// Black is nearest to ocean here, and nothing is unclassified - but it must be reported as far.
if m.Far != 1 {
t.Errorf("Far = %d, want 1: the black pixel", m.Far)
}
if m.MaxAt != [2]int{2, 1} {
t.Errorf("MaxAt = %v, want the black pixel at 2,1", m.MaxAt)
}
if m.MaxDist < 100 {
t.Errorf("MaxDist = %.1f, want it large", m.MaxDist)
}
if got := render(l, r)[0]; got != "ooLL" {
t.Errorf("row 0 = %q", got)
}
if n := m.Counts[l.Index("land")]; n != 3 {
t.Errorf("land count = %d, want 3", n)
}
}
func TestWhiteAtThePoleIsIceAndWhiteAroundAnIslandIsNot(t *testing.T) {
l := mustLegend(t, goodLegend)
px, w, h := picture(t, l, []string{
"WWWW", // the cap: touches row 0, so it is ice
"WWWW",
"oooo",
"oWWo", // an island's outline: touches nothing, so it dissolves
"oWLo",
"oooo",
})
r, _ := l.Classify(px, w, h)
edge, dissolved := r.DissolveStrokes(l)
if edge != 8 {
t.Errorf("edge rewrites = %d, want 8", edge)
}
if dissolved != 3 {
t.Errorf("dissolved = %d, want 3", dissolved)
}
got := render(l, r)
want := []string{"iiii", "iiii", "oooo", "oooo", "ooLo", "oooo"}
for y := range want {
if got[y] != want[y] {
t.Errorf("row %d = %q, want %q (whole picture %v)", y, got[y], want[y], got)
}
}
}
// A stroke lying between land and water is split down the middle. Giving it wholly to one side would
// move the coastline by the width of the artist's brush, which on a real template is hundreds of metres.
func TestStrokeSplitsDownItsMiddle(t *testing.T) {
l := mustLegend(t, goodLegend)
px, w, h := picture(t, l, []string{
"LLL",
"LLL",
"WWW",
"WWW",
"ooo",
"ooo",
})
r, _ := l.Classify(px, w, h)
if _, n := r.DissolveStrokes(l); n != 6 {
t.Errorf("dissolved = %d, want 6", n)
}
got := render(l, r)
want := []string{"LLL", "LLL", "LLL", "ooo", "ooo", "ooo"}
for y := range want {
if got[y] != want[y] {
t.Errorf("row %d = %q, want %q (whole picture %v)", y, got[y], want[y], got)
}
}
}
// The map is a cylinder: a stroke on the left edge is reached by land on the right edge.
func TestDissolveWrapsInX(t *testing.T) {
l := mustLegend(t, goodLegend)
// One row, and a stroke class with no edge_class so the polar rescue never applies. The stroke at
// x=0 has land only at x=5, on the far side of the seam; if X did not wrap it would take the ocean
// in the middle instead.
px, w, h := picture(t, l, []string{"XXoXXL"})
r, _ := l.Classify(px, w, h)
r.DissolveStrokes(l)
got := render(l, r)
want := []string{"LoooLL"}
for y := range want {
if got[y] != want[y] {
t.Errorf("row %d = %q, want %q", y, got[y], want[y])
}
}
}
func TestRasterAtWrapsXAndClampsY(t *testing.T) {
r := &Raster{W: 3, H: 2, Class: []uint8{1, 2, 3, 4, 5, 6}}
if got := r.At(-1, 0); got != 3 {
t.Errorf("At(-1,0) = %d, want 3", got)
}
if got := r.At(3, 0); got != 1 {
t.Errorf("At(3,0) = %d, want 1", got)
}
if got := r.At(0, -1); got != 1 {
t.Errorf("At(0,-1) = %d, want 1 (clamped to the pole)", got)
}
if got := r.At(0, 9); got != 4 {
t.Errorf("At(0,9) = %d, want 4", got)
}
}
func TestProjectIsNearestNeighbourAndPadsThePoles(t *testing.T) {
l := mustLegend(t, goodLegend)
// A 4x2 paint: land on the right half, ocean on the left.
px, w, h := picture(t, l, []string{
"ooLL",
"ooLL",
})
r, _ := l.Classify(px, w, h)
// 8 columns of 10 m is an 80 m circumference; the paint's 4:2 aspect gives 4 painted rows, plus 1 of
// pad at each end.
p, err := world.New(80, 10, w, h, 1, 80)
if err != nil {
t.Fatal(err)
}
if p.W != 8 || p.PaintH() != 4 || p.H != 6 {
t.Fatalf("planet is %dx%d with %d painted rows, want 8x6 with 4", p.W, p.H, p.PaintH())
}
m := r.Project(p, l, l.Index("ocean"))
for x := 0; x < p.W; x++ {
if !m.Sea[0*p.W+x] || !m.Sea[(p.H-1)*p.W+x] {
t.Fatalf("pad row is not sea at column %d", x)
}
}
// Every painted row upsamples the same way: four ocean cells then four land cells, and no third class
// has been invented in between.
for y := p.PadY; y < p.H-p.PadY; y++ {
for x := 0; x < p.W; x++ {
wantSea := x < 4
if m.Sea[y*p.W+x] != wantSea {
t.Fatalf("cell (%d,%d): sea = %v, want %v", x, y, m.Sea[y*p.W+x], wantSea)
}
name := l.Classes[m.Class[y*p.W+x]].Name
if name != "ocean" && name != "land" {
t.Fatalf("cell (%d,%d) is %q; projection invented a class", x, y, name)
}
}
}
perClass, land, total := m.Counts()
if total != p.W*p.PaintH() {
t.Errorf("Counts total = %d, want %d (the pad is not part of the world)", total, p.W*p.PaintH())
}
if land != 16 {
t.Errorf("land = %d, want 16", land)
}
if perClass[l.Index("land")] != 16 {
t.Errorf("land class count = %d, want 16", perClass[l.Index("land")])
}
}
func TestPerClassTables(t *testing.T) {
l := mustLegend(t, goodLegend)
rates := l.Rates()
if got := rates[l.Index("land")]; got != 0.0005 {
t.Errorf("land rate = %v, want 0.0005 m/yr", got)
}
if got := rates[l.Index("ocean")]; got != 0 {
t.Errorf("ocean rate = %v, want 0", got)
}
ks := l.Erodibilities()
if got := ks[l.Index("land")]; got != 2 {
t.Errorf("land K = %v, want 2", got)
}
if got := ks[l.Index("ocean")]; got != 1 {
t.Errorf("ocean K = %v, want 1: a zero would be carried into a division", got)
}
if got := l.Depths()[l.Index("ocean")]; got != 500 {
t.Errorf("ocean depth = %v, want 500", got)
}
}
// A class with no massif block is one rate all over, and the tables have to say so in the way internal/uplift
// reads them: a zero fraction, which is what switches the fabric off, and a floor that is the class's own rate
// so that nothing can read a plain out of a class that never asked for one.
func TestAClassWithNoMassifIsOneRateAllOver(t *testing.T) {
l := mustLegend(t, goodLegend)
floor, fraction := l.Massifs()
i := l.Index("land")
if fraction[i] != 0 {
t.Errorf("fraction = %v, want 0 for a class with no massif block", fraction[i])
}
if got := floor[i]; got != 0.0005 {
t.Errorf("floor = %v, want the class rate 0.0005 m/yr", got)
}
if l.HasMassifs() {
t.Error("HasMassifs is true for a legend with no massif block anywhere")
}
}
// And a class that asks for one reports the numbers the fabric is cut with.
func TestAMassifClassReportsItsFloorAndFraction(t *testing.T) {
l := mustLegend(t, `{"classes":[
{"name":"ocean","rgb":[0,0,255],"sea":true,"depth_m":500},
{"name":"land","rgb":[0,255,0],"uplift_mm_yr":0.08,
"massif":{"floor_mm_yr":0.012,"fraction":0.16}}]}`)
if !l.HasMassifs() {
t.Fatal("HasMassifs is false for a legend that has one")
}
floor, fraction := l.Massifs()
i := l.Index("land")
if got, want := float64(floor[i]), 0.000012; math.Abs(got-want) > 1e-12 {
t.Errorf("floor = %v m/yr, want %v", got, want)
}
if fraction[i] != 0.16 {
t.Errorf("fraction = %v, want 0.16", fraction[i])
}
// Sea classes carry neither, and the fraction has to be zero rather than inherited: a sea cell is held at
// base level for the whole run and a fabric there would be a field nobody reads.
if j := l.Index("ocean"); floor[j] != 0 || fraction[j] != 0 {
t.Errorf("ocean carries floor %v fraction %v, want both zero", floor[j], fraction[j])
}
}
// White is drawn twice on a hand-painted world map: the polar caps and the stroke around every island. Only
// one class can own that colour, and it has to be the stroke - so what the caps become is a class with no
// colour of its own.
func TestADerivedClassIsNeverMatched(t *testing.T) {
const src = `{"classes":[
{"name":"sea","rgb":[0,0,255],"sea":true},
{"name":"ice","derived":true,"uplift_mm_yr":0.05},
{"name":"white","rgb":[238,238,238],"stroke":true,"edge_class":"ice"}
]}`
l := mustLegend(t, src)
// A pixel near white must become the stroke, not the derived ice, however close ice's zero colour is.
px := []uint8{236, 236, 236}
r, m := l.Classify(px, 1, 1)
if got := l.Classes[r.Class[0]].Name; got != "white" {
t.Errorf("a near-white pixel classified as %q, want the painted stroke", got)
}
if m.Counts[l.Index("ice")] != 0 {
t.Error("the derived class matched a pixel")
}
}
// A derived class may carry a colour, and it is display only: the diagnostic maps need something to draw it
// with, and without one the polar caps came out as black holes in map_class.png.
func TestADerivedClassColourIsDisplayOnly(t *testing.T) {
l := mustLegend(t, `{"classes":[
{"name":"sea","rgb":[0,0,255],"sea":true},
{"name":"ice","derived":true,"rgb":[250,250,250]},
{"name":"white","rgb":[238,238,238],"stroke":true,"edge_class":"ice"}
]}`)
// 245,245,245 is nearer to ice's display colour than to the painted stroke, and must still be the stroke.
r, _ := l.Classify([]uint8{245, 245, 245}, 1, 1)
if got := l.Classes[r.Class[0]].Name; got != "white" {
t.Errorf("classified as %q, want the painted stroke: a derived colour must not match", got)
}
}
func TestLegendRefusesAllDerived(t *testing.T) {
_, err := Parse([]byte(`{"classes":[{"name":"a","derived":true}]}`))
if err == nil || !strings.Contains(err.Error(), "every class is derived") {
t.Fatalf("error = %v, want a refusal", err)
}
}
// The mask is opt-in: zero amplitude has to leave the painting exactly as drawn, because every template
// written before it existed was drawn against that contract.
func TestNoCoastMaskLeavesThePaintingExactly(t *testing.T) {
p := testCylinder(t, 512, 288)
l := mustLegend(t, goodLegend)
r := stripeRaster(512, 288, l)
out := r.RoughenCoast(l, p, Coast{AmplitudePx: 0, WavelengthPx: 64, Octaves: 4, Gain: 0.5})
for i := range r.Class {
if out.Class[i] != r.Class[i] {
t.Fatalf("pixel %d changed with the mask switched off", i)
}
}
}
// What it is for: a ruled painted coastline has to come back with bays in it. Measured as the spread of the
// waterline's row along the map - zero for a drawn line, tens of pixels for a coast.
func TestTheCoastMaskCutsBaysIntoARuledShore(t *testing.T) {
p := testCylinder(t, 1024, 512)
l := mustLegend(t, goodLegend)
r := stripeRaster(1024, 512, l) // land above the halfway row, ocean below
if lo, hi := shoreSpread(r, l); hi-lo != 0 {
t.Fatalf("the painted shore is not ruled: rows %d..%d; the test would measure nothing", lo, hi)
}
out := r.RoughenCoast(l, p, Coast{
AmplitudePx: 48, WavelengthPx: 256, Octaves: 5, Gain: 0.55, Seed: 7,
})
lo, hi := shoreSpread(out, l)
if hi-lo < 20 {
t.Errorf("the roughened shore spans %d rows (%d..%d); the mask is barely moving it", hi-lo+1, lo, hi)
}
// And it must stay a coastline rather than dissolving into speckle: the land has to remain one run down
// every column, not a scatter of pixels.
if runs := columnRuns(out, l, 1024/2); runs > 3 {
t.Errorf("a column crosses the waterline %d times; the mask is dissolving the shore, not shaping it",
runs)
}
}
// An archipelago has to survive. Under a wavelength far wider than an islet the noise is very nearly a
// constant across it, so without the guard the whole islet steps to the wrong side of zero at once and a
// scatter of islands disappears between two runs.
func TestSmallIslandsAreNibbledRatherThanDeleted(t *testing.T) {
p := testCylinder(t, 1024, 512)
l := mustLegend(t, goodLegend)
land := uint8(l.Index("land"))
sea := uint8(l.Index("ocean"))
r := &Raster{W: 1024, H: 512, Class: make([]uint8, 1024*512)}
for i := range r.Class {
r.Class[i] = sea
}
// Twelve islets of radius 8, well apart, none of them anywhere near the amplitude in size.
centres := [][2]int{}
for k := 0; k < 12; k++ {
centres = append(centres, [2]int{60 + k*80, 200 + (k%3)*90})
}
for _, c := range centres {
for dy := -8; dy <= 8; dy++ {
for dx := -8; dx <= 8; dx++ {
if dx*dx+dy*dy <= 64 {
r.Class[(c[1]+dy)*r.W+c[0]+dx] = land
}
}
}
}
out := r.RoughenCoast(l, p, Coast{
AmplitudePx: 64, WavelengthPx: 256, Octaves: 5, Gain: 0.55, Seed: 7,
})
gone := 0
for _, c := range centres {
alive := false
for dy := -20; dy <= 20 && !alive; dy++ {
for dx := -20; dx <= 20; dx++ {
x, y := c[0]+dx, c[1]+dy
if x < 0 || y < 0 || x >= out.W || y >= out.H {
continue
}
if out.Class[y*out.W+x] == land {
alive = true
break
}
}
}
if !alive {
gone++
}
}
if gone > 0 {
t.Errorf("%d of %d islets were erased by a mask four times their radius; the island guard is not "+
"holding", gone, len(centres))
}
}
// The seam is the one place a coastline can break invisibly, because the map's two edges are as far apart on
// screen as they can be. The mask is world-indexed and its distance transform wraps, so a shore crossing the
// seam has to come out continuous.
func TestTheCoastMaskWrapsAtTheSeam(t *testing.T) {
p := testCylinder(t, 1024, 512)
l := mustLegend(t, goodLegend)
r := stripeRaster(1024, 512, l)
out := r.RoughenCoast(l, p, Coast{
AmplitudePx: 48, WavelengthPx: 256, Octaves: 5, Gain: 0.55, Seed: 7,
})
// The waterline's row in the first column and in the last must be within a pixel or two of each other,
// exactly as two adjacent columns anywhere inside the map are.
rowAt := func(x int) int {
for y := 0; y < out.H; y++ {
if l.Classes[out.Class[y*out.W+x]].Sea {
return y
}
}
return -1
}
seam := rowAt(0) - rowAt(out.W-1)
if seam < 0 {
seam = -seam
}
worst := 0
for x := 1; x < out.W; x++ {
d := rowAt(x) - rowAt(x-1)
if d < 0 {
d = -d
}
if d > worst {
worst = d
}
}
if seam > worst {
t.Errorf("the shore steps %d rows across the seam against %d anywhere inside the map", seam, worst)
}
}
func testCylinder(t *testing.T, w, h int) world.Planet {
t.Helper()
p := world.Planet{CellM: 8, W: w, H: h, PadY: 0, NoisePeriodM: float64(w) * 8}
if err := p.Validate(); err != nil {
t.Fatal(err)
}
return p
}
// shoreSpread is the lowest and highest row at which a column first meets water.
func shoreSpread(r *Raster, l *Legend) (lo, hi int) {
lo, hi = 1<<30, -1
for x := 0; x < r.W; x++ {
for y := 0; y < r.H; y++ {
if l.Classes[r.Class[y*r.W+x]].Sea {
if y < lo {
lo = y
}
if y > hi {
hi = y
}
break
}
}
}
return lo, hi
}
// columnRuns counts how many times a column crosses the waterline.
func columnRuns(r *Raster, l *Legend, x int) int {
n := 0
prev := l.Classes[r.Class[x]].Sea
for y := 1; y < r.H; y++ {
cur := l.Classes[r.Class[y*r.W+x]].Sea
if cur != prev {
n++
prev = cur
}
}
return n
}
// stripeRaster is a painting with one ruled coastline: land in the top half, ocean in the bottom.
func stripeRaster(w, h int, l *Legend) *Raster {
r := &Raster{W: w, H: h, Class: make([]uint8, w*h)}
land := uint8(l.Index("land"))
sea := uint8(l.Index("ocean"))
for y := 0; y < h; y++ {
c := land
if y >= h/2 {
c = sea
}
for x := 0; x < w; x++ {
r.Class[y*w+x] = c
}
}
return r
}
// The failure this exists for, built in miniature: a one-pixel ribbon of a class nobody painted, lying along
// the boundary between the two it is a blend of. On the real template that ribbon was `desert` along every
// temperate coast, because the JPEG's blend of surf and lowland is nearer to desert than to either parent.
func TestDespeckleRemovesAHairlineBetweenTwoClasses(t *testing.T) {
l := mustLegend(t, goodLegend)
const w, h = 64, 64
land := uint8(l.Index("land"))
sea := uint8(l.Index("ocean"))
ice := uint8(l.Index("ice")) // standing in for the class nobody painted
r := &Raster{W: w, H: h, Class: make([]uint8, w*h)}
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
c := land
if y > h/2 {
c = sea
}
if y == h/2 {
c = ice // the hairline, one pixel wide, all the way across
}
r.Class[y*w+x] = c
}
}
n := r.Despeckle()
if n == 0 {
t.Fatal("nothing was despeckled; the hairline is still there")
}
for x := 0; x < w; x++ {
if got := r.Class[(h/2)*w+x]; got == ice {
t.Fatalf("column %d of the hairline survived as %q", x, l.Classes[got].Name)
}
}
// And it must have joined one of its neighbours rather than becoming something else again.
for x := 0; x < w; x++ {
if got := r.Class[(h/2)*w+x]; got != land && got != sea {
t.Fatalf("column %d became %q, which is neither side of the boundary", x, l.Classes[got].Name)
}
}
}
// The other half of the contract, and the one that keeps the rule honest: a band two pixels wide is
// something an author drew, and it has to survive untouched. Without this the threshold could be raised
// until it ate the map.
func TestDespeckleLeavesARealBandAlone(t *testing.T) {
l := mustLegend(t, goodLegend)
const w, h = 64, 64
land := uint8(l.Index("land"))
sea := uint8(l.Index("ocean"))
ice := uint8(l.Index("ice"))
r := &Raster{W: w, H: h, Class: make([]uint8, w*h)}
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
c := land
if y > h/2+1 {
c = sea
}
if y == h/2 || y == h/2+1 {
c = ice // two pixels wide: a painted shoreline band, not a codec artefact
}
r.Class[y*w+x] = c
}
}
before := append([]uint8(nil), r.Class...)
r.Despeckle()
for i := range before {
if before[i] != r.Class[i] {
t.Fatalf("pixel %d changed; a two-pixel band is a feature and must survive", i)
}
}
}
// The mask can be masked, which is the whole of D-57's contribution to the coastline: a shore somebody drew
// on purpose stays where they drew it while the rest of the world is still roughened.
func TestTheCoastMaskIsMaskedByTheOverlay(t *testing.T) {
const w, h = 1024, 512
p := testCylinder(t, w, h)
l := mustLegend(t, goodLegend)
r := stripeRaster(w, h, l) // land above the halfway row, ocean below
cfg := Coast{AmplitudePx: 48, WavelengthPx: 256, Octaves: 5, Gain: 0.55, Seed: 7}
free := r.RoughenCoast(l, p, cfg)
// Pin the left half and say nothing about the right. "Say nothing" is -1, not 1: an unmarked cell takes
// its instruction from the far side of the waterline, and that is what makes a stroke on one side enough.
scale := make([]float32, w*h)
for i := range scale {
scale[i] = -1
}
for y := 0; y < h; y++ {
for x := 0; x < w/2; x++ {
scale[y*w+x] = 0
}
}
cfg.Scale = scale
masked := r.RoughenCoast(l, p, cfg)
// The pinned half is the painting, exactly.
for y := 0; y < h; y++ {
for x := 0; x < w/2; x++ {
if masked.Class[y*w+x] != r.Class[y*w+x] {
t.Fatalf("pixel (%d,%d) moved inside a pinned stretch", x, y)
}
}
}
// And the half that said nothing is still roughened, or the test above proves nothing.
moved := 0
for y := 0; y < h; y++ {
for x := w / 2; x < w; x++ {
if masked.Class[y*w+x] != r.Class[y*w+x] {
moved++
}
}
}
if moved == 0 {
t.Fatal("nothing moved in the unmarked half; the mask is switching the whole pass off")
}
// The unmarked half must be exactly what it was with no mask at all - the noise is a function of world
// position, so pinning one stretch cannot move another.
for y := 0; y < h; y++ {
for x := w/2 + int(cfg.AmplitudePx) + 2; x < w; x++ {
if masked.Class[y*w+x] != free.Class[y*w+x] {
t.Fatalf("pixel (%d,%d) differs from the unmasked run; pinning one stretch moved another",
x, y)
}
}
}
}
// Painting only the water is enough, and so is painting only the land. A mark is a brush stroke along a
// coastline and it lands on whichever side the author's hand was on; if an unmarked cell took the default
// amplitude, the other side would march across the line anyway and the coast would move regardless.
func TestPinningOneSideOfTheWaterlineIsEnough(t *testing.T) {
const w, h = 512, 256
p := testCylinder(t, w, h)
l := mustLegend(t, goodLegend)
r := stripeRaster(w, h, l)
cfg := Coast{AmplitudePx: 24, WavelengthPx: 128, Octaves: 4, Gain: 0.55, Seed: 3}
// Everything that could move is within the amplitude of the halfway row, so the two cases below pin the
// same stretch of shore from opposite sides.
landOnly := make([]float32, w*h)
seaOnly := make([]float32, w*h)
for i := range landOnly {
landOnly[i], seaOnly[i] = -1, -1
}
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
if y < h/2 {
landOnly[y*w+x] = 0
} else {
seaOnly[y*w+x] = 0
}
}
}
for _, c := range []struct {
name string
scale []float32
}{{"the land side", landOnly}, {"the sea side", seaOnly}} {
cfg.Scale = c.scale
out := r.RoughenCoast(l, p, cfg)
for i := range r.Class {
if out.Class[i] != r.Class[i] {
t.Fatalf("painting %s only did not hold the shore: pixel %d moved", c.name, i)
}
}
}
}
@@ -34,6 +34,12 @@ func Apply(h []float32, w, hgt int, cellM, talus float64, passes int, fixed []bo
if passes <= 0 || talus <= 0 {
return
}
// The scratch is the caller's so a pass inside a loop does not allocate a grid every time; it is optional
// because every other caller of this package passes one and the one that did not spent its first run in a
// panic (slice bounds out of range) rather than in a weather simulation.
if cap(scratch) < len(h) {
scratch = make([]float32, len(h))
}
delta := scratch[:len(h)]
card := cellM
diag := cellM * math.Sqrt2
+210
View File
@@ -0,0 +1,210 @@
// Package tile cuts the detail grid into pieces that can be baked one at a time.
//
// Docs/Terrain-Next.md 3.3 splits the work in two and this is the easy half. The fluvial solve is global in a
// way that cannot be tiled - drainage area is an integral over the whole upstream catchment - and it is
// handled by decomposing the planet per landmass instead (internal/region). Every pass after it is *local*:
// noise is pointwise, thermal weathering propagates a cell at a time, and a droplet travels at most its
// lifetime in cells. So a tile is cut with an overlap margin sized by how far the pass it runs can move
// material, the passes run, and the margin is thrown away. Nothing is exchanged between tiles and nothing
// needs to be.
//
// That only works because of rule 1. Every hash and every noise lattice is keyed on absolute world position,
// so a cell reached in a tile's interior and the same cell reached inside a neighbour's margin get the same
// answer to the bit. Key anything on a tile-local index and every seam shows.
package tile
import (
"fmt"
"salty/terrain/internal/field"
"salty/terrain/internal/world"
)
// Grid is the tiling of one planet's detail resolution.
type Grid struct {
P world.Planet // the geology cylinder the tiles are cut from
Factor int // detail cells per geology cell, the manifest's geology_factor
SideGeo int // interior side of a tile, in geology cells
MarginGeo int // overlap carried on every side, in geology cells
NX, NY int // tiles across and down
GeoW, GeoH int // the painted geology raster the tiles cover
}
// NewGrid works out the tiling. sidePx is the interior side of a tile in *detail* cells, and must be a whole
// number of geology cells; marginPx is the overlap in detail cells.
//
// X must divide exactly, because it wraps: a tile grid that did not come out whole would leave the last tile
// overlapping the first by an arbitrary amount and there would be no honest way to name the seam.
func NewGrid(p world.Planet, factor, sidePx, marginPx int) (*Grid, error) {
if factor < 1 {
return nil, fmt.Errorf("detail factor is %d", factor)
}
if sidePx < factor || sidePx%factor != 0 {
return nil, fmt.Errorf("tile side %d detail cells is not a whole number of %d-cell geology blocks",
sidePx, factor)
}
side := sidePx / factor
if p.W%side != 0 {
return nil, fmt.Errorf("a %d cell planet does not divide into %d cell tiles; X wraps, so it must. "+
"The nearest sides that work are %s", p.W, side, divisorsNear(p.W, side))
}
margin := (marginPx + factor - 1) / factor
if margin < 1 {
margin = 1
}
g := &Grid{
P: p, Factor: factor, SideGeo: side, MarginGeo: margin,
GeoW: p.W, GeoH: p.PaintH(),
}
g.NX = g.GeoW / side
g.NY = (g.GeoH + side - 1) / side
return g, nil
}
// Tile is one piece: where it sits and how big it is.
type Tile struct {
IX, IY int
// X0, Y0 and W, H are the interior, in geology cells of the painted raster. The last row of tiles is
// short wherever the planet's height is not a whole number of tiles, and that is recorded rather than
// padded: padding would put invented ground in the output.
X0, Y0, W, H int
}
// Tiles lists every tile in row-major order.
func (g *Grid) Tiles() []Tile {
out := make([]Tile, 0, g.NX*g.NY)
for iy := 0; iy < g.NY; iy++ {
y0 := iy * g.SideGeo
h := g.SideGeo
if y0+h > g.GeoH {
h = g.GeoH - y0
}
for ix := 0; ix < g.NX; ix++ {
out = append(out, Tile{IX: ix, IY: iy, X0: ix * g.SideGeo, Y0: y0, W: g.SideGeo, H: h})
}
}
return out
}
// DetailW and DetailH are the tile's interior at detail resolution.
func (g *Grid) DetailW(t Tile) int { return t.W * g.Factor }
func (g *Grid) DetailH(t Tile) int { return t.H * g.Factor }
// OriginXM and OriginYM are the world position of the tile's first interior detail cell.
func (g *Grid) OriginXM(t Tile) float64 { return g.P.XM(t.X0) }
func (g *Grid) OriginYM(t Tile) float64 { return g.P.YM(t.Y0 + g.P.PadY) }
// Cut extracts a tile's geology source: the interior plus the margin, wrapping in X and clamping in Y.
//
// The extra sample is the upsample's: field.UpsampleInt turns N samples into (N-1)*factor+1, so covering
// W*factor interior detail cells needs W+1 geology samples, and the margin is on top of that.
//
// Clamping in Y rather than wrapping is not a shortcut - the top and bottom of the map are the poles, not
// each other - and it only ever touches the polar pad, which is water.
func (g *Grid) Cut(t Tile, src *field.Field, padY int) (out *field.Field, interiorX, interiorY int) {
w := t.W + 2*g.MarginGeo + 1
h := t.H + 2*g.MarginGeo + 1
out = field.New(w, h, src.CellM)
x0 := t.X0 - g.MarginGeo
y0 := t.Y0 - g.MarginGeo
for y := 0; y < h; y++ {
sy := y0 + y
if sy < 0 {
sy = 0
} else if sy >= g.GeoH {
sy = g.GeoH - 1
}
row := (sy + padY) * src.W
for x := 0; x < w; x++ {
out.Data[y*w+x] = src.Data[row+g.P.WrapX(x0+x)]
}
}
return out, g.MarginGeo * g.Factor, g.MarginGeo * g.Factor
}
// CutMask is Cut for a boolean field, nearest by construction.
func (g *Grid) CutMask(t Tile, src []bool, srcW, padY int) []bool {
w := t.W + 2*g.MarginGeo + 1
h := t.H + 2*g.MarginGeo + 1
out := make([]bool, w*h)
x0 := t.X0 - g.MarginGeo
y0 := t.Y0 - g.MarginGeo
for y := 0; y < h; y++ {
sy := y0 + y
if sy < 0 {
sy = 0
} else if sy >= g.GeoH {
sy = g.GeoH - 1
}
row := (sy + padY) * srcW
for x := 0; x < w; x++ {
out[y*w+x] = src[row+g.P.WrapX(x0+x)]
}
}
return out
}
// CutClass is Cut for the painted class raster, which is indexed over the whole planet including the polar
// pad, so it takes the pad offset rather than assuming the painted rows.
func (g *Grid) CutClass(t Tile, src []uint8, srcW, padY int) []uint8 {
w := t.W + 2*g.MarginGeo + 1
h := t.H + 2*g.MarginGeo + 1
out := make([]uint8, w*h)
x0 := t.X0 - g.MarginGeo
y0 := t.Y0 - g.MarginGeo
for y := 0; y < h; y++ {
sy := y0 + y
if sy < 0 {
sy = 0
} else if sy >= g.GeoH {
sy = g.GeoH - 1
}
row := (sy + padY) * srcW
for x := 0; x < w; x++ {
out[y*w+x] = src[row+g.P.WrapX(x0+x)]
}
}
return out
}
// Frame is the tile's cut rectangle as a world frame at *detail* resolution, which is what the noise and the
// hashes are indexed by.
func (g *Grid) Frame(t Tile) world.Frame {
detail := g.P
detail.CellM = g.P.CellM / float64(g.Factor)
detail.W = g.P.W * g.Factor
detail.H = g.P.H * g.Factor
detail.PadY = g.P.PadY * g.Factor
w := (t.W + 2*g.MarginGeo) * g.Factor
h := (t.H + 2*g.MarginGeo) * g.Factor
return world.Frame{
P: detail,
X0: detail.WrapX((t.X0 - g.MarginGeo) * g.Factor),
Y0: (t.Y0 - g.MarginGeo + g.P.PadY) * g.Factor,
W: w + 1, H: h + 1,
}
}
// Name is the file stem a tile is written under.
func (t Tile) Name(prefix string) string { return fmt.Sprintf("%s_x%02d_y%02d", prefix, t.IX, t.IY) }
// divisorsNear lists a few tile sides that do divide, for the error message.
func divisorsNear(w, want int) string {
var below, above int
for d := want; d >= 1; d-- {
if w%d == 0 {
below = d
break
}
}
for d := want; d <= w; d++ {
if w%d == 0 {
above = d
break
}
}
return fmt.Sprintf("%d and %d geology cells", below, above)
}
+172
View File
@@ -0,0 +1,172 @@
package tile
import (
"strings"
"testing"
"salty/terrain/internal/field"
"salty/terrain/internal/world"
)
func testPlanet(t *testing.T) world.Planet {
t.Helper()
// 64 geology columns of 8 m is a 512 m circumference, 40 painted rows, 4 of polar pad.
p := world.Planet{CellM: 8, W: 64, H: 48, PadY: 4, NoisePeriodM: 512}
if err := p.Validate(); err != nil {
t.Fatal(err)
}
return p
}
func TestNewGridDivides(t *testing.T) {
p := testPlanet(t)
// 64 detail cells at factor 4 is 16 geology cells, and 64 divides by 16.
g, err := NewGrid(p, 4, 64, 8)
if err != nil {
t.Fatal(err)
}
if g.NX != 4 {
t.Errorf("NX = %d, want 4", g.NX)
}
// 40 painted rows over 16-cell tiles is 3 rows, the last one short.
if g.NY != 3 {
t.Errorf("NY = %d, want 3", g.NY)
}
if g.MarginGeo != 2 {
t.Errorf("MarginGeo = %d, want 2 (8 detail cells at factor 4)", g.MarginGeo)
}
}
// X wraps, so a tile grid that did not come out whole would leave the last tile overlapping the first by an
// arbitrary amount and there would be no honest way to name the seam.
func TestNewGridRefusesASideThatDoesNotDivide(t *testing.T) {
p := testPlanet(t)
_, err := NewGrid(p, 4, 4*13, 8) // 13 geology cells does not divide 64
if err == nil {
t.Fatal("accepted a tile side that does not divide the circumference")
}
if !strings.Contains(err.Error(), "X wraps") {
t.Errorf("error %q does not say why", err)
}
if _, err := NewGrid(p, 4, 66, 8); err == nil {
t.Fatal("accepted a tile side that is not a whole number of geology cells")
}
}
// The last row is short rather than padded: padding would put invented ground in the output.
func TestTilesCoverThePaintedRowsExactly(t *testing.T) {
p := testPlanet(t)
g, err := NewGrid(p, 4, 64, 8)
if err != nil {
t.Fatal(err)
}
seen := make([]int, p.W*p.PaintH())
for _, tl := range g.Tiles() {
for y := tl.Y0; y < tl.Y0+tl.H; y++ {
for x := tl.X0; x < tl.X0+tl.W; x++ {
seen[y*p.W+x]++
}
}
}
for i, n := range seen {
if n != 1 {
t.Fatalf("cell %d covered %d times, want exactly 1", i, n)
}
}
last := g.Tiles()[len(g.Tiles())-1]
if last.H != p.PaintH()-2*g.SideGeo {
t.Errorf("the last tile row is %d cells, want %d", last.H, p.PaintH()-2*g.SideGeo)
}
}
// A tile at the seam reads its left margin from the far side of the map, and one at a pole clamps rather
// than wrapping: the top and bottom of the map are the poles, not each other.
func TestCutWrapsInXAndClampsInY(t *testing.T) {
p := testPlanet(t)
g, err := NewGrid(p, 4, 64, 8)
if err != nil {
t.Fatal(err)
}
// A source whose value encodes its own position, so a misplaced read is obvious.
src := field.New(p.W, p.PaintH(), p.CellM)
for y := 0; y < src.H; y++ {
for x := 0; x < src.W; x++ {
src.Data[y*src.W+x] = float32(y*1000 + x)
}
}
first := g.Tiles()[0] // X0 = 0, Y0 = 0: both edges
out, ix, iy := g.Cut(first, src, 0)
if ix != g.MarginGeo*g.Factor || iy != g.MarginGeo*g.Factor {
t.Errorf("interior offset %d,%d, want %d", ix, iy, g.MarginGeo*g.Factor)
}
// The left margin is the far side of the cylinder.
if got, want := out.Data[g.MarginGeo*out.W+0], float32(0*1000+p.W-g.MarginGeo); got != want {
t.Errorf("left margin reads %v, want %v (the columns across the seam)", got, want)
}
// The top margin is row 0 repeated, not the bottom of the map.
if got, want := out.Data[0*out.W+g.MarginGeo], float32(0*1000+0); got != want {
t.Errorf("top margin reads %v, want %v (row 0 clamped)", got, want)
}
// And the interior is itself.
if got, want := out.Data[g.MarginGeo*out.W+g.MarginGeo], float32(0); got != want {
t.Errorf("interior corner reads %v, want %v", got, want)
}
}
// The frame is what every hash and noise lattice in the detail passes is keyed on, so it has to name the
// right physical place at detail resolution.
func TestFrameIsTheDetailWorldPosition(t *testing.T) {
p := testPlanet(t)
g, err := NewGrid(p, 4, 64, 8)
if err != nil {
t.Fatal(err)
}
tiles := g.Tiles()
second := tiles[1] // X0 = 16 geology cells
f := g.Frame(second)
if f.P.CellM != 2 {
t.Errorf("frame cell = %v m, want 2", f.P.CellM)
}
if f.P.W != p.W*4 {
t.Errorf("frame planet width = %d, want %d detail columns", f.P.W, p.W*4)
}
// The cut starts a margin before the interior: (16 - 2) geology cells is 56 detail columns.
if f.X0 != (16-g.MarginGeo)*4 {
t.Errorf("frame X0 = %d, want %d", f.X0, (16-g.MarginGeo)*4)
}
// The interior's first detail column is the margin in, and it must name geology column 16.
wx, _ := f.PlanetXY(g.MarginGeo*g.Factor, 0)
if wx != 16*4 {
t.Errorf("the interior's first column is detail column %d, want %d", wx, 16*4)
}
if got, want := g.OriginXM(second), 16*8.0; got != want {
t.Errorf("OriginXM = %v, want %v", got, want)
}
// Row 0 of the painted map is world Y zero; the polar pad is behind it.
if got := g.OriginYM(tiles[0]); got != 0 {
t.Errorf("OriginYM of the first tile row = %v, want 0", got)
}
}
// A frame that straddles the seam names the same physical columns as one that does not.
func TestASeamTileNamesTheSameColumns(t *testing.T) {
p := testPlanet(t)
g, err := NewGrid(p, 4, 64, 8)
if err != nil {
t.Fatal(err)
}
first := g.Tiles()[0] // X0 = 0, so its left margin is across the seam
f := g.Frame(first)
// Detail column 0 of the cut is (0 - margin) geology cells, wrapped.
wx, _ := f.PlanetXY(0, 0)
if want := (p.W - g.MarginGeo) * 4; wx != want {
t.Errorf("the cut's first column is detail column %d, want %d across the seam", wx, want)
}
// And the interior's first column is detail column 0.
wx, _ = f.PlanetXY(g.MarginGeo*g.Factor, 0)
if wx != 0 {
t.Errorf("the interior's first column is %d, want 0", wx)
}
}
@@ -0,0 +1,358 @@
package uplift
import (
"math"
"salty/terrain/internal/noise"
"salty/terrain/internal/plates"
"salty/terrain/internal/world"
)
// Faults belonging to a plate boundary rather than to a painted class.
//
// The specification for this file is a map of the Alpide belt - Spain through the Maghreb, Italy, Greece,
// Turkey, Iran, Afghanistan, the Pamir, the Himalaya and into Burma - with every mapped fault trace on it.
// Six things are true of that picture and not one of them is true of a fault set scattered inside a painted
// colour:
//
// 1. **The traces are in swarms along a belt, and everywhere else is blank.** The Sahara has none. Arabia's
// interior has none. Peninsular India, Kazakhstan, Ukraine: none. A craton is not lightly faulted, it is
// unfaulted, and the belt next to it is saturated. Density is therefore a function of distance to a
// boundary and of nothing else - not of which colour the ground was painted.
// 2. **The belt is wide, and how wide varies enormously.** Through Italy and Greece it is a hundred
// kilometres; across Iran and Tibet it is well over a thousand, a fan of parallel traces from the Zagros
// to the Alborz. So the zone is not a fixed halo: it scales with what the margin is doing.
// 3. **Faults are near the line, not on it.** Almost none of those traces *is* the plate boundary. They sit
// tens to hundreds of kilometres either side of it, thickest near it and thinning outwards - deformation
// is distributed across a zone, and the boundary is only where it is centred.
// 4. **Within a swarm they are sub-parallel**, to each other and to the belt, and they follow it round its
// bends: the Turkish arc, the Zagros arc, the Himalayan arc, the fan at the Burma syntaxis. The strike
// comes from the local tangent of the boundary, which is why the whole set curves where the margin does.
// 5. **There is a second, conjugate direction** in the wide interiors - Tibet and Mongolia show two sets
// crossing at a high angle. One direction alone reads as corduroy, which is the defect Terrain-Next 4.A3
// records against the procedural path at a different scale.
// 6. **They splay and anastomose** rather than running as isolated segments, which the en-echelon stepping
// in painted_faults.go already produces and which is kept here unchanged.
//
// Everything about a *trace* - the walked heading, the taper over the last sixth, the en-echelon step past
// twelve kilometres, the escarpment profile, the repose ceiling - is shared with the class-based set through
// traceSet. What is new here is only where a fault is put and which way it points, which is exactly the part
// that was wrong.
const srcBeltFaults = 29
// beltWidth is how wide each kind of margin's deformation zone is, as a multiple of the configured width.
//
// These are ratios between kinds of boundary rather than tuning, which is why they are constants and not
// manifest keys. A continental collision has nowhere to put the convergence except into the crust on both
// sides, so it deforms a belt a thousand kilometres across; a subduction margin puts most of it down the slab
// and deforms an arc and a forearc; a transform is a narrow braid however long it runs, because the motion is
// taken up by sliding rather than by shortening; a rift deforms its two shoulders; and a mid-ocean ridge is
// the narrowest of all, an axis a few tens of kilometres wide.
var beltWidth = map[plates.Kind]float64{
plates.Collision: 1.00,
plates.Subduction: 0.55,
plates.Rift: 0.35,
plates.Transform: 0.30,
plates.Ridge: 0.15,
}
// beltFalloff shapes how the traces thin out away from the line.
//
// An offset drawn as zone*u would spread them evenly across the whole zone, which is not what the map shows:
// the swarm is dense at the margin and trails off. Raising a uniform draw to this power biases it towards
// zero, so the density falls smoothly outwards and the zone edge is a fading-out rather than a line where
// faults stop.
const beltFalloff = 1.8
// beltThrowFloor and beltThrowCeil bound how far the closing rate is allowed to scale a throw. A margin that
// has almost stopped still has inherited structure in it, and one going twice as fast as the reference does
// not build scarps four times the size, because the repose ceiling is waiting either way.
const (
beltThrowFloor = 0.35
beltThrowCeil = 2.0
)
// beltLandProbes is how many positions are sampled across the zone to find out how much of it is land.
//
// It is measured rather than assumed because the density has to keep meaning what it says. A margin running
// down the middle of an ocean and one running along a continent have the same length and the same zone area,
// and if the count came from the zone area alone the first would ask for as many traces as the second and
// then fail to place them - so the density would quietly mean something different on every boundary.
const beltLandProbes = 512
// beltPlaceTries is how many times a trace is redrawn when it lands in the sea before giving up on it.
const beltPlaceTries = 12
// beltLandShare is how much of a trace has to be on land for it to be kept. Half rather than all, because a
// fault that runs out to a coast and stops is right and a fault forbidden from reaching one is not: the
// result of demanding every probe be land is a set that avoids the shore, which is the opposite mistake.
const beltLandShare = 0.5
// BuildBeltFaults places a fault set in the deformation zones around a planet's plate boundaries.
//
// land reports whether a world position is painted land, the same callback plates.Build takes. Offshore
// faults are real - the reference map has them all over the Mediterranean and the Arabian Sea - but the solve
// fixes every ocean cell at sea level, so a trace out there changes nothing and only clutters the diagnostic.
// They are therefore kept on land, and the density is measured against the part of each zone that *is* land
// so that the number an author sets keeps meaning what it says.
func BuildBeltFaults(p world.Planet, seed int64, cfg plates.Belt, bs []plates.Boundary,
land func(xM, yM float64) bool) []FaultTrace {
if !cfg.Wanted() || len(bs) == 0 {
return nil
}
cfg = cfg.WithDefaults()
s := noise.NewSource(seed, srcBeltFaults)
// The bend lattice a trace's walk turns on. Its wavelength is tied to the zone rather than to the
// planet's fault grain: a trace inside a belt should curve on the belt's own scale.
bendCells := int(p.NoisePeriodM/(cfg.ZoneKm*1000) + 0.5)
if bendCells < 1 {
bendCells = 1
}
pl := &beltPlacer{
p: p, s: s, bend: noise.NewLattice(bendCells*4, s), bendCells: bendCells, cfg: cfg,
spread: cfg.Spread() * math.Pi / 180,
conj: cfg.ConjugateDeg * math.Pi / 180,
land: land,
}
refM := cfg.ReferenceCmYr / 100 // cm/yr to m/yr
var out []FaultTrace
for bi := range bs {
b := &bs[bi]
if len(b.V) < 2 {
continue
}
seg := beltSegments(b, cfg, refM)
if seg.zoneKm2 <= 0 {
continue
}
// How much of this belt's zone a fault can actually be placed in, measured by proposing faults
// exactly the way the placement loop below does and counting how many survive. The same draw and the
// same test, so the acceptance rate the loop will see is the one the count is scaled by and the
// density keeps meaning what it says.
kept := 0
for i := 0; i < beltLandProbes; i++ {
if len(pl.propose(seg)) > 0 {
kept++
}
}
usable := float64(kept) / beltLandProbes
if usable <= 0 {
continue
}
want := cfg.Per1000Km2 * seg.zoneKm2 * usable / 1000
n := int(want)
// Stochastic rounding, so a short margin too small for one whole fault still gets one sometimes and
// the density means what it says averaged over a planet rather than being floored to zero.
if s.Float() < want-float64(n) {
n++
}
for i := 0; i < n; i++ {
for try := 0; try < beltPlaceTries; try++ {
if set := pl.propose(seg); len(set) > 0 {
out = append(out, set...)
break
}
}
}
}
return out
}
// beltPlacer is everything one fault's placement needs, carried together because proposing a fault and
// measuring how often a proposal succeeds have to be the same code. See propose.
type beltPlacer struct {
p world.Planet
s *noise.Source
bend *noise.Lattice
bendCells int
cfg plates.Belt
spread float64
conj float64
land func(xM, yM float64) bool
}
// propose draws one fault in the zone, builds it, and returns the traces it is made of with any that ended
// up offshore removed. An empty result is a proposal the caller should redraw.
//
// The land test is applied to the **walked geometry**, not to the straight line the fault was proposed along,
// and that distinction is the whole reason this function exists. Between a proposal and a trace sit two
// things that move it: a fault over twelve kilometres is broken into en-echelon segments staggered across
// strike, and every segment is then walked with a perturbed heading. Testing the proposal let a trace be
// accepted on a headland and then stepped and walked out into open water, which is what the first run of
// this pass drew across two straits.
//
// Segments are filtered one at a time rather than the set being kept or dropped whole, because part of a
// fault continuing offshore while the rest of it is on land is the ordinary case at any coast.
func (pl *beltPlacer) propose(seg beltSeg) []FaultTrace {
xM, yM, tangent, halfM, side := seg.sample(pl.s)
a := tangent + (pl.s.Float()*2-1)*pl.spread
if pl.cfg.Conjugate() > 0 && pl.s.Float() < pl.cfg.Conjugate() {
// The second set, crossing the first. Which way it leans is drawn per fault, because a conjugate
// pair is two directions and picking one of them globally would be the corduroy this exists to avoid.
if pl.s.Float() < 0.5 {
a += pl.conj
} else {
a -= pl.conj
}
}
lengthM := (pl.cfg.LengthKm[0] + (pl.cfg.LengthKm[1]-pl.cfg.LengthKm[0])*pl.s.Float()) * 1000
// A wide belt carries long faults. Scaled against the configured width so that the length range an
// author sets is the one they get on a reference-rate collision.
lengthM *= clampF(halfM/(pl.cfg.ZoneKm*1000), 0.4, 2.2)
throw := pl.cfg.ThrowM[0] + (pl.cfg.ThrowM[1]-pl.cfg.ThrowM[0])*pl.s.Float()
// Vergence, and this is the point of carrying the side at all. A thrust belt is doubly vergent: the
// faults on each flank face outwards, away from the boundary and towards the foreland they are riding
// over. So which block goes up is decided by which side of the line the fault sits on, rather than by
// the coin flip a class fault set has to use for want of anything better.
set := traceSet(pl.p, pl.bend, pl.bendCells, pl.s,
xM, yM, a, lengthM, throw*seg.throwScale, side < 0, -1)
if pl.land == nil {
return set
}
out := set[:0]
for _, f := range set {
if traceLandShare(f, pl.land) >= beltLandShare {
out = append(out, f)
}
}
return out
}
// traceLandShare is how much of a built trace stands on painted land.
//
// Offshore faults are real - the reference map has them throughout the Mediterranean and the Arabian Sea -
// but the solve fixes every ocean cell at sea level, so a trace out there changes no height and does nothing
// but clutter the diagnostic the fault set is read from.
func traceLandShare(f FaultTrace, land func(xM, yM float64) bool) float64 {
if len(f.PointsM) == 0 {
return 0
}
on := 0
for _, pt := range f.PointsM {
if land(pt[0], pt[1]) {
on++
}
}
return float64(on) / float64(len(f.PointsM))
}
// beltSeg is one boundary prepared for sampling: cumulative length along it, the zone half-width at each
// vertex, and the zone's total area.
type beltSeg struct {
b *plates.Boundary
// cum[i] is the length along the polyline up to vertex i, so a uniform draw over cum[last] picks a point
// uniformly along the *line* rather than uniformly among its vertices - which would over-sample wherever
// the chain happened to be dense.
cum []float64
half []float64 // zone half-width in metres at each vertex
zoneKm2 float64
throwScale float64
}
// beltSegments measures a boundary: how wide its zone is at every point, how much ground that is, and how
// much the closing rate should scale the throws in it.
func beltSegments(b *plates.Boundary, cfg plates.Belt, refM float64) beltSeg {
seg := beltSeg{b: b, cum: make([]float64, len(b.V)), half: make([]float64, len(b.V))}
base := cfg.ZoneKm * 1000
closingTotal := 0.0
for i, v := range b.V {
w := beltWidth[v.Kind]
rate := math.Abs(v.ClosingMYr)
if v.Kind == plates.Transform {
// A transform closes at nothing by definition, so its zone has to be scaled by how fast it is
// *sliding* instead. Without this every transform margin in the world would have a zone of zero
// and the San Andreas would be unfaulted.
rate = math.Abs(v.SlipMYr)
}
// The square root, not the rate itself: doubling the convergence does not double the width of the
// belt it deforms, and a linear scale makes the fastest margin swallow a continent.
scale := math.Sqrt(clampF(rate/refM, 0.04, 6))
seg.half[i] = base * w * scale
closingTotal += rate
}
for i := 1; i < len(b.V); i++ {
d := math.Hypot(b.V[i].XM-b.V[i-1].XM, b.V[i].YM-b.V[i-1].YM)
seg.cum[i] = seg.cum[i-1] + d
// The zone either side of this segment, as a trapezium on each flank.
seg.zoneKm2 += d * (seg.half[i-1] + seg.half[i]) / 1e6
}
if n := len(b.V); n > 0 {
mean := closingTotal / float64(n)
seg.throwScale = clampF(mean/refM, beltThrowFloor, beltThrowCeil)
}
return seg
}
// sample draws one position in the zone: a point along the line, then an offset across it.
//
// It returns the world position, the belt's local strike there, the local zone half-width, and which side of
// the line the point fell on. The side is what vergence is read from, and the half-width is what a trace's
// length is scaled by.
func (seg beltSeg) sample(s *noise.Source) (xM, yM, strike, halfM, side float64) {
total := seg.cum[len(seg.cum)-1]
if total <= 0 {
return seg.b.V[0].XM, seg.b.V[0].YM, 0, seg.half[0], 1
}
at := s.Float() * total
// Walk to the segment holding it. Linear rather than a binary search on purpose: a boundary is a few
// hundred vertices and this runs a few thousand times, so the search is not where the time goes and a
// loop with no off-by-one in it is worth more here than the log.
i := 1
for i < len(seg.cum)-1 && seg.cum[i] < at {
i++
}
t := 0.0
if d := seg.cum[i] - seg.cum[i-1]; d > 0 {
t = (at - seg.cum[i-1]) / d
}
a, b := seg.b.V[i-1], seg.b.V[i]
lx := a.XM + (b.XM-a.XM)*t
ly := a.YM + (b.YM-a.YM)*t
nx := a.NX + (b.NX-a.NX)*t
ny := a.NY + (b.NY-a.NY)*t
if d := math.Hypot(nx, ny); d > 0 {
nx, ny = nx/d, ny/d
}
halfM = seg.half[i-1] + (seg.half[i]-seg.half[i-1])*t
// Across the line. The offset is biased towards zero so the swarm is dense at the margin and trails off,
// and the side is drawn separately so both flanks are populated.
side = 1
if s.Float() < 0.5 {
side = -1
}
off := halfM * math.Pow(s.Float(), beltFalloff) * side
// The strike is the boundary's own tangent, which is the perpendicular of the normal. This single line is
// the whole difference between a swarm that follows the Zagros round its arc and a set of traces pointing
// wherever a noise lattice happened to say.
strike = math.Atan2(-nx, ny)
return lx + nx*off, ly + ny*off, strike, halfM, side
}
func clampF(v, lo, hi float64) float64 {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
@@ -0,0 +1,240 @@
package uplift
import (
"math"
"testing"
"salty/terrain/internal/plates"
"salty/terrain/internal/world"
)
// A straight north-south margin down the middle of a planet, closing head-on. Everything a belt fault is
// supposed to do is measurable against a line whose direction is known: the traces should run along it, sit
// near it, and face away from it.
func straightMargin(t *testing.T, p world.Planet) []plates.Boundary {
t.Helper()
const n = 200
xM := p.CircumferenceM() / 2
v := make([]plates.Vertex, n)
for i := range v {
v[i] = plates.Vertex{
XM: xM,
YM: p.HeightM() * float64(i) / float64(n-1),
NX: 1, // the margin runs north-south, so its normal points east
NY: 0,
ClosingMYr: 0.04,
Kind: plates.Collision,
Over: -1,
}
}
return []plates.Boundary{{A: 0, B: 1, V: v}}
}
func beltPlanet(t *testing.T) world.Planet {
t.Helper()
p, err := world.New(40000, 8, 100, 50, 0, 40000)
if err != nil {
t.Fatalf("planet: %v", err)
}
return p
}
func testBelt() plates.Belt {
b := plates.DefaultBelt()
b.ZoneKm = 3
b.Per1000Km2 = 400
none := 0.0
b.ConjugateFraction = &none // measured separately; the main set has to be parallel on its own
return b
}
func allLand(xM, yM float64) bool { return true }
func TestBeltFaultsRunAlongTheMargin(t *testing.T) {
p := beltPlanet(t)
fs := BuildBeltFaults(p, 7, testBelt(), straightMargin(t, p), allLand)
if len(fs) < 20 {
t.Fatalf("%d traces; not enough to measure anything", len(fs))
}
// The margin runs north-south, so every trace should too. Measured as the angle between the trace's own
// end-to-end direction and the line, folded into 0..90 because a fault has no head or tail.
worst, total := 0.0, 0.0
for _, f := range fs {
a, b := f.PointsM[0], f.PointsM[len(f.PointsM)-1]
deg := foldedAngleDeg(math.Atan2(b[1]-a[1], b[0]-a[0]), math.Pi/2)
total += deg
if deg > worst {
worst = deg
}
}
mean := total / float64(len(fs))
// The configured spread is 11 degrees, and the walk wanders on top of it. A mean much above that would
// mean the strike is not coming from the boundary at all, which is the defect this whole file exists for.
if mean > 20 {
t.Errorf("traces average %.1f degrees off the margin; they are not following it", mean)
}
if worst > 55 {
t.Errorf("a trace is %.1f degrees off the margin; nothing should be near perpendicular to it", worst)
}
}
// foldedAngleDeg is the angle between two directions, in degrees, folded into 0..90: a line at 170 degrees
// and one at 10 are twenty degrees apart, not a hundred and sixty.
func foldedAngleDeg(a, b float64) float64 {
d := math.Abs(a-b) * 180 / math.Pi
d = math.Mod(d, 180)
if d > 90 {
d = 180 - d
}
return d
}
func TestBeltFaultsStayInTheDeformationZone(t *testing.T) {
p := beltPlanet(t)
cfg := testBelt()
fs := BuildBeltFaults(p, 7, cfg, straightMargin(t, p), allLand)
if len(fs) == 0 {
t.Fatal("no traces")
}
xM := p.CircumferenceM() / 2
// The zone half-width here is the configured width times the collision multiplier times the rate scale.
// A *fault's* centre is placed inside it, but a trace's need not be: a fault over twelve kilometres is
// broken into en-echelon segments staggered up to 0.06 of its length across strike, which is the whole
// point of the stepping. So the bound on a segment centre is the zone plus that stagger, and the bound on
// any point of it is a further half-length beyond that.
half := cfg.ZoneKm * 1000 * beltWidth[plates.Collision] * math.Sqrt(0.04/(cfg.ReferenceCmYr/100))
longest := cfg.LengthKm[1] * 1000 * 2.2
centreBound := half + 0.06*longest
anyBound := centreBound + longest
far := 0
inZone := 0
for _, f := range fs {
mid := f.PointsM[len(f.PointsM)/2]
d := math.Abs(mid[0] - xM)
if d > centreBound {
far++
}
if d <= half {
inZone++
}
for _, pt := range f.PointsM {
if math.Abs(pt[0]-xM) > anyBound {
t.Fatalf("a trace reaches %.0f m from the margin; the zone, the stagger and a trace is %.0f m",
math.Abs(pt[0]-xM), anyBound)
}
}
}
if far > 0 {
t.Errorf("%d of %d trace centres sit outside the deformation zone and its en-echelon stagger",
far, len(fs))
}
// And they should be *concentrated* near the line rather than spread evenly across the zone: that is what
// beltFalloff is for, and what the reference map shows.
near := 0
for _, f := range fs {
if math.Abs(f.PointsM[len(f.PointsM)/2][0]-xM) < half/2 {
near++
}
}
if float64(near)/float64(inZone) < 0.55 {
t.Errorf("only %d of %d traces are in the inner half of the zone; the falloff is not biting",
near, inZone)
}
}
func TestBeltFaultsVergeAwayFromTheMargin(t *testing.T) {
p := beltPlanet(t)
cfg := testBelt()
// Short faults only. Vergence is decided per *fault*, from which side of the line it was placed on, and
// then every en-echelon segment of it inherits that - correctly, since the segments are one fault. Keeping
// every fault under enEchelonM means one trace per placement, so the side a trace sits on and the side it
// was placed on are the same thing and the property can be measured at all.
cfg.LengthKm = [2]float64{2, 4}
fs := BuildBeltFaults(p, 7, cfg, straightMargin(t, p), allLand)
if len(fs) < 20 {
t.Fatalf("%d traces; not enough to measure anything", len(fs))
}
xM := p.CircumferenceM() / 2
wrong := 0
for _, f := range fs {
mid := f.PointsM[len(f.PointsM)/2]
// A doubly-vergent belt faces outwards on both flanks, so the two sides must disagree about which
// block goes up. Which flank got which sign does not matter; that they are consistent within a flank
// does, because the alternative is the coin flip a class fault set has to use.
if (mid[0] > xM) != f.Reverse {
wrong++
}
}
if wrong != 0 && wrong != len(fs) {
t.Errorf("%d of %d traces disagree with their own flank about vergence; a belt is doubly vergent, "+
"not randomly vergent", min(wrong, len(fs)-wrong), len(fs))
}
}
func TestBeltFaultsNeedLand(t *testing.T) {
p := beltPlanet(t)
m := straightMargin(t, p)
if got := BuildBeltFaults(p, 7, testBelt(), m, func(xM, yM float64) bool { return false }); len(got) != 0 {
t.Errorf("%d traces on a planet with no land", len(got))
}
// A coast down one side of the margin: every trace must be mostly on the land side.
xM := p.CircumferenceM() / 2
half := func(x, y float64) bool { return x < xM }
fs := BuildBeltFaults(p, 7, testBelt(), m, half)
if len(fs) == 0 {
t.Fatal("no traces on a half-land planet")
}
for _, f := range fs {
on := 0
for _, pt := range f.PointsM {
if half(pt[0], pt[1]) {
on++
}
}
if share := float64(on) / float64(len(f.PointsM)); share < 0.3 {
t.Errorf("a trace is only %.0f%% on land; the span test should have refused it", share*100)
}
}
}
func TestABeltWithNoNumbersAsksForNothing(t *testing.T) {
p := beltPlanet(t)
if got := BuildBeltFaults(p, 7, plates.Belt{}, straightMargin(t, p), allLand); got != nil {
t.Errorf("%d traces from an empty config; leaving the block out must leave the feature off", len(got))
}
}
func TestAFasterMarginDeformsAWiderBelt(t *testing.T) {
p := beltPlanet(t)
cfg := testBelt()
spread := func(closing float64) float64 {
bs := straightMargin(t, p)
for i := range bs[0].V {
bs[0].V[i].ClosingMYr = closing
}
fs := BuildBeltFaults(p, 7, cfg, bs, allLand)
if len(fs) == 0 {
t.Fatalf("no traces at %.3g m/yr", closing)
}
xM := p.CircumferenceM() / 2
worst := 0.0
for _, f := range fs {
mid := f.PointsM[len(f.PointsM)/2]
if d := math.Abs(mid[0] - xM); d > worst {
worst = d
}
}
return worst
}
slow, fast := spread(0.01), spread(0.08)
if fast <= slow*1.4 {
t.Errorf("a margin closing eight times faster deforms a belt %.0f m wide against %.0f m; the zone is "+
"not scaling with the rate", fast, slow)
}
}
+216
View File
@@ -0,0 +1,216 @@
package uplift
import (
"salty/terrain/internal/field"
"salty/terrain/internal/noise"
"salty/terrain/internal/world"
)
// The planet's upland fabric: where the ground stands above the plain, before any class has said how far.
//
// It exists because of one piece of arithmetic. For n = 1 the steady-state divide slope is U/(K*A^m) applied
// down to a single cell, so a class's uplift rate *is* its hillslope angle - 0.08 mm/yr is 11.3 degrees at an
// 8 m cell - and a class is one rate over every cell an author painted with it. A landmass painted one colour
// therefore comes out uniformly dissected from the waterline to the summit, with no flat ground anywhere on
// it. That is not what a continent looks like. Europe away from the Alps is a plain at a fraction of a degree
// with isolated massifs standing out of it, and the difference is not the rate, it is that the rate is not
// the same everywhere.
//
// So a class carries a floor as well as a rate, and this decides, per cell, how far between the two it sits.
//
// One fabric for the whole planet, cut at a different level by each class that asks. That is deliberate and
// it is the reason this is not a per-class noise: a highland belt and the hills in the lowland next to it
// then come out as the high and low parts of one structure - an orogen and its outliers - rather than as two
// unrelated fields meeting at a painted edge.
// fabricProbeW is how finely a fabric is sampled to find out what its values mean. 1024 columns is 98 m a
// sample on a 100 km planet against a finest octave of a kilometre or so, which is ten samples across it: the
// distribution the probe measures is the distribution the 8 m grid will draw from, which is the only thing
// asked of it.
const fabricProbeW = 1024
// fabricBins is the resolution of the measured distribution. The fabric lives in a fraction of 0..1, so four
// thousand bins over the whole interval is finer than the probe's sampling error by a wide margin.
const fabricBins = 4096
// massifOctaves and massifGain shape the fabric itself. Five octaves at 0.45 keeps the blocks legible at
// their own wavelength while giving their edges a fractal outline, which is what stops a massif reading as a
// painted blob - the thing an author would have drawn by hand, and the reason they should not have to.
const (
massifOctaves = 5
massifGain = 0.45
)
// massifWarp is how far the fabric is bent by the shared low-frequency warp, as a fraction of its own
// wavelength. Ridges take 0.8 and crest lines 3.1; a little under one wavelength keeps a block a block while
// stopping the lattice showing through as a grid of round hills.
const massifWarp = 0.9
// paintWarp is the low-frequency warp every painted noise field is built on, so that ridges curve and blocks
// are not polygons. Shared rather than copied: the fabric has to be bent by the same field the relief is, or
// a massif and the ridges on it would disagree about which way the grain runs.
func paintWarp(u, v *field.Field, seed int64) (wx, wy *field.Field) {
ws := noise.NewSource(seed, srcPaintWarp)
wx = noise.FBMAt(u, v, ws, noise.Params{BaseCells: 3, Octaves: 3, Gain: 0.5})
wy = noise.FBMAt(u, v, ws, noise.Params{BaseCells: 3, Octaves: 3, Gain: 0.5})
return wx, wy
}
// massifFabric samples the upland fabric at the given world coordinates.
func massifFabric(u, v, wx, wy *field.Field, seed int64, baseCells int) *field.Field {
ms := noise.NewSource(seed, srcPaintMassif)
mu, mv := noise.Warp(u, v, wx, wy, massifWarp/float64(baseCells))
return noise.FBMAt(mu, mv, ms, noise.Params{
BaseCells: baseCells, Octaves: massifOctaves, Gain: massifGain,
})
}
// fabricCDF is a planet-wide fabric's distribution, measured once over the whole cylinder: it turns a fabric
// value into the share of the planet standing below it.
//
// It is shared by every field that has to be cut at the same level in every region - the upland fabric here
// and the lithology in painted_rock.go - because the argument below is not about massifs, it is about what a
// threshold on a *decomposed* planet is allowed to be.
//
// The measurement is the hard part of this feature and it is worth saying why. A threshold cannot be a
// percentile of the region. uplift.Build takes percentiles of the grid it is handed and FromTemplate exists
// precisely not to do that: two regions taking quantiles of their own extents would put the same physical
// hillside on different sides of the cut, and the planet would disagree with itself along every region
// boundary. A quantile of the *planet* is a different animal. It is one number for the whole world, every
// region computes the same one from the same samples because the samples are defined by the planet and not by
// the caller, and it costs half a million noise evaluations - about ten milliseconds, once per region.
//
// It is a histogram rather than a sort for the same reason it is cheap: a sorted copy of the probe is four
// megabytes and a hundred milliseconds, and nothing here needs a resolution a sort would buy.
type fabricCDF struct {
lo, hi float64
cum []float64 // fabricBins+1 entries: cum[i] is the share below lo + i*(hi-lo)/fabricBins
}
// fabricFunc builds a planet-wide fabric at the given world coordinates. The two that exist are massifFabric
// and rockFabric; both take the shared low-frequency warp so that every field on a planet bends the same way.
type fabricFunc func(u, v, wx, wy *field.Field, seed int64, baseCells int) *field.Field
// measureFabric probes a fabric over the entire cylinder, the pad included, and measures its distribution.
//
// The pad is in on purpose. It is a couple of hundred rows of synthetic ocean at each pole, no class ever
// reads a rate there, and leaving it out would make the answer depend on how thick the pad happened to be.
// What matters is that the probe is a property of the planet and of nothing else.
func measureFabric(p world.Planet, seed int64, baseCells int, build fabricFunc) fabricCDF {
w := fabricProbeW
if w > p.W {
w = p.W
}
h := int(float64(w)*float64(p.H)/float64(p.W) + 0.5)
if h < 1 {
h = 1
}
// The probe walks the same world metres the regions do, at a coarser step, through the same WorldUV: what
// it measures is the same field, sampled more sparsely.
cellM := p.CircumferenceM() / float64(w)
u, v := noise.WorldUV(w, h, cellM, 0, p.YM(0), p.NoisePeriodM)
wx, wy := paintWarp(u, v, seed)
f := build(u, v, wx, wy, seed, baseCells)
lo, hi := f.MinMax()
c := fabricCDF{lo: float64(lo), hi: float64(hi), cum: make([]float64, fabricBins+1)}
if c.hi <= c.lo {
// A degenerate fabric - one lattice cell, or a probe of a single column. Every value is the same,
// so every cell is at the same place in the distribution and the shape below is flat.
c.hi = c.lo + 1
return c
}
// Counted serially. It is a millisecond and cross-cutting rule 12 says the answer must not depend on how
// many goroutines ran.
counts := make([]float64, fabricBins)
scale := float64(fabricBins) / (c.hi - c.lo)
for _, x := range f.Data {
b := int((float64(x) - c.lo) * scale)
if b < 0 {
b = 0
}
if b >= fabricBins {
b = fabricBins - 1
}
counts[b]++
}
total := float64(len(f.Data))
run := 0.0
for i, n := range counts {
c.cum[i] = run / total
run += n
}
c.cum[fabricBins] = 1
return c
}
// at is the share of the planet standing below this fabric value, in 0..1.
func (c fabricCDF) at(x float64) float64 {
t := (x - c.lo) / (c.hi - c.lo) * float64(fabricBins)
if t <= 0 {
return 0
}
if t >= float64(fabricBins) {
return 1
}
i := int(t)
return c.cum[i] + (c.cum[i+1]-c.cum[i])*(t-float64(i))
}
// MassifRate blends a class's floor and its rate at one place in the fabric: the plain where the fabric is
// low, the class rate where it is high.
//
// The ramp is cut in the *rank* - the share of the planet standing below this cell - rather than in the
// fabric's own values, which is what makes fraction mean something an author can predict: exactly `fraction`
// of the planet stands above the midpoint, half that again reaches the class rate outright, and half again
// above that is off the plain at all. Cutting in value space instead would make the realised share depend on
// the shape of the noise's distribution, which is not a number anybody should have to know.
func MassifRate(floorMYr, rateMYr, rank, fraction float64) float64 {
return floorMYr + (rateMYr-floorMYr)*massifShape(rank, fraction)
}
func massifShape(rank, fraction float64) float64 {
lo := 1 - 1.5*fraction
hi := 1 - 0.5*fraction
t := (rank - lo) / (hi - lo)
if t <= 0 {
return 0
}
if t >= 1 {
return 1
}
return t * t * (3 - 2*t)
}
// MassifRank is where every cell of a frame sits in the planet's upland fabric: 0 is the lowest ground on the
// planet and 1 the highest, as a share of the planet's surface rather than as a height.
//
// It takes world coordinates rather than a Frame so that the diagnostic maps, which point-sample the planet
// down to an image, can ask about exactly the cells they drew rather than about a frame they do not have.
func MassifRank(p world.Planet, seed int64, baseCells int, u, v *field.Field) *field.Field {
if baseCells < 1 {
baseCells = 1
}
wx, wy := paintWarp(u, v, seed)
fabric := massifFabric(u, v, wx, wy, seed, baseCells)
cdf := measureFabric(p, seed, baseCells, massifFabric)
out := field.NewLike(fabric)
field.Rows(out.H, func(y0, y1 int) {
for i := y0 * out.W; i < y1*out.W; i++ {
out.Data[i] = float32(cdf.at(float64(fabric.Data[i])))
}
})
return out
}
// anyMassif reports whether any class in the legend asked for a fabric.
func anyMassif(fraction []float64) bool {
for _, f := range fraction {
if f > 0 {
return true
}
}
return false
}
@@ -0,0 +1,242 @@
package uplift
import (
"math"
"testing"
"salty/terrain/internal/manifest"
"salty/terrain/internal/noise"
"salty/terrain/internal/world"
)
// testPlanet is a small cylinder with a period that divides its circumference, which world.Planet.Validate
// requires and which every noise field here depends on.
func testPlanet(t *testing.T, w, h int, cellM float64) world.Planet {
t.Helper()
p := world.Planet{CellM: cellM, W: w, H: h, PadY: 0, NoisePeriodM: float64(w) * cellM}
if err := p.Validate(); err != nil {
t.Fatal(err)
}
return p
}
// The whole contract of the fraction key: it is a share of the planet's surface, and it is the share standing
// above the midpoint between the class floor and the class rate.
//
// This is the test that makes the number worth writing in a legend. Cutting the fabric at a fixed *value*
// instead would make the realised share depend on the shape of the noise's distribution, which nobody can
// predict from a JSON file, and it would drift every time an octave count changed.
func TestTheMassifFractionIsTheShareOfThePlanetThatStandsUp(t *testing.T) {
// Wider than massifProbeW, deliberately. At 512 the probe clamps to the planet's own width and samples
// the identical grid, so every number below comes out exact and the test measures nothing - which is
// what the first version of it did. A real planet is 12500 columns against a 1024-column probe, so the
// distribution being applied is always a coarser measurement of the field than the field it is applied
// to, and that gap is the thing worth bounding.
const w, h, cellM = 2048, 512, 64.0
p := testPlanet(t, w, h, cellM)
f := world.Whole(p)
for _, fraction := range []float64{0.05, 0.15, 0.30, 0.50} {
u, v := noise.WorldUV(w, h, cellM, f.OriginXM(), f.OriginYM(), p.NoisePeriodM)
rank := MassifRank(p, 7, 8, u, v)
above, full, off := 0, 0, 0
for _, r := range rank.Data {
s := massifShape(float64(r), fraction)
if s > 0.5 {
above++
}
if s >= 1 {
full++
}
if s > 0 {
off++
}
}
got := float64(above) / float64(len(rank.Data))
// Two per cent of the planet. The probe is 1024 x 256 against this 2048 x 512 grid, so the two are
// sampling the same field at different steps and cannot agree to the cell.
if math.Abs(got-fraction) > 0.02 {
t.Errorf("fraction %.2f: %.1f%% of the planet stands above the midpoint, want %.0f%%",
fraction, 100*got, 100*fraction)
}
// Half the fraction again reaches the class rate outright and half again above that is off the plain
// at all. Both follow from the ramp and both are what the legend documents.
if g, want := float64(full)/float64(len(rank.Data)), fraction*0.5; math.Abs(g-want) > 0.02 {
t.Errorf("fraction %.2f: %.1f%% is at the full rate, want %.0f%%", fraction, 100*g, 100*want)
}
if g, want := float64(off)/float64(len(rank.Data)), fraction*1.5; math.Abs(g-want) > 0.03 {
t.Errorf("fraction %.2f: %.1f%% is off the plain, want %.0f%%", fraction, 100*g, 100*want)
}
}
}
// Rule 1 of the tiling plan, for the fabric: two regions covering the same physical place must agree to the
// bit. This is the one that would fail if the threshold were ever taken as a percentile of the region, which
// is the obvious implementation and the wrong one - see the note on massifCDF.
func TestTwoFramesAgreeAboutTheSameGround(t *testing.T) {
const w, h, cellM = 2048, 512, 64.0
p := testPlanet(t, w, h, cellM)
rankIn := func(f world.Frame) []float32 {
u, v := noise.WorldUV(f.W, f.H, cellM, f.OriginXM(), f.OriginYM(), p.NoisePeriodM)
return MassifRank(p, 7, 8, u, v).Data
}
whole := rankIn(world.Whole(p))
// A window well inside the planet, and a second one overlapping it from a different origin.
a := world.Frame{P: p, X0: 400, Y0: 80, W: 240, H: 160}
b := world.Frame{P: p, X0: 520, Y0: 120, W: 240, H: 160}
ra, rb := rankIn(a), rankIn(b)
checked := 0
for y := 0; y < a.H; y++ {
for x := 0; x < a.W; x++ {
px, py := a.PlanetXY(x, y)
if px < b.X0 || px >= b.X0+b.W || py < b.Y0 || py >= b.Y0+b.H {
continue
}
got := ra[y*a.W+x]
want := rb[(py-b.Y0)*b.W+(px-b.X0)]
if got != want {
t.Fatalf("at planet (%d,%d) frame A says %v and frame B says %v", px, py, got, want)
}
if wh := whole[py*p.W+px]; wh != got {
t.Fatalf("at planet (%d,%d) a frame says %v and the whole planet says %v", px, py, got, wh)
}
checked++
}
}
if checked == 0 {
t.Fatal("the two frames do not overlap; this test measured nothing")
}
}
// A frame that straddles the seam is ordinary, not special: column W-1 and column 0 are neighbours, so the
// fabric has to run continuously across them. A wrong noise period is the way this breaks, and it breaks
// invisibly on a map whose two edges are as far apart on screen as they can be.
func TestTheFabricCrossesTheSeam(t *testing.T) {
const w, h, cellM = 2048, 512, 64.0
p := testPlanet(t, w, h, cellM)
u, v := noise.WorldUV(w, h, cellM, 0, 0, p.NoisePeriodM)
whole := MassifRank(p, 7, 8, u, v)
// The step across the seam must be no bigger than a typical step inside the map.
worstSeam, worstInside := 0.0, 0.0
for y := 0; y < h; y++ {
d := math.Abs(float64(whole.Data[y*w] - whole.Data[y*w+w-1]))
if d > worstSeam {
worstSeam = d
}
for x := 1; x < w; x++ {
if e := math.Abs(float64(whole.Data[y*w+x] - whole.Data[y*w+x-1])); e > worstInside {
worstInside = e
}
}
}
if worstSeam > worstInside {
t.Errorf("the biggest step across the seam is %.4f against %.4f anywhere inside the map; "+
"the fabric does not wrap", worstSeam, worstInside)
}
}
// What the feature is for, measured on the thing an author actually gets: a class with a massif has to come
// out mostly plain, and the plain has to be the floor rather than some average of the two.
func TestAPaintedClassWithAMassifIsMostlyPlain(t *testing.T) {
const w, h, cellM = 2048, 512, 64.0
p := testPlanet(t, w, h, cellM)
f := world.Whole(p)
class := make([]uint8, w*h)
land := make([]bool, w*h)
for i := range class {
class[i], land[i] = 1, true
}
const rate = 0.00008 // 0.08 mm/yr, the rate a massif reaches
const floor = 0.00001 // 0.01 mm/yr, the plain
const fraction = 0.15
m := manifest.Defaults()
m.Source.Seed = 7
up := FromTemplate(Paint{
Frame: f, Class: class, Land: land,
Rates: []float32{0, rate},
Ks: []float32{1, 1},
PlainM: []float64{0, 0},
PlainFloor: []float32{0, 0},
MassifFloor: []float32{0, floor},
MassifFraction: []float64{0, fraction},
MassifCells: 8,
Variation: 0, // the swell off, so the fabric is the only thing being measured
}, m)
// Under twice the floor is "plain" for this purpose: the ramp is smooth, so a cell just off the plain is
// still plain, and the question being asked is whether most of the class is down there at all.
plain, high := 0, 0
for _, r := range up.Rate.Data {
if float64(r) < 2*floor {
plain++
}
if float64(r) > 0.5*(rate+floor) {
high++
}
}
if share := float64(plain) / float64(len(up.Rate.Data)); share < 0.6 {
t.Errorf("only %.0f%% of the class is plain; the point of a massif is that most of it is", 100*share)
}
if share := float64(high) / float64(len(up.Rate.Data)); math.Abs(share-fraction) > 0.02 {
t.Errorf("%.1f%% of the class is above the midpoint, want %.0f%%", 100*share, 100*fraction)
}
// And the floor has to be the floor. Before this existed the lowest rate on a uniformly painted class was
// the class rate itself, which is exactly the defect: 0.08 mm/yr is an 11 degree hillslope everywhere.
lo := math.Inf(1)
for _, r := range up.Rate.Data {
if float64(r) < lo {
lo = float64(r)
}
}
if math.Abs(lo-floor) > 0.02*floor {
t.Errorf("the lowest rate on the class is %.5f mm/yr, want the floor %.3f", lo*1000, floor*1000)
}
}
// A legend that asks for no massif has to produce exactly what it did before the fabric existed. The fabric
// is opt-in and it must not be a silent change to every template already written against the old contract.
func TestAClassWithoutAMassifIsUnchanged(t *testing.T) {
const w, h, cellM = 256, 128, 64.0
p := testPlanet(t, w, h, cellM)
f := world.Whole(p)
class := make([]uint8, w*h)
land := make([]bool, w*h)
for i := range class {
class[i], land[i] = 1, true
}
const rate = 0.00008
m := manifest.Defaults()
m.Source.Seed = 7
base := Paint{
Frame: f, Class: class, Land: land,
Rates: []float32{0, rate}, Ks: []float32{1, 1},
PlainM: []float64{0, 0}, PlainFloor: []float32{0, 0},
Variation: 0.3,
}
without := FromTemplate(base, m)
withTables := base
withTables.MassifFloor = []float32{0, 0}
withTables.MassifFraction = []float64{0, 0} // the tables present, the feature not asked for
withTables.MassifCells = 8
same := FromTemplate(withTables, m)
for i := range without.Rate.Data {
if without.Rate.Data[i] != same.Rate.Data[i] {
t.Fatalf("cell %d: %v without the massif tables, %v with them at fraction 0",
i, without.Rate.Data[i], same.Rate.Data[i])
}
}
}
+348
View File
@@ -0,0 +1,348 @@
package uplift
import (
"math"
"salty/terrain/internal/dt"
"salty/terrain/internal/field"
"salty/terrain/internal/manifest"
"salty/terrain/internal/noise"
"salty/terrain/internal/world"
)
// The painted source, and the one decision behind all of it: paint the uplift, never the height.
//
// Docs/Terrain-Next.md 6 lists importing a painted heightmap under "do not redo these", and the reason is
// not taste. A stream-power solve handed a painted surface erodes it into something else within a few
// hundred steps, and what it produces instead has no relationship to what was drawn - while the drainage
// network, which is the entire reason this generator replaced the droplet pipeline, is thrown away and
// rebuilt from whatever the painting happened to leave behind.
//
// Painting the uplift rate instead means an author draws intent - a range here, lowlands there, a coast like
// this - and the simulation produces terrain that honours it and has real rivers, real divides and a real
// valley hierarchy, because those came out of the physics rather than out of the brush.
//
// What is left for noise to do is therefore narrow, and it is not decoration:
//
// - The regional swell. D-49 is arithmetic: with critical_area_m2 at 0 the steady-state slope is
// U/(K*A^m) down to a single cell, so a uniform uplift rate over a wide area gives a surface with no
// divides at all. A painted lowland holds one rate over tens of kilometres. Without a long-wavelength
// modulation the plains come out table-flat, the only gradient across them is the priority-flood's
// epsilon, and the router draws the flood's traversal order as rivers. That was measured once already.
// - The initial relief, which only breaks the symmetry. Small on purpose: the solve is what produces
// relief, and starting it from big ridges means it spends its run tearing them down.
//
// Every noise field here is built on world coordinates through noise.WorldUV, so two regions covering the
// same physical place agree to the bit. That is rule 1 of the tiling plan.
// Pass indices for the painted path's seeded sources. They sit above the procedural path's 1..9 so that the
// two never share a stream and adding one here cannot reshuffle the other.
// Feature counts, in lattice cells per noise period. They are named because the warp amounts are derived
// from them - a warp is only meaningful as a fraction of the wavelength it is bending.
const (
swellCells = 4 // 25 km at a 100 km period: the regional swell
ridgeCells = 24 // 4.2 km: the initial relief
crestCells = 64 // 1.6 km: the crest lines
plainCells = 48 // 2.1 km: the lowland break-up
)
const (
srcPaintSwell = 20
srcPaintRidges = 21
srcPaintCrests = 22
srcPaintPlains = 23
srcPaintWarp = 24
srcPaintMassif = 25
)
// Paint is a region's painted world: which class every cell is, which cells are land, and what the legend
// says those classes mean.
type Paint struct {
Frame world.Frame
Class []uint8 // one legend index per frame cell
Land []bool // land the region owns; everything else is water, including other regions' islands
Rates []float32 // per class, metres a year
Ks []float32 // per class, the multiplier on stream-power K
// PlainM and PlainFloor put a class's range inland: within PlainM metres of the waterline the rate ramps
// from PlainFloor up to the class rate. Per class; zero PlainM means the class reaches the sea at its
// full rate, which is what every class did before and still does unless an author asks otherwise.
PlainM []float64
PlainFloor []float32
// MassifFloor and MassifFraction break a class into plain and upland instead of holding it at one rate.
// Where the fraction is zero the class is uniform, which is what every class did before this existed and
// what a legend that asks for nothing still gets. See massif.go: the class rate is then the rate a massif
// reaches, the floor is the plain between them, and the fraction is how much of the class stands above
// the midpoint of the two.
MassifFloor []float32
MassifFraction []float64
// MassifCells is the fabric's wavelength in lattice cells of the noise period, from
// manifest.Planet.MassifCells. Read only when some class asks for a massif.
MassifCells int
// RockCells and RockMult are the planet's lithology: the wavelength of the rock field in lattice cells,
// and the erodibility multiplier of each rock type. LithMix is per class, how much of it shows through.
// Zero cells, fewer than two multipliers, or every mix at zero means no rock field is built at all.
RockCells int
RockMult []float64
LithMix []float64
// Faults is the planet's whole fault set, in world metres. A region filters it to the traces that reach
// into its own frame, which is why it is the planet's and not the region's: a fault crossing a region
// boundary has to be one fault, and two decompositions of the same planet have to produce the same
// escarpment. RunYears is how long the solve runs, which turns a total throw into a rate.
Faults []FaultTrace
RunYears float64
// ClampCeilM is the uplift rate at which a divide reaches the angle of repose, at K x1, in metres a year.
// It bounds what a *fault* may add and nothing else: an author who paints a class past the ceiling gets
// what they asked for and a warning from `terrain plan`, but a fault stacking on top of one is an
// accident nobody chose. Zero switches the bound off.
ClampCeilM float64
// Variation is how far the swell modulates the painted rate, as a fraction. See the note above: this
// is what gives a painted plain its divides, and it is the first thing that will be cut for time.
Variation float64
}
// FromTemplate builds the geology inputs for one region of a painted planet.
//
// It is a sibling of Build rather than a branch inside it. Build's continent mask, percentile range band,
// normalised swell and percentile lithology split are all global operations over the grid they are given,
// and a region is not a world - two regions taking percentiles of their own extents would disagree about
// the same rock. None of them survives here; the paint replaces all four.
func FromTemplate(p Paint, m *manifest.Manifest) *Result {
f := p.Frame
cfg := m.Pipeline
seed := m.Source.Seed
w, h := f.W, f.H
cellM := f.P.CellM
u, v := noise.WorldUV(w, h, cellM, f.OriginXM(), f.OriginYM(), f.P.NoisePeriodM)
// A low-frequency warp, which bends everything built on it so that ridges curve and cells are not
// polygons. Build has one and this did not, which was a porting mistake with a very visible signature:
// Terrain.md records that cellular crest lines without a strong enough warp "turn ranges into a honeycomb
// of polygon walls", and that is exactly what the first painted mountains looked like - flat plates with
// hard edges, at every uplift rate, which is how it was eventually told apart from the repose clamp.
//
// The warp amounts need converting rather than copying. Build works in map coordinates where 0..1 spans
// the map once, so its 0.16 and 0.224 are fractions of a whole map; here 0..1 spans one noise period, and
// what has to be preserved is the warp measured in the *feature's own wavelength*. Build warps the ridges
// by 0.8 of their wavelength (0.16 against BaseCells 5) and the crests by 3.1 of theirs (0.224 against
// BaseCells 14), so those ratios are what carry across.
wx, wy := paintWarp(u, v, seed)
// The regional swell: long-wavelength, so a painted lowland has hills and basins of its own rather than
// one uniform rate across a whole continent. One turn of the planet at BaseCells 4 is a 25 km feature,
// and four octaves take it down to about 3 km.
ss := noise.NewSource(seed, srcPaintSwell)
swu, swv := noise.Warp(u, v, wx, wy, 0.2/swellCells)
swell := noise.FBMAt(swu, swv, ss, noise.Params{BaseCells: swellCells, Octaves: 4, Gain: 0.5})
rate := field.New(w, h, cellM)
k := field.New(w, h, cellM)
land := field.New(w, h, cellM)
base := make([]bool, w*h)
// Distance from every land cell to the nearest water, for the coastal plain. One exact transform over the
// region, computed only when some class asks for it. The region's frame is flat - it is a rectangle cut
// out of the cylinder with water all round it - so this does not wrap, and the water it measures to is
// this region's own coastline: anything else inside the frame is a different landmass, and a different
// landmass is more than a margin away by construction.
var shoreM []float32
if wantsPlain(p.PlainM) {
d2 := dt.Distance2(invert(p.Land), w, h, false)
shoreM = make([]float32, len(d2))
for i, d := range d2 {
shoreM[i] = float32(math.Sqrt(float64(d)) * cellM)
}
}
// The upland fabric, built only when a class asks for one. It is the one field here that is a cut of a
// planet-wide measurement rather than a value read straight off a noise, which is why it lives in
// massif.go with the note on why that measurement cannot be a percentile of the region.
var rank *field.Field
if anyMassif(p.MassifFraction) {
rank = MassifRank(f.P, seed, p.MassifCells, u, v)
}
// The rock field, the same way and for the same reason: a quantile of the planet, never of the region.
var rock *field.Field
if anyMix(p.LithMix) {
rock = RockK(f.P, seed, p.RockCells, p.RockMult, u, v)
}
// And the faults, which are a rate *difference* across a line rather than a field of their own. Built
// once for the planet and filtered to this frame; nil when none of them reaches it.
fault := FaultDelta(f, p.Faults, p.RunYears)
maxRate := 0.0
for _, r := range p.Rates {
if float64(r) > maxRate {
maxRate = float64(r)
}
}
if maxRate <= 0 {
maxRate = 1
}
// The painted class boundary is deliberately not smoothed. The blend rule in Docs/Terrain-Next.md 3.2
// exists because a painted map coarser than the grid reads as blocks; here a paint pixel is 12.9 m
// against an 8 m cell, so there is barely an upsample to soften. And a step in the uplift *rate* is a
// step in steady-state slope, not in height: the solve grades the transition over a hillslope of its own
// accord, which is a better answer than a blur, and blurring would have pulled the sea's zero into the
// coastal cells - the mistake D-52 undid, where the land ending decided how fast it was rising.
// preFault is the rate before any fault touches it: the class rate after the massif cut and the
// coastal-plain ramp. The initial relief is scaled by it rather than by the finished rate, which is
// D-63 and is not a detail - see the amplitude below.
preFault := make([]float32, len(rate.Data))
clamped := 0
for i := range rate.Data {
c0 := p.Class[i]
kk := float64(p.Ks[c0])
if rock != nil && p.LithMix[c0] > 0 {
// The rock field multiplies the class's own erodibility rather than replacing it: `k_mult` is
// what the author said this ground is made of, and the province is the variation within it.
kk *= 1 + p.LithMix[c0]*(float64(rock.Data[i])-1)
}
k.Data[i] = float32(kk)
if !p.Land[i] {
base[i] = true
continue
}
land.Data[i] = 1
c := p.Class[i]
r := float64(p.Rates[c])
if rank != nil && p.MassifFraction[c] > 0 {
// The class rate is the rate a massif reaches; the floor is the plain between them.
r = MassifRate(float64(p.MassifFloor[c]), r, float64(rank.Data[i]), p.MassifFraction[c])
}
if shoreM != nil && p.PlainM[c] > 0 {
// Smoothstep rather than linear, so the plain meets the range without a crease in the slope
// field - a crease there would be a line of channel heads all starting at the same distance
// from the sea, which is the sort of thing that reads as a contour rather than as terrain.
t := float64(shoreM[i]) / p.PlainM[c]
if t > 1 {
t = 1
}
t = t * t * (3 - 2*t)
floor := float64(p.PlainFloor[c])
// Only ever downwards. Before massifs the class rate was uniform and the legend guarantees the
// coastal floor is below it, so this could not fire; a cell of plain between two massifs now
// sits below the coastal floor perfectly legitimately, and ramping it *up* towards the shore
// would put a rim of hills round the edge of every continent.
if r > floor {
r = floor + (r-floor)*t
}
}
asked := r
preFault[i] = float32(asked)
if fault != nil {
// A fault is a difference in rate across a line. It adds on one side and subtracts on the other,
// and the subtraction is what tilts the block rather than merely raising a ridge - so it is
// allowed to take the rate down, but not below zero: subsidence is not modelled.
if r += float64(fault[i]); r < 0 {
r = 0
}
// The only ceiling in the whole painted path, and it binds on faults alone. Past
// U = tan(talus)*K*cell the repose clamp shapes the ground instead of erosion and the surface
// comes out as polygonal facets; an author may choose that for a class, but a fault stacking on
// top of ground that was already near it is nobody's choice. So the bound is the ceiling *or*
// whatever the author's own numbers asked for here, whichever is higher. The count is reported.
if p.ClampCeilM > 0 {
lim := p.ClampCeilM * kk
if lim < asked {
lim = asked
}
if r > lim {
r = lim
clamped++
}
}
}
rate.Data[i] = float32(r * (1 + p.Variation*(2*float64(swell.Data[i])-1)))
}
// Initial relief. The spec says 50-150 m times normalised uplift and means it; this only breaks the
// symmetry so the solve has something to bite on.
rs := noise.NewSource(seed, srcPaintRidges)
ru, rv := noise.Warp(u, v, wx, wy, 0.8/ridgeCells)
ridges := noise.FBMAt(ru, rv, rs, noise.Params{BaseCells: ridgeCells, Octaves: 6, Gain: 0.42, Ridged: true})
cs := noise.NewSource(seed, srcPaintCrests)
cu, cv := noise.Warp(u, v, wx, wy, 3.1/crestCells) // the stronger warp the crest lines need
crests := noise.CellularEdges(cu, cv, cs, int(crestCells), 0.95)
ps := noise.NewSource(seed, srcPaintPlains)
pu, pv := noise.Warp(u, v, wx, wy, 0.5/plainCells)
plains := noise.FBMAt(pu, pv, ps, noise.Params{BaseCells: int(plainCells), Octaves: 4, Gain: 0.45})
ampLo := cfg.Relief.AmplitudeM.Lo()
ampHi := cfg.Relief.AmplitudeM.Hi()
crestW := cfg.Relief.CrestWeight
height := field.New(w, h, cellM)
for i := range height.Data {
if base[i] {
// Ocean sits at sea level for the whole solve and the coastal pass lays the floor afterwards.
// Left at a real depth, a coastal cell drains into it and the solver cuts the river down to meet
// it; the first run with a coast eroded the land to 174 m below sea level for exactly that.
height.Data[i] = float32(m.SeaLevelM)
continue
}
// The amplitude comes from the rate *before* the faults, and is bounded at one (D-63).
//
// It used to come from rate.Data, which is the finished rate with the fault delta in it and no
// upper bound, and that coupling is half of why Bake_018's flanks came out ribbed. The initial
// relief exists only to break the symmetry of the background so the solve has something to bite
// on; how much noise is stamped on a hillside is not a fault's decision. With D-62's six
// kilometre footwalls the ratio went from about 0.18 on unfaulted foreland - 39 m of relief - to
// 1.12 on a footwall, which is 166 m, on a landmass whose whole relief is 221 m. A thousand steps
// cannot erase initial relief the size of the landscape, so the ridged noise stopped being a
// symmetry-breaker and became the terrain: the ribs measure 250-300 m, which is octave five of a
// 4.2 km ridged fBm. The bound at one is a guard rather than the fix - with the fault gone the
// rate cannot exceed the largest class rate - but it is the property worth stating.
norm := float64(preFault[i]) / maxRate
if norm > 1 {
norm = 1
} else if norm < 0 {
norm = 0
}
amp := ampLo + (ampHi-ampLo)*norm
shape := (1-crestW)*float64(ridges.Data[i]) + crestW*float64(crests.Data[i])
height.Data[i] = float32(m.SeaLevelM + 20 + amp*shape + float64(plains.Data[i])*8)
}
return &Result{Rate: rate, Height: height, Land: land, K: k, Base: base, FaultClamped: clamped}
}
// anyMix reports whether any class lets the rock field through.
func anyMix(mix []float64) bool {
for _, v := range mix {
if v > 0 {
return true
}
}
return false
}
func wantsPlain(plain []float64) bool {
for _, v := range plain {
if v > 0 {
return true
}
}
return false
}
func invert(b []bool) []bool {
out := make([]bool, len(b))
for i, v := range b {
out[i] = !v
}
return out
}
@@ -0,0 +1,592 @@
package uplift
import (
"math"
"salty/terrain/internal/noise"
"salty/terrain/internal/world"
)
// Faults on a painted planet: the structure inside a range that the painting cannot draw.
//
// A painted class is one rate over every cell of a colour, and a massif block breaks that into plain and
// upland. Neither can produce the thing a real range has running through it - an escarpment, a block tilted
// against its neighbour, a valley that is straight for fifteen kilometres because it is following a break in
// the rock. That is a fault, and a fault is not a shape to paint: it is a *difference in uplift rate across a
// line*, steep on one side and gentle on the other, which erosion then carves into a scarp. Paint it as
// terrain and the solve erodes it away; apply it as a rate and it maintains itself, which is the same
// argument as "paint the uplift, never the height" one scale down.
//
// The procedural path has had this since the beginning and none of it could be carried over as it stood,
// for two reasons that are by now familiar.
//
// **Placement is in world metres, not map fractions.** `uplift.Build` draws a trace centre as two calls to
// `s.Float()`, which are fractions of the grid it happens to be filling. On a decomposed planet that is a
// different place in every region. Here the whole set is drawn once for the planet, in metres east of the
// seam and metres south of the top painted row, and a region filters it to the traces that reach into its
// own frame - so a fault that crosses a region boundary is one fault, and two different decompositions of the
// same planet produce the same escarpment.
//
// **And a trace is placed in the ground the author painted for it.** A class carries `faults`, so an author
// says *this range is faulted and that plain is not*, which is both the control they want and what is
// actually true of the world: faults belong to orogens. The influence is not restricted to the class,
// because a range-front fault runs along the edge of a range by definition and its scarp faces the lowland.
//
// The four defects recorded against the procedural version in Terrain-Next 4.A2 are fixed here rather than
// carried, because writing a new implementation with a known fault list is cheaper than porting one and
// fixing it afterwards:
//
// - The trace is a walk with a *perturbed heading* rather than one 8-point parabola, so the distance field
// around it has no polygonal contours.
// - The throw is **tapered to zero over the last sixth at each tip** rather than stopping dead where the
// last segment ends, which is what made a fault cut abruptly across a summit.
// - A long fault is broken into overlapping en-echelon segments, which is how long faults actually step.
// - And nothing is clamped to a fraction of a global rate. The procedural version flattens its strongest
// throws with `if r > convergent*1.6`, which turns exactly the faults that matter most into plateaus.
// The ceiling here is the one with a physical meaning - the rate at which a divide reaches the angle of
// repose - and the count of cells that reach it is reported rather than hidden.
const (
srcPaintFaults = 27
srcPaintGrain = 28
)
// The escarpment's shape, in metres, and the reason it is that shape (D-62).
//
// The first version of this made the rate difference a *step*: the whole throw on one side of the trace and
// the whole throw negated on the other, one cell apart, the positive side decaying to nothing over six
// hundred metres and the negative side over six kilometres. Measured on Bake_013 that is a wall of two
// throws across a single 8 m cell, standing in a welt six hundred metres wide. Two things follow, and both
// are visible in a hillshade before any number is taken.
//
// **A step in the rate is a painted cliff.** "Paint the uplift, never the height" is a claim about what a
// solve can undo, and a discontinuity in the rate field is precisely what it cannot: the surface has
// nowhere to put the difference but into a scarp at the angle of repose, so the trace comes out as a
// facetted line at any throw, and turning throw_m down only lowers the same artefact.
//
// **And six hundred metres is narrower than one hillslope.** Bake_013's drainage density is 0.45 channels
// per kilometre, so a divide sits about 1.1 km from its channel. Nothing can dissect a block six hundred
// metres wide - there is no drainage area at that width for stream power to work with, and hillslope
// diffusion only smooths what is already there - so the uplift profile is *printed* onto the surface
// rather than eroded into a landform. That is why every fault in that bake reads as a smooth ruled ridge
// running through terrain dissected everywhere else: it is the one part of the map erosion never touched.
//
// So the profile is antisymmetric, continuous through the trace, and kilometres wide on both flanks.
//
// - faultRampM is how far the rate takes to cross from the hanging wall to the footwall - about one
// hillslope length, which makes the mountain front the sharpest thing this landscape can express
// without it being a cliff nobody solved for.
// - faultFootwallM and faultHangingM are how far each flank reaches. Several hillslope lengths, so a
// drainage network fits on the block and cuts it into spurs and valleys, which is what a range front
// is and what an extruded cross-section will never be.
//
// Neither width scales with the trace's length, and that is deliberate twice over. Physically, the width
// of flexural footwall uplift is set by how the crust bends rather than by the fault in it, so a short
// fault on the same lithosphere makes a *lower* range, not a narrower one - which is what a throw does
// here already. And practically, the floor is the one that matters: a flank has to be wide enough for a
// drainage network whatever the trace is, and scaling it down for the planet's shortest traces - 1.7 km
// on the shipped template against a 6.4 km median - would put the printing artefact straight back on
// exactly those. `length_km` says how far a fault runs along strike; it does not say how wide a belt it
// deforms.
//
// The anomaly is therefore zero *on the trace itself*, which is also the honest reading: a rate difference
// across a line says one side rises relative to the other, and at the line the two average to the regional
// rate. The old profile asserted +throw and -throw at the same point.
const (
faultRampM = 900.0
faultFootwallM = 6000.0
faultHangingM = 4000.0
)
// faultReachM is where the influence is cut off.
//
// The flank envelope reaches zero *with zero gradient* at its own width, so unlike the old
// exponential-minus-a-floor there is nothing to subtract and no step at the box edge to hide: the cut-off
// is the support of the function rather than a truncation of it.
const faultReachM = faultFootwallM
// faultShape is the unnormalised profile at a signed distance from the trace, positive on the upthrown
// side: an odd saturating ramp across the line, times a flank envelope.
//
// The ramp is d/sqrt(R*R+d*d) rather than tanh and the envelope is (1-u*u)^2 rather than an exponential,
// because this runs at every cell of every fault's box - a few hundred million times on a planet - and
// neither transcendental buys anything over the algebraic pair.
func faultShape(d float64) float64 {
w := faultFootwallM
if d < 0 {
w = faultHangingM
}
u := math.Abs(d) / w
if u >= 1 {
return 0
}
e := 1 - u*u
return d / math.Sqrt(faultRampM*faultRampM+d*d) * e * e
}
// faultNorm scales the profile so the whole step across a fault - the footwall crest less the hanging wall
// trough - is exactly the throw the author asked for, which is what the word means: the vertical
// displacement across the fault. The old profile put a full throw on each side and so built two.
//
// Measured over the profile rather than written down, so that changing a width above cannot silently
// change what throw_m means.
var faultNorm = func() float64 {
up, down := 0.0, 0.0
for d := 1.0; d < faultReachM; d++ {
if v := faultShape(d); v > up {
up = v
}
if v := -faultShape(-d); v > down {
down = v
}
}
return up + down
}()
// faultWeight is the escarpment profile, scaled so the crest-to-trough step across it is one throw.
func faultWeight(d float64) float64 { return faultShape(d) / faultNorm }
// faultStackBonus is how much more than its strongest single fault a whole stack of them may build,
// and it is the answer to the defect D-62 caused (D-63).
//
// Faults are rasterised with `+=`, which was harmless while a fault reached six hundred metres: two of
// them almost never met. At six kilometres they meet constantly, and a fault set is *sub-parallel by
// construction* - traces within one cell of the orientation grain share a strike, and a belt fault takes
// its strike from the plate margin - so where they meet they are all pushing the same way. Measured on
// Bake_018's region 11, twenty-two kilometres across with thirteen traces at strikes spanning fourteen
// degrees: **75 % of the faulted ground had two or more faults on it**, the sum was a median 1.77 times
// the largest single contribution there and up to 4.46 times, and the landmass came out at 221 m against
// 75 m for the same ground before D-62. Thirteen per cent of it asked for more uplift than the repose
// ceiling allows on its own, so the hard clamp downstream fired on 160 289 cells - 4.1 % of the region,
// against 0.15 % over the whole planet before - and a hard clamp makes plateaus.
//
// The knee is the **largest single contribution at that cell**, not the largest throw in the set. A
// planet-wide throw would not bite: the biggest throw on this template is 744 m while the biggest single
// contribution anywhere in region 11 is 209 m, because a fault's own taper and falloff have already
// reduced it by the time it reaches anywhere. Keyed per cell, one fault passes through untouched and only
// the stacking is bent.
const faultStackBonus = 0.6
// softStack combines the summed anomaly at a cell with the largest single contribution there.
//
// Below the knee it is the identity, so a cell reached by one fault gets exactly what that fault asked
// for and D-62's calibration - the step across a fault is its throw - is unchanged. Above it the excess
// is bent through a tanh onto an asymptote of (1+faultStackBonus) times the knee, so a belt still stands
// higher than an unfaulted one, which is the point of a belt, but five parallel faults cannot deliver
// five throws. Odd in `sum`, so a stack of hanging walls is bounded on the same terms.
//
// It is continuous: `peak` is a max of continuous functions and the join at the knee has gradient 1 on
// both sides. And it is frame-independent, which is what keeps the decomposition honest - `sum` and
// `peak` at a cell depend only on the faults within reach of it, and a fault too far away to be in a
// frame's box contributes nothing to either.
func softStack(sum, peak float64) float64 {
if peak <= 0 {
return 0
}
a := math.Abs(sum)
if a <= peak {
return sum
}
head := faultStackBonus * peak
v := peak + head*math.Tanh((a-peak)/head)
if sum < 0 {
return -v
}
return v
}
// enEchelonM is the length past which a fault is drawn as overlapping segments instead of one line, and
// enEchelonSpan is how long each segment is as a fraction of the parent.
const (
enEchelonM = 12000.0
enEchelonSpan = 0.55
)
// FaultSpec is what one painted class asks for. A class with no spec has no faults.
type FaultSpec struct {
// Per1000Km2 is how many traces to place in every thousand square kilometres of this class. It is a
// density rather than a count because a class covers whatever an author painted, and a count would mean
// something different on every template.
Per1000Km2 float64
// ThrowM is the whole step across the fault over the run, low to high: the footwall crest less the
// hanging wall trough, which is what the word throw means. It becomes a rate - the solve integrates it
// for `steps * dt_yr` years - so what an author picks is how much higher the upthrown side would stand
// than the downthrown one if erosion never touched either. Before D-62 the profile put a whole throw on
// each flank and so built two of them, and a legend written against that asks for half what it did.
ThrowM [2]float64
// LengthKm is how long a trace is, low to high.
LengthKm [2]float64
}
// Wanted reports whether this spec asks for anything.
func (s FaultSpec) Wanted() bool {
return s.Per1000Km2 > 0 && s.LengthKm[1] > 0 && s.ThrowM[1] > 0
}
// FaultTrace is one fault, in world metres.
//
// X is **unwrapped**: a trace that crosses the seam has X running past the circumference or below zero rather
// than jumping, so that every segment of it is a straight line between neighbouring points and no consumer
// has to special-case the meridian. Whoever draws or tests it wraps by the circumference.
type FaultTrace struct {
PointsM [][2]float64 `json:"points_m"`
ThrowM float64 `json:"throw_m"`
LengthM float64 `json:"length_m"`
Class int `json:"class"`
// Reverse flips which side goes up. Half of them do, drawn from the same stream, because a fault set in
// which every block tilts the same way reads as corduroy.
Reverse bool `json:"reverse"`
}
// BuildFaults draws the planet's whole fault set, once, deterministically from the seed.
//
// candidates[c] is a strided sample of the planet cells belonging to class c: the list a trace centre is
// drawn from, so a fault lands in the ground its class was painted on. Sampled rather than enumerated because
// the full list for a class covering a seventh of a 76-million-cell planet is ten million entries, and the
// only thing asked of it is a uniform draw. areaCells is the class's *exact* cell count, which the projection
// already counted, so the density is not estimated from the sample.
//
// grainKm is the wavelength of the orientation field. Faults within one of its cells come out sub-parallel
// and the set swings gradually across the world, which is what a fault set looks like and what a single
// global strike angle - the procedural path's `grainAngle` - does not: Terrain-Next 4.A3 records that one
// running as straight corduroy across a whole map.
func BuildFaults(p world.Planet, seed int64, grainKm float64, specs []FaultSpec,
candidates [][]int32, areaCells []int) []FaultTrace {
if grainKm <= 0 {
return nil
}
any := false
for _, s := range specs {
any = any || s.Wanted()
}
if !any {
return nil
}
grainCells := int(p.NoisePeriodM/(grainKm*1000) + 0.5)
if grainCells < 1 {
grainCells = 1
}
gs := noise.NewSource(seed, srcPaintGrain)
// Two lattices read as a vector rather than one read as an angle. A value lattice runs 0..1 and an angle
// taken straight from it jumps by a whole turn wherever it crosses its own wrap, which would put a hard
// seam through the fault set along a contour nobody can see. atan2 of two fields is continuous.
gx := noise.NewLattice(grainCells, gs)
gy := noise.NewLattice(grainCells, gs)
// The bend lattice is finer, so a trace curves within the province its strike came from.
bend := noise.NewLattice(grainCells*4, gs)
s := noise.NewSource(seed, srcPaintFaults)
cellArea := p.CellM * p.CellM
var out []FaultTrace
for c := range specs {
spec := specs[c]
if !spec.Wanted() || c >= len(candidates) || len(candidates[c]) == 0 || c >= len(areaCells) {
continue
}
areaKm2 := float64(areaCells[c]) * cellArea / 1e6
want := spec.Per1000Km2 * areaKm2 / 1000
// Stochastic rounding, so a class too small for one whole fault still gets one sometimes and the
// density means what it says when averaged over a world rather than being floored to zero.
n := int(want)
if s.Float() < want-float64(n) {
n++
}
for i := 0; i < n; i++ {
cell := candidates[c][s.IntN(len(candidates[c]))]
cx := float64(int(cell)%p.W) * p.CellM
cy := p.YM(int(cell) / p.W)
lengthM := spec.LengthKm[0] + (spec.LengthKm[1]-spec.LengthKm[0])*s.Float()
lengthM *= 1000
throw := spec.ThrowM[0] + (spec.ThrowM[1]-spec.ThrowM[0])*s.Float()
reverse := s.Float() < 0.5
a := strikeAt(p, gx, gy, grainCells, cx, cy)
out = append(out, traceSet(p, bend, grainCells, s, cx, cy, a, lengthM, throw, reverse, c)...)
}
}
return out
}
// traceSet turns one drawn fault into the one or more traces it is actually made of.
//
// The strike is an argument rather than something this function looks up. Where a fault points is the whole
// difference between the two placements that call it: a class fault takes its angle from a noise grain, and a
// belt fault takes it from the plate boundary it belongs to. Everything below - the en-echelon step, the
// walk, the taper - is the same fault either way.
func traceSet(p world.Planet, bend *noise.Lattice, grainCells int, s *noise.Source,
cx, cy, a, lengthM, throw float64, reverse bool, class int) []FaultTrace {
if lengthM <= enEchelonM {
return []FaultTrace{walkTrace(p, bend, grainCells, cx, cy, a, lengthM, throw, reverse, class)}
}
// A long fault steps. Two or three overlapping segments, each a little over half the parent's length,
// staggered along strike and offset across it - which is what a long fault does in the ground and is also
// the difference between a fifteen-kilometre ruled line and something that reads as structure.
n := 2
if s.Float() < 0.5 {
n = 3
}
segLen := lengthM * enEchelonSpan
out := make([]FaultTrace, 0, n)
for i := 0; i < n; i++ {
// Centres spread along the parent, from -0.5 to +0.5 of its length.
t := (float64(i)/float64(n-1) - 0.5) * (lengthM - segLen)
lateral := (s.Float() - 0.5) * 0.12 * lengthM
sx := cx + math.Cos(a)*t - math.Sin(a)*lateral
sy := cy + math.Sin(a)*t + math.Cos(a)*lateral
out = append(out, walkTrace(p, bend, grainCells, sx, sy, a, segLen, throw, reverse, class))
}
return out
}
// strikeAt is the fault grain's direction at a world position, as an angle.
func strikeAt(p world.Planet, gx, gy *noise.Lattice, cells int, xM, yM float64) float64 {
u := xM / p.NoisePeriodM * float64(cells)
v := yM / p.NoisePeriodM * float64(cells)
return math.Atan2(float64(gy.Sample(u, v))-0.5, float64(gx.Sample(u, v))-0.5)
}
// walkTrace steps outward from the centre in both directions, turning a little each step.
//
// A walk rather than a formula. The procedural path draws one parabola through eight points, and a distance
// field built from eight long straight segments has visibly polygonal contours - which is the first of the
// four things Terrain-Next 4.A2 lists against it. Short steps with a heading that wanders make the same
// gentle curve with none of that.
func walkTrace(p world.Planet, bend *noise.Lattice, grainCells int,
cx, cy, a, lengthM, throw float64, reverse bool, class int) FaultTrace {
segs := int(lengthM / 500)
if segs < 8 {
segs = 8
}
if segs > 64 {
segs = 64
}
if segs%2 == 1 {
segs++ // even, so the centre is a point rather than the middle of a segment
}
step := lengthM / float64(segs)
// The heading turns by at most this much per step. Correlated through the lattice, so consecutive steps
// see nearly the same value and the walk integrates into a smooth arc rather than a jitter.
const maxTurn = 0.12
pts := make([][2]float64, segs+1)
mid := segs / 2
pts[mid] = [2]float64{cx, cy}
for dir := -1; dir <= 1; dir += 2 {
x, y, ang := cx, cy, a
if dir < 0 {
ang += math.Pi
}
for k := 1; k <= mid; k++ {
u := x / p.NoisePeriodM * float64(grainCells*4)
v := y / p.NoisePeriodM * float64(grainCells*4)
ang += (float64(bend.Sample(u, v))*2 - 1) * maxTurn * float64(dir)
x += math.Cos(ang) * step
y += math.Sin(ang) * step
pts[mid+dir*k] = [2]float64{x, y}
}
}
return FaultTrace{PointsM: pts, ThrowM: throw, LengthM: lengthM, Class: class, Reverse: reverse}
}
// FaultDelta is the uplift-rate change the fault set contributes over one frame, in metres a year, or nil
// when none of them reaches it.
//
// Rasterised per fault into its own box rather than per cell over every fault: the set is planet-wide, so a
// per-cell loop over all of it would be the whole planet's faults tested at every cell of every region. A
// fault's box is its trace plus faultReachM on all sides, clipped to the frame, and inside it each cell tests
// only the segments whose own boxes contain it.
func FaultDelta(f world.Frame, faults []FaultTrace, runYears float64) []float32 {
if len(faults) == 0 || runYears <= 0 {
return nil
}
cellM := f.P.CellM
circ := f.P.CircumferenceM()
x0M, y0M := f.OriginXM(), f.OriginYM()
x1M, y1M := x0M+float64(f.W)*cellM, y0M+float64(f.H)*cellM
// peak is the largest single contribution at each cell, which is the knee softStack bends the sum
// over. Carried alongside rather than derived afterwards because a second pass over every fault would
// cost exactly what the first one did.
var out, peak []float32
for fi := range faults {
ft := &faults[fi]
if len(ft.PointsM) < 2 {
continue
}
// Shift the trace by whole turns of the planet so it sits nearest this frame. X is unwrapped in the
// stored trace, so this is the one place the seam is dealt with, once per fault instead of per cell.
pts := shiftToFrame(ft.PointsM, (x0M+x1M)/2, circ)
lo, hi := traceBounds(pts)
if lo[0]-faultReachM > x1M || hi[0]+faultReachM < x0M ||
lo[1]-faultReachM > y1M || hi[1]+faultReachM < y0M {
continue
}
if out == nil {
out = make([]float32, f.W*f.H)
peak = make([]float32, f.W*f.H)
}
cxa := clampInt(int((lo[0]-faultReachM-x0M)/cellM), 0, f.W-1)
cxb := clampInt(int((hi[0]+faultReachM-x0M)/cellM)+1, 0, f.W-1)
cya := clampInt(int((lo[1]-faultReachM-y0M)/cellM), 0, f.H-1)
cyb := clampInt(int((hi[1]+faultReachM-y0M)/cellM)+1, 0, f.H-1)
sign := 1.0
if ft.Reverse {
sign = -1
}
rate := ft.ThrowM / runYears
// One box per segment, computed here rather than inside the cell loop. The rejection below runs for
// every segment at every cell in the fault's box - a few hundred million times on a large region -
// and recomputing four min/max per test was most of what the pass cost.
boxes := segmentBoxes(pts)
for y := cya; y <= cyb; y++ {
py := y0M + float64(y)*cellM
row := y * f.W
for x := cxa; x <= cxb; x++ {
px := x0M + float64(x)*cellM
d, along, ok := nearestOnTrace(px, py, pts, boxes)
if !ok {
continue
}
v := float32(rate * faultWeight(d*sign) * tipTaper(along))
out[row+x] += v
if v < 0 {
v = -v
}
if v > peak[row+x] {
peak[row+x] = v
}
}
}
}
// Bend the stacking. Unconditional whenever anything was rasterised, including when a single trace
// reached this frame: skipping it there would make a cell's value depend on which frame it was asked
// about, which is the one thing FaultDelta is not allowed to do.
for i := range out {
if peak[i] > 0 {
out[i] = float32(softStack(float64(out[i]), float64(peak[i])))
}
}
return out
}
// tipTaper is how much of its throw a fault carries at a fraction along its length.
//
// It was a ramp over the last sixth at each end with a flat top over the middle two thirds. That dies out
// at the tips, which is what it was written for, and leaves the cross-section above *extruded* unchanged
// along two thirds of every trace - which is the other half of why a fault reads as a ruled line. An
// extrusion has no along-strike structure, so erosion has no reason to head a valley in one place rather
// than another and the ridge stays as smooth as the function that drew it.
//
// A real fault carries most of its displacement near the middle and none at either tip, in a profile
// somewhere between elliptical and a linear taper. This is that: an ellipse in q, lifted by q*(2-q) so the
// middle four fifths keeps a body rather than coming to a point. Polynomial on purpose - it is evaluated
// at every cell of every fault's box.
func tipTaper(along float64) float64 {
if along <= 0 || along >= 1 {
return 0
}
q := 4 * along * (1 - along)
return q * (2 - q)
}
// nearestOnTrace is the signed perpendicular distance to a polyline and how far along it the nearest point
// sits, 0 at one tip and 1 at the other. ok is false beyond the ends, where a fault has no effect.
func nearestOnTrace(px, py float64, pts [][2]float64, boxes [][4]float64) (dist, along float64, ok bool) {
best := math.Inf(1)
sign := 1.0
at := 0.0
n := len(pts) - 1
for j := 0; j < n; j++ {
// Cheap rejection first: this loop runs for every cell in the fault's box, and on most of them every
// segment misses.
b := &boxes[j]
if px < b[0] || px > b[2] || py < b[1] || py > b[3] {
continue
}
ax, ay := pts[j][0], pts[j][1]
bx, by := pts[j+1][0], pts[j+1][1]
dx, dy := bx-ax, by-ay
l2 := dx*dx + dy*dy
if l2 < 1e-9 {
continue
}
t := ((px-ax)*dx + (py-ay)*dy) / l2
if t < 0 || t > 1 {
continue // beyond this segment; a neighbouring one may still claim the point
}
projx, projy := ax+t*dx, ay+t*dy
d := math.Hypot(px-projx, py-projy)
if d < best {
best = d
at = (float64(j) + t) / float64(n)
if (px-ax)*dy-(py-ay)*dx < 0 {
sign = -1
} else {
sign = 1
}
}
}
if math.IsInf(best, 1) {
return 0, 0, false
}
return best * sign, at, true
}
// segmentBoxes is each segment's own box, grown by the reach: the rejection test in nearestOnTrace.
func segmentBoxes(pts [][2]float64) [][4]float64 {
out := make([][4]float64, len(pts)-1)
for j := range out {
ax, ay := pts[j][0], pts[j][1]
bx, by := pts[j+1][0], pts[j+1][1]
out[j] = [4]float64{
math.Min(ax, bx) - faultReachM, math.Min(ay, by) - faultReachM,
math.Max(ax, bx) + faultReachM, math.Max(ay, by) + faultReachM,
}
}
return out
}
// shiftToFrame moves a trace by whole circumferences so its middle is nearest a given longitude.
func shiftToFrame(pts [][2]float64, centreXM, circ float64) [][2]float64 {
mid := pts[len(pts)/2][0]
k := math.Round((centreXM - mid) / circ)
if k == 0 {
return pts
}
out := make([][2]float64, len(pts))
for i, p := range pts {
out[i] = [2]float64{p[0] + k*circ, p[1]}
}
return out
}
func traceBounds(pts [][2]float64) (lo, hi [2]float64) {
lo = [2]float64{math.Inf(1), math.Inf(1)}
hi = [2]float64{math.Inf(-1), math.Inf(-1)}
for _, p := range pts {
lo[0] = math.Min(lo[0], p[0])
lo[1] = math.Min(lo[1], p[1])
hi[0] = math.Max(hi[0], p[0])
hi[1] = math.Max(hi[1], p[1])
}
return lo, hi
}
func clampInt(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
@@ -0,0 +1,345 @@
package uplift
import (
"math"
"testing"
"salty/terrain/internal/world"
)
// oneClassCandidates is a planet where every painted cell belongs to class 0, sampled at a stride.
func oneClassCandidates(p world.Planet, stride int) ([][]int32, []int) {
var cells []int32
for y := 0; y < p.H; y += stride {
for x := 0; x < p.W; x += stride {
cells = append(cells, int32(y*p.W+x))
}
}
return [][]int32{cells}, []int{p.W * p.H}
}
func testSpec() []FaultSpec {
return []FaultSpec{{Per1000Km2: 40, ThrowM: [2]float64{200, 400}, LengthKm: [2]float64{4, 8}}}
}
// A seed names a fault set, and the same seed names the same one. Everything else here rests on that.
func TestTheSameSeedDrawsTheSameFaults(t *testing.T) {
const w, h, cellM = 1024, 512, 64.0
p := testPlanet(t, w, h, cellM)
cand, area := oneClassCandidates(p, 8)
a := BuildFaults(p, 7, 32, testSpec(), cand, area)
b := BuildFaults(p, 7, 32, testSpec(), cand, area)
if len(a) == 0 {
t.Fatal("no faults were drawn; this test measured nothing")
}
if len(a) != len(b) {
t.Fatalf("two runs of one seed drew %d and %d traces", len(a), len(b))
}
for i := range a {
if a[i].ThrowM != b[i].ThrowM || a[i].Reverse != b[i].Reverse ||
len(a[i].PointsM) != len(b[i].PointsM) || a[i].PointsM[0] != b[i].PointsM[0] {
t.Fatalf("trace %d differs between two runs of one seed", i)
}
}
// And a different seed is a different world.
if c := BuildFaults(p, 9342, 32, testSpec(), cand, area); len(c) > 0 && c[0].PointsM[0] == a[0].PointsM[0] {
t.Error("a different seed put the first trace in the same place")
}
}
// The density means what it says: traces per thousand square kilometres of the class, not per map.
func TestFaultDensityIsPerAreaOfTheClass(t *testing.T) {
const cellM = 64.0
small := testPlanet(t, 512, 256, cellM)
big := testPlanet(t, 1024, 512, cellM)
count := func(p world.Planet) int {
cand, area := oneClassCandidates(p, 8)
return len(BuildFaults(p, 7, 32, testSpec(), cand, area))
}
ns, nb := count(small), count(big)
if ns == 0 {
t.Fatal("the small planet drew nothing; this test measured nothing")
}
// Four times the area, so about four times the traces. Loose, because a long fault becomes two or three
// en-echelon segments and the draw is stochastic - the assertion is that it scales, not that it is exact.
if ratio := float64(nb) / float64(ns); ratio < 2.5 || ratio > 6 {
t.Errorf("four times the area gave %d traces against %d, a ratio of %.1f", nb, ns, ratio)
}
}
// The property the whole port exists for: a fault is the planet's, not a region's. Two frames overlapping the
// same ground have to agree about the rate it contributes, and both have to agree with the whole planet.
//
// `uplift.Build` places a trace at two calls to Float() read as fractions of the grid it is filling, so the
// obvious port of it fails this - the same fault would land somewhere different in every region.
func TestTwoFramesAgreeAboutTheSameFaults(t *testing.T) {
const w, h, cellM = 1024, 512, 64.0
p := testPlanet(t, w, h, cellM)
cand, area := oneClassCandidates(p, 8)
faults := BuildFaults(p, 7, 32, testSpec(), cand, area)
if len(faults) == 0 {
t.Fatal("no faults; this test measured nothing")
}
const runYears = 1.5e6
whole := FaultDelta(world.Whole(p), faults, runYears)
a := world.Frame{P: p, X0: 200, Y0: 100, W: 300, H: 200}
b := world.Frame{P: p, X0: 380, Y0: 160, W: 300, H: 200}
da, db := FaultDelta(a, faults, runYears), FaultDelta(b, faults, runYears)
if da == nil || db == nil {
t.Fatal("neither frame was reached by any fault; move the windows")
}
checked, nonZero := 0, 0
for y := 0; y < a.H; y++ {
for x := 0; x < a.W; x++ {
px, py := a.PlanetXY(x, y)
if px < b.X0 || px >= b.X0+b.W || py < b.Y0 || py >= b.Y0+b.H {
continue
}
got := da[y*a.W+x]
if want := db[(py-b.Y0)*b.W+(px-b.X0)]; got != want {
t.Fatalf("at planet (%d,%d) frame A says %v and frame B says %v", px, py, got, want)
}
if wh := whole[py*p.W+px]; wh != got {
t.Fatalf("at planet (%d,%d) a frame says %v and the whole planet says %v", px, py, got, wh)
}
checked++
if got != 0 {
nonZero++
}
}
}
if checked == 0 {
t.Fatal("the two frames do not overlap")
}
if nonZero == 0 {
t.Fatal("every cell of the overlap is zero; the agreement is vacuous")
}
}
// X wraps, so a fault whose trace runs past the meridian has to reach the ground on the other side of it.
// The trace is stored unwrapped and shifted once per fault; this is what says that shift works.
func TestAFaultReachesAcrossTheSeam(t *testing.T) {
const w, h, cellM = 512, 256, 64.0
p := testPlanet(t, w, h, cellM)
circ := p.CircumferenceM()
// A trace lying just east of the seam, running north-south, well inside the reach of the map's west edge.
x := 300.0
trace := FaultTrace{
PointsM: [][2]float64{{x, 2000}, {x, 5000}, {x, 8000}},
ThrowM: 400, LengthM: 6000, Reverse: false,
}
west := world.Frame{P: p, X0: 0, Y0: 0, W: 40, H: h}
if d := FaultDelta(west, []FaultTrace{trace}, 1.5e6); d == nil {
t.Fatal("a trace 300 m east of the seam did not reach a frame at the seam")
}
// The same trace written with its X a whole world further east is the same fault, so a frame at the far
// end of the map must see the identical field.
shifted := FaultTrace{
PointsM: [][2]float64{{x + circ, 2000}, {x + circ, 5000}, {x + circ, 8000}},
ThrowM: 400, LengthM: 6000,
}
near := FaultDelta(west, []FaultTrace{trace}, 1.5e6)
far := FaultDelta(west, []FaultTrace{shifted}, 1.5e6)
if far == nil {
t.Fatal("the shifted trace reached nothing; the wrap is not being applied")
}
for i := range near {
if near[i] != far[i] {
t.Fatalf("a trace and the same trace one circumference east differ at cell %d: %v vs %v",
i, near[i], far[i])
}
}
}
// The scarp is asymmetric, which is what makes a fault a tilted block rather than a ridge: it rises fast on
// one side over a couple of hundred metres and falls away slowly on the other over a couple of kilometres.
func TestAFaultIsATiltedBlockAndNotARidge(t *testing.T) {
const w, h, cellM = 1024, 1024, 32.0
p := testPlanet(t, w, h, cellM)
mid := float64(h) * cellM / 2
// A straight east-west trace across the middle.
pts := make([][2]float64, 9)
for i := range pts {
pts[i] = [2]float64{float64(i) * float64(w) * cellM / 8, mid}
}
d := FaultDelta(world.Whole(p), []FaultTrace{{PointsM: pts, ThrowM: 400, LengthM: float64(w) * cellM}}, 1.5e6)
if d == nil {
t.Fatal("the trace reached nothing")
}
col := w / 2
at := func(yM float64) float64 { return float64(d[int(yM/cellM)*w+col]) }
// One side is positive and the other negative: the block tilts.
up, down := at(mid-1400), at(mid+1200)
if up*down >= 0 {
t.Fatalf("both sides of the trace have the same sign (%v, %v); that is a ridge, not a fault", up, down)
}
// And the footwall reaches further than the hanging wall.
if math.Abs(at(mid-4800)) <= math.Abs(at(mid+4800)) {
t.Errorf("at 4800 m the footwall is %v and the hanging wall %v; the asymmetry is the wrong way "+
"round or absent", math.Abs(at(mid-4800)), math.Abs(at(mid+4800)))
}
}
// The defect this file's shape block is about (D-62), as two numbers rather than a hillshade.
//
// The profile used to put the whole throw on one side of the trace and the whole throw negated on the
// other, one cell apart, inside a welt six hundred metres wide. That is unsolvable twice over: a step in
// the rate field is a cliff the erosion can only clamp at the angle of repose, and a block narrower than
// one hillslope has no drainage area on it for stream power to cut with, so the profile is printed onto
// the surface instead of being eroded into a landform.
//
// So: continuous through the trace, and wide enough on the upthrown side for a drainage network to live
// on. 1100 m is the hillslope length measured on Bake_013 (a drainage density of 0.45 channels per km),
// and three of them is the least that can carry a valley and its two divides.
func TestAFaultIsSolvableRatherThanPrinted(t *testing.T) {
const w, h, cellM = 2048, 2048, 8.0
const throw, runYears = 400.0, 1.5e6
p := testPlanet(t, w, h, cellM)
mid := float64(h) * cellM / 2
pts := make([][2]float64, 17)
for i := range pts {
pts[i] = [2]float64{float64(i) * float64(w) * cellM / 16, mid}
}
d := FaultDelta(world.Whole(p), []FaultTrace{{PointsM: pts, ThrowM: throw, LengthM: float64(w) * cellM}}, runYears)
if d == nil {
t.Fatal("the trace reached nothing")
}
col := w / 2
// Metres of displacement the rate builds over the whole run, which is what the surface has to carry.
at := func(yM float64) float64 { return float64(d[int(yM/cellM)*w+col]) * runYears }
var crest, trough, crestAt, troughAt, steepest, steepestAt float64
prev := at(mid - faultReachM)
for dy := -faultReachM + cellM; dy <= faultReachM; dy += cellM {
v := at(mid + dy)
if v > crest {
crest, crestAt = v, dy
}
if v < trough {
trough, troughAt = v, dy
}
if g := math.Abs(v-prev) / cellM; g > steepest {
steepest, steepestAt = g, dy
}
prev = v
}
// Continuous: no cell-to-cell step steeper than ground the solve can actually shape. The repose clamp
// is at 35 degrees and the old profile measured 89.
if deg := math.Atan(steepest) * 180 / math.Pi; deg > 25 {
t.Errorf("the steepest cell-to-cell step in the rate field is %.1f degrees at %+.0f m; that is a "+
"cliff in the uplift, and the solve can only clamp it at the angle of repose", deg, steepestAt)
}
// Zero on the trace itself: a rate difference across a line averages to the regional rate at the line.
if v := math.Abs(at(mid)); v > throw/50 {
t.Errorf("the anomaly on the trace is %.1f m; it should be nothing", v)
}
// Wide enough to be dissected: the upthrown flank has to carry a drainage network.
const hillslopeM = 1100
var above float64
for dy := 0.0; dy <= faultReachM; dy += cellM {
if at(mid-dy) >= crest/2 {
above = dy
}
}
if above < 3*hillslopeM {
t.Errorf("the footwall stands above half its crest for only %.0f m, under three hillslope lengths "+
"(%d m); nothing can cut a valley into it and the profile will print", above, 3*hillslopeM)
}
// And the step across the fault is the throw the author asked for, not two of them.
if step := crest - trough; math.Abs(step-throw) > throw/20 {
t.Errorf("the step across the fault is %.0f m against a throw of %.0f m", step, throw)
}
t.Logf("crest %+.0f m at %+.0f m, trough %+.0f m at %+.0f m, step %.0f m over %.0f m (%.1f deg mean), "+
"steepest cell %.1f deg, footwall above half-crest for %.0f m",
crest, crestAt, trough, troughAt, crest-trough, crestAt-troughAt,
math.Atan((crest-trough)/math.Abs(crestAt-troughAt))*180/math.Pi,
math.Atan(steepest)*180/math.Pi, above)
}
// A fault dies out along strike instead of stopping dead, which is what left an abrupt cut across a summit on
// the procedural path - and it is never flat along strike either, which is what left it extruded.
func TestTheThrowTapersToNothingAtTheTips(t *testing.T) {
if tipTaper(0) != 0 || tipTaper(1) != 0 {
t.Errorf("the tips carry no throw: got %v and %v", tipTaper(0), tipTaper(1))
}
if tipTaper(0.5) != 1 {
t.Errorf("the middle carries all of it: got %v", tipTaper(0.5))
}
// Monotone to the middle, so the ramp has no step in it.
prev := 0.0
for a := 0.0; a <= 0.5; a += 0.01 {
v := tipTaper(a)
if v < prev-1e-12 {
t.Fatalf("the taper goes backwards at %v: %v after %v", a, v, prev)
}
prev = v
}
// Symmetric about the middle.
for _, a := range []float64{0.05, 0.2, 0.37} {
if math.Abs(tipTaper(a)-tipTaper(1-a)) > 1e-12 {
t.Errorf("the two ends differ at %v: %v against %v", a, tipTaper(a), tipTaper(1-a))
}
}
// Nowhere flat: the old taper held exactly 1 across the middle two thirds, which extrudes the
// cross-section along most of every trace. Nothing between the tips and the centre may repeat.
if tipTaper(0.2) >= tipTaper(0.35) || tipTaper(0.35) >= tipTaper(0.5) {
t.Errorf("the throw is flat along strike: %v, %v, %v at a fifth, a third and the middle",
tipTaper(0.2), tipTaper(0.35), tipTaper(0.5))
}
// But it still has a body: most of a trace carries at least half its throw.
above := 0
for i := 0; i <= 1000; i++ {
if tipTaper(float64(i)/1000) >= 0.5 {
above++
}
}
if above < 750 {
t.Errorf("only %d parts in a thousand of the trace carry half the throw; the fault is a spike", above)
}
}
// A long fault steps rather than running as one ruled line.
func TestALongFaultBreaksIntoEnEchelonSegments(t *testing.T) {
const w, h, cellM = 2048, 1024, 64.0
p := testPlanet(t, w, h, cellM)
cand, area := oneClassCandidates(p, 8)
short := []FaultSpec{{Per1000Km2: 40, ThrowM: [2]float64{200, 400}, LengthKm: [2]float64{4, 6}}}
long := []FaultSpec{{Per1000Km2: 40, ThrowM: [2]float64{200, 400}, LengthKm: [2]float64{20, 26}}}
ns := len(BuildFaults(p, 7, 32, short, cand, area))
nl := len(BuildFaults(p, 7, 32, long, cand, area))
if ns == 0 {
t.Fatal("nothing was drawn; this test measured nothing")
}
if nl <= ns {
t.Errorf("faults over the en-echelon length gave %d traces against %d for short ones; they are not "+
"stepping", nl, ns)
}
}
// Nothing is drawn when nothing asks, and nothing is rasterised when no trace reaches a frame - which is what
// keeps a planet with no faults paying nothing for the pass.
func TestNoFaultsCostsNothing(t *testing.T) {
const w, h, cellM = 256, 128, 64.0
p := testPlanet(t, w, h, cellM)
cand, area := oneClassCandidates(p, 8)
if got := BuildFaults(p, 7, 32, []FaultSpec{{}}, cand, area); got != nil {
t.Errorf("an empty spec drew %d traces", len(got))
}
if got := BuildFaults(p, 7, 0, testSpec(), cand, area); got != nil {
t.Error("a zero grain wavelength should draw nothing")
}
far := FaultTrace{PointsM: [][2]float64{{0, 100000}, {1000, 100000}}, ThrowM: 400}
if d := FaultDelta(world.Whole(p), []FaultTrace{far}, 1.5e6); d != nil {
t.Error("a trace far off the frame should allocate no field at all")
}
}
@@ -0,0 +1,117 @@
package uplift
import (
"salty/terrain/internal/field"
"salty/terrain/internal/noise"
"salty/terrain/internal/world"
)
// Lithology on a painted planet: what the rock is, underneath what the author painted it as.
//
// A class is a rate and an erodibility, and on a painted world the erodibility was one flat number over every
// cell of a colour. That is one range made of one rock, everywhere, and it shows: `map_erodibility.png` on a
// painted planet was a recolour of `map_class.png`, and two bakes of the same painting under different seeds
// differed on it only where the *coastline* had moved. Texture inside a range - the reason one flank is
// gullied and the next is a set of benches - has nowhere to come from.
//
// So there is a rock field: low-frequency noise cut into a few types, each with its own multiplier on K,
// exactly the spec's 4.3 and exactly what the procedural path has always had. What is different here is the
// two things a decomposed planet forces, and both are the same two the massif fabric ran into first.
//
// **The cut is a quantile of the planet, never of the region.** `uplift.Build` takes `f.Percentile()` of the
// grid it is handed, which on a planet means two regions measuring their own extents and putting the same
// physical hillside in different rock. The threshold is measured once over the whole cylinder by
// measureFabric, from a probe that is a property of the planet and of nothing else, so every region computes
// the identical number from the identical samples.
//
// **And nothing here may look at a neighbour.** The procedural version ends in `out.Blur(2)`, so that a rock
// boundary is a transition rather than a wall the solver carves into a cliff. A blur is a neighbourhood
// operation, and a neighbourhood operation near a region's edge reads cells that a different decomposition
// would not have given it. The softening is therefore done **pointwise, in rank space**: a cell near the edge
// of its band is blended towards the next band by how near it is, which needs only the cell's own value. The
// width of the transition on the ground then follows the fabric's own gradient - sharp where the rock changes
// fast, gradual where it does not - which is a better answer than a fixed blur radius anyway.
const (
srcPaintRock = 26
)
// rockOctaves and rockGain shape the rock field. Fewer octaves than the upland fabric on purpose: a lithology
// map is broad provinces with ragged edges, not a fractal at every scale, and the detail that does belong at
// metre scale is the strata model in the detail passes rather than this.
const (
rockOctaves = 4
rockGain = 0.5
)
// rockWarp bends the rock field by the shared low-frequency warp, as a fraction of its own wavelength. The
// same field that bends the massifs and the ridges, because a province boundary that ignored the grain
// everything else follows would read as a stencil laid over the world.
const rockWarp = 0.7
// rockEdge is how much of a band's width is spent blending into its neighbour, at each end. At 0.15 a
// province is flat over the middle seven tenths of its range and graded across the rest.
const rockEdge = 0.15
// rockFabric samples the rock field at the given world coordinates.
func rockFabric(u, v, wx, wy *field.Field, seed int64, baseCells int) *field.Field {
rs := noise.NewSource(seed, srcPaintRock)
ru, rv := noise.Warp(u, v, wx, wy, rockWarp/float64(baseCells))
return noise.FBMAt(ru, rv, rs, noise.Params{
BaseCells: baseCells, Octaves: rockOctaves, Gain: rockGain,
})
}
// RockK is the erodibility multiplier the lithology contributes at every cell of a frame, around 1.
//
// mult is the manifest's k_multipliers, in order, and the bands are **equal area over the planet**: the rank
// is uniform on 0..1 by construction, so cutting it into n equal pieces gives each rock type the same share
// of the world whatever the seed did to the noise. That is the property the procedural path got from
// `f.Percentile` and the reason it is worth keeping - a seed that produced no hard rock anywhere would be a
// seed that quietly removed a process.
//
// Returns nil when there is nothing to build, which is what a planet with no lithology_wavelength_km gets and
// what every painted planet got before this existed.
func RockK(p world.Planet, seed int64, baseCells int, mult []float64, u, v *field.Field) *field.Field {
if baseCells < 1 || len(mult) < 2 {
return nil
}
wx, wy := paintWarp(u, v, seed)
fabric := rockFabric(u, v, wx, wy, seed, baseCells)
cdf := measureFabric(p, seed, baseCells, rockFabric)
n := len(mult)
out := field.NewLike(fabric)
field.Rows(out.H, func(y0, y1 int) {
for i := y0 * out.W; i < y1*out.W; i++ {
out.Data[i] = float32(bandValue(cdf.at(float64(fabric.Data[i])), mult, n))
}
})
return out
}
// bandValue picks the rock type a rank falls in and softens the boundary, pointwise.
//
// The blend is half-and-half exactly at a boundary from either side, which is what makes it continuous: a
// cell at the top of band b is (b + b+1)/2 and a cell at the bottom of band b+1 is (b+1 + b)/2, the same
// number approached from opposite directions.
func bandValue(rank float64, mult []float64, n int) float64 {
x := rank * float64(n)
b := int(x)
if b >= n {
b = n - 1
}
if b < 0 {
b = 0
}
f := x - float64(b)
switch {
case f > 1-rockEdge && b+1 < n:
t := noise.Smoothstep((f-(1-rockEdge))/rockEdge) * 0.5
return mult[b] + (mult[b+1]-mult[b])*t
case f < rockEdge && b > 0:
t := noise.Smoothstep((rockEdge-f)/rockEdge) * 0.5
return mult[b] + (mult[b-1]-mult[b])*t
}
return mult[b]
}
@@ -0,0 +1,162 @@
package uplift
import (
"math"
"testing"
"salty/terrain/internal/noise"
"salty/terrain/internal/world"
)
var testMult = []float64{0.6, 1.0, 1.8}
func rockIn(p world.Planet, f world.Frame, cells int) []float32 {
u, v := noise.WorldUV(f.W, f.H, p.CellM, f.OriginXM(), f.OriginYM(), p.NoisePeriodM)
return RockK(p, 7, cells, testMult, u, v).Data
}
// The rule the whole painted path is built on, applied to the rock field: a threshold on a decomposed planet
// has to be a quantile of the *planet*. Two regions taking percentiles of their own extents would put the
// same physical hillside in different rock, and the boundary between them would be a wall the solver carves.
//
// It is the same negative control TestTwoFramesAgreeAboutTheSameGround is for the upland fabric, and it is
// here rather than assumed because `uplift.Build`'s lithology does take a percentile of its own grid - so the
// obvious port of it would fail this and nothing else would have noticed.
func TestTwoFramesAgreeAboutTheSameRock(t *testing.T) {
const w, h, cellM = 2048, 512, 64.0
p := testPlanet(t, w, h, cellM)
whole := rockIn(p, world.Whole(p), 8)
a := world.Frame{P: p, X0: 400, Y0: 80, W: 240, H: 160}
b := world.Frame{P: p, X0: 520, Y0: 120, W: 240, H: 160}
ra, rb := rockIn(p, a, 8), rockIn(p, b, 8)
checked := 0
for y := 0; y < a.H; y++ {
for x := 0; x < a.W; x++ {
px, py := a.PlanetXY(x, y)
if px < b.X0 || px >= b.X0+b.W || py < b.Y0 || py >= b.Y0+b.H {
continue
}
got := ra[y*a.W+x]
if want := rb[(py-b.Y0)*b.W+(px-b.X0)]; got != want {
t.Fatalf("at planet (%d,%d) frame A says %v and frame B says %v", px, py, got, want)
}
if wh := whole[py*p.W+px]; wh != got {
t.Fatalf("at planet (%d,%d) a frame says %v and the whole planet says %v", px, py, got, wh)
}
checked++
}
}
if checked == 0 {
t.Fatal("the two frames do not overlap; this test measured nothing")
}
}
// Every rock type has to appear, whatever the seed did to the noise. Equal-area bands are what the procedural
// path got out of a percentile and the reason it is worth keeping: a seed that happened to produce no hard
// rock anywhere would be a seed that quietly removed a process.
func TestRockTypesComeOutInEqualShares(t *testing.T) {
const w, h, cellM = 2048, 512, 64.0
p := testPlanet(t, w, h, cellM)
data := rockIn(p, world.Whole(p), 8)
count := map[float64]int{}
for _, v := range data {
// Only the flat interior of a band counts: the edges are deliberately blended, so a cell there is
// between two types and belongs to neither.
for _, m := range testMult {
if math.Abs(float64(v)-m) < 1e-4 {
count[m]++
}
}
}
total := 0
for _, n := range count {
total += n
}
if total < len(data)/2 {
t.Fatalf("only %d of %d cells are in the flat middle of a band; the blend is eating the field",
total, len(data))
}
for _, m := range testMult {
share := float64(count[m]) / float64(total)
if share < 0.2 || share > 0.47 {
t.Errorf("rock type %v is %.1f%% of the land; three equal bands should each be about a third",
m, 100*share)
}
}
}
// The softening is pointwise, in rank space, and it has to be: a blur is a neighbourhood operation and a
// neighbourhood operation near a region's edge reads cells a different decomposition would not have given it.
// What the test asserts is the consequence - the field is continuous, so a rock boundary is a transition and
// not a wall - measured as the largest step between neighbouring cells.
//
// The geometry has to be the real one to mean anything. What decides how wide a boundary comes out *in cells*
// is the wavelength divided by the cell size: the real planet is a 9 km province on an 8 m cell, about eleven
// hundred cells across one, so a blend of a twentieth of the rank falls over tens of cells. A coarse test grid
// compresses the same blend into three or four and would fail a threshold the real run passes comfortably,
// which is a test measuring its own resolution rather than the code.
func TestRockBoundariesAreGradedRatherThanWalls(t *testing.T) {
const w, h, cellM, cells = 4096, 64, 8.0, 4
p := testPlanet(t, w, h, cellM)
d := rockIn(p, world.Whole(p), cells)
if perWave := w / cells; perWave < 512 {
t.Fatalf("%d cells across a province; too coarse to say anything about the real grid", perWave)
}
// The largest gap between neighbouring rock types, which is what a wall would look like.
gap := 0.0
for i := 1; i < len(testMult); i++ {
gap = math.Max(gap, math.Abs(testMult[i]-testMult[i-1]))
}
worst := 0.0
for y := 0; y < h; y++ {
for x := 0; x+1 < w; x++ {
worst = math.Max(worst, math.Abs(float64(d[y*w+x+1]-d[y*w+x])))
}
}
if worst > gap/8 {
t.Errorf("the largest step between neighbouring cells is %.4f against a %.2f gap between types; "+
"the bands are walls, not transitions", worst, gap)
}
if worst == 0 {
t.Fatal("the field is flat; this test measured nothing")
}
}
// bandValue is the pointwise part on its own: continuous, and exactly half-way at a boundary from either side.
func TestBandValueIsContinuousAcrossABoundary(t *testing.T) {
n := len(testMult)
below := bandValue(1/float64(n)-1e-9, testMult, n)
above := bandValue(1/float64(n)+1e-9, testMult, n)
want := (testMult[0] + testMult[1]) / 2
if math.Abs(below-want) > 1e-6 || math.Abs(above-want) > 1e-6 {
t.Errorf("at the first boundary: below %v, above %v, want %v from both sides", below, above, want)
}
if got := bandValue(0.5/float64(n), testMult, n); got != testMult[0] {
t.Errorf("the middle of the first band should be the type itself: got %v want %v", got, testMult[0])
}
// The ends clamp rather than running off.
if got := bandValue(0, testMult, n); got != testMult[0] {
t.Errorf("rank 0 is the first type, got %v", got)
}
if got := bandValue(1, testMult, n); got != testMult[n-1] {
t.Errorf("rank 1 is the last type, got %v", got)
}
}
// Nothing is built when nothing asks for it, which is what a planet with no lithology_wavelength_km gets.
func TestNoRockFieldWhenNoneIsAskedFor(t *testing.T) {
const w, h, cellM = 256, 128, 64.0
p := testPlanet(t, w, h, cellM)
f := world.Whole(p)
u, v := noise.WorldUV(f.W, f.H, cellM, 0, 0, p.NoisePeriodM)
if RockK(p, 7, 0, testMult, u, v) != nil {
t.Error("zero cells should build no field")
}
if RockK(p, 7, 8, []float64{1.0}, u, v) != nil {
t.Error("one rock type is no lithology at all")
}
}

Some files were not shown because too many files have changed in this diff Show More