Added: Initial world generation tool
This commit is contained in:
@@ -0,0 +1,546 @@
|
||||
// Package manifest reads RawContent/World/World.json, the one place that says how big L_World is, what a
|
||||
// heightmap value means in metres, and where the height comes from. It is the Go half of a contract whose
|
||||
// other half is Scripts/Authoring/world_manifest.py: create_world.py still reads the same file to place the
|
||||
// landscape, so the two must derive the same Z scale and the same Z offset from the same keys. Any change to
|
||||
// the height contract here is a change there.
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// The engine maps heightmap value v to local height (v - 32768) / 128 * ZScale cm, so ZScale 100 spans 512 m.
|
||||
const EngineSpanMAtScale100 = 512.0
|
||||
|
||||
// Range is the [low, high] pair the pipeline block uses for anything a seed picks between.
|
||||
type Range [2]float64
|
||||
|
||||
func (r Range) Lo() float64 { return r[0] }
|
||||
func (r Range) Hi() float64 { return r[1] }
|
||||
|
||||
// Pick returns a value in the range from a unit random.
|
||||
func (r Range) Pick(u float64) float64 { return r[0] + (r[1]-r[0])*u }
|
||||
|
||||
type Elevation struct {
|
||||
Min float64 `json:"min"`
|
||||
Max float64 `json:"max"`
|
||||
}
|
||||
|
||||
type Source struct {
|
||||
Kind string `json:"kind"`
|
||||
Seed int64 `json:"seed"`
|
||||
Path string `json:"path"`
|
||||
Elevation *Elevation `json:"elevation_m"`
|
||||
Width int `json:"width"`
|
||||
FlipY bool `json:"flip_y"`
|
||||
SmoothPasses int `json:"smooth_passes"`
|
||||
}
|
||||
|
||||
// Layers is the paint-layer rule block, unchanged in meaning from the numpy pipeline.
|
||||
type Layers struct {
|
||||
RockSlopeStart float64 `json:"rock_slope_start"`
|
||||
RockSlopeFull float64 `json:"rock_slope_full"`
|
||||
HighAltitudeStartM float64 `json:"high_altitude_start_m"`
|
||||
HighAltitudeFullM float64 `json:"high_altitude_full_m"`
|
||||
BreakupM float64 `json:"breakup_m"`
|
||||
WearRockStart float64 `json:"wear_rock_start"`
|
||||
RidgeRock float64 `json:"ridge_rock"`
|
||||
DepositSoftens float64 `json:"deposit_softens"`
|
||||
}
|
||||
|
||||
type Plates struct {
|
||||
Count int `json:"count"`
|
||||
VelocityCmYr Range `json:"velocity_cm_yr"`
|
||||
ConvergentMmYr Range `json:"convergent_mm_yr"`
|
||||
BandKm Range `json:"band_km"`
|
||||
DivergentMmYr Range `json:"divergent_mm_yr"`
|
||||
RiftKm Range `json:"rift_km"`
|
||||
// IntraplateMmYr and IntraplateSwellMmYr are the two ends of the regional swell: the interior warps
|
||||
// between them over tens of kilometres. A single uniform intraplate rate is what produced a table-flat
|
||||
// plain with no divides on it, and therefore no drainage for the router to find. See package uplift.
|
||||
IntraplateMmYr float64 `json:"intraplate_mm_yr"`
|
||||
IntraplateSwellMmYr float64 `json:"intraplate_swell_mm_yr"`
|
||||
LowUpliftFraction Range `json:"low_uplift_fraction"`
|
||||
}
|
||||
|
||||
type Faults struct {
|
||||
Major Range `json:"major"`
|
||||
Minor Range `json:"minor"`
|
||||
LengthKm Range `json:"length_km"`
|
||||
SpacingKm Range `json:"spacing_km"`
|
||||
ThrowMajorM Range `json:"throw_major_m"`
|
||||
ThrowMinorM Range `json:"throw_minor_m"`
|
||||
StrikeSlipM Range `json:"strike_slip_m"`
|
||||
}
|
||||
|
||||
type Lithology struct {
|
||||
Types int `json:"types"`
|
||||
KMultipliers []float64 `json:"k_multipliers"`
|
||||
}
|
||||
|
||||
type Relief struct {
|
||||
Octaves int `json:"octaves"`
|
||||
Gain float64 `json:"gain"`
|
||||
BaseFrequencyM float64 `json:"base_frequency_m"`
|
||||
AmplitudeM Range `json:"amplitude_m"`
|
||||
CrestWeight float64 `json:"crest_weight"`
|
||||
}
|
||||
|
||||
// Fluvial is the stream-power block: dh/dt = U - K * A^m * S^n, solved implicitly up the drainage stack.
|
||||
type Fluvial struct {
|
||||
K float64 `json:"k"`
|
||||
M float64 `json:"m"`
|
||||
N float64 `json:"n"`
|
||||
DtYr float64 `json:"dt_yr"`
|
||||
Steps int `json:"steps"`
|
||||
DiffusionM2Yr float64 `json:"diffusion_m2_yr"`
|
||||
// FillEvery is the one number that decides whether a full run is five minutes or half an hour: the
|
||||
// priority-flood is the only part of a step that is not O(n). See Docs/Terrain.md, the time budget.
|
||||
FillEvery int `json:"fill_every"`
|
||||
// CriticalAreaM2 is where channels begin; below it a cell is a hillslope. See package fluvial.
|
||||
CriticalAreaM2 float64 `json:"critical_area_m2"`
|
||||
ChannelTaper float64 `json:"channel_taper"`
|
||||
|
||||
// The nonlinear hillslope law, q = D*S/(1-(S/Sc)^2). CriticalSlopeDeg is Sc as an angle; 0 falls back to
|
||||
// linear diffusion with the repose clamp inside the step loop. See internal/fluvial/hillslope.go.
|
||||
CriticalSlopeDeg float64 `json:"critical_slope_deg"`
|
||||
SlopeCap float64 `json:"slope_cap"`
|
||||
MaxHillslopeSub int `json:"max_hillslope_substeps"`
|
||||
}
|
||||
|
||||
type Thermal struct {
|
||||
CoarsePasses int `json:"coarse_passes"`
|
||||
Every int `json:"every"`
|
||||
FinePasses int `json:"fine_passes"`
|
||||
TalusDeg float64 `json:"talus_deg"`
|
||||
}
|
||||
|
||||
type Strata struct {
|
||||
PeriodM float64 `json:"period_m"`
|
||||
Contrast float64 `json:"contrast"`
|
||||
}
|
||||
|
||||
type Detail struct {
|
||||
Octaves int `json:"octaves"`
|
||||
AmplitudeM Range `json:"amplitude_m"`
|
||||
}
|
||||
|
||||
// Particle is the droplet block, demoted by D-47 from "carves the valleys" to detail only. Every brake in it
|
||||
// was learned the hard way; see Docs/Terrain.md.
|
||||
type Particle struct {
|
||||
Droplets int `json:"droplets"`
|
||||
Lifetime int `json:"lifetime"`
|
||||
Scale float64 `json:"scale"`
|
||||
MinErodeSlope float64 `json:"min_erode_slope"`
|
||||
MaxChange float64 `json:"max_change"`
|
||||
MaxSpeed float64 `json:"max_speed"`
|
||||
MaxLoad float64 `json:"max_load"`
|
||||
Inertia float64 `json:"inertia"`
|
||||
Capacity float64 `json:"capacity"`
|
||||
MinSlope float64 `json:"min_slope"`
|
||||
ErodeRate float64 `json:"erode_rate"`
|
||||
DepositRate float64 `json:"deposit_rate"`
|
||||
Evaporation float64 `json:"evaporation"`
|
||||
Gravity float64 `json:"gravity"`
|
||||
Batch int `json:"batch"`
|
||||
}
|
||||
|
||||
// Continent is the coast and the sea floor: not in the incoming spec at all, kept by D-48 because sea level
|
||||
// is a better-posed base level for the fluvial solve than one outlet edge.
|
||||
type Continent struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Radius float64 `json:"radius"`
|
||||
CoastWarp float64 `json:"coast_warp"`
|
||||
SeaFloorM Range `json:"sea_floor_m"`
|
||||
// LandFraction is met exactly, by thresholding the continent field at the percentile that yields it, so
|
||||
// the land area does not wander with the seed.
|
||||
LandFraction float64 `json:"land_fraction"`
|
||||
// RadialBias pulls the land towards the middle. It only biases: at 0 the continent is wherever the noise
|
||||
// puts it, and high values return the disc with a wobbly edge that the first version produced.
|
||||
RadialBias float64 `json:"radial_bias"`
|
||||
// ShoreWidthPct is how many percentiles the shore transition spans. Small is a cliff coast, large is a
|
||||
// wide tidal shelf.
|
||||
ShoreWidthPct float64 `json:"shore_width_pct"`
|
||||
// OutlineOctaves and OutlineGain are how much detail the coastline itself has. A real coastline is
|
||||
// fractal — that is the whole point of the Richardson coastline paradox — and five octaves over a 14 km
|
||||
// map puts the finest feature at about 450 m, which is a smooth blob with no inlets, no headlands and no
|
||||
// islands. Measured on seed 7 with five octaves: the fetch called the median stretch of coast fully open,
|
||||
// because there was nothing at the fetch scale to shelter anything from anything.
|
||||
OutlineOctaves int `json:"outline_octaves"`
|
||||
OutlineGain float64 `json:"outline_gain"`
|
||||
}
|
||||
|
||||
// Coast is what happens where the land meets the sea: the shape of the sea floor, and the two processes
|
||||
// that work on the shoreline itself.
|
||||
//
|
||||
// It is a separate block from Continent because the two answer different questions. Continent decides *where*
|
||||
// the coastline runs — it is part of the tectonics, it is what the fluvial solve takes as its base level, and
|
||||
// it is fixed before a single step of erosion. Coast decides what the shoreline *is*, and it runs after the
|
||||
// solve, on the terrain the solve produced: the sea floor cannot be laid until the land behind it has its
|
||||
// relief, and the surf cannot cut a cliff into a mountain that has not been built yet.
|
||||
type Coast struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
// The sea floor. A real margin is a shelf at a very gentle grade out to a shelf break, and then a much
|
||||
// steeper continental slope down to the abyssal floor; the flat plane at the bottom of the elevation
|
||||
// range that this replaces was neither. ShelfKm is a range because the shelf width is not a constant:
|
||||
// it is wide off a low coastal plain and narrow off a mountain range that comes down to the water, so it
|
||||
// is interpolated per stretch of shore by the relief standing behind that stretch.
|
||||
ShelfKm Range `json:"shelf_km"`
|
||||
SteepCoastM float64 `json:"steep_coast_m"`
|
||||
SlopeKm float64 `json:"slope_km"`
|
||||
ShelfExponent float64 `json:"shelf_exponent"`
|
||||
RoughnessM float64 `json:"roughness_m"`
|
||||
RoughWaveM float64 `json:"rough_wavelength_m"`
|
||||
|
||||
// Shelter. Fetch is cast from every waterline cell in FetchDirections directions out to FetchRangeM, and
|
||||
// how far the rays get before they hit land is what separates an exposed headland from the back of a bay.
|
||||
// It is the one field both coastal processes are driven by: the surf reaches furthest inland where the
|
||||
// water is open, and sediment settles where it is not.
|
||||
FetchDirections int `json:"fetch_directions"`
|
||||
FetchRangeM float64 `json:"fetch_range_m"`
|
||||
|
||||
// The surf. Within a reach of the waterline the land is planed towards a shore platform at
|
||||
// PlatformGrade; the step at the back of the planed strip is the cliff, and it is a consequence of the
|
||||
// reach ending rather than something drawn. CutFraction below 1 leaves the platform rough.
|
||||
SurfReachM float64 `json:"surf_reach_m"`
|
||||
PlatformGrade float64 `json:"platform_grade"`
|
||||
CutFraction float64 `json:"cut_fraction"`
|
||||
|
||||
// Deposition. What the surf cuts does not vanish: it is carried DriftM along the shore and laid in
|
||||
// sheltered water shallower than DepositDepthM and within DepositReachM of the shore, up to BermM above
|
||||
// sea level. Rivers deliver their own load at their mouths, which is what makes a delta.
|
||||
DepositReachM float64 `json:"deposit_reach_m"`
|
||||
DepositDepthM float64 `json:"deposit_depth_m"`
|
||||
ShelterBias float64 `json:"shelter_bias"`
|
||||
BermM float64 `json:"berm_m"`
|
||||
DriftM float64 `json:"drift_m"`
|
||||
RiverM3PerKm2 float64 `json:"river_m3_per_km2"`
|
||||
RiverExponent float64 `json:"river_exponent"`
|
||||
RiverChannelKm2 float64 `json:"river_channel_km2"`
|
||||
}
|
||||
|
||||
type Pipeline struct {
|
||||
GeologyFactor int `json:"geology_factor"`
|
||||
Continent Continent `json:"continent"`
|
||||
Coast Coast `json:"coast"`
|
||||
Plates Plates `json:"plates"`
|
||||
Faults Faults `json:"faults"`
|
||||
Lithology Lithology `json:"lithology"`
|
||||
Relief Relief `json:"relief"`
|
||||
Fluvial Fluvial `json:"fluvial"`
|
||||
Thermal Thermal `json:"thermal"`
|
||||
Strata Strata `json:"strata"`
|
||||
Detail Detail `json:"detail"`
|
||||
Particle Particle `json:"particle"`
|
||||
}
|
||||
|
||||
type Manifest struct {
|
||||
Path string `json:"-"`
|
||||
|
||||
Level string `json:"level"`
|
||||
VerticesPerSide int `json:"vertices_per_side"`
|
||||
QuadCm float64 `json:"quad_cm"`
|
||||
ElevationM Elevation `json:"elevation_m"`
|
||||
SeaLevelM float64 `json:"sea_level_m"`
|
||||
SpawnPadM float64 `json:"spawn_pad_m"`
|
||||
StreamingGridComponents int `json:"streaming_grid_components"`
|
||||
Source Source `json:"source"`
|
||||
Layers Layers `json:"layers"`
|
||||
Pipeline Pipeline `json:"pipeline"`
|
||||
|
||||
// Erosion is the pre-D-47 block. Kept only so a manifest that still carries it can be reported rather
|
||||
// than silently ignored.
|
||||
Erosion map[string]any `json:"erosion"`
|
||||
}
|
||||
|
||||
// Defaults are the generator's own numbers, so a manifest carries only what differs from them. This is the
|
||||
// Go equivalent of heightmap_erosion.DEFAULTS and it plays the same role.
|
||||
func Defaults() *Manifest {
|
||||
return &Manifest{
|
||||
Level: "/Game/Maps/L_World",
|
||||
VerticesPerSide: 7141, // 255*28+1 (D-48): the importer's own rule then gives 28x28 components
|
||||
QuadCm: 200,
|
||||
ElevationM: Elevation{Min: -512, Max: 1536}, // span 2048 m is exactly Z scale 400
|
||||
SeaLevelM: 0,
|
||||
SpawnPadM: 150,
|
||||
StreamingGridComponents: 2,
|
||||
Source: Source{Kind: "noise", Seed: 7},
|
||||
Layers: Layers{
|
||||
RockSlopeStart: 0.55, RockSlopeFull: 1.05,
|
||||
HighAltitudeStartM: 1100, HighAltitudeFullM: 1650, BreakupM: 18,
|
||||
WearRockStart: 0.35, RidgeRock: 0.6, DepositSoftens: 0.7,
|
||||
},
|
||||
Pipeline: Pipeline{
|
||||
GeologyFactor: 4,
|
||||
Continent: Continent{
|
||||
Enabled: true, Radius: 0.62, CoastWarp: 0.28,
|
||||
SeaFloorM: Range{-180, -30}, LandFraction: 0.62,
|
||||
RadialBias: 0.85, ShoreWidthPct: 3,
|
||||
// Measured on seed 7 at 1400, sweeping the gain with everything else held: shoreline length
|
||||
// 64 km at 0.50, 81 at 0.58, 96 at 0.62, 114 at 0.66, and the fetch's view of the coast went
|
||||
// from "the median stretch is fully open" (1.00) to 0.98, 0.84 and 0.51. 0.62 is where the
|
||||
// coast has islands, inlets and headlands that shelter each other without the outline
|
||||
// breaking up into speckle. Octaves past 9 buy nothing: at gain 0.50 the sweep 5, 7, 8, 9, 10
|
||||
// gave 59, 63, 64, 65, 66 km and it had flattened.
|
||||
OutlineOctaves: 8, OutlineGain: 0.62,
|
||||
},
|
||||
Coast: Coast{
|
||||
Enabled: true,
|
||||
// The canvas is 14.28 km a side and the sea is a third of it, so a real shelf — 75 km out
|
||||
// to a break at 130 m — does not fit and is not what these numbers are. They are the same
|
||||
// *shape* scaled to the map: a gentle shelf a kilometre or two wide, a break at the
|
||||
// SeaFloorM high end, and a slope to the SeaFloorM low end over another kilometre and a
|
||||
// half. The two SeaFloorM numbers keep their meaning; what changes is that the depth
|
||||
// between them is now a function of distance offshore rather than of the mask's ramp.
|
||||
ShelfKm: Range{0.6, 3.0}, SteepCoastM: 300, SlopeKm: 1.6, ShelfExponent: 0.7,
|
||||
RoughnessM: 10, RoughWaveM: 1200,
|
||||
FetchDirections: 16, FetchRangeM: 1500,
|
||||
// 110 m of reach is 14 cells at the 8 m geology cell, which is about the least that can
|
||||
// carry a platform and a cliff at this resolution. The shore is the one landform whose
|
||||
// scale is set by physics rather than by the map, so it does not grow with the canvas;
|
||||
// when the detail passes exist this pass is where the beach itself gets built, at 2 m.
|
||||
SurfReachM: 110, PlatformGrade: 0.02, CutFraction: 0.85,
|
||||
DepositReachM: 350, DepositDepthM: 25, ShelterBias: 1.5, BermM: 2, DriftM: 300,
|
||||
// Untuned, and deliberately reported rather than assumed: the summary prints the volume
|
||||
// cut, the volume laid and the volume the rivers delivered, so the next round of tuning
|
||||
// has a number to work from instead of an impression of a picture.
|
||||
RiverM3PerKm2: 1.2e5, RiverExponent: 0.6, RiverChannelKm2: 0.5,
|
||||
},
|
||||
Plates: Plates{
|
||||
Count: 6, VelocityCmYr: Range{1, 5}, BandKm: Range{2, 4},
|
||||
DivergentMmYr: Range{-2, -1}, RiftKm: Range{3, 6},
|
||||
// The swell is the fix for the dead plains and it stays: what a lowland needs in order to
|
||||
// have drainage is not a higher uplift rate but a *varying* one, because divides come from
|
||||
// variation. What was wrong was the absolute rate, not the idea.
|
||||
//
|
||||
// Steady-state slope is S = U/(K*A^m), and at CriticalAreaM2 0 that law is applied down to
|
||||
// a single cell, so every divide on the map sits at A = cell². At K 5e-5, m 0.5 and an 8 m
|
||||
// cell that is S = U/4e-4: 0.25 mm/yr puts every divide at 32 degrees and 0.9 mm/yr puts it
|
||||
// past the 35 degree repose clamp. Measured on the old numbers, 81 % of the land came out
|
||||
// in the >0.5 mm/yr class and the plain class held 1 %, all of it sea cliff. The plains were
|
||||
// not over-dissected; they were being uplifted at mountain rates, and U sets how *high* the
|
||||
// summits get, not how steep the ground is — for n = 1 the hillslope angle is the same
|
||||
// everywhere the same U is applied.
|
||||
//
|
||||
// So the rate drops an order of magnitude and the variation stays: 0.03 to 0.08 is still
|
||||
// the ~2.5-fold warp that puts divides on a plain, and it gives 4 to 11 degree hillslopes
|
||||
// and lowland channel gradients near 1 m/km. Against a convergent 1-2 mm/yr that is a
|
||||
// 30-to-60-fold mountain-to-plain ratio, which is what real ones are; the three-fold ratio
|
||||
// this replaces was not mountains and plains, it was mountains and slightly lower mountains.
|
||||
// The percentile ramp in rangeMask keeps the foreland continuous, so nothing becomes bimodal.
|
||||
ConvergentMmYr: Range{1.0, 2.0},
|
||||
IntraplateMmYr: 0.03,
|
||||
IntraplateSwellMmYr: 0.08,
|
||||
LowUpliftFraction: Range{0.2, 0.4},
|
||||
},
|
||||
Faults: Faults{
|
||||
Major: Range{3, 6}, Minor: Range{10, 30}, LengthKm: Range{2, 15}, SpacingKm: Range{1, 4},
|
||||
ThrowMajorM: Range{100, 400}, ThrowMinorM: Range{20, 80}, StrikeSlipM: Range{200, 800},
|
||||
},
|
||||
Lithology: Lithology{Types: 3, KMultipliers: []float64{0.5, 1.0, 3.0}},
|
||||
Relief: Relief{
|
||||
// The low end is 15 m, not 50: amplitude scales with normalised uplift, so the lo end is
|
||||
// what the plains start as, and steady-state plain relief at the rates above is about 10 m.
|
||||
// Starting them as 50 m hills means the run spends itself eroding away relief it was handed
|
||||
// rather than carving what the uplift field asks for.
|
||||
Octaves: 7, Gain: 0.45, BaseFrequencyM: 4000, AmplitudeM: Range{15, 150}, CrestWeight: 0.12,
|
||||
},
|
||||
Fluvial: Fluvial{
|
||||
// 1000 steps, not the incoming spec's 5000: at this K the trunk response time is about
|
||||
// 45 000 yr, and the exponent stops moving after 500 steps at 512². FillEvery is 1 and is
|
||||
// not a budget knob: at 50 the solve is simply wrong (see Docs/Terrain.md).
|
||||
K: 5e-5, M: 0.5, N: 1.0, DtYr: 1500, Steps: 1000, DiffusionM2Yr: 0.02, FillEvery: 1,
|
||||
// 0 disables it, and it is disabled on purpose. A channelization threshold is the textbook
|
||||
// answer to stream power over-steepening hillslopes, but it only works paired with a
|
||||
// hillslope transport law strong enough to carry the uplift into the channels, and at this
|
||||
// timescale there isn't one: the diffusivity it would need (~0.3 m²/yr over a 220 m
|
||||
// hillslope) has a diffusion length of sqrt(D*t) ≈ 470 m over 1.5 Myr, which smooths away
|
||||
// every landform the generator exists to make. Measured: the map went to melted wax. With
|
||||
// the threshold on and diffusion left low, hillslopes instead accumulate uplift unchecked
|
||||
// and the map clipped 22% of the elevation range. Landsliding carries the hillslopes here.
|
||||
// Measured again after the uplift field was fixed, and it still fails: at 1e4 the plains
|
||||
// went from 0.8 to 7.0 degrees median, the rolling class from 7.4 to 32.8 with half of it
|
||||
// pinned against the repose clamp, and the mountains to 79 % pinned. The reason is the same
|
||||
// one as before — the hillslope the threshold creates has to shed its uplift by diffusion,
|
||||
// and at D 0.02 it cannot, so the clamp takes the job instead. It stays at 0 until there is
|
||||
// a transport law strong enough to pair it with.
|
||||
CriticalAreaM2: 0,
|
||||
ChannelTaper: 2,
|
||||
// Sc is the repose angle, so the nonlinear law limits at the same place the clamp did; what
|
||||
// changes is that it approaches it smoothly and isotropically instead of cutting to it along
|
||||
// eight grid directions. See internal/fluvial/hillslope.go for what the cap and the sub-step
|
||||
// budget buy and what they cost.
|
||||
CriticalSlopeDeg: 35,
|
||||
SlopeCap: 0.9,
|
||||
MaxHillslopeSub: 24,
|
||||
},
|
||||
Thermal: Thermal{CoarsePasses: 2, Every: 4, FinePasses: 24, TalusDeg: 35},
|
||||
Strata: Strata{PeriodM: 160, Contrast: 0.6},
|
||||
Detail: Detail{Octaves: 4, AmplitudeM: Range{2, 8}},
|
||||
Particle: Particle{
|
||||
Droplets: 9000000, Lifetime: 40, Scale: 0.5, MinErodeSlope: 0.25, MaxChange: 0.2,
|
||||
MaxSpeed: 5, MaxLoad: 2, Inertia: 0.1, Capacity: 2, MinSlope: 0.01,
|
||||
ErodeRate: 0.2, DepositRate: 0.2, Evaporation: 0.02, Gravity: 4, Batch: 200000,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Load reads the manifest over the defaults, so a key absent from the file keeps the generator's number.
|
||||
// encoding/json only assigns fields that are present, which gives exactly the merge the numpy pipeline did
|
||||
// with {**DEFAULTS, **settings}.
|
||||
func Load(path string) (*Manifest, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := Defaults()
|
||||
if err := json.Unmarshal(raw, m); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
m.Path = path
|
||||
return m, m.Validate()
|
||||
}
|
||||
|
||||
func (m *Manifest) Validate() error {
|
||||
if m.VerticesPerSide < 2 {
|
||||
return fmt.Errorf("%s: vertices_per_side must be at least 2", m.Path)
|
||||
}
|
||||
if m.QuadCm <= 0 {
|
||||
return fmt.Errorf("%s: quad_cm must be positive", m.Path)
|
||||
}
|
||||
if m.ElevationM.Max <= m.ElevationM.Min {
|
||||
return fmt.Errorf("%s: elevation_m.max must be above .min", m.Path)
|
||||
}
|
||||
// D-45: the importer picks the largest section size that divides the quad count, preferring one section
|
||||
// per component, so a resolution off the ladder of 255*N+1 or 127*N+1 silently multiplies the component
|
||||
// count. 4033 gave 4096 components and a forty-minute import. Refuse rather than let it happen again.
|
||||
q := m.QuadsPerSide()
|
||||
section := 0
|
||||
for _, s := range []int{255, 127, 63, 31, 15, 7} {
|
||||
if q%s == 0 {
|
||||
section = s
|
||||
break
|
||||
}
|
||||
}
|
||||
if section == 0 {
|
||||
return fmt.Errorf("%s: vertices_per_side %d gives %d quads, which no section size divides; use 255*N+1 or 127*N+1",
|
||||
m.Path, m.VerticesPerSide, q)
|
||||
}
|
||||
if components := (q / section) * (q / section); components > 1024 {
|
||||
return fmt.Errorf("%s: vertices_per_side %d gives %d components of %d quads; that import takes tens of minutes (D-45)",
|
||||
m.Path, m.VerticesPerSide, components, section)
|
||||
}
|
||||
if f := m.Pipeline.GeologyFactor; f < 1 || q%f != 0 {
|
||||
return fmt.Errorf("%s: geology_factor %d must divide the quad count %d exactly", m.Path, f, q)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Derived geometry, all of it mirroring world_manifest.py.
|
||||
|
||||
func (m *Manifest) QuadsPerSide() int { return m.VerticesPerSide - 1 }
|
||||
func (m *Manifest) QuadM() float64 { return m.QuadCm / 100 }
|
||||
func (m *Manifest) SideM() float64 { return float64(m.QuadsPerSide()) * m.QuadM() }
|
||||
func (m *Manifest) AreaKm2() float64 { s := m.SideM() / 1000; return s * s }
|
||||
|
||||
func (m *Manifest) ElevationSpanM() float64 { return m.ElevationM.Max - m.ElevationM.Min }
|
||||
func (m *Manifest) ElevationMidM() float64 { return (m.ElevationM.Max + m.ElevationM.Min) / 2 }
|
||||
|
||||
// ZScale is the landscape actor's Z scale so the 16-bit range spans exactly the manifest's elevation range.
|
||||
func (m *Manifest) ZScale() float64 { return m.ElevationSpanM() / EngineSpanMAtScale100 * 100 }
|
||||
|
||||
// LandscapeZCm places value 32768 at the middle of the range, so elevation 0 m lands on world Z 0.
|
||||
func (m *Manifest) LandscapeZCm() float64 { return m.ElevationMidM() * 100 }
|
||||
|
||||
func (m *Manifest) MetresToValue(metres float64) float64 {
|
||||
return (metres - m.ElevationM.Min) / m.ElevationSpanM() * 65535
|
||||
}
|
||||
|
||||
func (m *Manifest) ValueToMetres(v float64) float64 {
|
||||
return m.ElevationM.Min + v/65535*m.ElevationSpanM()
|
||||
}
|
||||
|
||||
// GeologySize is the coarse grid the tectonics and the fluvial solve run on: an exact integer factor of the
|
||||
// quad count, so the upsample back to full resolution lands every sample on a sample.
|
||||
func (m *Manifest) GeologySize() int {
|
||||
return m.QuadsPerSide()/m.Pipeline.GeologyFactor + 1
|
||||
}
|
||||
|
||||
func (m *Manifest) GeologyCellM() float64 {
|
||||
return m.QuadM() * float64(m.Pipeline.GeologyFactor)
|
||||
}
|
||||
|
||||
// SectionLayout reports what the engine's importer will choose, so a run can print it and a person can see
|
||||
// the component count before the editor spends minutes on it.
|
||||
func (m *Manifest) SectionLayout() (section, componentsPerSide int) {
|
||||
q := m.QuadsPerSide()
|
||||
for _, s := range []int{255, 127, 63, 31, 15, 7} {
|
||||
if q%s == 0 {
|
||||
return s, q / s
|
||||
}
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// Resolve reads a manifest path as relative to the project root.
|
||||
func (m *Manifest) Resolve(rel string) string {
|
||||
if filepath.IsAbs(rel) {
|
||||
return rel
|
||||
}
|
||||
return filepath.Join(ProjectRoot(m.Path), rel)
|
||||
}
|
||||
|
||||
// ProjectRoot walks up from the manifest (RawContent/World/World.json) to the repository root.
|
||||
func ProjectRoot(manifestPath string) string {
|
||||
abs, err := filepath.Abs(manifestPath)
|
||||
if err != nil {
|
||||
return "."
|
||||
}
|
||||
return filepath.Dir(filepath.Dir(filepath.Dir(abs)))
|
||||
}
|
||||
|
||||
func (m *Manifest) Describe() string {
|
||||
section, perSide := m.SectionLayout()
|
||||
return fmt.Sprintf(
|
||||
"%d vertices a side at %g cm: %.2f km, %.0f km2; elevation %g..%g m (Z scale %g, actor Z %g cm, %.2f cm a step); "+
|
||||
"%dx%d components of %d quads; geology %d at %.1f m; source %s seed %d",
|
||||
m.VerticesPerSide, m.QuadCm, m.SideM()/1000, m.AreaKm2(),
|
||||
m.ElevationM.Min, m.ElevationM.Max, m.ZScale(), m.LandscapeZCm(), m.ElevationSpanM()/65535*100,
|
||||
perSide, perSide, section, m.GeologySize(), m.GeologyCellM(), m.Source.Kind, m.Source.Seed)
|
||||
}
|
||||
|
||||
// ClipFraction is the check D-48 made a pass/fail: U/K is the one relief knob and the elevation ceiling is a
|
||||
// hard clip in the 16-bit encoding, so a run that clips is a failed run, not a rounded one.
|
||||
func (m *Manifest) ClipFraction(metres []float32) float64 {
|
||||
if len(metres) == 0 {
|
||||
return 0
|
||||
}
|
||||
var n int
|
||||
for _, v := range metres {
|
||||
if float64(v) < m.ElevationM.Min || float64(v) > m.ElevationM.Max {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return float64(n) / float64(len(metres))
|
||||
}
|
||||
|
||||
// Encode turns metres into the 16-bit values the PNG carries, clamping to the range.
|
||||
func (m *Manifest) Encode(metres []float32) []uint16 {
|
||||
out := make([]uint16, len(metres))
|
||||
for i, v := range metres {
|
||||
x := m.MetresToValue(float64(v))
|
||||
if x < 0 {
|
||||
x = 0
|
||||
} else if x > 65535 {
|
||||
x = 65535
|
||||
}
|
||||
out[i] = uint16(math.Round(x))
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user