Tooling
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
// Package tile cuts the detail grid into pieces that can be baked one at a time.
|
||||
//
|
||||
// Docs/Terrain-Next.md 3.3 splits the work in two and this is the easy half. The fluvial solve is global in a
|
||||
// way that cannot be tiled - drainage area is an integral over the whole upstream catchment - and it is
|
||||
// handled by decomposing the planet per landmass instead (internal/region). Every pass after it is *local*:
|
||||
// noise is pointwise, thermal weathering propagates a cell at a time, and a droplet travels at most its
|
||||
// lifetime in cells. So a tile is cut with an overlap margin sized by how far the pass it runs can move
|
||||
// material, the passes run, and the margin is thrown away. Nothing is exchanged between tiles and nothing
|
||||
// needs to be.
|
||||
//
|
||||
// That only works because of rule 1. Every hash and every noise lattice is keyed on absolute world position,
|
||||
// so a cell reached in a tile's interior and the same cell reached inside a neighbour's margin get the same
|
||||
// answer to the bit. Key anything on a tile-local index and every seam shows.
|
||||
package tile
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
// Grid is the tiling of one planet's detail resolution.
|
||||
type Grid struct {
|
||||
P world.Planet // the geology cylinder the tiles are cut from
|
||||
|
||||
Factor int // detail cells per geology cell, the manifest's geology_factor
|
||||
SideGeo int // interior side of a tile, in geology cells
|
||||
MarginGeo int // overlap carried on every side, in geology cells
|
||||
|
||||
NX, NY int // tiles across and down
|
||||
GeoW, GeoH int // the painted geology raster the tiles cover
|
||||
}
|
||||
|
||||
// NewGrid works out the tiling. sidePx is the interior side of a tile in *detail* cells, and must be a whole
|
||||
// number of geology cells; marginPx is the overlap in detail cells.
|
||||
//
|
||||
// X must divide exactly, because it wraps: a tile grid that did not come out whole would leave the last tile
|
||||
// overlapping the first by an arbitrary amount and there would be no honest way to name the seam.
|
||||
func NewGrid(p world.Planet, factor, sidePx, marginPx int) (*Grid, error) {
|
||||
if factor < 1 {
|
||||
return nil, fmt.Errorf("detail factor is %d", factor)
|
||||
}
|
||||
if sidePx < factor || sidePx%factor != 0 {
|
||||
return nil, fmt.Errorf("tile side %d detail cells is not a whole number of %d-cell geology blocks",
|
||||
sidePx, factor)
|
||||
}
|
||||
side := sidePx / factor
|
||||
if p.W%side != 0 {
|
||||
return nil, fmt.Errorf("a %d cell planet does not divide into %d cell tiles; X wraps, so it must. "+
|
||||
"The nearest sides that work are %s", p.W, side, divisorsNear(p.W, side))
|
||||
}
|
||||
margin := (marginPx + factor - 1) / factor
|
||||
if margin < 1 {
|
||||
margin = 1
|
||||
}
|
||||
g := &Grid{
|
||||
P: p, Factor: factor, SideGeo: side, MarginGeo: margin,
|
||||
GeoW: p.W, GeoH: p.PaintH(),
|
||||
}
|
||||
g.NX = g.GeoW / side
|
||||
g.NY = (g.GeoH + side - 1) / side
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// Tile is one piece: where it sits and how big it is.
|
||||
type Tile struct {
|
||||
IX, IY int
|
||||
|
||||
// X0, Y0 and W, H are the interior, in geology cells of the painted raster. The last row of tiles is
|
||||
// short wherever the planet's height is not a whole number of tiles, and that is recorded rather than
|
||||
// padded: padding would put invented ground in the output.
|
||||
X0, Y0, W, H int
|
||||
}
|
||||
|
||||
// Tiles lists every tile in row-major order.
|
||||
func (g *Grid) Tiles() []Tile {
|
||||
out := make([]Tile, 0, g.NX*g.NY)
|
||||
for iy := 0; iy < g.NY; iy++ {
|
||||
y0 := iy * g.SideGeo
|
||||
h := g.SideGeo
|
||||
if y0+h > g.GeoH {
|
||||
h = g.GeoH - y0
|
||||
}
|
||||
for ix := 0; ix < g.NX; ix++ {
|
||||
out = append(out, Tile{IX: ix, IY: iy, X0: ix * g.SideGeo, Y0: y0, W: g.SideGeo, H: h})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DetailW and DetailH are the tile's interior at detail resolution.
|
||||
func (g *Grid) DetailW(t Tile) int { return t.W * g.Factor }
|
||||
func (g *Grid) DetailH(t Tile) int { return t.H * g.Factor }
|
||||
|
||||
// OriginXM and OriginYM are the world position of the tile's first interior detail cell.
|
||||
func (g *Grid) OriginXM(t Tile) float64 { return g.P.XM(t.X0) }
|
||||
func (g *Grid) OriginYM(t Tile) float64 { return g.P.YM(t.Y0 + g.P.PadY) }
|
||||
|
||||
// Cut extracts a tile's geology source: the interior plus the margin, wrapping in X and clamping in Y.
|
||||
//
|
||||
// The extra sample is the upsample's: field.UpsampleInt turns N samples into (N-1)*factor+1, so covering
|
||||
// W*factor interior detail cells needs W+1 geology samples, and the margin is on top of that.
|
||||
//
|
||||
// Clamping in Y rather than wrapping is not a shortcut - the top and bottom of the map are the poles, not
|
||||
// each other - and it only ever touches the polar pad, which is water.
|
||||
func (g *Grid) Cut(t Tile, src *field.Field, padY int) (out *field.Field, interiorX, interiorY int) {
|
||||
w := t.W + 2*g.MarginGeo + 1
|
||||
h := t.H + 2*g.MarginGeo + 1
|
||||
out = field.New(w, h, src.CellM)
|
||||
x0 := t.X0 - g.MarginGeo
|
||||
y0 := t.Y0 - g.MarginGeo
|
||||
for y := 0; y < h; y++ {
|
||||
sy := y0 + y
|
||||
if sy < 0 {
|
||||
sy = 0
|
||||
} else if sy >= g.GeoH {
|
||||
sy = g.GeoH - 1
|
||||
}
|
||||
row := (sy + padY) * src.W
|
||||
for x := 0; x < w; x++ {
|
||||
out.Data[y*w+x] = src.Data[row+g.P.WrapX(x0+x)]
|
||||
}
|
||||
}
|
||||
return out, g.MarginGeo * g.Factor, g.MarginGeo * g.Factor
|
||||
}
|
||||
|
||||
// CutMask is Cut for a boolean field, nearest by construction.
|
||||
func (g *Grid) CutMask(t Tile, src []bool, srcW, padY int) []bool {
|
||||
w := t.W + 2*g.MarginGeo + 1
|
||||
h := t.H + 2*g.MarginGeo + 1
|
||||
out := make([]bool, w*h)
|
||||
x0 := t.X0 - g.MarginGeo
|
||||
y0 := t.Y0 - g.MarginGeo
|
||||
for y := 0; y < h; y++ {
|
||||
sy := y0 + y
|
||||
if sy < 0 {
|
||||
sy = 0
|
||||
} else if sy >= g.GeoH {
|
||||
sy = g.GeoH - 1
|
||||
}
|
||||
row := (sy + padY) * srcW
|
||||
for x := 0; x < w; x++ {
|
||||
out[y*w+x] = src[row+g.P.WrapX(x0+x)]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CutClass is Cut for the painted class raster, which is indexed over the whole planet including the polar
|
||||
// pad, so it takes the pad offset rather than assuming the painted rows.
|
||||
func (g *Grid) CutClass(t Tile, src []uint8, srcW, padY int) []uint8 {
|
||||
w := t.W + 2*g.MarginGeo + 1
|
||||
h := t.H + 2*g.MarginGeo + 1
|
||||
out := make([]uint8, w*h)
|
||||
x0 := t.X0 - g.MarginGeo
|
||||
y0 := t.Y0 - g.MarginGeo
|
||||
for y := 0; y < h; y++ {
|
||||
sy := y0 + y
|
||||
if sy < 0 {
|
||||
sy = 0
|
||||
} else if sy >= g.GeoH {
|
||||
sy = g.GeoH - 1
|
||||
}
|
||||
row := (sy + padY) * srcW
|
||||
for x := 0; x < w; x++ {
|
||||
out[y*w+x] = src[row+g.P.WrapX(x0+x)]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Frame is the tile's cut rectangle as a world frame at *detail* resolution, which is what the noise and the
|
||||
// hashes are indexed by.
|
||||
func (g *Grid) Frame(t Tile) world.Frame {
|
||||
detail := g.P
|
||||
detail.CellM = g.P.CellM / float64(g.Factor)
|
||||
detail.W = g.P.W * g.Factor
|
||||
detail.H = g.P.H * g.Factor
|
||||
detail.PadY = g.P.PadY * g.Factor
|
||||
w := (t.W + 2*g.MarginGeo) * g.Factor
|
||||
h := (t.H + 2*g.MarginGeo) * g.Factor
|
||||
return world.Frame{
|
||||
P: detail,
|
||||
X0: detail.WrapX((t.X0 - g.MarginGeo) * g.Factor),
|
||||
Y0: (t.Y0 - g.MarginGeo + g.P.PadY) * g.Factor,
|
||||
W: w + 1, H: h + 1,
|
||||
}
|
||||
}
|
||||
|
||||
// Name is the file stem a tile is written under.
|
||||
func (t Tile) Name(prefix string) string { return fmt.Sprintf("%s_x%02d_y%02d", prefix, t.IX, t.IY) }
|
||||
|
||||
// divisorsNear lists a few tile sides that do divide, for the error message.
|
||||
func divisorsNear(w, want int) string {
|
||||
var below, above int
|
||||
for d := want; d >= 1; d-- {
|
||||
if w%d == 0 {
|
||||
below = d
|
||||
break
|
||||
}
|
||||
}
|
||||
for d := want; d <= w; d++ {
|
||||
if w%d == 0 {
|
||||
above = d
|
||||
break
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%d and %d geology cells", below, above)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package tile
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"salty/terrain/internal/field"
|
||||
"salty/terrain/internal/world"
|
||||
)
|
||||
|
||||
func testPlanet(t *testing.T) world.Planet {
|
||||
t.Helper()
|
||||
// 64 geology columns of 8 m is a 512 m circumference, 40 painted rows, 4 of polar pad.
|
||||
p := world.Planet{CellM: 8, W: 64, H: 48, PadY: 4, NoisePeriodM: 512}
|
||||
if err := p.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestNewGridDivides(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
// 64 detail cells at factor 4 is 16 geology cells, and 64 divides by 16.
|
||||
g, err := NewGrid(p, 4, 64, 8)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g.NX != 4 {
|
||||
t.Errorf("NX = %d, want 4", g.NX)
|
||||
}
|
||||
// 40 painted rows over 16-cell tiles is 3 rows, the last one short.
|
||||
if g.NY != 3 {
|
||||
t.Errorf("NY = %d, want 3", g.NY)
|
||||
}
|
||||
if g.MarginGeo != 2 {
|
||||
t.Errorf("MarginGeo = %d, want 2 (8 detail cells at factor 4)", g.MarginGeo)
|
||||
}
|
||||
}
|
||||
|
||||
// X wraps, so a tile grid that did not come out whole would leave the last tile overlapping the first by an
|
||||
// arbitrary amount and there would be no honest way to name the seam.
|
||||
func TestNewGridRefusesASideThatDoesNotDivide(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
_, err := NewGrid(p, 4, 4*13, 8) // 13 geology cells does not divide 64
|
||||
if err == nil {
|
||||
t.Fatal("accepted a tile side that does not divide the circumference")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "X wraps") {
|
||||
t.Errorf("error %q does not say why", err)
|
||||
}
|
||||
if _, err := NewGrid(p, 4, 66, 8); err == nil {
|
||||
t.Fatal("accepted a tile side that is not a whole number of geology cells")
|
||||
}
|
||||
}
|
||||
|
||||
// The last row is short rather than padded: padding would put invented ground in the output.
|
||||
func TestTilesCoverThePaintedRowsExactly(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
g, err := NewGrid(p, 4, 64, 8)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := make([]int, p.W*p.PaintH())
|
||||
for _, tl := range g.Tiles() {
|
||||
for y := tl.Y0; y < tl.Y0+tl.H; y++ {
|
||||
for x := tl.X0; x < tl.X0+tl.W; x++ {
|
||||
seen[y*p.W+x]++
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, n := range seen {
|
||||
if n != 1 {
|
||||
t.Fatalf("cell %d covered %d times, want exactly 1", i, n)
|
||||
}
|
||||
}
|
||||
last := g.Tiles()[len(g.Tiles())-1]
|
||||
if last.H != p.PaintH()-2*g.SideGeo {
|
||||
t.Errorf("the last tile row is %d cells, want %d", last.H, p.PaintH()-2*g.SideGeo)
|
||||
}
|
||||
}
|
||||
|
||||
// A tile at the seam reads its left margin from the far side of the map, and one at a pole clamps rather
|
||||
// than wrapping: the top and bottom of the map are the poles, not each other.
|
||||
func TestCutWrapsInXAndClampsInY(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
g, err := NewGrid(p, 4, 64, 8)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A source whose value encodes its own position, so a misplaced read is obvious.
|
||||
src := field.New(p.W, p.PaintH(), p.CellM)
|
||||
for y := 0; y < src.H; y++ {
|
||||
for x := 0; x < src.W; x++ {
|
||||
src.Data[y*src.W+x] = float32(y*1000 + x)
|
||||
}
|
||||
}
|
||||
|
||||
first := g.Tiles()[0] // X0 = 0, Y0 = 0: both edges
|
||||
out, ix, iy := g.Cut(first, src, 0)
|
||||
if ix != g.MarginGeo*g.Factor || iy != g.MarginGeo*g.Factor {
|
||||
t.Errorf("interior offset %d,%d, want %d", ix, iy, g.MarginGeo*g.Factor)
|
||||
}
|
||||
// The left margin is the far side of the cylinder.
|
||||
if got, want := out.Data[g.MarginGeo*out.W+0], float32(0*1000+p.W-g.MarginGeo); got != want {
|
||||
t.Errorf("left margin reads %v, want %v (the columns across the seam)", got, want)
|
||||
}
|
||||
// The top margin is row 0 repeated, not the bottom of the map.
|
||||
if got, want := out.Data[0*out.W+g.MarginGeo], float32(0*1000+0); got != want {
|
||||
t.Errorf("top margin reads %v, want %v (row 0 clamped)", got, want)
|
||||
}
|
||||
// And the interior is itself.
|
||||
if got, want := out.Data[g.MarginGeo*out.W+g.MarginGeo], float32(0); got != want {
|
||||
t.Errorf("interior corner reads %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The frame is what every hash and noise lattice in the detail passes is keyed on, so it has to name the
|
||||
// right physical place at detail resolution.
|
||||
func TestFrameIsTheDetailWorldPosition(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
g, err := NewGrid(p, 4, 64, 8)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tiles := g.Tiles()
|
||||
second := tiles[1] // X0 = 16 geology cells
|
||||
|
||||
f := g.Frame(second)
|
||||
if f.P.CellM != 2 {
|
||||
t.Errorf("frame cell = %v m, want 2", f.P.CellM)
|
||||
}
|
||||
if f.P.W != p.W*4 {
|
||||
t.Errorf("frame planet width = %d, want %d detail columns", f.P.W, p.W*4)
|
||||
}
|
||||
// The cut starts a margin before the interior: (16 - 2) geology cells is 56 detail columns.
|
||||
if f.X0 != (16-g.MarginGeo)*4 {
|
||||
t.Errorf("frame X0 = %d, want %d", f.X0, (16-g.MarginGeo)*4)
|
||||
}
|
||||
// The interior's first detail column is the margin in, and it must name geology column 16.
|
||||
wx, _ := f.PlanetXY(g.MarginGeo*g.Factor, 0)
|
||||
if wx != 16*4 {
|
||||
t.Errorf("the interior's first column is detail column %d, want %d", wx, 16*4)
|
||||
}
|
||||
if got, want := g.OriginXM(second), 16*8.0; got != want {
|
||||
t.Errorf("OriginXM = %v, want %v", got, want)
|
||||
}
|
||||
// Row 0 of the painted map is world Y zero; the polar pad is behind it.
|
||||
if got := g.OriginYM(tiles[0]); got != 0 {
|
||||
t.Errorf("OriginYM of the first tile row = %v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A frame that straddles the seam names the same physical columns as one that does not.
|
||||
func TestASeamTileNamesTheSameColumns(t *testing.T) {
|
||||
p := testPlanet(t)
|
||||
g, err := NewGrid(p, 4, 64, 8)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := g.Tiles()[0] // X0 = 0, so its left margin is across the seam
|
||||
f := g.Frame(first)
|
||||
// Detail column 0 of the cut is (0 - margin) geology cells, wrapped.
|
||||
wx, _ := f.PlanetXY(0, 0)
|
||||
if want := (p.W - g.MarginGeo) * 4; wx != want {
|
||||
t.Errorf("the cut's first column is detail column %d, want %d across the seam", wx, want)
|
||||
}
|
||||
// And the interior's first column is detail column 0.
|
||||
wx, _ = f.PlanetXY(g.MarginGeo*g.Factor, 0)
|
||||
if wx != 0 {
|
||||
t.Errorf("the interior's first column is %d, want 0", wx)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user