Files
UnrealPrototyping/Tools/Terrain/internal/world/world.go
T
2026-09-25 17:02:24 +03:00

184 lines
8.0 KiB
Go

// 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 }