24 KiB
The world's terrain source
L_Worldis the planet-map region (D-72). Two manifests live in this folder and they build different levels.Region.jsonbuildsL_World, the world the game uses — skip to The region for it.World.json, described first below, buildsL_Canvas_Proto: the numpy pipeline's square 14.28 km canvas, legacy by D-47 and kept only because it is still the one path that carries the erosion pass's flow, wear and deposit maps into Unreal.The names are apart on purpose.
create_world.pyempties whatever level it is handed before rebuilding it, so a manifest still pointing atL_Worldwould replace 98 landscapes with a 14 km square on a single run, with no prompt and no warning.
This document describes the pipeline as it is built today. Where it is going — a Go core, stream-power erosion, plates and faults, a
Generatededit layer, and a canvas of 7141 vertices at 200 cm — is settled in../../Docs/Terrain.md(D-47). Nothing here is wrong yet; several things in it are scheduled to be replaced, and that document says which and by what.
L_Canvas_Proto is a product of three inputs, none of them hand-edited: this folder's World.json (the manifest),
the PNGs in Heightmaps/ that Scripts/Authoring/generate_heightmap.py writes from it, and
Scripts/Authoring/create_world.py, which imports them into the level and dresses it with Elite_RockyMeadows'
kit. Change an input, rerun the two scripts, and the level is rebuilt from scratch.
D:/UE_5.8/Engine/Binaries/ThirdParty/Python3/Win64/python.exe Scripts/Authoring/generate_heightmap.py
D:/UE_5.8/Engine/Binaries/Win64/UnrealEditor-Cmd.exe Salty.uproject -run=pythonscript -script=Scripts/Authoring/create_world.py -AllowCommandletRendering
The first needs numpy in Scripts/Authoring/.pylib (bootstrap-pylib.sh, once per machine) and takes about
five minutes at 4081, most of it erosion (40 s without). The second takes a minute or two (the landscape's
textures are built through the derived-data cache)
and must not be killed part way: run it detached, not under a tool with a timeout. It can run while the editor
is open, as long as the editor does not have L_Canvas_Proto loaded at that moment. An existing level is
loaded and emptied rather than deleted, because an editor that has had it open keeps its two HLOD layer assets
locked and a recreation would fail to save; the previous build's proxy packages are swept after the save.
The manifest
| Key | Meaning |
|---|---|
vertices_per_side |
Heightmap resolution. 4081 gives 16x16 landscape components of 255 quads, one streaming proxy each; see the layout note below before changing it |
quad_cm |
Metres between vertices, in centimetres. 350 makes 4081 vertices 14.28 km, 204 km² |
elevation_m.min / .max |
What heightmap values 0 and 65535 mean, in metres. The landscape's Z scale and the actor's Z offset follow from these, so that elevation 0 m is world Z 0 |
sea_level_m |
Where the sea plane sits and what the noise source builds its continent around. Keep it 0 unless there is a reason |
spawn_pad_m |
Radius of the flat disc blended into the centre of the map for the player starts |
streaming_grid_components |
Landscape components per world-partition streaming proxy, per side |
source |
Where the height comes from; see below |
layers |
The paint-layer rules: rock by slope (rise over run), high rock by altitude (metres), and a noise break-up so boundaries are not contour lines |
Erosion
Whatever the source, the height then goes through heightmap_erosion.py before the layers are derived,
because fractal noise alone gives pillowy hills and no drainage. The erosion block of the manifest drives
it; every key has a default in heightmap_erosion.DEFAULTS, and "enabled": false skips the whole stage
(for a real DEM, which is already eroded).
| Key | Meaning |
|---|---|
coarse_factor, coarse_droplets, coarse_lifetime |
The first hydraulic pass runs on the map downsampled by the factor, with long-lived droplets (one cell per step, so 120 on 14 m cells is a 1.7 km path): this carves the valleys. Its result is applied to the full map as a delta, so the fine detail survives |
fine_droplets, fine_lifetime |
The second pass at full resolution, short-lived droplets: gullies and rills |
thermal_passes, talus_deg |
Thermal weathering with an angle of repose: where a cell stands above a neighbour by more than the angle allows, half of the largest excess slides down, shared among the lower neighbours. Mass is conserved, so cliffs keep a face and scree builds at their foot |
strata_period_m, strata_contrast |
Rock hardness as horizontal bands with a slow tilt, scaling the hydraulic erosion: hard bands hold shelves and ledges. Contrast 0 is uniform rock |
inertia, capacity, min_slope, deposit_rate, erode_rate, evaporation, gravity |
The droplet constants, in cell units (a slope of 1 is 45°), so they mean the same at both resolutions |
max_change, max_speed, max_load |
Brakes. Droplets step in vectorised batches that share cells; without a cap on what one droplet may cut or fill per step, a crowd in one cell runs away to infinity. The load cap bounds the mound a droplet can leave where it stops |
min_erode_slope, fine_scale |
Below min_erode_slope (rise over run) water deposits but barely cuts, so lowland soil holds and meadows stay smooth; fine_scale runs the full-resolution pass at a fraction of the cutting rate, so it leaves gullies rather than trenches |
Each droplet cuts through a 3x3 brush around its cell, not a single cell: a one-cell footprint leaves every path as a rill one cell wide, which reads as brush strokes across the lowlands. Deposits land on the droplet's own cell, so a pit fills to the brim and the droplets move on; spread through the brush, a pit's rim rises faster than its floor and every droplet draining into it adds to a mound.
The pass also writes four derivative maps next to the weightmaps: L_Canvas_Proto_Flow.png (water passed, log scaled),
L_Canvas_Proto_Wear.png (bedrock scraped), L_Canvas_Proto_Deposit.png (sediment laid down) and L_Canvas_Proto_Curvature.png
(128 flat, brighter convex, darker concave). The layer rules use them: scraped bedrock and convex ridges read as
rock, sediment fans and basins read as meadow. Nothing in the landscape material samples them yet; they are
there for the material that will.
Swapping the noise for a real heightmap
The source block decides. Today it is noise:
"source": { "kind": "noise", "seed": 7 }
To use a real heightmap, point it at the file and say what its value range means in metres:
"source": { "kind": "file", "path": "RawContent/World/Sources/my_area.png", "elevation_m": { "min": 0, "max": 2400 } }
Then rerun the two scripts. What happens to the file: it is read (16-bit greyscale PNG, or raw 16-bit
little-endian .r16/.raw with "width" given when it is not square; 8-bit PNGs are accepted and widened),
optionally flipped with "flip_y": true, cropped to a centred square, converted to metres with its own
elevation_m, resampled onto vertices_per_side (box-filtered when shrinking, bilinear otherwise; add
"smooth_passes": 2 to soften a coarse DEM that was scaled up), and re-encoded into the world's
elevation_m range, clipping and reporting anything outside it. The paint layers are derived from the finished
height by the same rules as for noise, so a real heightmap needs no weightmaps of its own, and the spawn pad is
blended in at the centre either way. Widen the world's elevation_m if the file's range does not fit; the
range costs nothing but height precision (65535 steps over the span: 4 cm at 2560 m).
Any DEM tool that writes 16-bit PNG or r16 works: QGIS (gdal_translate -ot UInt16 -scale), World Machine,
Gaea, terrain.party, the engine's own landscape export. A 30 m DEM of a 14 km area is only about 470 samples
across; it will be smooth after resampling, which is what smooth_passes and the layer break-up are for.
--source-file and --source-elevation on generate_heightmap.py try a file for one run without editing
the manifest; --seed does the same for noise.
The component layout
create_world.py hands the PNG to the engine's own importer, which picks the section size the way the
editor's Import button does: the largest of 255, 127, 63, 31, 15, 7 quads that divides vertices_per_side - 1
exactly, preferring one section per component. 4081 - 1 = 16 x 255, so 16x16 components of 255 quads.
The count matters more than the size: every component is a draw call and carries its own height and weight
textures, all built through the derived-data cache on import. Epic's own recommended 4033 would divide only
by 63, giving 64x64 components and a build four times as long for no visible gain. If you change the
resolution, pick 255 x N + 1 (or 127 x N + 1) with N at most 32.
Rocky Meadows' part
The pack contributes the landscape material (M_Landscape_Main_Inst_RockyMeadows02) and its three layer infos,
which is why the weightmaps carry the pack's names. Those names mislead: its Base_Layer samples the rock
textures, Layer_02 the grass, Layer_03 the high rock, so the meadow weightmap is L_Canvas_Proto_Layer_02.png; the
sun with the pack's cloud-shadow light function; its skybox dome and sky light; its height fog and post-process
grade. The numbers are copied from the pack's Rocky_Meadows_01 demo map as Scripts/Authoring/dump_level.py
read them, and live at the top of create_world.py. The sea is a plane, World_Sea_Proto, until a water
body replaces it.
It wears a placeholder grey (/Game/World/M_Sea_Proto, opaque and default-lit) rather than the engine's
single-layer water. The water material is a lake shader stretched over a whole planet here and reads at every
scale as something it is not; a plane that is honestly a placeholder is worth more while the ground is being
looked at than one pretending to be an ocean. rocky_meadows.SEA_GREY is the switch — set it False and the
water material comes back, unchanged and still the first thing tried. The material is authored on demand
rather than picked out of /Engine, because nothing there is the right value: BasicShapeMaterial is the
near-white that once read as an ice sheet to the horizon, and WorldGridMaterial puts a metre grid on a plane
seventy kilometres across.
ensure_dressing only spawns a sea when the level has none — correct, or a rerun would leave a second sun —
so changing the switch cannot by itself reach a world that already exists. That is what
fix_sea_material.py is for: it repaints the sea in a finished level and saves it, without rebuilding
anything.
D:/UE_5.8/Engine/Binaries/Win64/UnrealEditor-Cmd.exe <abs>/Salty.uproject -run=pythonscript \
-script="<abs>/Scripts/Authoring/fix_sea_material.py --level /Game/Maps/L_World" \
-AllowCommandletRendering -unattended -nopause -abslog=<abs>/Saved/Logs/sea.log
Pass --level more than once for several worlds. It probes the .umap first and refuses when an editor holds
it, for the same reason the region builder does.
The region: 900 km² of land from a planet map
L_Canvas_Proto above is the numpy pipeline's square 14.28 km canvas. L_World is this, built from a
different manifest: a window cut out of a finished planet heightmap and laid out as a grid of Unreal
landscapes, so there is ground at the scale the game wants long before the Go generator's detail passes reach
Unreal. Its contract is Region.json; every key in it is explained in Scripts/Authoring/region_manifest.py.
Region.json's level decides which level is built and what the tile files are called — tile_name
is the level's last segment plus the tile's coordinates, so L_World means L_World_x0_y0_Height.png.
Change level and every tile in RegionTiles/ looks missing; rename the PNGs to match or the generator
rebuilds all ninety-eight of them. build_region.sh reads the level out of the manifest for the same reason
it reads the grid from there: a name written into the script goes stale the moment the manifest changes.
cd Tools/MapArt && go run . biomes # the biome masks, if any paint layer reads one
D:/UE_5.8/Engine/Binaries/ThirdParty/Python3/Win64/python.exe Scripts/Authoring/generate_region_tiles.py --scout
D:/UE_5.8/Engine/Binaries/ThirdParty/Python3/Win64/python.exe Scripts/Authoring/generate_region_tiles.py
D:/UE_5.8/Engine/Binaries/Win64/UnrealEditor-Cmd.exe <abs>/Salty.uproject -run=pythonscript \
-script=<abs>/Scripts/Authoring/create_region_world.py -AllowCommandletRendering -abslog=<abs>/region.log
Do not leave the editor sitting on L_World while a build runs. It holds a write lock on the .umap,
and the save is the last thing a batch does while --rebuild is the first: the run aborts on
MoveFile … (Error Code 32) having already emptied the level. That happened on 2026-09-20 and left twelve
of ninety-eight tiles, all of row y=0 — the polar strip, which on this planet is nearly all ocean — so the
level opened on 71 km of sea and read as a corrupted world. Both build_region.sh and
create_region_world.py now probe the file before anything is destroyed and refuse with a message naming
the cause. Load another level in the editor (or close it) and rerun; --append keeps whatever survived.
--scout measures the window and prints what it holds without writing anything: it is the cheap way to try a
scale or a position. The tiles take about two minutes and 208 MB (untracked; they are a product of the manifest
and the source). The level takes about ten minutes and must be run detached, with absolute paths and its own
-abslog: with the editor open and a relative project path the commandlet exits silently having done nothing.
What it is today
| Source | Orogen Gens/orogen-heightmap-7945.png, 8192x4096, the painted planet exported from the browser twin. The absolute heightmap (-5..6 km), not orogen-land-heightmap-*.png, which Orogen's "Export All" confusingly labels "Heightmap" and which has every ocean pixel at 0 m |
| Window | the whole export, 8192 x 4096 at (0, 0). Not a crop: every pixel of the planet is imported |
| Scale | 8.7158203125 m a source pixel, resampled to 2 m quads: a 4.36x upsample |
| Grid | 14 x 7 landscapes of 2551 vertices, 10 x 10 components of 255 quads each: 9800 components |
| Extent | 71.40 x 35.70 km, 2549 km² of map holding about 925 km² of land, elevation -1024..6144 m |
14:7 is exactly the export's own 2:1, so the aspect is undistorted and both axes come out at the same metres
per pixel, which is what --scout checks.
Three things about it that are not obvious
These three were written when the window was a 1355 px square at latitude -24 — 6 x 6 tiles, 30.60 km a side, 22.583 m a pixel. The window is the whole planet now and two of the numbers below moved with it, but the reasoning is unchanged and is why the manifest looks the way it does. The second one got worse, not better: a 1355 px window at latitude -24 stretched a uniform 9.6%, whereas reading the entire cylinder flat stretches by 1/cos(latitude) at every row, which is unbounded at the poles. The polar strips of
L_Worldare therefore smeared east-west, and that is the price of importing the whole map rather than a patch of it. It is not a defect to fix here; it is the reason a window at middle latitudes was the original shape, and the reason Orogen's own export (below) cosine-corrects at the centre latitude.
The source carries no scale, so the scale is a choice. Orogen's heightmap export is a cylindrical
projection with a fixed -5000..6000 m ramp and nothing saying how wide the planet is. Planet.json says 100 km
round, and at 100 km the planet is 31.8 km across: a flat 30 km square is bigger than the planet and there is
no window to cut. metres_per_pixel is therefore a manifest number rather than something derived, and it is
the number that decides how much land a window can hold. 22.583 m a pixel is a 185 km circumference, chosen as
the finest reading whose best window still clears 900 km² of land. Coarser buys more land and blunter ground.
The map is read flat, not unprojected. A cylindrical map read flat stretches east-west by 1/cos(latitude);
this window sits at latitude -24 so its ground is 9.6% wider east-west than Orogen drew it. That is the price
of not projecting, and it is much cheaper than the alternatives: cos-correcting the crop stretches the window's
own edges by ±35% across the latitudes it spans, and a proper azimuthal projection of a patch two thirds the
width of the planet distorts more still. Keep a window at middle latitudes and the flat reading is a few
per cent. --scout prints the stretch it would cause.
The heights are Orogen's, not the generator's. Orogen normalises land so its 99.5th percentile stands at
the import page's peak setting, so these are a browser preview's metres. terrain bake makes this same
continent a plain about 116 m tall; the export makes it 2972 m. Treat the relief as art. sea_scale is the one
correction applied, and only to the sea: the export's abyss is 3 km down on a whole-planet ramp, which over a
30 km window is either a clipped plateau with a cliff at every shore, or an elevation range so wide the land
loses its precision.
The other way to get the tiles: straight out of Orogen
generate_region_tiles.py cuts the tiles out of a whole-planet PNG. World Orogen can now write them
itself, which skips the PNG and, more usefully, skips inventing the scale. Open Tools/Orogen in a
browser, press Export Map then Unreal Landscape…, and point the folder picker at
RawContent/World/. It writes RegionTiles/ and a Region.json beside it, in exactly the shape
region_manifest.py reads, so create_region_world.py and build_region.sh are unchanged.
What that buys, and what it does not:
- The scale stops being a guess. You give the planet's circumference (this project's is in
Planet.json: 100 km) and the export recordsmetres_per_pixel, the window in degrees and the projection into the manifest.metres_per_pixelin the hand-written manifest above is a number somebody chose; here it is a consequence. - The window is a window, not a crop of a planet-wide raster, so the sampling resolution is spent on the ground you are cutting, and the heights come back as float rather than through a 16-bit ramp.
- The projection is cosine-corrected at the window's centre latitude, so the east-west stretch is split between the north and south edges instead of landing entirely on one. The panel prints it.
- It does not make the ground finer. The mesh still resolves about 200 m. That is the next section.
- It will not overwrite this
Region.json. Most of that file is the reasoning behind its numbers, so when one is already there the export writesRegion.generated.jsonbeside it and says so; rename it over the old one once you have read the difference. The tiles inRegionTiles/are overwritten. - The cutter cannot run against that manifest, and says so. Its
source.kindisorogen_renderand it names no file, because there is no PNG to re-cut from — the tiles came out of the browser. If a tile file goes missing,create_region_world.pynotices and reaches forgenerate_region_tiles.py, which now stops with a message telling you to re-export from Orogen rather than aKeyErrorthree frames down. Everything elseregion_manifest.pyexposes works unchanged,metres_per_pixel()included.
It also puts a hard number on something this document only implies. A 100 km circumference is a 3183 km² planet. The 936 km² window above is 29% of its entire surface, which is why it comes out as a rectangle 110° on a side with 74.7% of east-west stretch at its edge. Nothing is wrong with the tiles that produces — they are what a flat reading of most of a small globe looks like — but if you want a window that a sphere this size can hold flat, it is a few hundred km², not nine hundred. The panel's default, 4 × 2 tiles, is 20.4 × 10.2 km and 208 km² at 5.4% stretch.
Requires Chrome or Edge on desktop (the File System Access API); the panel says so if the browser lacks it.
What is missing, and where it comes from
The source resolves about 200 m — Orogen solves on a 204 K-region sphere mesh — so below that the ground is
smooth, and an 11x upsample cannot invent what is not there. There is no erosion pass here and therefore no
wear, flow or deposit map, which is why the paint layers are slope and altitude alone rather than L_World's
richer rules. None of that is a defect to be fixed here: the detail is the Go generator's job, and
terrain tiles already writes 5 km tiles of 2 m samples over a bake. When those tiles replace the window as
the source, generate_region_tiles.py is what changes and nothing downstream of it does.
Seams
Neighbouring tiles share their edge vertices and every vertex is sampled from its global position in the
window, so a shared column is computed twice from the same source coordinates and comes out bit-identical;
nothing blends or stitches. The paint-layer break-up noise goes through fbm_at at global coordinates for the
same reason. The one thing that did not follow from this was slope: np.gradient takes a one-sided difference
at an array edge, which is not what the neighbour computes for that vertex, and every tile boundary came out as
a one-vertex line of different paint. Tiles are therefore sampled with one vertex of margin on each side, the
layers derived over the lot, and the margin cropped off.
The overlay: reserved, not built
Region.json reserves an overlay block for the annotation layer (D-57), and region_manifest.py has the
place a tile's mark map would go (marks_path). Neither is implemented. No mark map is written, and
nothing here reads a mark: no forest, no road and no settlement is placed from one, which is deliberate — the
overlay is a feature this pipeline carries a slot for, not an input to any outcome in it.
It is left unbuilt rather than written blind because there is no overlay to run it against yet, and a carry
that has never carried anything is a guess about a file format. When there is one, this is the shape it should
take, and it is the shape terrain tiles already uses: an 8-bit mark index beside every tile, registered to
the same window and cut on the same global coordinates as the height, plus the features in world metres, with
the legend from RawContent/World/Templates/*.overlay.json. Until then the key is documentation of intent.
Why the level can look empty, and where the terrain actually is
ChangeGridSize splits every landscape into world-partition streaming proxies: at
streaming_grid_components 5 over a tile's 10 x 10 components that is four proxies a tile, 144 over the
window, and they hold all 3600 components. The thirty-six Landscape actors left behind are always-loaded
and carry none. So a freshly opened level shows only what is always loaded - the sun, the sky dome, the fog
and the sea plane - and the sea plane at Z 0, lit through the pack's cloud-shadow light function, looks
convincingly like soft terrain from above. It is not. trace_world straight down at the origin hitting Z 0
is the quick way to tell.
Nothing is lost when this happens: World Partition holds all 190 actor descriptors and reports the right world bounds. Three ways to see the ground:
- Load a region. Window > World Partition, drag a box, right-click > Load Region. No rebuild, and it is the intended editor workflow.
- Build HLODs. The World Partition window's Build HLODs button, or the
WorldPartitionHLODsBuildercommandlet. Unloaded ground then draws as proxy meshes, so the whole 936 km² is visible from the air. This is the right answer at this size andL_Worldneeds it too. - Stop splitting.
streaming_grid_components: 0skipsChangeGridSizeentirely and leaves the components on the always-loaded landscape - measured, 100 a tile instead of 0. The level then just opens showing everything, at the cost of loading 3600 components at once and giving up streaming.
A related trap, and the reason the player starts were once 180 m underground: do not trace for the ground in
a commandlet. The landscape's collision is not reliably present there, the sea plane's is, and a trace that
hits the sea returns 0.0 rather than failing. Read the height out of the heightmap instead, as
create_region_world.pad_height_cm does.