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
+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
}