Added: Initial world generation tool

This commit is contained in:
Rainer Leit
2026-09-17 17:55:48 +03:00
parent d64748f76f
commit cc43ed8dc8
2065 changed files with 23664 additions and 1011 deletions
+129
View File
@@ -0,0 +1,129 @@
"""The world manifest: 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. Pure Python (no numpy, no engine), shared by
generate_heightmap.py (writes the PNGs) and create_world.py (imports them), so both agree without either
knowing about the other.
The height contract. The landscape is `vertices_per_side` vertices a side at `quad_cm` a quad. The 16-bit
heightmap spans `elevation_m.min` (value 0) to `elevation_m.max` (value 65535), and the level places the
landscape so that world Z 0 is elevation 0 m: sea level, when `sea_level_m` is 0. From that the engine's
Z scale and the actor's Z offset follow; nothing else in the project needs to know them.
Sources. `{"kind": "noise", "seed": N}` builds a continent with heightmap_noise.py. `{"kind": "file", "path":
..., "elevation_m": {"min": ..., "max": ...}}` takes a real heightmap (16-bit greyscale PNG or raw 16-bit
little-endian .r16), whose 0..65535 spans its own elevation range, and resamples it onto the world. Either way
the paint layers are derived from the finished height, so a real heightmap needs no weightmaps of its own.
"""
import json
import os
HERE = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.normpath(os.path.join(HERE, "..", ".."))
WORLD_DIR = os.path.join(PROJECT_ROOT, "RawContent", "World")
MANIFEST_PATH = os.path.join(WORLD_DIR, "World.json")
HEIGHTMAP_DIR = os.path.join(WORLD_DIR, "Heightmaps")
HEIGHTMAP_FILE = "L_World_Height.png"
# Paint layer name (as the Elite_RockyMeadows landscape material calls it) -> weightmap file. The names mislead:
# in this pack Base_Layer is the rock, Layer_02 the grass (the meadow) and Layer_03 the high rock.
LAYER_FILES = {
"Base_Layer": "L_World_Base_Layer.png",
"Layer_02": "L_World_Layer_02.png",
"Layer_03": "L_World_Layer_03.png",
}
# Derivative maps the erosion pass leaves behind, 8-bit, for painting and for a material that wants them later:
# how much water passed (log scaled), how much bedrock was scraped, how much sediment was laid down, and the
# curvature (128 flat, brighter convex, darker concave).
DERIVED_FILES = {
"flow": "L_World_Flow.png",
"wear": "L_World_Wear.png",
"deposit": "L_World_Deposit.png",
"curvature": "L_World_Curvature.png",
}
# The engine maps heightmap value v to local height (v - 32768) / 128 * ZScale cm, so ZScale 100 spans 512 m.
ENGINE_SPAN_M_AT_SCALE_100 = 512.0
class WorldManifest:
def __init__(self, data, path=MANIFEST_PATH):
self.path = path
self.level = data.get("level", "/Game/Maps/L_World")
self.vertices_per_side = int(data["vertices_per_side"])
self.quad_cm = float(data["quad_cm"])
self.elevation_min_m = float(data["elevation_m"]["min"])
self.elevation_max_m = float(data["elevation_m"]["max"])
self.sea_level_m = float(data.get("sea_level_m", 0.0))
self.spawn_pad_m = float(data.get("spawn_pad_m", 150.0))
self.streaming_grid_components = int(data.get("streaming_grid_components", 1))
self.source = dict(data.get("source", {"kind": "noise", "seed": 7}))
self.erosion = dict(data.get("erosion", {})) # keys and defaults in heightmap_erosion.DEFAULTS
self.layers = dict(data.get("layers", {}))
if self.vertices_per_side < 2 or self.elevation_max_m <= self.elevation_min_m or self.quad_cm <= 0:
raise ValueError(f"{path}: vertices_per_side, quad_cm and elevation_m must be positive and ordered")
# Derived geometry.
@property
def quads_per_side(self):
return self.vertices_per_side - 1
@property
def side_m(self):
return self.quads_per_side * self.quad_cm / 100.0
@property
def area_km2(self):
return (self.side_m / 1000.0) ** 2
@property
def elevation_span_m(self):
return self.elevation_max_m - self.elevation_min_m
@property
def elevation_mid_m(self):
return (self.elevation_max_m + self.elevation_min_m) / 2.0
@property
def z_scale(self):
"""The landscape actor's Z scale so that the 16-bit range spans exactly the manifest's elevation range."""
return self.elevation_span_m / ENGINE_SPAN_M_AT_SCALE_100 * 100.0
@property
def landscape_z_cm(self):
"""The landscape actor's world Z: value 32768 sits at elevation_mid, so elevation 0 m lands on world Z 0."""
return self.elevation_mid_m * 100.0
@property
def sea_level_z_cm(self):
return self.sea_level_m * 100.0
# The height encoding, in plain floats so the numpy side can vectorise the same formula.
def metres_to_value(self, metres):
return (metres - self.elevation_min_m) / self.elevation_span_m * 65535.0
def value_to_metres(self, value):
return self.elevation_min_m + value / 65535.0 * self.elevation_span_m
# Files.
@property
def heightmap_path(self):
return os.path.join(HEIGHTMAP_DIR, HEIGHTMAP_FILE)
def weightmap_path(self, layer_name):
return os.path.join(HEIGHTMAP_DIR, LAYER_FILES[layer_name])
def derived_path(self, map_name):
return os.path.join(HEIGHTMAP_DIR, DERIVED_FILES[map_name])
def resolve(self, relative):
"""A manifest path is relative to the project root unless absolute."""
return relative if os.path.isabs(relative) else os.path.normpath(os.path.join(PROJECT_ROOT, relative))
def describe(self):
return (f"{self.vertices_per_side} vertices a side at {self.quad_cm:g} cm: {self.side_m / 1000:.2f} km, "
f"{self.area_km2:.0f} km2; elevation {self.elevation_min_m:g}..{self.elevation_max_m:g} m "
f"(Z scale {self.z_scale:g}, actor Z {self.landscape_z_cm:g} cm); source {self.source}")
def load_manifest(path=MANIFEST_PATH):
with open(path, "r", encoding="utf-8") as f:
return WorldManifest(json.load(f), path)