Files
2026-09-25 17:02:24 +03:00

687 lines
27 KiB
Go

package planet
import (
"encoding/json"
"fmt"
"image/png"
"math"
"os"
"path/filepath"
"sync"
"time"
"salty/terrain/internal/detail"
"salty/terrain/internal/field"
"salty/terrain/internal/manifest"
"salty/terrain/internal/noise"
"salty/terrain/internal/overlay"
"salty/terrain/internal/thermal"
"salty/terrain/internal/tile"
)
// The detail bake: the geology grid becomes ground somebody can stand on, one tile at a time.
//
// It reads the heightmap a geology bake left behind rather than solving anything itself, which is what makes
// it batchable. The geology is hours; a tile is seconds, and the islands somebody cares about can be baked
// first and the rest later or never.
//
// Every pass here is local, and every hash and noise lattice in them is keyed on absolute world position, so
// a tile's interior comes out the same as it would have in one impossible whole-world run. That is measured
// rather than asserted: internal/detail's TestHowFarTheCutEdgeReachesIn is where the margin comes from.
// TileOptions steer a detail bake.
type TileOptions struct {
In *Inputs
HeightM *field.Field // the geology heightmap, painted rows only, in metres
Sea []bool // painted rows
// Exposure is the coastal pass's fetch field, painted rows, 0 sheltered to 1 open water, or nil when the
// bake predates it. A tile cannot compute this - see detail.CoastalParams - so without it the coastal
// detail pass treats every shore as fully exposed and the run says so once.
Exposure *field.Field
Out string
Prefix string
Only [4]int // x0,y0,x1,y1 in tile indices; zero means all
OnlySet bool
// NoDetail writes the tile as the geology upsampled and nothing else. It is a diagnostic and it earns
// its place: when ground looks wrong at two metres, the first question is always whether the detail
// passes did it or whether they are faithfully magnifying something the solve produced, and there is no
// other way to ask.
NoDetail bool
// NoShore skips pass 11b and nothing else, for the same reason NoDetail exists one level up: when a
// coastline looks wrong the first question is whether the shore pass did it or whether it is faithfully
// magnifying what the geology handed it, and a diff between two runs is the only way to ask.
NoShore bool
Jobs int
Log func(string, ...any)
}
// TileRecord is one tile in the index.
type TileRecord struct {
IX int `json:"ix"`
IY int `json:"iy"`
File string `json:"file"`
W int `json:"w"`
H int `json:"h"`
OriginXM float64 `json:"origin_x_m"`
OriginYM float64 `json:"origin_y_m"`
MinM float64 `json:"min_m"`
MaxM float64 `json:"max_m"`
ClipFrac float64 `json:"clip_fraction"`
Droplets int `json:"droplets"`
Rounds int `json:"rounds"`
LargestCutM float64 `json:"largest_cut_m"`
LargestFillM float64 `json:"largest_fill_m"`
Seconds float64 `json:"seconds"`
// Coastal is what pass 11b moved on this tile, or nil on a tile with no shore in it.
Coastal *detail.CoastalStats `json:"coastal,omitempty"`
// OverlayFile is the annotation mask beside this tile - one mark index a detail cell, zero for nothing -
// or empty when the planet has no overlay. The key is in overlay.json in the same directory.
OverlayFile string `json:"overlay_file,omitempty"`
}
// TileIndex is tiles.json: everything a consumer needs to place the tiles back into a world.
type TileIndex struct {
When time.Time `json:"when"`
Prefix string `json:"prefix"`
CellM float64 `json:"cell_m"`
TilePx int `json:"tile_px"`
MarginPx int `json:"margin_px"`
NX int `json:"nx"`
NY int `json:"ny"`
WrapX bool `json:"wrap_x"`
WorldWM float64 `json:"world_w_m"`
WorldHM float64 `json:"world_h_m"`
ElevationM struct {
Min float64 `json:"min"`
Max float64 `json:"max"`
} `json:"elevation_m"`
Tiles []TileRecord `json:"tiles"`
}
// CheckBake refuses a detail bake whose geology was produced by a different manifest.
//
// The failure it exists for is silent and total. A heightmap is 16-bit samples over an elevation range, so a
// bake made under one range and decoded under another comes out shifted - and if the shift takes the land
// below sea level, every tile decides it is ocean, holds itself at sea level, and writes a flat zero. That
// happened on the first run of this command and there was nothing in the output to say why.
func CheckBake(dir string, m *manifest.Manifest, warn func(string, ...any)) error {
raw, err := os.ReadFile(filepath.Join(dir, "meta.json"))
if err != nil {
return fmt.Errorf("%s: %w (run `terrain bake` first)", filepath.Join(dir, "meta.json"), err)
}
// Pointers, so that a field a bake did not record is distinguishable from one it recorded as zero. An
// absent field is a bake older than this check, which is a reason to say so and carry on; a different
// field is a reason to stop. The first version conflated the two and refused a perfectly good bake.
var meta struct {
Seed *int64 `json:"seed"`
Plan struct {
CircumferenceKm *float64 `json:"circumference_km"`
CellM *float64 `json:"cell_m"`
ElevationMinM *float64 `json:"elevation_min_m"`
ElevationMaxM *float64 `json:"elevation_max_m"`
} `json:"plan"`
}
if err := json.Unmarshal(raw, &meta); err != nil {
return fmt.Errorf("%s: %w", filepath.Join(dir, "meta.json"), err)
}
if warn == nil {
warn = func(string, ...any) {}
}
unknown := 0
for _, c := range []struct {
what string
was *float64
now float64
}{
{"elevation_m.min", meta.Plan.ElevationMinM, m.ElevationM.Min},
{"elevation_m.max", meta.Plan.ElevationMaxM, m.ElevationM.Max},
{"circumference_km", meta.Plan.CircumferenceKm, m.Planet.CircumferenceKm},
{"the geology cell", meta.Plan.CellM, m.GeologyCellM()},
} {
if c.was == nil {
unknown++
continue
}
if *c.was != c.now {
return fmt.Errorf("%s was baked with %s %v and the manifest now says %v. The heightmap on disk "+
"means something different from what this run would read it as; rebake, or put the manifest "+
"back", dir, c.what, *c.was, c.now)
}
}
if meta.Seed != nil && *meta.Seed != m.Source.Seed {
return fmt.Errorf("%s was baked with seed %d and the manifest now says %d; the detail passes would "+
"be hashing a different world from the one in the heightmap", dir, *meta.Seed, m.Source.Seed)
}
if unknown > 0 {
warn("warning %s predates this check and does not record %d of the numbers it would be checked "+
"against; if the manifest has moved since it was baked, the heights will be read as something "+
"they are not", dir, unknown)
}
return nil
}
// BakeTiles runs the detail passes over a rectangle of tiles and writes them.
func BakeTiles(opt TileOptions) (*TileIndex, error) {
log := opt.Log
if log == nil {
log = func(string, ...any) {}
}
in := opt.In
m := in.M
cfg := m.Pipeline
marginPx := detail.MarginCells(cfg.Particle)
g, err := tile.NewGrid(in.P, cfg.GeologyFactor, cfg.Detail.TilePx, marginPx)
if err != nil {
return nil, err
}
detailCellM := in.P.CellM / float64(cfg.GeologyFactor)
log("tiles %d x %d of %d px at %.1f m (%.2f km), margin %d px (%.0f m)",
g.NX, g.NY, cfg.Detail.TilePx, detailCellM,
float64(cfg.Detail.TilePx)*detailCellM/1000, g.MarginGeo*g.Factor, float64(g.MarginGeo)*in.P.CellM)
if want := int(cfg.Detail.ClassBlendM/in.P.CellM + 0.5); want > g.MarginGeo/2 {
log("warning class_blend_m is %.0f m, which a tile cannot reach past its own margin; it will blend "+
"over %.0f m instead. Two passes of the blur reach twice its radius, and the margin is %.0f m",
cfg.Detail.ClassBlendM, float64(g.MarginGeo/2)*in.P.CellM, float64(g.MarginGeo)*in.P.CellM)
}
if cd := cfg.CoastDetail; cd.Enabled && !opt.NoShore {
log("shore the coastal detail pass is on: %.0f m of surf reach is %.0f cells here, a beach below "+
"%.0f m of backshore and a cliff above %.0f", cfg.Coast.SurfReachM,
cfg.Coast.SurfReachM/detailCellM, cd.CliffFromM, cd.CliffToM)
} else {
log("shore the coastal detail pass is off; the shore is the geology upsampled")
}
all := g.Tiles()
wanted := all[:0:0]
for _, t := range all {
if opt.OnlySet {
if t.IX < opt.Only[0] || t.IX > opt.Only[2] || t.IY < opt.Only[1] || t.IY > opt.Only[3] {
continue
}
}
wanted = append(wanted, t)
}
if len(wanted) == 0 {
return nil, fmt.Errorf("no tiles selected; the grid is %d x %d", g.NX, g.NY)
}
if err := os.MkdirAll(opt.Out, 0o755); err != nil {
return nil, err
}
prefix := opt.Prefix
if prefix == "" {
prefix = "Planet"
}
jobs := opt.Jobs
if jobs <= 0 {
jobs = 4
}
if jobs > len(wanted) {
jobs = len(wanted)
}
recs := make([]TileRecord, len(wanted))
errs := make([]error, len(wanted))
var wg sync.WaitGroup
var mu sync.Mutex
next := make(chan int)
go func() {
for i := range wanted {
next <- i
}
close(next)
}()
for w := 0; w < jobs; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := range next {
rec, err := bakeOneTile(g, wanted[i], opt, prefix)
recs[i], errs[i] = rec, err
mu.Lock()
if err != nil {
log("tile %s FAILED: %v", wanted[i].Name(prefix), err)
} else {
log("tile %s %d x %d %.0f..%.0f m %.3f%% clipped [%.1f s]",
rec.File, rec.W, rec.H, rec.MinM, rec.MaxM, rec.ClipFrac*100, rec.Seconds)
}
mu.Unlock()
}
}()
}
wg.Wait()
for _, err := range errs {
if err != nil {
return nil, err
}
}
idx := &TileIndex{
When: time.Now().UTC().Truncate(time.Second), Prefix: prefix,
CellM: detailCellM, TilePx: cfg.Detail.TilePx, MarginPx: g.MarginGeo * g.Factor,
NX: g.NX, NY: g.NY, WrapX: true,
WorldWM: in.P.CircumferenceM(), WorldHM: in.P.HeightM(),
Tiles: recs,
}
idx.ElevationM.Min, idx.ElevationM.Max = m.ElevationM.Min, m.ElevationM.Max
data, err := json.MarshalIndent(idx, "", " ")
if err != nil {
return nil, err
}
if err := os.WriteFile(filepath.Join(opt.Out, "tiles.json"), append(data, '\n'), 0o644); err != nil {
return nil, err
}
// The key to every *_overlay.png, plus the features in world metres, written beside them so an importer
// reads one directory rather than two. It is the same document the plan and the bake write; it is small,
// it describes the whole planet, and a tile batch that did not carry it would be a folder of masks with
// no legend.
if in.OverlayDoc != nil {
if err := in.OverlayDoc.WriteJSON(opt.Out); err != nil {
return nil, err
}
}
return idx, nil
}
// bakeOneTile is passes 8 to 12 and 14 over one tile.
func bakeOneTile(g *tile.Grid, t tile.Tile, opt TileOptions, prefix string) (TileRecord, error) {
in := opt.In
m := in.M
cfg := m.Pipeline
// Pass 8: cut with the margin and upsample. UpsampleInt is exact-factor Catmull-Rom, so every geology
// sample lands exactly on a detail sample and there is no phase error to accumulate along a tile row.
//
// The sea is flattened to sea level *before* the upsample, not after, and both halves of that matter. The
// geology raster drops from the shore to the painted ocean depth in a single cell, so a Catmull-Rom
// upsample of it rings at every coastline - hundreds of metres of overshoot in the water and a wave of it
// back into the land. And with the sea flat, the interpolated height crosses sea level on a smooth
// contour, so the detail land mask can be read off the height itself; taken up from the geology mask by
// nearest neighbour instead, the coastline comes out as a staircase of 8 m blocks and it is plainly
// visible in a hillshade.
//
// It is the same invariant the fluvial solve keeps, for the same reason: with the floor in place a cell at
// the waterline stands five hundred metres above its neighbour, and thermal weathering would find the
// whole coastline past the angle of repose and pour it into the sea.
geo, _, _ := g.Cut(t, opt.HeightM, 0)
geoSea := g.CutMask(t, opt.Sea, opt.HeightM.W, 0)
floor := geo.Clone()
for i, isSea := range geoSea {
if isSea {
geo.Data[i] = float32(m.SeaLevelM)
}
}
h := geo.UpsampleInt(cfg.GeologyFactor)
land := make([]bool, len(h.Data))
for i, v := range h.Data {
land[i] = float64(v) > m.SeaLevelM
}
f := g.Frame(t)
periodM := m.Planet.DetailNoisePeriodKm * 1000
classes := blendedClasses(g, t, opt, geo, h)
if opt.NoDetail {
restoreSeaFloor(h, land, geo, floor, m, cfg.GeologyFactor)
return finishTile(g, t, opt, prefix, h, land, nil, nil)
}
// Pass 9.
detail.RunDetailNoise(h, land, detail.DetailNoiseParams{
Cfg: cfg.Detail, Seed: m.Source.Seed, Frame: f, PeriodM: periodM, SeaLevelM: m.SeaLevelM,
Classes: classes,
})
// Pass 10 feeds pass 11 rather than standing alone: strata is hardness, and hardness is what the
// droplets scale their cutting by, which is how a hard band ends up holding a shelf on a cut face.
hard := detail.NewHardness(f, m.Source.Seed, m.Planet.NoisePeriodKm*1000,
cfg.Strata.PeriodM, cfg.Strata.Contrast, classes)
// Pass 11.
maps, _ := detail.RunParticle(h, land, detail.ParticleParams{
Cfg: cfg.Particle, Seed: m.Source.Seed, Frame: f, SeaLevelM: m.SeaLevelM, Hardness: hard,
Classes: classes,
})
// Pass 12: the same mass-conserving weathering the coarse grid gets, at the cell size where scree and a
// cliff face are actually resolved.
fixed := make([]bool, len(land))
for i := range land {
fixed[i] = !land[i]
}
thermal.Apply(h.Data, h.W, h.H, h.CellM, thermal.TalusFromDegrees(cfg.Thermal.TalusDeg),
cfg.Thermal.FinePasses, fixed, nil)
// The sea floor goes back *here*, before the shore is drawn, rather than on the way out. Pass 11b works
// on both sides of the waterline - a foreshore is below it and a berm is above it - so a shore laid onto
// water that is about to be overwritten would be half a shore.
restoreSeaFloor(h, land, geo, floor, m, cfg.GeologyFactor)
// Pass 9b: the same texture as pass 9, under water, now that there is a sea bed to put it on. It runs
// before the shore rather than after, so the beach the shore pass draws is smooth sand over it rather
// than sand with noise on top.
detail.RunSeabedNoise(h, detail.DetailNoiseParams{
Cfg: cfg.Detail, Seed: m.Source.Seed, Frame: f, PeriodM: periodM, SeaLevelM: m.SeaLevelM,
Classes: classes,
})
// Pass 11b: the shore. It runs last of the detail passes because marine processes are the last thing to
// act on a coast and they act faster than anything inland: a berm is rebuilt by every tide, while the
// hillslope creep that pass 12 stands for takes the age of the cliff behind it. Running it before the
// fine thermal would have that creep immediately relax the one face on the map that is meant to be
// steeper than the angle of repose.
var coastal *detail.CoastalStats
if cfg.CoastDetail.Enabled && !opt.NoShore {
st := detail.RunCoastal(h, land, detail.CoastalParams{
Cfg: cfg.CoastDetail, Surf: cfg.Coast, Seed: m.Source.Seed, Frame: f, PeriodM: periodM,
SeaLevelM: m.SeaLevelM, Exposure: cutExposure(g, t, opt, geo, h), Hardness: hard,
})
coastal = &st
}
return finishTile(g, t, opt, prefix, h, land, maps, coastal)
}
// cutExposure lifts the coastal pass's fetch field onto this tile's detail grid, or nil when the bake did not
// carry one. Interpolated rather than nearest: it is a smooth field and a staircase in it would put a
// staircase into the berm height along every beach.
func cutExposure(g *tile.Grid, t tile.Tile, opt TileOptions, geo, h *field.Field) []float32 {
if opt.Exposure == nil {
return nil
}
cut, _, _ := g.Cut(t, opt.Exposure, 0)
up := cut.UpsampleInt(opt.In.M.Pipeline.GeologyFactor)
if len(up.Data) != len(h.Data) {
return nil
}
return up.Data
}
// restoreSeaFloor puts the water back after the land passes, which ran with the sea flattened to sea level.
//
// It used to be nearest neighbour, unconditionally, and the comment said why: the geology raster dropped from
// the shore to the painted ocean depth in a single cell, and interpolating a five-hundred-metre step is
// exactly what the flattening exists to avoid. The cost was a four-fold staircase over the whole sea floor,
// which nobody could see while the shore was a cliff into five hundred metres of water.
//
// D-60 changed the input. There is a continental shelf now, and a surf-cut platform, and a beach, and between
// them they carry the sea floor down from the waterline to the shelf break over kilometres rather than over
// one cell. So the shallow water is interpolated - from the *unflattened* cut, which still holds the land
// heights, so the surface runs across the waterline with no seam in it - and only the drop past the break is
// still nearest. The two are blended over a depth band rather than switched between, because a hard switch
// would put back a smaller version of the step it exists to avoid.
func restoreSeaFloor(h *field.Field, land []bool, geo, floor *field.Field, m *manifest.Manifest, factor int) {
breakM := m.ShelfBreakM()
if breakM <= 0 {
breakM = 30
}
// The clamp sits at the *far* end of the blend band rather than at the break, so that everywhere the blend
// is still reading the interpolation, the interpolation is of the real sea floor. Clamped at the break
// instead, the smooth half of the blend was a flat surface at break depth while the nearest half followed
// the slope down, and the mixture lifted the floor by up to half the band - ten metres of invented shelf
// in exactly the strip the blend exists to make invisible.
const bandM = 40.0
deepest := float32(m.SeaLevelM - (breakM + bandM))
shallow := floor.Clone()
for i, v := range shallow.Data {
if v < deepest {
shallow.Data[i] = deepest
}
}
smooth := shallow.UpsampleInt(factor)
// Not smoothed, and it is worth saying why not, because the first version was.
//
// The interpolated sea floor comes out of a hillshade covered in dotted contour lines, which look exactly
// like an interpolation artefact and are not: measured, an eighty by hundred patch of open water takes
// three distinct 8-bit shade values, 94 % of them the same one. It is the hillshade's own quantisation on
// a surface that slopes at one in three hundred, it was there before and it is in the picture rather than
// in the ground. A box blur over the floor was tried against it and changed the tile by a fifth of a
// height quantum on average - its only real effect was to soften genuine one-cell steps in the geology,
// which is not what it was for.
for y := 0; y < h.H; y++ {
sy := y / factor
if sy >= geo.H {
sy = geo.H - 1
}
for x := 0; x < h.W; x++ {
i := y*h.W + x
if land[i] {
continue
}
sx := x / factor
if sx >= geo.W {
sx = geo.W - 1
}
near := float64(floor.Data[sy*geo.W+sx])
depth := m.SeaLevelM - near
t := (depth - breakM) / bandM
if t <= 0 {
h.Data[i] = smooth.Data[i]
continue
}
if t >= 1 {
h.Data[i] = float32(near)
continue
}
w := noise.Smoothstep(t)
h.Data[i] = float32((1-w)*float64(smooth.Data[i]) + w*near)
}
}
}
// finishTile restores the sea floor, crops the margin away and writes everything out.
func finishTile(g *tile.Grid, t tile.Tile, opt TileOptions, prefix string, h *field.Field, land []bool,
maps *detail.Maps, coastal *detail.CoastalStats) (TileRecord, error) {
start := time.Now()
_ = land
m := opt.In.M
cfg := m.Pipeline
// Pass 14: crop the margin away and write. Everything outside the interior was only ever there so the
// passes above had somewhere to read from.
ix, iy := g.MarginGeo*g.Factor, g.MarginGeo*g.Factor
iw, ih := g.DetailW(t), g.DetailH(t)
out := crop(h, ix, iy, iw, ih)
rec := TileRecord{
IX: t.IX, IY: t.IY, File: t.Name(prefix) + ".png", W: iw, H: ih,
OriginXM: g.OriginXM(t), OriginYM: g.OriginYM(t),
}
if coastal != nil && coastal.ShoreCells > 0 {
rec.Coastal = coastal
}
// The annotation layer, sampled onto this tile's interior. It is written before the height, because it is
// the cheap one and a failure here should not leave a heightmap with no mask beside it.
//
// Nothing in the detail passes read it and nothing here consults it: it is the author's layer travelling
// through to whatever builds the level. The values are mark indices, zero for nothing, and the key is in
// overlay.json beside tiles.json.
if ov := opt.In.OverlayRaster; ov != nil {
marks := ov.SampleWorld(rec.OriginXM, rec.OriginYM, h.CellM, iw, ih, opt.In.OverlayScale())
rec.OverlayFile = t.Name(prefix) + "_overlay.png"
if err := overlay.WriteMask(filepath.Join(opt.Out, rec.OverlayFile), iw, ih, marks); err != nil {
return rec, err
}
}
lo, hi := out.MinMax()
rec.MinM, rec.MaxM = float64(lo), float64(hi)
rec.ClipFrac = m.ClipFraction(out.Data)
if err := field.WriteGray16(filepath.Join(opt.Out, rec.File), iw, ih,
m.Encode(out.Data), png.DefaultCompression); err != nil {
return rec, err
}
// A hillshade beside the heightmap, at full resolution. A 16-bit grey PNG of a hundred metres of relief
// is a flat grey rectangle to look at, and the whole reason these passes exist is what they do to the
// surface - which cannot be judged from a number.
// Shaded with the water clamped at the shelf break rather than at sea level. Clamping at sea level was
// right while the shore was a step into five hundred metres of water and there was nothing below the
// waterline worth looking at; now there is a shore platform, a foreshore and a beach down there, and
// they are most of what pass 11b does. The break is still clamped, because a continental slope in the
// corner of a tile would otherwise set the whole hillshade's contrast.
shade := out.Clone()
shadeFloor := float32(m.SeaLevelM - m.ShelfBreakM())
for i := range shade.Data {
if shade.Data[i] < shadeFloor {
shade.Data[i] = shadeFloor
}
}
if err := field.WriteHillshade(filepath.Join(opt.Out, t.Name(prefix)+"_shade.png"), shade, iw, 1); err != nil {
return rec, err
}
// A slice, not a map: map iteration order is randomised in Go and nothing in this generator is allowed
// to depend on it (cross-cutting rule 12). Here it would only reorder two file writes, which is exactly
// the kind of "it does not matter this time" that makes the rule worth keeping without exception.
// The full-scale values are fixed constants, not percentiles of the tile.
//
// Field.ToUnit takes the 99th percentile of whatever it is given, which is exactly right for one map of
// one world and exactly wrong here: it is a statistic of the tile's own extent, so two tiles would stretch
// by different anchors and their shared valley would come out two different greys. That is the same
// mistake the coastal pass's exposure made and had withdrawn, and the same rule - no pass computes a
// statistic of the piece of the world it happens to be looking at.
//
// Flow is water-units accumulated and runs over decades, so it is log-scaled; wear and deposit are metres
// and a metre of either is a great deal at a 2 m cell.
flowFull := 40 * cfg.Particle.DropletsPerCell * float64(cfg.Particle.Lifetime)
var derived []struct {
name string
data []float32
full float64
log bool
}
if maps != nil {
derived = []struct {
name string
data []float32
full float64
log bool
}{
{"flow", maps.Flow, flowFull, true},
{"wear", maps.Wear, 1.0, false},
{"deposit", maps.Deposit, 1.0, false},
}
}
for _, d := range derived {
c := crop(&field.Field{W: h.W, H: h.H, CellM: h.CellM, Data: d.data}, ix, iy, iw, ih)
if err := field.WriteGray8(filepath.Join(opt.Out, t.Name(prefix)+"_"+d.name+".png"), iw, ih,
toBytes(normalise(c.Data, d.full, d.log)), png.BestSpeed); err != nil {
return rec, err
}
}
rec.Seconds = time.Since(start).Seconds()
return rec, nil
}
// upsampleClass takes the class raster up by an integer factor, nearest. A class index is a name and not a
// quantity: interpolating one would invent a class that is neither of its neighbours.
func crop(f *field.Field, x0, y0, w, h int) *field.Field {
out := field.New(w, h, f.CellM)
for y := 0; y < h; y++ {
copy(out.Data[y*w:(y+1)*w], f.Data[(y0+y)*f.W+x0:(y0+y)*f.W+x0+w])
}
return out
}
// normalise maps values onto 0..1 against a fixed full-scale, never a percentile of the data. See the note
// where the constants are chosen.
func normalise(data []float32, full float64, logScale bool) []float32 {
if full <= 0 {
full = 1
}
top := full
if logScale {
top = math.Log1p(full)
}
out := make([]float32, len(data))
for i, v := range data {
x := float64(v)
if x < 0 {
x = 0
}
if logScale {
x = math.Log1p(x)
}
out[i] = float32(x / top)
}
return out
}
func toBytes(data []float32) []uint8 {
out := make([]uint8, len(data))
for i, v := range data {
x := v
if x < 0 {
x = 0
} else if x > 1 {
x = 1
}
out[i] = uint8(x*255 + 0.5)
}
return out
}
// blendedClasses turns the painted class raster into the four numbers the detail passes read, per cell, with
// the boundaries between classes faded rather than stepped.
//
// **Why the fade.** A class is a name and a name is never interpolated - the mask that travels to whatever
// builds the level is still nearest neighbour, and it has to be. But the numbers a class stands for are
// quantities. Kept as a lookup on the class index, a desert meeting a lowland went from seven metres of dune
// amplitude to two, and from a fifth of the running water to all of it, in the width of one cell, along a
// line somebody drew with a mouse. It read as what it was: a boundary in a picture rather than a change in
// the ground. Faded over `class_blend_m`, the same boundary is a few hundred metres of one becoming the
// other, which is what the edge of a sand sea looks like from inside it.
//
// **Why at the geology grid.** The class raster is a geology-resolution field, so blurring it there costs a
// four-hundredth of blurring at detail resolution, and the upsample afterwards is the same exact-factor
// Catmull-Rom every other field gets - so the result is smoother than a blur at detail resolution would have
// been, not coarser.
//
// **Why the radius is clamped.** A blur reads outside the cell it writes, and a tile only has its margin to
// read from. Two passes of a box blur of radius r reach 2r, so r is capped at half the margin and the run
// says so once when the manifest asks for more. Past that cap a tile would be blending against its own cut
// edge and two tiles would disagree about the same ground, which is the one thing the tiling may not do.
func blendedClasses(g *tile.Grid, t tile.Tile, opt TileOptions, geo, h *field.Field) *detail.Classes {
in := opt.In
cfg := in.M.Pipeline
if !in.Legend.Overrides() {
return nil
}
tbl := in.Legend.DetailTables(cfg.Particle.DropletsPerCell,
cfg.Detail.AmplitudeM.Lo(), cfg.Detail.AmplitudeM.Hi(), cfg.Strata.Contrast)
cls := g.CutClass(t, in.Map.Class, in.P.W, in.P.PadY)
radius := int(cfg.Detail.ClassBlendM/in.P.CellM + 0.5)
if max := g.MarginGeo / 2; radius > max {
radius = max
}
lift := func(table []float64) []float32 {
f := field.New(geo.W, geo.H, geo.CellM)
for i, k := range cls {
f.Data[i] = float32(table[k])
}
if radius > 0 {
field.BoxSmooth(f.Data, f.W, f.H, radius, 2)
}
return f.UpsampleInt(cfg.GeologyFactor).Data
}
return &detail.Classes{
Droplets: lift(tbl.Droplets), AmpLo: lift(tbl.AmpLo),
AmpHi: lift(tbl.AmpHi), Contrast: lift(tbl.Contrast),
}
}