Tooling
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user