Files
UnrealPrototyping/Tools/Terrain/internal/coast/coast_test.go
T

352 lines
13 KiB
Go

package coast
import (
"math"
"testing"
"salty/terrain/internal/field"
"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)
}
}
}
}
// 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
// the continent mask in a real run, and a test that read the border back would be measuring the boundary
// condition rather than the transform.
func TestSignedDistanceIsMetresEitherWay(t *testing.T) {
const w, h = 60, 20
const cellM = 8.0
sea := make([]bool, w*h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
sea[y*w+x] = x < 30
}
}
g := Measure(sea, w, h, cellM)
y := h / 2
for _, c := range []struct {
x int
want float64
}{{29, -cellM}, {30, cellM}, {33, 4 * cellM}, {26, -4 * cellM}} {
if got := float64(g.Dist.Data[y*w+c.x]); math.Abs(got-c.want) > 1e-3 {
t.Errorf("x=%d: distance %.3f m, want %.3f m", c.x, got, c.want)
}
}
// The waterline is the sea side of the boundary, one column of it.
for _, i := range g.Waterline {
if x := int(i) % w; x != 29 {
t.Fatalf("waterline cell at x=%d, want 29", x)
}
}
if len(g.Waterline) != h {
t.Errorf("%d waterline cells, want %d", len(g.Waterline), h)
}
// A straight coast of h cells has h boundary edges.
if want := float64(h) * cellM; math.Abs(g.ShoreM-want) > 1e-6 {
t.Errorf("shoreline %.1f m, want %.1f m", g.ShoreM, want)
}
}
// coastFixture is a straight coast: sea to the left of x=split, a plateau at heightM to the right.
func coastFixture(w, h, split 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
if x < split {
sea[i] = true
f.Data[i] = 0 // held at sea level by the solve; the pass overwrites it
} else {
f.Data[i] = float32(heightM)
}
}
}
return f, sea
}
func testCfg() manifest.Coast {
c := manifest.Defaults().Pipeline.Coast
c.RoughnessM = 0 // the profile tests are about the profile, not about the noise on it
return c
}
// shelfOnlyCfg silences the surf, which silences the sediment with it: no cut means no supply, and no supply
// means the sea floor is the shelf profile and nothing else. Without this the two shelf tests are also testing
// the beach the deposition step builds over the top of it, which is a different question and has its own test.
func shelfOnlyCfg() manifest.Coast {
c := testCfg()
c.SurfReachM = 0
c.RiverM3PerKm2 = 0
return c
}
// TestShelfDeepensAwayFromTheShore is the sea floor's shape: monotone down from the waterline, through the
// break, to the abyssal floor, and never above sea level.
//
// The coast in this fixture stands 5 m above the water, so the shelf comes out at its widest — 3 km of shelf
// and 1.6 km of slope — and the map is made wide enough to hold both. That matters: on a narrower map the
// abyssal floor is simply never reached, which is correct behaviour and would read as a failed test.
func TestShelfDeepensAwayFromTheShore(t *testing.T) {
const w, h, split = 1000, 40, 600
const cellM = 8.0
cfg := shelfOnlyCfg()
f, sea := coastFixture(w, h, split, cellM, 5)
Build(Input{Height: f, Sea: sea, SeaLevelM: 0, BreakM: 30, AbyssM: 180, Seed: 7, Cfg: cfg})
y := h / 2
prev := 0.0
for x := split - 1; x >= 1; x-- {
z := float64(f.Data[y*w+x])
if z > 0 {
t.Fatalf("x=%d: sea floor at %.2f m, above sea level", x, z)
}
if x < split-1 && z > prev+1e-4 {
t.Fatalf("x=%d: sea floor rose from %.2f to %.2f m going offshore", x, prev, z)
}
prev = z
}
// Past the shelf and the slope together, 4.6 km out, is the abyssal floor.
if z := float64(f.Data[y*w+2]); math.Abs(z+180) > 1 {
t.Errorf("the far sea floor is at %.1f m, want -180 m", z)
}
// And the break is where it was asked for: just inside the shelf width, the depth is the break depth.
shelfCells := int(cfg.ShelfKm.Hi()*1000/cellM) - 2
if z := float64(f.Data[y*w+split-1-shelfCells]); math.Abs(z+30) > 2 {
t.Errorf("the shelf break is at %.1f m, want -30 m", z)
}
}
// TestShelfIsNarrowerOffAMountain is the one behaviour that makes the shelf width worth deriving rather than
// setting: the same manifest gives a wide shelf off a plain and a narrow one off a range.
func TestShelfIsNarrowerOffAMountain(t *testing.T) {
const w, h, split = 700, 60, 400
const cellM = 8.0
depthAt := func(backshoreM float64, x int) float64 {
f, sea := coastFixture(w, h, split, cellM, backshoreM)
in := Input{Height: f, Sea: sea, SeaLevelM: 0, BreakM: 30, AbyssM: 180, Seed: 7, Cfg: shelfOnlyCfg()}
Build(in)
return float64(f.Data[(h/2)*w+x])
}
// One kilometre offshore: on a plain coast that is still shelf, on a mountain coast it is past the break.
const probe = 400 - 125
plain := depthAt(20, probe)
mountain := depthAt(600, probe)
if !(mountain < plain-20) {
t.Errorf("1 km offshore: %.1f m off a 20 m coast, %.1f m off a 600 m coast; "+
"the mountain coast should be far deeper", plain, mountain)
}
}
// TestSurfCutsACliffNotARamp is the shape the surf is for. A ramp would satisfy "the land is lower near the
// water" just as well, and it is not what a coast looks like, so the test asks for both halves: a nearly flat
// platform at the water and a step at the back of it.
func TestSurfCutsACliffNotARamp(t *testing.T) {
const w, h, split = 700, 60, 400
const cellM, plateau = 8.0, 120.0
f, sea := coastFixture(w, h, split, cellM, plateau)
cfg := testCfg()
in := Input{Height: f, Sea: sea, SeaLevelM: 0, BreakM: 30, AbyssM: 180, Seed: 7, Cfg: cfg}
Build(in)
y := h / 2
// The platform: the first five cells inland, 8 to 40 m from the water.
for x := split; x < split+5; x++ {
if z := float64(f.Data[y*w+x]); z > 8 {
t.Errorf("x=%d (%.0f m inland): %.1f m, want a platform near sea level",
x, float64(x-split+1)*cellM, z)
}
}
// The land beyond twice the reach is untouched.
far := split + int(2*cfg.SurfReachM/cellM)
if z := float64(f.Data[y*w+far]); math.Abs(z-plateau) > 1e-3 {
t.Errorf("%.0f m inland: %.1f m, want the plateau at %.0f m", 2*cfg.SurfReachM, z, plateau)
}
// The cliff: somewhere in the strip there is a step of at least a third of the plateau in one cell.
biggest := 0.0
for x := split; x < far; x++ {
if d := float64(f.Data[y*w+x+1] - f.Data[y*w+x]); d > biggest {
biggest = d
}
}
if biggest < plateau/3 {
t.Errorf("the biggest step in the surf strip is %.1f m over %.0f m; a %0.f m plateau should leave a "+
"cliff, not a ramp", biggest, cellM, plateau)
}
}
// bayFixture is a straight coast with a semicircular bay bitten out of it, which is the smallest shape that
// has both an exposed stretch and a sheltered one.
func bayFixture(w, h, split, radius int, cellM, heightM float64) (*field.Field, []bool) {
f, sea := coastFixture(w, h, split, cellM, heightM)
cx, cy := split, h/2
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
dx, dy := float64(x-cx), float64(y-cy)
if math.Hypot(dx, dy) < float64(radius) {
i := y*w + x
sea[i] = true
f.Data[i] = 0
}
}
}
return f, sea
}
// TestSedimentBudgetBalances is an accounting identity, and it is worth asserting because the deposition step
// is the only place in the generator where material is moved from one place to another rather than created or
// destroyed by a law. Everything cut, plus everything the rivers deliver, is either laid down or reported as
// unplaced; nothing evaporates.
func TestSedimentBudgetBalances(t *testing.T) {
const w, h, split = 400, 400, 250
const cellM = 8.0
f, sea := bayFixture(w, h, split, 90, cellM, 90)
res := Build(Input{Height: f, Sea: sea, SeaLevelM: 0, BreakM: 30, AbyssM: 180, Seed: 7, Cfg: testCfg()})
s := res.Stats
in := s.CutM3 + s.RiverM3
out := s.LaidM3 + s.UnplacedM3
if in <= 0 {
t.Fatalf("the surf cut nothing: there is no budget to balance")
}
if rel := math.Abs(out-in) / in; rel > 0.02 {
t.Errorf("cut %.0f m3 + rivers %.0f m3 = %.0f, but laid %.0f + unplaced %.0f = %.0f (%.1f%% out)",
s.CutM3, s.RiverM3, in, s.LaidM3, s.UnplacedM3, out, rel*100)
}
if s.LaidM3 <= 0 {
t.Errorf("nothing was laid down at all; a bay should collect sediment")
}
}
// TestSedimentPrefersTheBay is the behaviour the fetch field exists to produce. Without it the surf would cut
// a headland and lay the debris straight back down on the headland, which is the one thing a coast never does.
func TestSedimentPrefersTheBay(t *testing.T) {
const w, h, split, radius = 400, 400, 250, 90
const cellM = 8.0
f, sea := bayFixture(w, h, split, radius, cellM, 90)
res := Build(Input{Height: f, Sea: sea, SeaLevelM: 0, BreakM: 30, AbyssM: 180, Seed: 7, Cfg: testCfg()})
// Two windows of sea cells: the back of the bay, and open water the same distance offshore from the
// straight coast well clear of it.
var bay, open float64
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*w + x
if !sea[i] || res.Change.Data[i] <= 0 {
continue
}
inBay := math.Hypot(float64(x-split), float64(y-h/2)) < float64(radius)
farFromBay := math.Abs(float64(y-h/2)) > float64(radius)*1.6
if inBay {
bay += float64(res.Change.Data[i])
} else if farFromBay && x > split-40 {
open += float64(res.Change.Data[i])
}
}
}
if !(bay > open*2) {
t.Errorf("sediment laid: %.0f m in the bay against %.0f m on the open coast; "+
"shelter is not steering deposition", bay, open)
}
}
// TestBoxBlurIsMassPreservingAndSymmetric guards the drift kernel, which the deposition mass balance rests on.
// Support well inside the grid must come through with its total intact, and a single grain must spread to a
// kernel that is the same either side of where it started — that symmetry is what makes "what i gives j" equal
// "what j gives i", and it is what the zero padding is there to protect.
func TestBoxBlurIsMassPreservingAndSymmetric(t *testing.T) {
f := field.New(64, 64, 1)
seed := uint32(5)
var before float64
for y := 20; y < 44; y++ { // support clear of the border by more than the kernel
for x := 20; x < 44; x++ {
seed = seed*1664525 + 1013904223
f.Data[y*64+x] = float32(seed>>16&255) / 255
before += float64(f.Data[y*64+x])
}
}
out := boxBlur(f, 5, 3)
var after float64
for _, v := range out.Data {
after += float64(v)
}
if rel := math.Abs(after-before) / before; rel > 1e-4 {
t.Errorf("the kernel moved the total from %.4f to %.4f (%.4f%%)", before, after, rel*100)
}
one := field.New(64, 64, 1)
one.Data[32*64+32] = 1
k := boxBlur(one, 5, 3)
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 {
t.Fatalf("the kernel is not symmetric at offset %d: %g against %g", d, l, r)
}
}
}
// TestDisabledIsThePreCoastBehaviour keeps the escape hatch honest: --no-coast has to give the flat sea floor
// the generator had before this pass, not a half-applied version of it.
func TestDisabledIsThePreCoastBehaviour(t *testing.T) {
const w, h, split = 200, 40, 120
f, sea := coastFixture(w, h, split, 8, 100)
cfg := testCfg()
cfg.Enabled = false
Build(Input{Height: f, Sea: sea, SeaLevelM: 0, BreakM: 30, AbyssM: 180, Seed: 7, Cfg: cfg})
for i := range sea {
if sea[i] && math.Abs(float64(f.Data[i])+180) > 1e-3 {
t.Fatalf("cell %d: %.2f m, want a flat floor at -180 m", i, f.Data[i])
}
if !sea[i] && math.Abs(float64(f.Data[i])-100) > 1e-3 {
t.Fatalf("cell %d: land at %.2f m, want it untouched at 100 m", i, f.Data[i])
}
}
}