184 lines
8.0 KiB
Go
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 }
|