387 lines
13 KiB
Go
387 lines
13 KiB
Go
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
|
|
}
|