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