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

211 lines
7.0 KiB
Go

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