Tooling
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
// Package world is the coordinate system a planet-scale bake works in, and it is the only place that
|
||||
// knows the world is a cylinder.
|
||||
//
|
||||
// Two ideas, and they are deliberately small.
|
||||
//
|
||||
// A Planet is the raster the whole bake composites into: X is cyclic, so column W-1 is next to column 0
|
||||
// and the seam is nowhere in particular, and Y is bounded by the poles. A Frame is a rectangle of it -
|
||||
// a region during the geology solve, a tile during the detail passes - carried as an explicit argument
|
||||
// rather than stored on a field.
|
||||
//
|
||||
// That last choice is worth the sentence. It would be tidier to hang an origin on field.Field, and it
|
||||
// would be wrong: a Field is used for masks, coordinate pairs, scratch and a dozen other things that
|
||||
// have no position at all, and field.New has no origin to give them, so every one of them would quietly
|
||||
// claim to sit at (0, 0). A wrong-by-default origin cannot be seen; a missing argument is a compile
|
||||
// error. Only a handful of places need world coordinates and they all take a Frame.
|
||||
package world
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Planet is the cylinder.
|
||||
//
|
||||
// CellM is the geology cell and it never varies. D-48 fixes it at 8 m and Docs/Terrain-Next.md 4.D.3
|
||||
// says why: stream power applied down to a single cell puts every divide at S = U/(K*cell^2m), so
|
||||
// halving the cell steepens every divide for ever. The same painted map solved at two cell sizes would
|
||||
// be two different landscapes, which is fatal to the idea of a template.
|
||||
type Planet struct {
|
||||
CellM float64 // metres per cell, 8.0
|
||||
|
||||
W int // columns; the circumference is exactly W*CellM
|
||||
H int // rows, including the polar pad at both ends
|
||||
|
||||
// PadY is how many rows of synthetic ocean sit above the painted map and below it.
|
||||
//
|
||||
// It exists because fluvial.isOutlet treats every cell in the top and bottom rows of a grid as an
|
||||
// outlet: it takes no uplift and is never eroded, so painted land touching a pole would freeze while
|
||||
// the interior eroded out from under it. That is precisely the failure continentMask's four per cent
|
||||
// sea margin exists to prevent and TestBorderIsAlwaysOcean exists to catch. Padding with ocean makes
|
||||
// a polar cap an ordinary landmass with a shore, so isOutlet stays exactly as written and the
|
||||
// invariant keeps meaning what it means.
|
||||
//
|
||||
// The fiction lives entirely in rows that are discarded before anything is written out. Its one real
|
||||
// consequence: the outermost row of a polar cap is cut down to a sea that does not exist. An ice
|
||||
// sheet calving into a polar ocean is, as lies go, the right one.
|
||||
PadY int
|
||||
|
||||
// NoisePeriodM is how far a world-coordinate noise lattice runs before it repeats.
|
||||
//
|
||||
// It must divide the circumference exactly. noise.Lattice.Sample wraps modulo its cell count and
|
||||
// noise.WorldUV divides world metres by this period, so u returns to the same lattice point at
|
||||
// x = W if and only if W*CellM is a whole number of periods. Get it wrong and every noise field in
|
||||
// the world has a visible discontinuity down one meridian.
|
||||
NoisePeriodM float64
|
||||
}
|
||||
|
||||
// New derives a planet from the circumference and the shape of the painted map.
|
||||
//
|
||||
// The paint's aspect is preserved rather than forced to 2:1, because an author's canvas is whatever
|
||||
// they drew on and stretching it is a silent change to their map.
|
||||
func New(circumferenceM, cellM float64, paintW, paintH, padY int, noisePeriodM float64) (Planet, error) {
|
||||
if cellM <= 0 {
|
||||
return Planet{}, fmt.Errorf("cell size is %v m", cellM)
|
||||
}
|
||||
if paintW <= 0 || paintH <= 0 {
|
||||
return Planet{}, fmt.Errorf("painted map is %dx%d", paintW, paintH)
|
||||
}
|
||||
cols := circumferenceM / cellM
|
||||
w := int(cols + 0.5)
|
||||
if diff := cols - float64(w); diff > 1e-9 || diff < -1e-9 {
|
||||
return Planet{}, fmt.Errorf("circumference %.1f m is %.4f cells of %.1f m; it must be a whole "+
|
||||
"number, or the seam falls between two columns", circumferenceM, cols, cellM)
|
||||
}
|
||||
if padY < 0 {
|
||||
return Planet{}, fmt.Errorf("pad is %d rows", padY)
|
||||
}
|
||||
rows := int(float64(w)*float64(paintH)/float64(paintW) + 0.5)
|
||||
if rows < 1 {
|
||||
return Planet{}, fmt.Errorf("painted map %dx%d gives %d rows at %d columns", paintW, paintH, rows, w)
|
||||
}
|
||||
p := Planet{CellM: cellM, W: w, H: rows + 2*padY, PadY: padY, NoisePeriodM: noisePeriodM}
|
||||
if p.NoisePeriodM == 0 {
|
||||
p.NoisePeriodM = p.CircumferenceM()
|
||||
}
|
||||
if err := p.Validate(); err != nil {
|
||||
return Planet{}, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Validate refuses a planet whose noise would show a seam.
|
||||
func (p Planet) Validate() error {
|
||||
if p.W <= 0 || p.H <= 0 {
|
||||
return fmt.Errorf("planet is %dx%d", p.W, p.H)
|
||||
}
|
||||
if p.H <= 2*p.PadY {
|
||||
return fmt.Errorf("planet is %d rows with %d of pad at each end; nothing is left", p.H, p.PadY)
|
||||
}
|
||||
if p.NoisePeriodM <= 0 {
|
||||
return fmt.Errorf("noise period is %v m", p.NoisePeriodM)
|
||||
}
|
||||
k := p.CircumferenceM() / p.NoisePeriodM
|
||||
n := int(k + 0.5)
|
||||
if n < 1 || k-float64(n) > 1e-9 || k-float64(n) < -1e-9 {
|
||||
return fmt.Errorf("noise period %.1f m does not divide the circumference %.1f m (%.4f times); "+
|
||||
"every noise field would break at the seam", p.NoisePeriodM, p.CircumferenceM(), k)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CircumferenceM is the distance all the way round.
|
||||
func (p Planet) CircumferenceM() float64 { return float64(p.W) * p.CellM }
|
||||
|
||||
// PaintH is the row count of the painted map, without the polar pad.
|
||||
func (p Planet) PaintH() int { return p.H - 2*p.PadY }
|
||||
|
||||
// HeightM is the pole-to-pole extent of the painted map.
|
||||
func (p Planet) HeightM() float64 { return float64(p.PaintH()) * p.CellM }
|
||||
|
||||
// WrapX brings any column into 0..W-1. Negative and far-out values are both fine; this is the only
|
||||
// arithmetic that makes the map a cylinder.
|
||||
func (p Planet) WrapX(x int) int {
|
||||
x %= p.W
|
||||
if x < 0 {
|
||||
x += p.W
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
// ClampY bounds a row. Y does not wrap: the top and bottom of the map are the poles, not each other.
|
||||
func (p Planet) ClampY(y int) int {
|
||||
if y < 0 {
|
||||
return 0
|
||||
}
|
||||
if y >= p.H {
|
||||
return p.H - 1
|
||||
}
|
||||
return y
|
||||
}
|
||||
|
||||
// Idx is the index of a cell, wrapping X and clamping Y.
|
||||
func (p Planet) Idx(x, y int) int { return p.ClampY(y)*p.W + p.WrapX(x) }
|
||||
|
||||
// XM and YM are world metres. Y is measured from the painted map's first row, so the pad is negative and
|
||||
// the numbers an author would recognise are the ones they painted.
|
||||
func (p Planet) XM(x int) float64 { return float64(x) * p.CellM }
|
||||
func (p Planet) YM(y int) float64 { return float64(y-p.PadY) * p.CellM }
|
||||
|
||||
// InPad reports whether a planet row is synthetic polar ocean rather than painted map.
|
||||
func (p Planet) InPad(y int) bool { return y < p.PadY || y >= p.H-p.PadY }
|
||||
|
||||
// Frame is a rectangle of a planet: a region during the solve, a tile during the detail passes.
|
||||
//
|
||||
// X0 is a planet column and may be anything; the frame's columns are X0, X0+1, ... taken round the
|
||||
// cylinder, so a frame that straddles the seam is ordinary rather than special. Y0 is a planet row and
|
||||
// is not wrapped.
|
||||
type Frame struct {
|
||||
P Planet
|
||||
X0, Y0 int
|
||||
W, H int
|
||||
}
|
||||
|
||||
// Whole is the frame covering the entire planet.
|
||||
func Whole(p Planet) Frame { return Frame{P: p, X0: 0, Y0: 0, W: p.W, H: p.H} }
|
||||
|
||||
// Cells is how many cells the frame holds.
|
||||
func (f Frame) Cells() int { return f.W * f.H }
|
||||
|
||||
// PlanetXY maps a frame cell to a planet cell. X is wrapped; Y is returned as it is, so a caller that
|
||||
// framed rows outside the planet gets to notice.
|
||||
func (f Frame) PlanetXY(x, y int) (int, int) { return f.P.WrapX(f.X0 + x), f.Y0 + y }
|
||||
|
||||
// PlanetIdx maps a frame cell to a planet index.
|
||||
func (f Frame) PlanetIdx(x, y int) int { return f.P.Idx(f.X0+x, f.Y0+y) }
|
||||
|
||||
// OriginXM and OriginYM are what noise.WorldUV wants: the world position of the frame's first cell.
|
||||
// Every noise field in a framed pass is built on these, which is rule 1 of the tiling plan in
|
||||
// Docs/Terrain-Next.md - index by absolute world position, never by grid index, or two frames covering
|
||||
// the same physical place disagree and every seam shows.
|
||||
func (f Frame) OriginXM() float64 { return f.P.XM(f.X0) }
|
||||
func (f Frame) OriginYM() float64 { return f.P.YM(f.Y0) }
|
||||
|
||||
// Wraps reports whether the frame goes all the way round, in which case its left and right edges are
|
||||
// neighbours and no pass may treat them as boundaries.
|
||||
func (f Frame) Wraps() bool { return f.W >= f.P.W }
|
||||
@@ -0,0 +1,177 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"salty/terrain/internal/noise"
|
||||
)
|
||||
|
||||
func testPlanet(t *testing.T) Planet {
|
||||
t.Helper()
|
||||
// 64 columns of 8 m is a 512 m circumference: small, and a whole number of cells.
|
||||
p, err := New(512, 8, 100, 50, 2, 512)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestNewDerivesTheGrid(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
if p.W != 64 {
|
||||
t.Errorf("W = %d, want 64", p.W)
|
||||
}
|
||||
// 64 columns at a 100x50 paint aspect is 32 painted rows, plus 2 of pad at each end.
|
||||
if p.PaintH() != 32 {
|
||||
t.Errorf("PaintH = %d, want 32", p.PaintH())
|
||||
}
|
||||
if p.H != 36 {
|
||||
t.Errorf("H = %d, want 36", p.H)
|
||||
}
|
||||
if p.CircumferenceM() != 512 {
|
||||
t.Errorf("CircumferenceM = %v, want 512", p.CircumferenceM())
|
||||
}
|
||||
if p.HeightM() != 256 {
|
||||
t.Errorf("HeightM = %v, want 256", p.HeightM())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRefusesAFractionalColumn(t *testing.T) {
|
||||
_, err := New(511, 8, 100, 50, 0, 0)
|
||||
if err == nil {
|
||||
t.Fatal("accepted a circumference that is not a whole number of cells")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "whole number") {
|
||||
t.Errorf("error %q does not say why", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoisePeriodMustDivideTheCircumference(t *testing.T) {
|
||||
if _, err := New(512, 8, 100, 50, 0, 300); err == nil {
|
||||
t.Fatal("accepted a noise period that does not divide the circumference")
|
||||
}
|
||||
for _, period := range []float64{512, 256, 512.0 / 3.0} {
|
||||
p := Planet{CellM: 8, W: 64, H: 32, NoisePeriodM: period}
|
||||
if err := p.Validate(); err != nil {
|
||||
t.Errorf("period %v rejected: %v", period, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapXIsExactAtTheSeam(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
cases := map[int]int{-1: 63, 0: 0, 63: 63, 64: 0, 65: 1, -64: 0, -65: 63, 129: 1}
|
||||
for in, want := range cases {
|
||||
if got := p.WrapX(in); got != want {
|
||||
t.Errorf("WrapX(%d) = %d, want %d", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestYDoesNotWrap(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
if got := p.ClampY(-3); got != 0 {
|
||||
t.Errorf("ClampY(-3) = %d, want 0", got)
|
||||
}
|
||||
if got := p.ClampY(999); got != p.H-1 {
|
||||
t.Errorf("ClampY(999) = %d, want %d", got, p.H-1)
|
||||
}
|
||||
// The pad is where the synthetic ocean lives, and world metres are measured from the paint.
|
||||
if !p.InPad(0) || !p.InPad(p.H-1) {
|
||||
t.Error("the outermost rows should be pad")
|
||||
}
|
||||
if p.InPad(p.PadY) {
|
||||
t.Error("the paint's first row should not be pad")
|
||||
}
|
||||
if got := p.YM(p.PadY); got != 0 {
|
||||
t.Errorf("YM at the paint's first row = %v, want 0", got)
|
||||
}
|
||||
if got := p.YM(0); got != -16 {
|
||||
t.Errorf("YM at the top of the pad = %v, want -16", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameRoundTripsThroughTheSeam(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
f := Frame{P: p, X0: 60, Y0: 3, W: 8, H: 4}
|
||||
if f.Cells() != 32 {
|
||||
t.Errorf("Cells = %d, want 32", f.Cells())
|
||||
}
|
||||
// Columns 60,61,62,63,0,1,2,3.
|
||||
want := []int{60, 61, 62, 63, 0, 1, 2, 3}
|
||||
for x, wx := range want {
|
||||
gx, gy := f.PlanetXY(x, 1)
|
||||
if gx != wx || gy != 4 {
|
||||
t.Errorf("PlanetXY(%d,1) = %d,%d, want %d,4", x, gx, gy, wx)
|
||||
}
|
||||
}
|
||||
if got := f.OriginXM(); got != 480 {
|
||||
t.Errorf("OriginXM = %v, want 480", got)
|
||||
}
|
||||
if got := f.OriginYM(); got != 8 {
|
||||
t.Errorf("OriginYM = %v, want 8 (row 3 is one row into the paint)", got)
|
||||
}
|
||||
if f.Wraps() {
|
||||
t.Error("an 8-wide frame on a 64-wide planet does not wrap")
|
||||
}
|
||||
if !Whole(p).Wraps() {
|
||||
t.Error("the whole planet wraps")
|
||||
}
|
||||
}
|
||||
|
||||
// The seam test. Two frames covering the same physical column must produce the same noise, to the bit -
|
||||
// that is rule 1 of the tiling plan, and it is what stops a visible line down one meridian.
|
||||
func TestNoiseIsContinuousAcrossTheSeam(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
params := noise.Params{BaseCells: 2, Octaves: 4, Gain: 0.5}
|
||||
|
||||
sample := func(f Frame) []float32 {
|
||||
u, v := noise.WorldUV(f.W, f.H, f.P.CellM, f.OriginXM(), f.OriginYM(), f.P.NoisePeriodM)
|
||||
return noise.FBMAt(u, v, noise.NewSource(7, 1), params).Data
|
||||
}
|
||||
|
||||
a := Frame{P: p, X0: 0, Y0: 0, W: 8, H: 4} // columns 0..7
|
||||
b := Frame{P: p, X0: 60, Y0: 0, W: 8, H: 4} // columns 60..63, 0..3
|
||||
fa, fb := sample(a), sample(b)
|
||||
|
||||
for y := 0; y < 4; y++ {
|
||||
for k := 0; k < 4; k++ {
|
||||
// Frame b's column 4+k is planet column k, which is frame a's column k.
|
||||
got := fb[y*8+4+k]
|
||||
want := fa[y*8+k]
|
||||
if got != want {
|
||||
t.Errorf("planet column %d row %d: seam frame gives %v, origin frame gives %v",
|
||||
k, y, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// And the negative control: if the period does not divide the circumference, the seam is discontinuous.
|
||||
// This is here so the test above is known to be measuring something.
|
||||
func TestNoiseBreaksWhenThePeriodDoesNotDivide(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
p.NoisePeriodM = 300 // deliberately invalid; Validate would refuse it
|
||||
params := noise.Params{BaseCells: 2, Octaves: 4, Gain: 0.5}
|
||||
sample := func(f Frame) []float32 {
|
||||
u, v := noise.WorldUV(f.W, f.H, f.P.CellM, f.OriginXM(), f.OriginYM(), f.P.NoisePeriodM)
|
||||
return noise.FBMAt(u, v, noise.NewSource(7, 1), params).Data
|
||||
}
|
||||
a := Frame{P: p, X0: 0, Y0: 0, W: 8, H: 4}
|
||||
b := Frame{P: p, X0: 60, Y0: 0, W: 8, H: 4}
|
||||
fa, fb := sample(a), sample(b)
|
||||
same := true
|
||||
for y := 0; y < 4 && same; y++ {
|
||||
for k := 0; k < 4; k++ {
|
||||
if fb[y*8+4+k] != fa[y*8+k] {
|
||||
same = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if same {
|
||||
t.Error("a period that does not divide the circumference still matched at the seam; " +
|
||||
"the continuity test above is not measuring what it claims")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user