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 }