This commit is contained in:
Rainer Leit
2026-09-25 17:02:24 +03:00
parent cc43ed8dc8
commit 9597629951
2149 changed files with 460234 additions and 1770 deletions
+183
View File
@@ -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 }
+177
View File
@@ -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")
}
}