Tooling
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user