Files
UnrealPrototyping/RawContent/World
2026-09-25 17:02:24 +03:00
..
2026-09-25 17:02:24 +03:00
2026-09-25 17:02:24 +03:00
2026-09-25 17:02:24 +03:00
2026-09-25 17:02:24 +03:00
2026-09-25 17:02:24 +03:00
2026-09-25 17:02:24 +03:00
2026-09-25 17:02:24 +03:00
2026-09-25 17:02:24 +03:00
2026-09-25 17:02:24 +03:00
2026-09-25 17:02:24 +03:00

The world's terrain source

L_World is the planet-map region (D-72). Two manifests live in this folder and they build different levels. Region.json builds L_World, the world the game uses — skip to The region for it. World.json, described first below, builds L_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.py empties whatever level it is handed before rebuilding it, so a manifest still pointing at L_World would 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 Generated edit 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_World are 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 records metres_per_pixel, the window in degrees and the projection into the manifest. metres_per_pixel in 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 writes Region.generated.json beside it and says so; rename it over the old one once you have read the difference. The tiles in RegionTiles/ are overwritten.
  • The cutter cannot run against that manifest, and says so. Its source.kind is orogen_render and 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.py notices and reaches for generate_region_tiles.py, which now stops with a message telling you to re-export from Orogen rather than a KeyError three frames down. Everything else region_manifest.py exposes 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 WorldPartitionHLODsBuilder commandlet. 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 and L_World needs it too.
  • Stop splitting. streaming_grid_components: 0 skips ChangeGridSize entirely 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.