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