130 lines
5.8 KiB
Python
130 lines
5.8 KiB
Python
"""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)
|