This commit is contained in:
Rainer Leit
2026-09-25 17:02:24 +03:00
parent cc43ed8dc8
commit 9597629951
2149 changed files with 460234 additions and 1770 deletions
+17 -7
View File
@@ -20,7 +20,8 @@ import (
type DataMapOptions struct {
// Sea marks cells to render as flat water rather than data. Optional.
Sea []bool
// Size is the output side in pixels; the field is point-sampled down to it.
// Size is the output width in pixels; the field is point-sampled down to it and the height follows the
// field's own aspect.
Size int
// Log renders log10 of the value, for anything with a heavy tail — drainage area spans seven decades and
// is unreadable linearly.
@@ -64,9 +65,10 @@ func WriteDataMap(path string, f *Field, opt DataMapOptions) error {
span = 1
}
img := image.NewRGBA(image.Rect(0, 0, size, size))
for y := 0; y < size; y++ {
sy := y * f.H / size
sizeH := aspectH(f, size)
img := image.NewRGBA(image.Rect(0, 0, size, sizeH))
for y := 0; y < sizeH; y++ {
sy := y * f.H / sizeH
for x := 0; x < size; x++ {
sx := x * f.W / size
i := sy*f.W + sx
@@ -123,9 +125,13 @@ func WriteBasinMap(path string, w, h int, receiver []int32, sea []bool, size int
}
}
img := image.NewRGBA(image.Rect(0, 0, size, size))
for y := 0; y < size; y++ {
sy := y * h / size
sizeH := int(float64(size)*float64(h)/float64(w) + 0.5)
if sizeH < 1 {
sizeH = 1
}
img := image.NewRGBA(image.Rect(0, 0, size, sizeH))
for y := 0; y < sizeH; y++ {
sy := y * h / sizeH
for x := 0; x < size; x++ {
sx := x * w / size
i := sy*w + sx
@@ -191,6 +197,10 @@ func sampleStops(s [][3]float64, t float64) [3]float64 {
return [3]float64{a[0] + (b[0]-a[0])*u, a[1] + (b[1]-a[1])*u, a[2] + (b[2]-a[2])*u}
}
// HSV is exported because the region map colours its regions the same way the basin map colours its basins:
// a hash of the id straight to a hue, so neighbours get unrelated colours and a boundary is a hard edge.
func HSV(hue, sat, val float64) [3]float64 { return hsv(hue, sat, val) }
func hsv(hue, sat, val float64) [3]float64 {
h6 := hue * 6
i := int(h6)
+95 -10
View File
@@ -176,26 +176,111 @@ func (f *Field) Blur(passes int) *Field {
// starts and each writes only into its own, so the output is identical at any GOMAXPROCS. Every parallel
// loop in the generator goes through here; none spawns goroutines of its own.
func Rows(h int, fn func(y0, y1 int)) {
workers := runtime.GOMAXPROCS(0)
if workers > h {
workers = h
}
if workers <= 1 {
fn(0, h)
RowsIndexed(h, func(_, y0, y1 int) { fn(y0, y1) })
}
// RowsIndexed is Rows with the band number, which is what a parallel loop needs when it has to reduce
// something rather than only write into its own rows: it gives each goroutine a pre-allocated indexed
// slot to accumulate into, so the reduction can be replayed in band order afterwards instead of
// depending on which goroutine finished first. Size the slots with BandCount.
func RowsIndexed(h int, fn func(band, y0, y1 int)) {
step := rowStep(h)
if step >= h {
fn(0, 0, h)
return
}
var wg sync.WaitGroup
step := (h + workers - 1) / workers
band := 0
for y0 := 0; y0 < h; y0 += step {
y1 := y0 + step
if y1 > h {
y1 = h
}
wg.Add(1)
go func(a, b int) {
go func(k, a, b int) {
defer wg.Done()
fn(a, b)
}(y0, y1)
fn(k, a, b)
}(band, y0, y1)
band++
}
wg.Wait()
}
// FixedBands is RowsIndexed with a partition that does not depend on the core count: bands of exactly rows
// rows, run by however many workers there are.
//
// It exists for one reason. A parallel loop that only writes into its own rows can be partitioned any way at
// all, which is what Rows does. A loop that *reduces* into overlapping buffers cannot: floating-point addition
// is not associative, so summing a cell's contributions in a different grouping gives a different last bit,
// and the result would depend on GOMAXPROCS. The particle pass is that loop. Fix the partition and the
// arithmetic is fixed with it.
func FixedBands(h, rows int, fn func(band, y0, y1 int)) {
if rows < 1 {
rows = 1
}
n := FixedBandCount(h, rows)
workers := runtime.GOMAXPROCS(0)
if workers > n {
workers = n
}
if workers <= 1 {
for b := 0; b < n; b++ {
y0 := b * rows
y1 := min(y0+rows, h)
fn(b, y0, y1)
}
return
}
next := make(chan int)
go func() {
for b := 0; b < n; b++ {
next <- b
}
close(next)
}()
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for b := range next {
y0 := b * rows
y1 := min(y0+rows, h)
fn(b, y0, y1)
}
}()
}
wg.Wait()
}
// FixedBandCount is how many bands FixedBands will make.
func FixedBandCount(h, rows int) int {
if rows < 1 {
rows = 1
}
if h <= 0 {
return 0
}
return (h + rows - 1) / rows
}
// BandCount is how many ranges Rows and RowsIndexed split h into. It is fixed by h and GOMAXPROCS, so it
// can be called to size a reduction before the loop starts.
func BandCount(h int) int {
step := rowStep(h)
if step >= h {
return 1
}
return (h + step - 1) / step
}
func rowStep(h int) int {
workers := runtime.GOMAXPROCS(0)
if workers > h {
workers = h
}
if workers <= 1 {
return h
}
return (h + workers - 1) / workers
}
+271
View File
@@ -0,0 +1,271 @@
package field
import (
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
)
// Palette is how a preview is drawn: the hypsometric ramp, the water, the rivers, the ice and the light.
//
// It is a file rather than a set of constants because it is the one part of a bake that is purely a matter of
// taste, and taste is the thing most likely to want swapping. Nothing in it changes a height; a palette is
// read only by WritePreview, and two bakes of the same world under two palettes are the same terrain.
//
// The zero value is not usable - use DefaultPalette, which holds the numbers the generator shipped with.
type Palette struct {
// LandStops is the hypsometric ramp, from sea level at t = 0 to the top of the land at t = 1. Stops must
// be in ascending t; the ends are clamped rather than extrapolated.
LandStops []Stop `json:"land_stops"`
SeaShallow RGB `json:"sea_shallow"`
SeaDeep RGB `json:"sea_deep"`
River RGB `json:"river"`
// Ice is drawn wherever the snow mask says so, whatever height the ground is. Pure white is a poor
// choice: it has nowhere left to go under the hillshade, so an ice sheet comes out as a flat cut-out
// with no shape in it.
Ice RGB `json:"ice"`
// LandTopPercentile is where the ramp's top is taken from, over land elevations. Not the maximum: one
// 2800 m summit over a continent whose land is mostly under 300 m puts every other cell in the bottom
// tenth of the ramp, and the map then says far more about one pixel than about the terrain.
LandTopPercentile float64 `json:"land_top_percentile"`
// LandTopM puts the top of the ramp at a fixed height instead, in metres above sea level. Zero keeps
// the percentile, which is what every preview did before this existed.
//
// It is here because a relative ramp is a picture that lies about scale, and it lies hardest exactly
// where it matters. A lowland continent 47 m high, drawn against its own 99.5th percentile, gets the
// whole ramp - green, tan, bare rock and snow - so its 40 m hills come out with the same white caps a
// 2800 m range would, and a plain whose median slope is 0.6 degrees reads as an alpine massif. That was
// measured on this planet's central landmass and it is the most misleading thing the generator draws.
//
// The percentile stays the default all the same, because the alternative fails the other way: an
// absolute ramp over a world with no mountains is a flat green shape with nothing legible on it, and
// judging "is there drainage here" needs the contrast. What is added is the *choice*, plus a line in
// the run summary saying which ceiling a picture was drawn against - a relative picture is fine as long
// as nobody reads it as an absolute one.
LandTopM float64 `json:"land_top_m"`
// The light. Azimuth is degrees clockwise from north and altitude is degrees above the horizon; the
// north-west at 45 degrees is the convention every DEM hillshade uses and is what these default to.
SunAzimuthDeg float64 `json:"sun_azimuth_deg"`
SunAltitudeDeg float64 `json:"sun_altitude_deg"`
// Ambient is how lit the fully shaded side is and Gain how much the lit side brightens. Ambient at zero
// makes a shadow a hole.
Ambient float64 `json:"ambient"`
Gain float64 `json:"gain"`
}
// Stop is one entry in the hypsometric ramp.
type Stop struct {
T float64 `json:"t"`
RGB RGB `json:"rgb"`
}
// RGB is a colour in 0..255, kept as float64 so the hillshade can multiply it before it is clamped.
type RGB [3]float64
func (c RGB) String() string { return fmt.Sprintf("[%s, %s, %s]", num(c[0]), num(c[1]), num(c[2])) }
// num prints a float without trailing zeros, so a palette reads as numbers rather than as measurements.
func num(v float64) string { return strconv.FormatFloat(v, 'g', -1, 64) }
// DefaultPalette is what the generator ships with: salt-marsh green at sea level through farmland and rock to
// snow, with the stops chosen so the lowland does not read as one flat colour - which is where most of a map
// is, and where a badly chosen ramp hides everything.
func DefaultPalette() *Palette {
return &Palette{
LandStops: []Stop{
{0.00, RGB{72, 106, 68}},
{0.08, RGB{104, 132, 74}},
{0.20, RGB{142, 152, 88}},
{0.38, RGB{164, 148, 104}},
{0.58, RGB{150, 128, 106}},
{0.75, RGB{138, 130, 128}},
{0.88, RGB{176, 174, 174}},
{1.00, RGB{246, 246, 250}},
},
SeaShallow: RGB{56, 104, 136},
SeaDeep: RGB{18, 40, 72},
River: RGB{70, 132, 180},
Ice: RGB{232, 238, 245},
LandTopPercentile: 99.5,
SunAzimuthDeg: 315, // north-west
SunAltitudeDeg: 45,
Ambient: 0.45,
Gain: 0.75,
}
}
// LoadPalette reads a palette, filling anything the file leaves out from the default. Unknown keys are an
// error: a misspelt colour that silently keeps the default is a palette that does not do what it says.
func LoadPalette(path string) (*Palette, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
clean, err := StripJSONComments(raw)
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
p := DefaultPalette()
dec := json.NewDecoder(strings.NewReader(string(clean)))
dec.DisallowUnknownFields()
if err := dec.Decode(p); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
if err := p.Validate(); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return p, nil
}
// Validate refuses a palette that would draw nonsense.
func (p *Palette) Validate() error {
if len(p.LandStops) < 2 {
return fmt.Errorf("land_stops needs at least two entries, got %d", len(p.LandStops))
}
for i, s := range p.LandStops {
if i > 0 && s.T <= p.LandStops[i-1].T {
return fmt.Errorf("land_stops must ascend: stop %d is at t %.3f, after %.3f",
i, s.T, p.LandStops[i-1].T)
}
for k, v := range s.RGB {
if v < 0 || v > 255 {
return fmt.Errorf("land_stops[%d].rgb[%d] is %v, outside 0..255", i, k, v)
}
}
}
if p.LandTopPercentile <= 0 || p.LandTopPercentile > 100 {
return fmt.Errorf("land_top_percentile is %v, outside 0..100", p.LandTopPercentile)
}
if p.LandTopM < 0 {
return fmt.Errorf("land_top_m is %v; it is metres above sea level, so positive, or zero to use "+
"land_top_percentile instead", p.LandTopM)
}
if p.SunAltitudeDeg <= 0 || p.SunAltitudeDeg >= 90 {
return fmt.Errorf("sun_altitude_deg is %v; it is degrees above the horizon", p.SunAltitudeDeg)
}
if p.Ambient < 0 || p.Ambient > 1 {
return fmt.Errorf("ambient is %v, outside 0..1", p.Ambient)
}
if p.Gain < 0 {
return fmt.Errorf("gain is %v", p.Gain)
}
return nil
}
// ramp samples the hypsometric stops, clamped at both ends.
func (p *Palette) ramp(t float64) RGB {
if t <= p.LandStops[0].T {
return p.LandStops[0].RGB
}
for i := 1; i < len(p.LandStops); i++ {
if t <= p.LandStops[i].T {
a, b := p.LandStops[i-1], p.LandStops[i]
u := (t - a.T) / (b.T - a.T)
return RGB{
a.RGB[0] + (b.RGB[0]-a.RGB[0])*u,
a.RGB[1] + (b.RGB[1]-a.RGB[1])*u,
a.RGB[2] + (b.RGB[2]-a.RGB[2])*u,
}
}
}
return p.LandStops[len(p.LandStops)-1].RGB
}
// Write saves a palette as something a person can read and edit.
//
// Hand-formatted rather than through MarshalIndent, and that is not stubbornness: MarshalIndent re-indents
// whatever a custom marshaler returns, so there is no way to keep a colour on one line through it, and its
// default puts every channel of every stop on a line of its own - sixty lines for eight stops, a table whose
// shape is invisible. The comments are the other half: this is a file somebody opens to change one number,
// and it should say what the numbers are.
func (p *Palette) Write(path string) error {
var b strings.Builder
line := func(format string, a ...any) { fmt.Fprintf(&b, format+"\n", a...) }
line("{")
line(` "_comment": "How a preview is drawn. Nothing here changes a height - two bakes of the same ` +
`world under two palettes are the same terrain. Point a planet manifest at this file with ` +
`\"palette\": \"<path relative to the manifest>\"; leave it out and these numbers are used ` +
`anyway. Keys beginning with an underscore are comments.",`)
line("")
line(` "_comment_land_stops": "The hypsometric ramp: sea level at t 0 to the top of the land at t 1. ` +
`The top is a percentile rather than the maximum, so one high summit cannot push a whole continent ` +
`into the bottom of the ramp.",`)
line(` "land_stops": [`)
for i, s := range p.LandStops {
comma := ","
if i == len(p.LandStops)-1 {
comma = ""
}
line(` { "t": %-6s "rgb": %s }%s`, num(s.T)+",", s.RGB, comma)
}
line(" ],")
line("")
line(` "sea_shallow": %s,`, p.SeaShallow)
line(` "sea_deep": %s,`, p.SeaDeep)
line(` "river": %s,`, p.River)
line(` "_comment_ice": "Drawn wherever a class is marked snow, whatever height the ground stands at. ` +
`Not pure white: white has nowhere left to go under the hillshade, so an ice sheet comes out as a ` +
`flat cut-out with no shape in it at all.",`)
line(` "ice": %s,`, p.Ice)
line("")
line(` "_comment_top": "Where the top of the hypsometric ramp sits. The percentile is relative to the ` +
`world being drawn, which is the only way a low continent is legible at all and is also a picture ` +
`that lies about scale: a 47 m lowland gets the same rock and snow a 2800 m range would. Set ` +
`land_top_m to a height in metres for an absolute ramp instead; the run summary says which ceiling ` +
`every preview was drawn against.",`)
line(` "land_top_percentile": %s,`, num(p.LandTopPercentile))
line(` "land_top_m": %s,`, num(p.LandTopM))
line("")
line(` "_comment_light": "Azimuth is degrees clockwise from north and altitude degrees above the ` +
`horizon; north-west at 45 is what every DEM hillshade uses. Ambient is how lit the shaded side ` +
`is - at zero a shadow is a hole - and gain how much the lit side brightens.",`)
line(` "sun_azimuth_deg": %s,`, num(p.SunAzimuthDeg))
line(` "sun_altitude_deg": %s,`, num(p.SunAltitudeDeg))
line(` "ambient": %s,`, num(p.Ambient))
line(` "gain": %s`, num(p.Gain))
line("}")
return os.WriteFile(path, []byte(b.String()), 0o644)
}
// StripJSONComments removes every object key beginning with an underscore, at any depth.
//
// Every manifest in this repository carries its commentary that way, and a loader that refuses unknown
// fields - which both the legend and the palette do, because a misspelt key silently ignored is a setting
// that does not do what it says - has to let them through.
func StripJSONComments(data []byte) ([]byte, error) {
var v any
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
}
return json.Marshal(stripUnderscored(v))
}
func stripUnderscored(v any) any {
switch t := v.(type) {
case map[string]any:
out := make(map[string]any, len(t))
for k, val := range t {
if strings.HasPrefix(k, "_") {
continue
}
out[k] = stripUnderscored(val)
}
return out
case []any:
for i := range t {
t[i] = stripUnderscored(t[i])
}
return t
default:
return v
}
}
+89 -4
View File
@@ -7,6 +7,7 @@ import (
"image"
"image/png"
"io"
"math"
"os"
"path/filepath"
)
@@ -45,6 +46,48 @@ func WriteGray8(path string, w, h int, values []uint8, level png.CompressionLeve
return encode(path, img, level)
}
// WriteRGB writes tightly packed 8-bit RGB, three bytes a pixel. It is what a categorical map wants: a class
// raster has no ramp to run through a palette, only a colour per class.
// WriteRGBA writes colour with a separate alpha plane, which is what an overlay sheet is: strokes on a
// transparent background, where blank is decided by alpha rather than by a reserved colour.
//
// Non-premultiplied (NRGBA), deliberately. A mark's colour has to come back out of the file exactly as it
// went in, because the classifier reads exact colours against a tolerance; premultiplying would scale every
// channel by the alpha and round on the way, and a mark would classify as something else or as nothing.
func WriteRGBA(path string, w, h int, px []uint8, alpha []uint8, level png.CompressionLevel) error {
if len(px) != w*h*3 {
return fmt.Errorf("%s: %d bytes for a %dx%d RGB image", path, len(px), w, h)
}
if len(alpha) != w*h {
return fmt.Errorf("%s: %d alpha bytes for a %dx%d image", path, len(alpha), w, h)
}
img := image.NewNRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
row := img.Pix[y*img.Stride:]
src := px[y*w*3:]
a := alpha[y*w:]
for x := 0; x < w; x++ {
row[x*4], row[x*4+1], row[x*4+2], row[x*4+3] = src[x*3], src[x*3+1], src[x*3+2], a[x]
}
}
return encode(path, img, level)
}
func WriteRGB(path string, w, h int, px []uint8, level png.CompressionLevel) error {
if len(px) != w*h*3 {
return fmt.Errorf("%s: %d bytes for a %dx%d RGB image", path, len(px), w, h)
}
img := image.NewNRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
row := img.Pix[y*img.Stride:]
src := px[y*w*3:]
for x := 0; x < w; x++ {
row[x*4], row[x*4+1], row[x*4+2], row[x*4+3] = src[x*3], src[x*3+1], src[x*3+2], 255
}
}
return encode(path, img, level)
}
func encode(path string, img image.Image, level png.CompressionLevel) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
@@ -163,18 +206,60 @@ func isqrt(n int) int {
return r
}
// WriteHillshade writes an 8-bit relief shade of a height field, at any scale.
//
// It is not WriteThumbnail with a bigger number, and the difference is the whole reason it exists.
// WriteThumbnail's shading term is a raw gradient over the cell size, which is fine on a 512-pixel picture of
// a whole map where the gradients are small, and saturates to pure black and white the moment it is used on
// real ground at two metres a cell. The result reads as flat-topped terraces with hard edges - a mountainside
// rendered as a staircase - and it is convincing enough to be mistaken for a defect in the terrain. It was.
//
// This is the standard DEM hillshade instead: the surface normal against a light from the north-west at 45
// degrees, which is bounded by construction and says the same thing at any cell size.
func WriteHillshade(path string, h *Field, size int, exaggeration float64) error {
sizeH := aspectH(h, size)
small := h
if size != h.W || sizeH != h.H {
small = h.Resample(size, sizeH)
}
exag := exaggeration
if exag <= 0 {
exag = 1
}
px := make([]uint8, size*sizeH)
Rows(sizeH, func(y0, y1 int) {
for y := y0; y < y1; y++ {
for x := 0; x < size; x++ {
gx := float64(small.AtClamped(x+1, y)-small.AtClamped(x-1, y)) * exag
gy := float64(small.AtClamped(x, y+1)-small.AtClamped(x, y-1)) * exag
slope := math.Atan(math.Hypot(gx, gy) / (2 * small.CellM))
aspect := math.Atan2(gy, -gx)
lum := math.Cos(slope)*math.Cos(math.Pi/4) +
math.Sin(slope)*math.Sin(math.Pi/4)*math.Cos(3*math.Pi/4-aspect)
v := 0.25 + 0.75*math.Max(0, lum)
if v > 1 {
v = 1
}
px[y*size+x] = uint8(v * 255)
}
}
})
return WriteGray8(path, size, sizeH, px, png.BestSpeed)
}
// WriteThumbnail writes a small 8-bit preview of a height field, hillshaded so the drainage is actually
// visible: a flat grey ramp hides exactly the thing this generator exists to produce.
func WriteThumbnail(path string, h *Field, size int) error {
small := h.Resample(size, size)
sizeH := aspectH(h, size)
small := h.Resample(size, sizeH)
lo, hi := small.MinMax()
span := float64(hi - lo)
if span < 1e-6 {
span = 1
}
// Light from the north-west at 45 degrees, the convention every DEM hillshade uses.
px := make([]uint8, size*size)
for y := 0; y < size; y++ {
px := make([]uint8, size*sizeH)
for y := 0; y < sizeH; y++ {
for x := 0; x < size; x++ {
gx := float64(small.AtClamped(x+1, y) - small.AtClamped(x-1, y))
gy := float64(small.AtClamped(x, y+1) - small.AtClamped(x, y-1))
@@ -189,7 +274,7 @@ func WriteThumbnail(path string, h *Field, size int) error {
px[y*size+x] = uint8(lum * 255)
}
}
return WriteGray8(path, size, size, px, png.BestSpeed)
return WriteGray8(path, size, sizeH, px, png.BestSpeed)
}
var _ io.Writer = (*bufio.Writer)(nil)
+78 -71
View File
@@ -24,65 +24,39 @@ type PreviewOptions struct {
// Sea marks cells below sea level. Optional.
Sea []bool
SeaLevelM float64
// Snow marks land that is permanently under ice. Optional, and it exists because the hypsometric ramp
// tops out at snow by *elevation*: a polar cap fifty metres above the water therefore comes out the same
// green as a meadow, and an ice sheet that reads as a meadow is a map lying about the one thing it is
// for. No height is touched; only the colour.
Snow []bool
// RiverKm2 is the drainage area at which a channel starts being drawn.
RiverKm2 float64
Size int
// Size is the output width in pixels. The height follows the field's own aspect, so a 2:1 planet comes
// out 2:1 rather than squashed into a square; on the square canvas the two are the same number and
// nothing changes.
Size int
// Crop is a sub-rectangle in map coordinates (x0, y0, x1, y1 in 0..1), rendered at full resolution.
// A whole continent at 1500 px puts ten kilometres into a hundred pixels, which is enough to see that
// there is drainage and not nearly enough to see whether it is the right *kind* of drainage. Judging
// hill country against real hill country needs a crop.
Crop [4]float64
// Palette is how the picture is drawn: the ramp, the water, the rivers, the ice and the light. Nil is
// the generator's own, which is what every caller wanted before this was a file.
Palette *Palette
// Hillshade exaggerates the vertical before shading. Lowland relief is a few tens of metres over
// kilometres and disappears at true scale, which is the same reason every printed relief map lies.
Exaggeration float64
}
// rgb is a colour in 0..255 kept as float64 so the hillshade can multiply it before it is clamped.
type rgb = [3]float64
type stop struct {
t float64
c rgb
}
var (
// A hypsometric ramp: salt-marsh green at sea level through farmland and rock to snow. Stops are chosen
// so the lowland does not read as one flat colour, which is where most of the map is.
landStops = []stop{
{0.00, rgb{72, 106, 68}},
{0.08, rgb{104, 132, 74}},
{0.20, rgb{142, 152, 88}},
{0.38, rgb{164, 148, 104}},
{0.58, rgb{150, 128, 106}},
{0.75, rgb{138, 130, 128}},
{0.88, rgb{176, 174, 174}},
{1.00, rgb{246, 246, 250}},
}
seaShallow = rgb{56, 104, 136}
seaDeep = rgb{18, 40, 72}
riverTint = rgb{70, 132, 180}
)
func ramp(t float64) rgb {
if t <= 0 {
return landStops[0].c
}
for i := 1; i < len(landStops); i++ {
if t <= landStops[i].t {
a, b := landStops[i-1], landStops[i]
u := (t - a.t) / (b.t - a.t)
return rgb{
a.c[0] + (b.c[0]-a.c[0])*u,
a.c[1] + (b.c[1]-a.c[1])*u,
a.c[2] + (b.c[2]-a.c[2])*u,
}
}
}
return landStops[len(landStops)-1].c
}
// rgb is the palette's colour type under the name the drawing code uses.
type rgb = RGB
// WritePreview renders the field at opt.Size and writes an RGB PNG.
func WritePreview(path string, h *Field, opt PreviewOptions) error {
//
// It returns the height the hypsometric ramp topped out at, in metres, which a caller is expected to print.
// The ramp is relative by default and a relative picture is only honest when the reader is told so: without
// that line, a 47 m plain drawn with snow on its hills is indistinguishable from an alpine one.
func WritePreview(path string, h *Field, opt PreviewOptions) (topM float64, err error) {
size := opt.Size
if size <= 0 {
size = 1024
@@ -103,7 +77,8 @@ func WritePreview(path string, h *Field, opt PreviewOptions) error {
size = h.W
}
}
small := h.Resample(size, size)
sizeH := aspectH(h, size)
small := h.Resample(size, sizeH)
exag := opt.Exaggeration
if exag <= 0 {
exag = 1
@@ -116,7 +91,12 @@ func WritePreview(path string, h *Field, opt PreviewOptions) error {
// uniform green with a white dot on it — which says far more about one pixel than about the terrain. The
// percentile lets the tint span the distribution that is actually there; the few cells above it clamp to
// snow, which is what they should look like anyway.
sea := resampleMask(opt.Sea, h.W, h.H, size)
pal := opt.Palette
if pal == nil {
pal = DefaultPalette()
}
sea := resampleMask(opt.Sea, h.W, h.H, size, sizeH)
snow := resampleMask(opt.Snow, h.W, h.H, size, sizeH)
landVals := make([]float64, 0, len(small.Data))
for i, v := range small.Data {
if sea != nil && sea[i] {
@@ -125,9 +105,11 @@ func WritePreview(path string, h *Field, opt PreviewOptions) error {
landVals = append(landVals, float64(v))
}
landMax := 1.0
if len(landVals) > 0 {
if pal.LandTopM > 0 {
landMax = pal.LandTopM
} else if len(landVals) > 0 {
sort.Float64s(landVals)
landMax = landVals[int(0.995*float64(len(landVals)-1))]
landMax = landVals[int(pal.LandTopPercentile/100*float64(len(landVals)-1))]
}
if landMax <= 0 {
landMax = 1
@@ -142,11 +124,11 @@ func WritePreview(path string, h *Field, opt PreviewOptions) error {
var flow *Field
riverA := opt.RiverKm2 * 1e6
if opt.Flow != nil && riverA > 0 {
flow = opt.Flow.Resample(size, size)
flow = opt.Flow.Resample(size, sizeH)
}
img := image.NewRGBA(image.Rect(0, 0, size, size))
for y := 0; y < size; y++ {
img := image.NewRGBA(image.Rect(0, 0, size, sizeH))
for y := 0; y < sizeH; y++ {
for x := 0; x < size; x++ {
i := y*size + x
elev := float64(small.Data[i])
@@ -158,21 +140,30 @@ func WritePreview(path string, h *Field, opt PreviewOptions) error {
d = math.Min(1, (opt.SeaLevelM-elev)/(opt.SeaLevelM-seaMin))
}
c = rgb{
seaShallow[0] + (seaDeep[0]-seaShallow[0])*d,
seaShallow[1] + (seaDeep[1]-seaShallow[1])*d,
seaShallow[2] + (seaDeep[2]-seaShallow[2])*d,
pal.SeaShallow[0] + (pal.SeaDeep[0]-pal.SeaShallow[0])*d,
pal.SeaShallow[1] + (pal.SeaDeep[1]-pal.SeaShallow[1])*d,
pal.SeaShallow[2] + (pal.SeaDeep[2]-pal.SeaShallow[2])*d,
}
} else {
c = ramp(math.Min(1, math.Max(0, elev)/landMax))
c = pal.ramp(math.Min(1, math.Max(0, elev)/landMax))
if snow != nil && snow[i] {
// Ice, whatever height it stands at. It still takes the hillshade below rather than
// being stamped flat, so a dome and the valleys cut into it still read.
c = pal.Ice
}
// Hillshade from the north-west at 45 degrees, the DEM convention. Applied to land only;
// shading the sea floor would draw attention to bathymetry nobody will ever see.
gx := float64(small.AtClamped(x+1, y)-small.AtClamped(x-1, y)) * exag
gy := float64(small.AtClamped(x, y+1)-small.AtClamped(x, y-1)) * exag
slope := math.Atan(math.Hypot(gx, gy) / (2 * small.CellM))
aspect := math.Atan2(gy, -gx)
lum := math.Cos(slope)*math.Cos(math.Pi/4) +
math.Sin(slope)*math.Sin(math.Pi/4)*math.Cos(3*math.Pi/4-aspect)
lum = 0.45 + 0.75*math.Max(0, lum)
alt := pal.SunAltitudeDeg * math.Pi / 180
// Azimuth is clockwise from north; the shading wants the direction the light comes *from*
// measured the way Atan2 returns it, which is this quarter turn away.
az := (90 - pal.SunAzimuthDeg) * math.Pi / 180
lum := math.Cos(slope)*math.Sin(alt) +
math.Sin(slope)*math.Cos(alt)*math.Cos(az-aspect)
lum = pal.Ambient + pal.Gain*math.Max(0, lum)
for k := range c {
c[k] *= lum
}
@@ -185,7 +176,7 @@ func WritePreview(path string, h *Field, opt PreviewOptions) error {
w := math.Min(1, math.Log10(a/riverA)/2.2)
blend := 0.45 + 0.55*w
for k := range c {
c[k] = c[k]*(1-blend) + riverTint[k]*blend
c[k] = c[k]*(1-blend) + pal.River[k]*blend
}
}
}
@@ -195,38 +186,54 @@ func WritePreview(path string, h *Field, opt PreviewOptions) error {
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
return landMax, err
}
f, err := os.Create(path)
if err != nil {
return err
return landMax, err
}
defer f.Close()
bw := bufio.NewWriterSize(f, 1<<20)
enc := png.Encoder{CompressionLevel: png.DefaultCompression}
if err := enc.Encode(bw, img); err != nil {
return err
return landMax, err
}
return bw.Flush()
return landMax, bw.Flush()
}
// resampleMask takes a boolean mask down to the preview size by nearest neighbour; a mask has no meaningful
// average.
func resampleMask(mask []bool, w, h, size int) []bool {
func resampleMask(mask []bool, w, h, sw, sh int) []bool {
if mask == nil {
return nil
}
out := make([]bool, size*size)
for y := 0; y < size; y++ {
sy := y * (h - 1) / (size - 1)
for x := 0; x < size; x++ {
sx := x * (w - 1) / (size - 1)
out[y*size+x] = mask[sy*w+sx]
out := make([]bool, sw*sh)
for y := 0; y < sh; y++ {
sy := 0
if sh > 1 {
sy = y * (h - 1) / (sh - 1)
}
for x := 0; x < sw; x++ {
sx := 0
if sw > 1 {
sx = x * (w - 1) / (sw - 1)
}
out[y*sw+x] = mask[sy*w+sx]
}
}
return out
}
// aspectH is the output height that keeps a field's shape. Every writer in this package uses it, so a
// rectangular world is never silently squashed into a square image.
func aspectH(f *Field, w int) int {
h := int(float64(w)*float64(f.H)/float64(f.W) + 0.5)
if h < 1 {
h = 1
}
return h
}
func clamp8(v float64) uint8 {
if v <= 0 {
return 0
@@ -0,0 +1,291 @@
package field
import (
"image"
"image/png"
"os"
"path/filepath"
"strings"
"testing"
)
// The hypsometric ramp tops out at snow by *elevation*, so an ice cap fifty metres above the water came out
// the same green as a meadow - a map lying about the one thing it is for. The snow mask fixes the colour and
// nothing else, and it must still take the hillshade rather than being stamped flat, or a dome and the
// valleys cut into it read as a white cut-out.
func TestSnowRendersAsIceAndStillTakesTheHillshade(t *testing.T) {
// The ramp's top is the 99.5th percentile of *land* elevation, so the ice cap only reads as meadow when
// there is real high ground on the map to set that percentile. A cap alone on an empty map is the highest
// thing there is and the ramp would call it snow anyway - which is how the first version of this test
// managed to pass for the wrong reason.
const w, h = 96, 64
f := New(w, h, 8)
sea := make([]bool, w*h)
snow := make([]bool, w*h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*w + x
d2 := float64((x-24)*(x-24) + (y-32)*(y-32))
v := 40 - d2/60 // a low ice dome on the left
if x > 56 {
// and a 700 m range on the right, which is what sets the top of the ramp
v = 700 - float64((x-76)*(x-76)+(y-32)*(y-32))*0.7
}
if v < 0 {
v = 0
sea[i] = true
}
f.Data[i] = float32(v)
snow[i] = !sea[i] && x <= 56
}
}
dir := t.TempDir()
plain := filepath.Join(dir, "plain.png")
iced := filepath.Join(dir, "iced.png")
opt := PreviewOptions{Sea: sea, SeaLevelM: 0, Size: w}
if _, err := WritePreview(plain, f, opt); err != nil {
t.Fatal(err)
}
opt.Snow = snow
if _, err := WritePreview(iced, f, opt); err != nil {
t.Fatal(err)
}
a, b := readRGBA(t, plain), readRGBA(t, iced)
cx, cy := 24, 32 // the ice dome's summit
pr, pg, pb, _ := a.At(cx, cy).RGBA()
sr, sg, sb, _ := b.At(cx, cy).RGBA()
t.Logf("land at the summit: plain rgb(%d,%d,%d), iced rgb(%d,%d,%d)",
pr>>8, pg>>8, pb>>8, sr>>8, sg>>8, sb>>8)
// Ice is much lighter than the ramp's low-ground green, and it is not green: blue is at least green.
if sr <= pr || sb <= pb {
t.Errorf("the iced summit is not lighter than the plain one")
}
if sb < sg {
t.Errorf("the ice reads green (b %d < g %d); it should be neutral to slightly blue", sb>>8, sg>>8)
}
// It still takes the hillshade: the lit and shaded flanks of the dome must differ.
lr, _, _, _ := b.At(cx-12, cy-12).RGBA() // north-west flank, towards the light
dr, _, _, _ := b.At(cx+12, cy+12).RGBA() // south-east flank, away from it
t.Logf("ice flanks: lit %d, shaded %d", lr>>8, dr>>8)
if lr <= dr {
t.Errorf("the ice is flat: lit flank %d against shaded %d, so it was stamped rather than shaded",
lr>>8, dr>>8)
}
// And the water is untouched. The probe has to be a cell that really is sea - the first version used the
// corner, which on this map is land, so it was comparing two ice pixels and calling the difference a bug.
sx, sy := -1, -1
for i, isSea := range sea {
if isSea {
sx, sy = i%w, i/w
break
}
}
if sx < 0 {
t.Fatal("the test terrain has no sea in it")
}
wr, wg, wb, _ := a.At(sx, sy).RGBA()
xr, xg, xb, _ := b.At(sx, sy).RGBA()
if wr != xr || wg != xg || wb != xb {
t.Errorf("the sea at %d,%d changed: rgb(%d,%d,%d) became rgb(%d,%d,%d); the mask should only touch land",
sx, sy, wr>>8, wg>>8, wb>>8, xr>>8, xg>>8, xb>>8)
}
}
func readRGBA(t *testing.T, path string) image.Image {
t.Helper()
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
img, err := png.Decode(f)
if err != nil {
t.Fatal(err)
}
return img
}
// A palette is a file somebody edits, so it has to survive the trip to disk and back unchanged - and it has
// to keep the comments the writer puts in, because a loader that refuses unknown keys would otherwise choke
// on its own output.
func TestPaletteRoundTripsThroughDiskWithItsComments(t *testing.T) {
path := filepath.Join(t.TempDir(), "p.json")
want := DefaultPalette()
if err := want.Write(path); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), "_comment") {
t.Error("the written palette carries no commentary")
}
got, err := LoadPalette(path)
if err != nil {
t.Fatalf("reading back what Write produced: %v", err)
}
if len(got.LandStops) != len(want.LandStops) {
t.Fatalf("%d stops, want %d", len(got.LandStops), len(want.LandStops))
}
for i := range want.LandStops {
if got.LandStops[i] != want.LandStops[i] {
t.Errorf("stop %d: %v, want %v", i, got.LandStops[i], want.LandStops[i])
}
}
if got.Ice != want.Ice || got.SeaDeep != want.SeaDeep || got.River != want.River {
t.Errorf("colours differ: %v %v %v", got.Ice, got.SeaDeep, got.River)
}
if got.SunAzimuthDeg != want.SunAzimuthDeg || got.Ambient != want.Ambient {
t.Errorf("light differs: %v %v", got.SunAzimuthDeg, got.Ambient)
}
}
// A palette fills what it leaves out from the default, so a two-line file is a valid one.
func TestAPartialPaletteKeepsTheDefaults(t *testing.T) {
path := filepath.Join(t.TempDir(), "p.json")
if err := os.WriteFile(path, []byte(`{"_why": "just the sea", "sea_deep": [1, 2, 3]}`), 0o644); err != nil {
t.Fatal(err)
}
got, err := LoadPalette(path)
if err != nil {
t.Fatal(err)
}
if got.SeaDeep != (RGB{1, 2, 3}) {
t.Errorf("sea_deep = %v, want the file's", got.SeaDeep)
}
if got.Ice != DefaultPalette().Ice {
t.Errorf("ice = %v, want the default", got.Ice)
}
}
// And a misspelt key is an error rather than a setting that silently does nothing.
func TestAMisspeltPaletteKeyIsRefused(t *testing.T) {
path := filepath.Join(t.TempDir(), "p.json")
if err := os.WriteFile(path, []byte(`{"sea_dep": [1,2,3]}`), 0o644); err != nil {
t.Fatal(err)
}
if _, err := LoadPalette(path); err == nil {
t.Fatal("accepted a misspelt key")
}
}
// The palette actually reaches the picture: swapping the sea colour changes the sea.
func TestThePaletteIsWhatGetsDrawn(t *testing.T) {
const w, h = 32, 32
f := New(w, h, 8)
sea := make([]bool, w*h)
for i := range sea {
sea[i] = i%w < w/2
if !sea[i] {
f.Data[i] = 50
}
}
dir := t.TempDir()
a := filepath.Join(dir, "a.png")
b := filepath.Join(dir, "b.png")
if _, err := WritePreview(a, f, PreviewOptions{Sea: sea, Size: w}); err != nil {
t.Fatal(err)
}
pal := DefaultPalette()
pal.SeaShallow, pal.SeaDeep = RGB{255, 0, 0}, RGB{255, 0, 0}
if _, err := WritePreview(b, f, PreviewOptions{Sea: sea, Size: w, Palette: pal}); err != nil {
t.Fatal(err)
}
ar, ag, ab, _ := readRGBA(t, a).At(2, 2).RGBA()
br, bg, bb, _ := readRGBA(t, b).At(2, 2).RGBA()
if br>>8 != 255 || bg>>8 != 0 || bb>>8 != 0 {
t.Errorf("the sea is rgb(%d,%d,%d), want the palette's red", br>>8, bg>>8, bb>>8)
}
if ar == br && ag == bg && ab == bb {
t.Error("the palette changed nothing")
}
}
// The ramp is relative by default and that is a picture which lies about scale: a lowland continent 47 m high
// gets the same rock and snow a 2800 m range would, because the top of the ramp is a percentile of whatever
// world it is drawing. land_top_m is the way out, and the point of the test is that the two differ.
func TestAnAbsoluteRampDrawsALowContinentAsLowGround(t *testing.T) {
const w = 96
f := New(w, w, 10)
sea := make([]bool, w*w)
for y := 0; y < w; y++ {
for x := 0; x < w; x++ {
i := y*w + x
dx, dy := float64(x-w/2)/float64(w/2), float64(y-w/2)/float64(w/2)
d := dx*dx + dy*dy
if d > 0.8 {
sea[i] = true
f.Data[i] = -50
continue
}
// A 40 m hill on a continent, which is a plain by any reading.
f.Data[i] = float32(40 * (1 - d/0.8))
}
}
dir := t.TempDir()
rel := filepath.Join(dir, "relative.png")
abs := filepath.Join(dir, "absolute.png")
top, err := WritePreview(rel, f, PreviewOptions{Sea: sea, Size: w})
if err != nil {
t.Fatal(err)
}
if top > 45 {
t.Fatalf("the relative ramp should top out near the highest land, about 40 m; got %.1f", top)
}
pal := DefaultPalette()
pal.LandTopM = 2000
top, err = WritePreview(abs, f, PreviewOptions{Sea: sea, Size: w, Palette: pal})
if err != nil {
t.Fatal(err)
}
if top != 2000 {
t.Fatalf("an absolute ramp tops out where it is told: got %.1f, want 2000", top)
}
// And the pictures differ: the summit is high on the ramp in one and at the bottom of it in the other.
relTop := brightestLand(t, rel, sea, w)
absTop := brightestLand(t, abs, sea, w)
if relTop <= absTop {
t.Errorf("the relative picture should carry the summit far higher up the ramp: %d vs %d",
relTop, absTop)
}
}
// brightestLand is the highest luma any land pixel reached, which is how far up the hypsometric ramp the
// summit got: the ramp ends in near-white snow and starts in dark green.
func brightestLand(t *testing.T, path string, sea []bool, w int) int {
t.Helper()
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
img, err := png.Decode(f)
if err != nil {
t.Fatal(err)
}
best := 0
b := img.Bounds()
for y := 0; y < b.Dy(); y++ {
for x := 0; x < b.Dx(); x++ {
if sea[y*w+x] {
continue
}
r, g, bl, _ := img.At(b.Min.X+x, b.Min.Y+y).RGBA()
if v := int(r+g+bl) >> 8; v > best {
best = v
}
}
}
return best
}
+236
View File
@@ -0,0 +1,236 @@
package field
// SlidingMax is the maximum over a square window, separable and O(1) a cell whatever the radius.
//
// The naive form is a loop over the window, which is what internal/stats' localRelief used to do and is fine
// on the 500 m window it uses at 8 m cells - until the map is a planet. 28 million land cells times a 63-cell
// radius is 1.1e11 comparisons, which is not a slow diagnostic, it is one nobody will ever see the end of.
// The monotonic deque is the standard answer: each index enters and leaves once, so the row pass is linear in
// the row however wide the window.
//
// wrapX makes the row pass periodic, which is what a cylinder needs; the column pass always clamps, because
// the top and bottom of the map are the poles and not each other.
func SlidingMax(f *Field, radius int, wrapX bool) *Field {
return sliding(f, radius, wrapX, func(inDeque, arriving float32) bool { return inDeque <= arriving })
}
// SlidingMin is the same window, the other way up. The pair is what local relief is made of.
func SlidingMin(f *Field, radius int, wrapX bool) *Field {
return sliding(f, radius, wrapX, func(inDeque, arriving float32) bool { return inDeque >= arriving })
}
// LocalRelief is max minus min over a square window: the standard field measure of how rugged a place is, and
// the one thing slope cannot tell you. A 5 m hummock and a 500 m mountainside both stand at 30 degrees.
//
// Two sliding passes and a subtract, so it costs the same as one of them twice and nothing per radius. It
// holds two fields at once at the peak, which at planet scale is 600 MB - worth saying, because the naive
// version held none and could not finish.
func LocalRelief(f *Field, radius int, wrapX bool) *Field {
hi := SlidingMax(f, radius, wrapX)
lo := SlidingMin(f, radius, wrapX)
for i := range hi.Data {
hi.Data[i] -= lo.Data[i]
}
return hi
}
// sliding is the shared separable pass. keep reports whether the value already at the back of the deque can
// be dropped when a new one arrives, which is the only thing that differs between the maximum and the
// minimum: the deque holds indices whose values are monotone, so its front is always the answer for the live
// window and anything the arriving value dominates can never be the answer again.
func sliding(f *Field, radius int, wrapX bool, keep func(inDeque, arriving float32) bool) *Field {
if radius < 1 {
return f.Clone()
}
w, h := f.W, f.H
row := New(w, h, f.CellM)
buf := make([]float32, 0, w+2*radius)
idx := make([]int, 0, w+2*radius)
for y := 0; y < h; y++ {
// The row, extended by the radius at each end so the deque never has to special-case an edge.
buf = buf[:0]
for x := -radius; x < w+radius; x++ {
sx := x
if wrapX {
sx = ((sx % w) + w) % w
} else if sx < 0 {
sx = 0
} else if sx >= w {
sx = w - 1
}
buf = append(buf, f.Data[y*w+sx])
}
slide(buf, idx[:0], 2*radius+1, keep, func(i int, v float32) {
if i < w {
row.Data[y*w+i] = v
}
})
}
out := New(w, h, f.CellM)
col := make([]float32, 0, h+2*radius)
for x := 0; x < w; x++ {
col = col[:0]
for y := -radius; y < h+radius; y++ {
sy := y
if sy < 0 {
sy = 0
} else if sy >= h {
sy = h - 1
}
col = append(col, row.Data[sy*w+x])
}
slide(col, idx[:0], 2*radius+1, keep, func(i int, v float32) {
if i < h {
out.Data[i*w+x] = v
}
})
}
return out
}
// slide walks a padded line with a monotonic deque and reports the window's answer ending at each output
// position.
func slide(line []float32, dq []int, window int, keep func(inDeque, arriving float32) bool,
emit func(i int, v float32)) {
dq = dq[:0]
for i, v := range line {
for len(dq) > 0 && keep(line[dq[len(dq)-1]], v) {
dq = dq[:len(dq)-1]
}
dq = append(dq, i)
if dq[0] <= i-window {
dq = dq[1:]
}
if out := i - window + 1; out >= 0 {
emit(out, line[dq[0]])
}
}
}
// BoxSmooth blurs a field in place with `passes` of a separable box blur of the given radius, clamping at the
// edges. Two passes are near enough to a Gaussian for anything here and cost four linear sweeps.
//
// Deterministic by construction: fixed traversal order, running sums, no goroutines. It lives here rather than
// in the pass that first wanted it because two now do - the coastal detail pass smooths the signed distance to
// the shoreline, and the tile bake smooths the interpolated sea floor.
func BoxSmooth(data []float32, w, h, radius, passes int) {
if radius < 1 || passes < 1 || len(data) < w*h {
return
}
tmp := make([]float32, len(data))
for p := 0; p < passes; p++ {
boxRows(data, tmp, w, h, radius)
boxCols(tmp, data, w, h, radius)
}
}
func boxRows(src, dst []float32, w, h, radius int) {
n := float32(2*radius + 1)
for y := 0; y < h; y++ {
row := y * w
var sum float32
for k := -radius; k <= radius; k++ {
sum += src[row+clampIdx(k, w)]
}
for x := 0; x < w; x++ {
dst[row+x] = sum / n
sum += src[row+clampIdx(x+radius+1, w)] - src[row+clampIdx(x-radius, w)]
}
}
}
func boxCols(src, dst []float32, w, h, radius int) {
n := float32(2*radius + 1)
for x := 0; x < w; x++ {
var sum float32
for k := -radius; k <= radius; k++ {
sum += src[clampIdx(k, h)*w+x]
}
for y := 0; y < h; y++ {
dst[y*w+x] = sum / n
sum += src[clampIdx(y+radius+1, h)*w+x] - src[clampIdx(y-radius, h)*w+x]
}
}
}
func clampIdx(i, n int) int {
if i < 0 {
return 0
}
if i >= n {
return n - 1
}
return i
}
// BoxSmoothMasked is BoxSmooth restricted to the cells the mask selects: a cell outside it is neither read
// nor written, so the blur never averages across the boundary.
//
// That distinction is the whole reason it exists. The coastal detail pass damps the metre-scale texture near
// the shore, and an unmasked blur there does not damp texture, it bridges the waterline: measured on a
// fixture with forty metres of water against the land, the plain blur lifted the sea floor by twenty metres.
// The step at a shoreline is a landform, not roughness, and a filter that cannot tell them apart is the wrong
// filter.
//
// Separable and weighted: the row pass carries a running sum of values and of weights, the column pass sums
// those, and the quotient is the mean over the masked cells in the window. Deterministic, like BoxSmooth.
func BoxSmoothMasked(data []float32, mask []bool, w, h, radius, passes int) {
if radius < 1 || passes < 1 || len(data) < w*h || len(mask) < w*h {
return
}
n := w * h
val := make([]float32, n)
wgt := make([]float32, n)
tv := make([]float32, n)
tw := make([]float32, n)
for p := 0; p < passes; p++ {
for i := 0; i < n; i++ {
if mask[i] {
val[i], wgt[i] = data[i], 1
} else {
val[i], wgt[i] = 0, 0
}
}
boxRowsSum(val, tv, w, h, radius)
boxRowsSum(wgt, tw, w, h, radius)
boxColsSum(tv, val, w, h, radius)
boxColsSum(tw, wgt, w, h, radius)
for i := 0; i < n; i++ {
if mask[i] && wgt[i] > 0 {
data[i] = val[i] / wgt[i]
}
}
}
}
// boxRowsSum and boxColsSum are the running sums BoxSmooth uses, without the division: a masked blur needs
// the weight sum as well as the value sum, and dividing in the middle would be dividing by the wrong thing.
func boxRowsSum(src, dst []float32, w, h, radius int) {
for y := 0; y < h; y++ {
row := y * w
var sum float32
for k := -radius; k <= radius; k++ {
sum += src[row+clampIdx(k, w)]
}
for x := 0; x < w; x++ {
dst[row+x] = sum
sum += src[row+clampIdx(x+radius+1, w)] - src[row+clampIdx(x-radius, w)]
}
}
}
func boxColsSum(src, dst []float32, w, h, radius int) {
for x := 0; x < w; x++ {
var sum float32
for k := -radius; k <= radius; k++ {
sum += src[clampIdx(k, h)*w+x]
}
for y := 0; y < h; y++ {
dst[y*w+x] = sum
sum += src[clampIdx(y+radius+1, h)*w+x] - src[clampIdx(y-radius, h)*w+x]
}
}
}
@@ -0,0 +1,91 @@
package field
import (
"math"
"math/rand/v2"
"testing"
)
// The deque has to give the same answer as the loop it replaces, on every cell, including the edges and the
// seam. It is O(1) a cell against O(radius squared), which is the difference between a diagnostic and a hang
// at planet scale - and an optimisation that is only nearly right is worse than the version that was slow.
func TestSlidingWindowsMatchTheNaiveLoop(t *testing.T) {
r := rand.New(rand.NewPCG(7, 9))
const w, h = 61, 37
f := New(w, h, 8)
for i := range f.Data {
f.Data[i] = float32(r.NormFloat64() * 50)
}
naive := func(cx, cy, radius int, wrapX, wantMax bool) float32 {
best := float32(math.Inf(1))
if wantMax {
best = float32(math.Inf(-1))
}
for y := cy - radius; y <= cy+radius; y++ {
sy := y
if sy < 0 {
sy = 0
} else if sy >= h {
sy = h - 1
}
for x := cx - radius; x <= cx+radius; x++ {
sx := x
if wrapX {
sx = ((sx % w) + w) % w
} else if sx < 0 {
sx = 0
} else if sx >= w {
sx = w - 1
}
v := f.Data[sy*w+sx]
if (wantMax && v > best) || (!wantMax && v < best) {
best = v
}
}
}
return best
}
for _, radius := range []int{1, 3, 8, 20} {
for _, wrapX := range []bool{false, true} {
hi := SlidingMax(f, radius, wrapX)
lo := SlidingMin(f, radius, wrapX)
rel := LocalRelief(f, radius, wrapX)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*w + x
if got, want := hi.Data[i], naive(x, y, radius, wrapX, true); got != want {
t.Fatalf("max r=%d wrap=%v at (%d,%d): %v want %v", radius, wrapX, x, y, got, want)
}
if got, want := lo.Data[i], naive(x, y, radius, wrapX, false); got != want {
t.Fatalf("min r=%d wrap=%v at (%d,%d): %v want %v", radius, wrapX, x, y, got, want)
}
if got := rel.Data[i]; got != hi.Data[i]-lo.Data[i] {
t.Fatalf("relief r=%d at (%d,%d): %v against %v", radius, x, y, got,
hi.Data[i]-lo.Data[i])
}
}
}
}
}
}
// A radius of zero is a no-op rather than an error, which is what a caller with a window smaller than one
// cell should get.
func TestASlidingWindowOfNothingIsTheFieldItself(t *testing.T) {
f := New(4, 3, 8)
for i := range f.Data {
f.Data[i] = float32(i)
}
for _, got := range []*Field{SlidingMax(f, 0, true), SlidingMin(f, 0, false)} {
for i := range f.Data {
if got.Data[i] != f.Data[i] {
t.Fatalf("radius 0 changed cell %d", i)
}
}
}
if rel := LocalRelief(f, 0, true); rel.Data[5] != 0 {
t.Errorf("relief over a single cell is zero, got %v", rel.Data[5])
}
}
+94
View File
@@ -0,0 +1,94 @@
package field
import "math"
// SmoothEdgePreserving relaxes a height field towards its neighbours with a weight that falls away as the
// step between them grows, so a channel wall or a ridge crest survives a pass that takes a grid-cut facet
// off. It is a port of the bilateral smooth in the World Orogen browser generator, which has one for exactly
// this reason - to blend the artefacts its own routing leaves without rounding the landforms off with them.
//
// It is a filter and not a process. It conserves nothing, it has no time in it, and running it inside the
// solve loop would act as an uncontrolled extra diffusivity: that changes the steady-state slope, which is
// U/K, which is the one knob the whole generator's relief hangs on. It runs once, after the solve, and it is
// off by default. The point of having it is that the alternative - raising diffusion_m2_yr until the
// artefacts go - is measured to smooth away the landforms too, at about 0.05.
//
// Two deviations from the reference, both about units.
//
// The weight is 1/(1 + |dh|/(d*slopeRef)) rather than 1/(1 + |dh|*sensitivity). A sensitivity in 1/m is a
// height threshold, and a height threshold means one thing on a 32 m geology cell and something four times
// as aggressive on an 8 m one, so the same painted world would come out differently at two resolutions -
// which is the property Docs/Terrain-Next.md section 4.D says the generator lives or dies by. slopeRef is a
// rise over run and carries across. Ground steeper than it is preserved; ground gentler is relaxed.
//
// And a diagonal neighbour is sqrt(2) further away, so it carries both its own distance in the slope and an
// inverse-distance geometric weight - which is what a Gaussian would give those two offsets.
//
// The waterline is a wall, not a value. A neighbour that is not land is skipped entirely rather than clamped:
// clamping to sea level would pull the shore down, and clamping the other way would drown the beach the
// coastal pass built. Sea cells are never written.
//
// scratch must be at least len(h); it is used as the destination of each pass.
func SmoothEdgePreserving(h []float32, w, hgt int, cellM float64, land []bool, passes int, slopeRef float64, scratch []float32) {
if passes <= 0 || slopeRef <= 0 || cellM <= 0 {
return
}
if passes > smoothMaxPasses {
passes = smoothMaxPasses
}
tmp := scratch[:len(h)]
// dh/(d*slopeRef) per face, folded into one reciprocal each.
invCard := float32(1 / (cellM * slopeRef))
invDiag := float32(1 / (cellM * math.Sqrt2 * slopeRef))
const geomDiag = float32(1 / math.Sqrt2)
for p := 0; p < passes; p++ {
src := h
Rows(hgt, func(y0, y1 int) {
for y := y0; y < y1; y++ {
for x := 0; x < w; x++ {
i := y*w + x
if !land[i] {
tmp[i] = src[i]
continue
}
c := src[i]
var sumW, sumH float32
face := func(nx, ny int, inv, geom float32) {
if nx < 0 || ny < 0 || nx >= w || ny >= hgt {
return
}
ni := ny*w + nx
if !land[ni] {
return
}
n := src[ni]
d := n - c
if d < 0 {
d = -d
}
wk := geom / (1 + d*inv)
sumW += wk
sumH += wk * n
}
face(x-1, y, invCard, 1)
face(x+1, y, invCard, 1)
face(x, y-1, invCard, 1)
face(x, y+1, invCard, 1)
face(x-1, y-1, invDiag, geomDiag)
face(x+1, y-1, invDiag, geomDiag)
face(x-1, y+1, invDiag, geomDiag)
face(x+1, y+1, invDiag, geomDiag)
tmp[i] = (c + sumH) / (1 + sumW)
}
}
})
copy(h, tmp)
}
}
// smoothMaxPasses is a hard ceiling, not a default. Past about three passes the edge weight has stopped
// protecting anything - every face inside a landform is gentler than slopeRef by then - and what is left is a
// box blur with extra steps.
const smoothMaxPasses = 4
+106
View File
@@ -0,0 +1,106 @@
package field
import (
"math"
"testing"
)
// The claim the smooth has to earn: it takes the ripple off and leaves the landform. Two surfaces in one
// grid - a plane carrying a small corrugation, and a cliff far steeper than slopeRef - and the pass has to
// treat them differently or it is a box blur with extra arithmetic.
func TestSmoothTakesTheRippleAndLeavesTheCliff(t *testing.T) {
const (
w, h = 128, 128
cellM = 8.0
slopeRef = 0.3
)
land := make([]bool, w*h)
for i := range land {
land[i] = true
}
hgt := make([]float32, w*h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
v := 100.0
if x >= w/2 {
v = 400.0 // a 300 m cliff at mid-grid: 37.5 rise over run, a hundred times slopeRef
}
// A 4 m corrugation at a four-cell wavelength, which is 0.125 rise over run - under slopeRef.
v += 2 * math.Sin(2*math.Pi*float64(y)/4)
hgt[y*w+x] = float32(v)
}
}
before := make([]float32, len(hgt))
copy(before, hgt)
SmoothEdgePreserving(hgt, w, h, cellM, land, 2, slopeRef, make([]float32, w*h))
// The ripple, measured well away from the cliff.
rip := func(f []float32, x int) float64 {
lo, hi := math.Inf(1), math.Inf(-1)
for y := 8; y < h-8; y++ {
v := float64(f[y*w+x])
lo, hi = math.Min(lo, v), math.Max(hi, v)
}
return hi - lo
}
ripBefore, ripAfter := rip(before, w/4), rip(hgt, w/4)
// The cliff, measured across the step on a row far from the edges.
step := func(f []float32) float64 {
y := h / 2
return float64(f[y*w+w/2] - f[y*w+w/2-1])
}
stepBefore, stepAfter := step(before), step(hgt)
t.Logf("ripple %.2f -> %.2f m (%.0f%% removed); cliff %.1f -> %.1f m (%.0f%% kept)",
ripBefore, ripAfter, 100*(1-ripAfter/ripBefore), stepBefore, stepAfter, 100*stepAfter/stepBefore)
if ripAfter > 0.5*ripBefore {
t.Errorf("the ripple is still %.0f%% of what it was; the pass is not smoothing", 100*ripAfter/ripBefore)
}
if stepAfter < 0.9*stepBefore {
t.Errorf("the cliff lost %.0f%% of its height; the edge weight is not preserving", 100*(1-stepAfter/stepBefore))
}
}
// The waterline is a wall. A sea cell is never written, and a land cell beside one is never pulled towards
// sea level - clamping either way would move the shore, and the coastal pass owns the shore.
func TestSmoothNeverReachesAcrossTheWaterline(t *testing.T) {
const (
w, h = 64, 64
cellM = 8.0
)
land := make([]bool, w*h)
hgt := make([]float32, w*h)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
i := y*w + x
if x < w/2 {
hgt[i] = -40 // sea floor
} else {
land[i] = true
hgt[i] = 60 // a plateau meeting it at a hundred-metre cliff
}
}
}
before := make([]float32, len(hgt))
copy(before, hgt)
SmoothEdgePreserving(hgt, w, h, cellM, land, 4, 0.3, make([]float32, w*h))
for i := range hgt {
if !land[i] && hgt[i] != before[i] {
t.Fatalf("a sea cell moved %.4f m", hgt[i]-before[i])
}
}
// The first land column has three land neighbours and five sea ones. On a flat plateau it must not move
// at all: the sea neighbours contribute nothing, and the land ones are all at its own height.
worst := float32(0)
for y := 1; y < h-1; y++ {
if d := hgt[y*w+w/2] - before[y*w+w/2]; math.Abs(float64(d)) > float64(worst) {
worst = d
}
}
t.Logf("worst move on the shore column: %.6f m", worst)
if math.Abs(float64(worst)) > 1e-3 {
t.Errorf("the shore column moved %.4f m; the pass is reading across the waterline", worst)
}
}