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
+216 -64
View File
@@ -1,15 +1,19 @@
"""Builds /Game/Maps/L_World: a world-partitioned level with a ~200 km2 landscape from the heightmap PNGs in
RawContent/World/Heightmaps/, the Elite_RockyMeadows landscape material, daylight and a player start. Rebuilds
from scratch every run; the level is a product of this script and the PNGs, never hand-edited.
"""Builds /Game/Maps/L_World: a world-partitioned level with the landscape that RawContent/World/World.json
describes, imported from the PNGs in RawContent/World/Heightmaps/ and dressed as Elite_RockyMeadows dresses its
demo maps: the pack's landscape material and three paint layers, its sun with the moving cloud shadows, its
skybox dome, sky light, height fog and post-process settings (numbers read from the pack's maps with
dump_level.py), plus a sea plane at sea level and two player starts on the spawn pad. Rebuilds from scratch
every run; the level is a product of this script, the manifest and the PNGs, never hand-edited.
UnrealEditor-Cmd.exe Salty.uproject -run=pythonscript -script=Scripts/Authoring/create_world.py
UnrealEditor-Cmd.exe Salty.uproject -run=pythonscript -script=Scripts/Authoring/create_world.py -AllowCommandletRendering
Swapping the terrain later: replace L_World_Height.png (16-bit greyscale, any resolution the landscape
supports) and the three L_World_<layer>.png weightmaps, or rerun generate_heightmap.py with another seed,
then rerun this. If the PNGs are missing this script generates them first.
-AllowCommandletRendering matters: the landscape's render heightmaps come from the edit-layer merge on the GPU,
and a commandlet without it silently skips that, leaving a landscape with collision but no visible surface.
Size: 4033 vertices a side at 350 cm a quad is 14.11 km a side, 199 km2. Z scale 500 spans -1280 m to
+1280 m, so 65535 in the PNG is 1280 m above the landscape origin and sea level (0.18 of the range) is -820 m.
Swapping the terrain: point the manifest's source at a real heightmap (or change the seed), rerun
generate_heightmap.py, then rerun this. If the PNGs are missing or the wrong size this script generates them
first. The landscape's component layout is the engine's choice for the resolution: 4081 vertices a side gives
16x16 components of 255 quads, each its own streaming proxy (see RawContent/World/README.md before changing it).
"""
import os
import shutil
@@ -20,20 +24,53 @@ import unreal
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, ".pylib"))
import heightmap_io # noqa: E402
from world_manifest import LAYER_FILES, load_manifest # noqa: E402
LEVEL_PATH = "/Game/Maps/L_World"
HEIGHTMAP_DIR = os.path.normpath(os.path.join(HERE, "..", "..", "RawContent", "World", "Heightmaps"))
HEIGHTMAP = os.path.join(HEIGHTMAP_DIR, "L_World_Height.png")
PACK = "/Game/Elite_RockyMeadows"
LANDSCAPE_MATERIAL = f"{PACK}/Materials/M_Landscape_Main_Inst_RockyMeadows02"
LAYER_INFOS = { # layer info asset -> weightmap file
f"{PACK}/Materials/Material_Layers/Base_Layer_LayerInfo": "L_World_Base_Layer.png",
f"{PACK}/Materials/Material_Layers/Layer_02_LayerInfo": "L_World_Layer_02.png",
f"{PACK}/Materials/Material_Layers/Layer_03_LayerInfo": "L_World_Layer_03.png",
LAYER_INFOS = { # paint layer name -> the pack's layer info asset; the weightmap file comes from the manifest module
"Base_Layer": f"{PACK}/Materials/Material_Layers/Base_Layer_LayerInfo",
"Layer_02": f"{PACK}/Materials/Material_Layers/Layer_02_LayerInfo",
"Layer_03": f"{PACK}/Materials/Material_Layers/Layer_03_LayerInfo",
}
QUAD_SCALE_CM = 350.0
Z_SCALE = 500.0
GRID_SIZE_COMPONENTS = 4 # streaming proxies of 4x4 components (4 x 126 quads x 3.5 m = 1.76 km a side)
SKYBOX_MESH = f"{PACK}/Materials/Skybox/Skybox_Mesh"
SKYBOX_MATERIAL = f"{PACK}/Materials/Skybox/M_Skybox_Inst_RockyMeadows"
CLOUD_SHADOWS = f"{PACK}/Materials/Light_Material/M_Cloud_Shadows_Inst02"
SEA_MESH = "/Engine/BasicShapes/Plane"
SEA_MATERIAL = "/Engine/EngineMaterials/WaterMaterial"
# The pack's Rocky_Meadows_01 demo map, as dump_level.py read it. Distances are scaled up where the demo's
# 8 km scene would otherwise cut the effect short on a 14 km world.
PACK_SUN = {
"rotation": unreal.Rotator(roll=-51.273, pitch=-31.342, yaw=36.413), # keyword arguments: positional order is roll, pitch, yaw
"intensity": 9.2368,
"light_color": unreal.Color(r=223, g=245, b=255, a=255),
"light_function_scale": unreal.Vector(1024.0, 1024.0, 1024.0),
"light_function_fade_distance": 2000000.0, # the demo fades its cloud shadows out at 2 km; keep them to 20 km here
"dynamic_shadow_distance_movable_light": 200000.0,
"cascade_distribution_exponent": 3.0,
"light_source_angle": 0.5357,
"shadow_bias": 0.5,
}
PACK_SKY_LIGHT = {"intensity": 1.5, "lower_hemisphere_color": unreal.LinearColor(0.0, 0.0, 0.0, 1.0), "sky_distance_threshold": 150000.0}
PACK_FOG = {
"fog_density": 0.027143,
"fog_height_falloff": 0.039076,
"fog_inscattering_luminance": unreal.LinearColor(0.238715, 0.329426, 0.458333, 1.0),
"directional_inscattering_luminance": unreal.LinearColor(0.25, 0.20832, 0.154948, 1.0),
"directional_inscattering_exponent": 4.0,
"directional_inscattering_start_distance": 10000.0,
}
PACK_POST_PROCESS = { # FPostProcessSettings field -> value; the override flag of each is set alongside
"auto_exposure_min_brightness": 1.0,
"auto_exposure_max_brightness": 1.0,
"auto_exposure_bias": 0.263034,
"color_saturation": unreal.Vector4(1.0, 1.0, 1.0, 1.25),
}
manifest = load_manifest()
LEVEL_PATH = manifest.level
level_subsystem = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
@@ -41,14 +78,42 @@ editor_subsystem = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem)
asset_lib = unreal.EditorAssetLibrary
def load_or_raise(asset_path):
asset = unreal.load_asset(asset_path)
if not asset:
raise RuntimeError(f"{asset_path} not found; is the pack in Content/?")
return asset
def set_properties(target, values):
for name, value in values.items():
target.set_editor_property(name, value)
def keep_always_loaded(actor):
"""World partition streams actors by their bounds; the sky dome and the sea are the whole world and must
not stream at all."""
try:
actor.set_editor_property("is_spatially_loaded", False)
except Exception as error:
unreal.log_warning(f"{actor.get_actor_label()}: could not clear is_spatially_loaded ({error}); it will stream by bounds")
def ensure_heightmaps():
missing = [f for f in [HEIGHTMAP] + [os.path.join(HEIGHTMAP_DIR, f) for f in LAYER_INFOS.values()] if not os.path.isfile(f)]
if not missing:
files = [manifest.heightmap_path] + [manifest.weightmap_path(name) for name in LAYER_FILES]
reason = None
missing = [f for f in files if not os.path.isfile(f)]
if missing:
reason = f"missing {missing}"
else:
width, height, depth, _ = heightmap_io.read_png_header(manifest.heightmap_path)
if (width, height, depth) != (manifest.vertices_per_side, manifest.vertices_per_side, 16):
reason = f"{manifest.heightmap_path} is {width}x{height} at {depth} bit, the manifest wants {manifest.vertices_per_side} at 16"
if reason is None:
return
unreal.log(f"generating heightmaps, missing {missing}")
unreal.log(f"generating heightmaps: {reason}")
import generate_heightmap
sys.argv = ["generate_heightmap.py"]
generate_heightmap.main()
generate_heightmap.main([])
def spawn(actor_class, label, location=unreal.Vector(0, 0, 0), rotation=unreal.Rotator(0, 0, 0)):
@@ -57,39 +122,84 @@ def spawn(actor_class, label, location=unreal.Vector(0, 0, 0), rotation=unreal.R
return actor
def recreate_level():
KEEP_CLASSES = {"WorldSettings", "WorldDataLayers", "WorldPartitionMiniMap"} # what a fresh partitioned level has
def content_path():
return os.path.abspath(unreal.Paths.convert_relative_path_to_full(unreal.Paths.project_content_dir()))
def external_actor_dir():
return os.path.join(content_path(), "__ExternalActors__", LEVEL_PATH.replace("/Game/", "", 1))
def prepare_level():
"""An empty level to build into. An existing level is loaded and emptied rather than deleted and recreated:
recreating it resaves its two HLOD layer assets, and an open editor that has had the level loaded keeps
those files locked, so the recreation ends in a nameless temp world whose save goes nowhere (2026-09-16).
Streaming proxies the editor does not load cannot be destroyed here; sweep_stale_actor_packages removes
their files after the save."""
if asset_lib.does_asset_exist(LEVEL_PATH):
unreal.log(f"{LEVEL_PATH} exists; deleting it and its external actors")
level_subsystem.new_level("/Temp/Untitled_Scratch", False) # do not delete the level we stand in
asset_lib.delete_asset(LEVEL_PATH)
# The external actor packages of streaming proxies cannot be loaded on their own, so the asset library
# refuses to delete the folder; remove what is left on disk instead.
content_dir = unreal.Paths.project_content_dir()
for folder in ("__ExternalActors__/Maps/L_World", "__ExternalObjects__/Maps/L_World"):
path = os.path.join(content_dir, folder)
if os.path.isdir(path):
shutil.rmtree(path, ignore_errors=True)
level_subsystem.new_level(LEVEL_PATH, True) # world-partitioned: 200 km2 must stream
if not level_subsystem.load_level(LEVEL_PATH):
raise RuntimeError(f"could not load {LEVEL_PATH}")
removed = 0
for actor in actor_subsystem.get_all_level_actors():
if actor.get_class().get_name() in KEEP_CLASSES:
continue
actor_subsystem.destroy_actor(actor)
removed += 1
unreal.log(f"{LEVEL_PATH} loaded and emptied: {removed} loaded actors removed")
else:
folder = external_actor_dir()
if os.path.isdir(folder):
shutil.rmtree(folder, ignore_errors=True)
level_subsystem.new_level(LEVEL_PATH, True) # world-partitioned: 200 km2 must stream
world_path = editor_subsystem.get_editor_world().get_path_name()
if not world_path.startswith(LEVEL_PATH):
raise RuntimeError(f"the editor world is {world_path}, not {LEVEL_PATH}; a save would go nowhere")
def sweep_stale_actor_packages():
"""After the save: every file under the level's external actor folder that belongs to none of the level's
actors is a leftover of an earlier build (a proxy the editor never loaded, so it could not be destroyed).
Left there, it would come back as a second landscape the next time the level loads."""
keep = set()
for actor in actor_subsystem.get_all_level_actors():
package = actor.get_outermost().get_path_name() # /Game/__ExternalActors__/Maps/L_World/1/YM/XXXX
keep.add(os.path.normcase(os.path.join(content_path(), package.replace("/Game/", "", 1).replace("/", os.sep) + ".uasset")))
removed, failed = 0, 0
for root, _, files in os.walk(external_actor_dir()):
for name in files:
path = os.path.join(root, name)
if os.path.normcase(path) in keep:
continue
try:
os.remove(path)
removed += 1
except OSError as error:
failed += 1
unreal.log_warning(f"stale actor package {path} could not be removed: {error}")
unreal.log(f"stale actor packages: {removed} removed, {failed} left, {len(keep)} kept")
if failed:
raise RuntimeError(f"{failed} stale actor packages remain under {external_actor_dir()}; the level would load duplicates")
def create_landscape():
material = unreal.load_asset(LANDSCAPE_MATERIAL)
if not material:
raise RuntimeError(f"landscape material {LANDSCAPE_MATERIAL} not found; is the pack in Content/?")
material = load_or_raise(LANDSCAPE_MATERIAL)
weightmaps = []
for asset_path, file_name in LAYER_INFOS.items():
layer_info = unreal.load_asset(asset_path)
if not layer_info:
raise RuntimeError(f"layer info {asset_path} not found")
for layer_name, asset_path in LAYER_INFOS.items():
entry = unreal.LandscapeAuthoringWeightmap()
entry.set_editor_property("layer_info", layer_info)
entry.set_editor_property("file", os.path.join(HEIGHTMAP_DIR, file_name))
entry.set_editor_property("layer_info", load_or_raise(asset_path))
entry.set_editor_property("file", manifest.weightmap_path(layer_name))
weightmaps.append(entry)
world = editor_subsystem.get_editor_world()
unreal.log(f"landscape: {manifest.describe()}")
landscape = unreal.LandscapeAuthoringLibrary.create_landscape_from_heightmap(
world, HEIGHTMAP, weightmaps, material,
unreal.Vector(0, 0, 0), unreal.Vector(QUAD_SCALE_CM, QUAD_SCALE_CM, Z_SCALE), GRID_SIZE_COMPONENTS)
world, manifest.heightmap_path, weightmaps, material,
unreal.Vector(0.0, 0.0, manifest.landscape_z_cm),
unreal.Vector(manifest.quad_cm, manifest.quad_cm, manifest.z_scale),
manifest.streaming_grid_components)
if not landscape:
raise RuntimeError("landscape creation failed; see LogSaltyEditor")
return landscape
@@ -97,7 +207,8 @@ def create_landscape():
def ground_height_at(x, y, fallback):
world = editor_subsystem.get_editor_world()
start, end = unreal.Vector(x, y, 200000.0), unreal.Vector(x, y, -200000.0)
reach = manifest.elevation_max_m * 100.0 + 100000.0
start, end = unreal.Vector(x, y, reach), unreal.Vector(x, y, -reach)
hit = unreal.SystemLibrary.line_trace_single(world, start, end, unreal.TraceTypeQuery.ECC_VISIBILITY,
False, [], unreal.DrawDebugTrace.NONE, True)
if hit and hit.to_tuple()[0]:
@@ -106,24 +217,62 @@ def ground_height_at(x, y, fallback):
return fallback
def ensure_daylight():
# Keyword arguments on purpose: positional unreal.Rotator is (roll, pitch, yaw), and a sun pitched upward is night.
sun = spawn(unreal.DirectionalLight, "World_Sun", unreal.Vector(0, 0, 50000), unreal.Rotator(roll=0.0, pitch=-38.0, yaw=25.0))
sun.light_component.set_editor_property("intensity", 10.0)
sun.light_component.set_editor_property("atmosphere_sun_light", True)
sun.light_component.set_editor_property("dynamic_shadow_distance_movable_light", 60000.0)
spawn(unreal.SkyAtmosphere, "World_Sky")
def dress_with_rocky_meadows():
"""The pack's sky, sun, fog and grade, so L_World reads like its demo maps."""
sun = spawn(unreal.DirectionalLight, "World_Sun", unreal.Vector(0, 0, 50000), PACK_SUN["rotation"])
light = sun.light_component
light.set_editor_property("mobility", unreal.ComponentMobility.MOVABLE)
set_properties(light, {k: v for k, v in PACK_SUN.items() if k != "rotation"})
light.set_editor_property("light_function_material", load_or_raise(CLOUD_SHADOWS))
sky_light = spawn(unreal.SkyLight, "World_SkyLight", unreal.Vector(0, 0, 50000))
sky_light.light_component.set_editor_property("real_time_capture", True)
spawn(unreal.VolumetricCloud, "World_Clouds")
fog = spawn(unreal.ExponentialHeightFog, "World_Fog")
fog.component.set_editor_property("fog_density", 0.005)
fog.component.set_editor_property("fog_height_falloff", 0.05)
sky_light.light_component.set_editor_property("mobility", unreal.ComponentMobility.MOVABLE)
set_properties(sky_light.light_component, PACK_SKY_LIGHT)
# The pack's skybox is a textured dome mesh, not a sky atmosphere. Scale it so the whole world sits inside
# with room to spare, and sink its centre so the dome's equator is below the sea from any shore.
mesh = load_or_raise(SKYBOX_MESH)
native_radius = max(mesh.get_bounds().sphere_radius, 1.0)
radius = manifest.side_m * 100.0 * 1.1
skybox = spawn(unreal.StaticMeshActor, "World_Skybox", unreal.Vector(0.0, 0.0, -radius * 0.25))
skybox.static_mesh_component.set_editor_property("mobility", unreal.ComponentMobility.MOVABLE)
skybox.static_mesh_component.set_static_mesh(mesh)
skybox.static_mesh_component.set_material(0, load_or_raise(SKYBOX_MATERIAL))
skybox.static_mesh_component.set_editor_property("cast_shadow", False)
skybox.static_mesh_component.set_collision_enabled(unreal.CollisionEnabled.NO_COLLISION)
skybox.set_actor_scale3d(unreal.Vector(radius / native_radius, radius / native_radius, radius / native_radius))
keep_always_loaded(skybox)
unreal.log(f"skybox dome radius {radius / 100000:.1f} km (mesh radius {native_radius:g} cm, scale {radius / native_radius:g})")
fog = spawn(unreal.ExponentialHeightFog, "World_Fog", unreal.Vector(0.0, 0.0, manifest.sea_level_z_cm))
set_properties(fog.component, PACK_FOG)
post = spawn(unreal.PostProcessVolume, "World_PostProcess")
post.set_editor_property("unbound", True)
settings = post.get_editor_property("settings")
for field, value in PACK_POST_PROCESS.items():
settings.set_editor_property(f"override_{field}", True)
settings.set_editor_property(field, value)
post.set_editor_property("settings", settings)
def ensure_sea():
"""A flat plane at sea level with the engine's water material: enough to read as sea until a water body
replaces it. It keeps collision so a walk off the coast is a walk, not a fall to the sea floor."""
mesh = load_or_raise(SEA_MESH)
material = unreal.load_asset(SEA_MATERIAL) or load_or_raise("/Engine/BasicShapes/BasicShapeMaterial")
side = manifest.side_m * 100.0 * 1.5 / 100.0 # the plane is 100 cm; cover the world and the sea beyond its edge
sea = spawn(unreal.StaticMeshActor, "World_Sea_Proto", unreal.Vector(0.0, 0.0, manifest.sea_level_z_cm))
sea.static_mesh_component.set_static_mesh(mesh)
sea.static_mesh_component.set_material(0, material)
sea.static_mesh_component.set_editor_property("cast_shadow", False)
sea.set_actor_scale3d(unreal.Vector(side, side, 1.0))
keep_always_loaded(sea)
def ensure_player_starts():
# The heightmap has a flat pad at its centre; two starts so two PIE clients spawn without a warning.
fallback = (0.24 * 65535.0 - 32768.0) / 128.0 * Z_SCALE
fallback = manifest.sea_level_z_cm + 15000.0
z = ground_height_at(0.0, 0.0, fallback) + 120.0
for index, y in enumerate((-200.0, 200.0)):
spawn(unreal.PlayerStart, f"World_PlayerStart_{index}", unreal.Vector(0.0, y, z))
@@ -131,12 +280,15 @@ def ensure_player_starts():
def main():
ensure_heightmaps()
recreate_level()
prepare_level()
landscape = create_landscape()
ensure_daylight()
dress_with_rocky_meadows()
ensure_sea()
ensure_player_starts()
level_subsystem.save_current_level()
if not level_subsystem.save_current_level():
raise RuntimeError(f"saving {LEVEL_PATH} failed; see the log above")
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
sweep_stale_actor_packages()
unreal.log(f"{LEVEL_PATH} saved with landscape {landscape.get_actor_label()}")
+160
View File
@@ -0,0 +1,160 @@
"""Dumps the actors of one or more levels to JSON, so a reference level (a pack's demo map) can be read without
opening it in the editor: class, label, transform, and the properties that matter for lighting, sky, fog,
post-processing and landscape. Written for reading Elite_RockyMeadows' demo maps while building L_World.
UnrealEditor-Cmd.exe Salty.uproject -run=pythonscript -script="Scripts/Authoring/dump_level.py /Game/Elite_RockyMeadows/Maps/Rocky_Meadows_01"
Levels come from the script arguments, else from the SALTY_DUMP_LEVELS environment variable (semicolon
separated), else the pack's first demo map. Output: Saved/Authoring/<LevelName>.json, one per level.
"""
import json
import os
import sys
import unreal
HERE = os.path.dirname(os.path.abspath(__file__))
OUT_DIR = os.path.normpath(os.path.join(HERE, "..", "..", "Saved", "Authoring"))
DEFAULT_LEVELS = ["/Game/Elite_RockyMeadows/Maps/Rocky_Meadows_01"]
# Component class name -> editor properties worth reading. Missing ones are skipped, so the list can be generous.
COMPONENT_PROPERTIES = {
"DirectionalLightComponent": [
"intensity", "light_color", "use_temperature", "temperature", "light_function_material", "light_function_scale",
"light_function_fade_distance", "atmosphere_sun_light", "cast_shadows", "dynamic_shadow_distance_movable_light",
"dynamic_shadow_distance_stationary_light", "num_dynamic_shadow_cascades", "cascade_distribution_exponent",
"cascade_transition_fraction", "shadow_distance_fadeout_fraction", "light_source_angle", "shadow_bias",
"volumetric_scattering_intensity", "cast_cloud_shadows", "cast_volumetric_shadow", "indirect_lighting_intensity",
"mobility", "specular_scale",
],
"SkyLightComponent": [
"intensity", "light_color", "source_type", "cubemap", "cubemap_resolution", "source_cubemap_angle", "real_time_capture",
"lower_hemisphere_color", "sky_distance_threshold", "cast_shadows", "volumetric_scattering_intensity",
"indirect_lighting_intensity", "occlusion_max_distance", "mobility",
],
"ExponentialHeightFogComponent": [
"fog_density", "fog_height_falloff", "second_fog_data", "fog_inscattering_luminance", "skybox_inscattering_color_cubemap",
"fog_inscattering_luminance_scale", "directional_inscattering_luminance", "directional_inscattering_exponent",
"directional_inscattering_start_distance", "fog_max_opacity", "start_distance", "end_distance", "fog_cutoff_distance",
"volumetric_fog", "volumetric_fog_scattering_distribution", "volumetric_fog_albedo", "volumetric_fog_emissive",
"volumetric_fog_extinction_scale", "volumetric_fog_distance", "volumetric_fog_start_distance",
"volumetric_fog_near_fade_in_distance", "volumetric_fog_static_lighting_scattering_intensity",
],
"StaticMeshComponent": ["static_mesh", "cast_shadow", "visible"],
"SkyAtmosphereComponent": ["rayleigh_scattering_scale", "mie_scattering_scale", "aerial_pespective_view_distance_scale"],
"VolumetricCloudComponent": ["layer_bottom_altitude", "layer_height", "material"],
}
ACTOR_PROPERTIES = {
"Landscape": ["landscape_material", "landscape_hole_material", "component_size_quads", "subsection_size_quads", "num_subsections",
"static_lighting_lod", "lod_distribution_setting", "lod0_distribution_setting", "lod0_screen_size",
"streaming_distance_multiplier", "collision_mip_level", "nanite_lod_index", "enable_nanite", "target_display_order"],
"LandscapeStreamingProxy": ["landscape_material", "component_size_quads", "subsection_size_quads", "num_subsections"],
"PostProcessVolume": ["unbound", "enabled", "priority", "blend_weight"],
"Actor": ["hidden"],
}
def encode(value):
"""A JSON-able rendering of a Python-wrapped Unreal value."""
if value is None or isinstance(value, (bool, int, float, str)):
return value
if isinstance(value, unreal.Object):
return value.get_path_name()
if isinstance(value, (unreal.Name, unreal.Text)):
return str(value)
if isinstance(value, (unreal.Vector, unreal.Rotator, unreal.LinearColor, unreal.Color, unreal.Vector2D, unreal.IntPoint)):
return list(value.to_tuple())
if isinstance(value, unreal.Array):
return [encode(v) for v in value]
return str(value)
def read_properties(obj, names):
out = {}
for name in names:
try:
out[name] = encode(obj.get_editor_property(name))
except Exception:
pass
return out
def overridden_post_process(settings):
"""The FPostProcessSettings fields whose override flag is set, with their values."""
out = {}
for name in dir(settings):
if not name.startswith("override_"):
continue
try:
if not settings.get_editor_property(name):
continue
field = name[len("override_"):]
out[field] = encode(settings.get_editor_property(field))
except Exception:
pass
return out
def dump_actor(actor):
class_name = actor.get_class().get_name()
record = {
"class": class_name,
"label": actor.get_actor_label(),
"location": list(actor.get_actor_location().to_tuple()),
"rotation": list(actor.get_actor_rotation().to_tuple()), # the tuple order is (roll, pitch, yaw)
"scale": list(actor.get_actor_scale3d().to_tuple()),
}
for key, names in ACTOR_PROPERTIES.items():
if key == "Actor" or class_name.startswith(key):
record.update(read_properties(actor, names))
if class_name.startswith("PostProcessVolume"):
try:
record["settings"] = overridden_post_process(actor.get_editor_property("settings"))
except Exception as error:
record["settings_error"] = str(error)
components = {}
for component in actor.get_components_by_class(unreal.ActorComponent):
component_class = component.get_class().get_name()
for key, names in COMPONENT_PROPERTIES.items():
if component_class == key or component_class.startswith(key):
entry = read_properties(component, names)
if isinstance(component, unreal.SceneComponent):
entry["relative_location"] = list(component.get_editor_property("relative_location").to_tuple())
entry["relative_rotation"] = list(component.get_editor_property("relative_rotation").to_tuple())
entry["relative_scale3d"] = list(component.get_editor_property("relative_scale3d").to_tuple())
if isinstance(component, unreal.MeshComponent):
entry["materials"] = [encode(m) for m in component.get_materials()]
components[f"{component_class}:{component.get_name()}"] = entry
if class_name.startswith("Landscape"):
try:
components["landscape_component_count"] = len(actor.get_editor_property("landscape_components"))
except Exception:
pass
record["components"] = components
return record
def dump_level(level_path):
unreal.log(f"dump_level: loading {level_path}")
level_subsystem = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
if not level_subsystem.load_level(level_path):
unreal.log_error(f"dump_level: could not load {level_path}")
return
actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
actors = [dump_actor(actor) for actor in actor_subsystem.get_all_level_actors()]
os.makedirs(OUT_DIR, exist_ok=True)
out_path = os.path.join(OUT_DIR, level_path.rsplit("/", 1)[-1] + ".json")
with open(out_path, "w", encoding="utf-8") as f:
json.dump({"level": level_path, "actors": actors}, f, indent=2)
unreal.log(f"dump_level: {len(actors)} actors of {level_path} written to {out_path}")
def main():
levels = [a for a in sys.argv[1:] if a.startswith("/")]
if not levels and os.environ.get("SALTY_DUMP_LEVELS"):
levels = [p for p in os.environ["SALTY_DUMP_LEVELS"].split(";") if p]
for level in levels or DEFAULT_LEVELS:
dump_level(level)
main()
+150 -137
View File
@@ -1,161 +1,174 @@
"""Generates a seeded heightmap and three weightmaps for L_World, as 16-bit and 8-bit greyscale PNGs.
"""Writes the heightmap and the three weightmaps of L_World to RawContent/World/Heightmaps/ from the manifest
RawContent/World/World.json: 16-bit greyscale height, 8-bit greyscale weights, sized for the landscape.
Pure numpy, no engine: run it with any Python that has numpy, or let create_world.py call it. The output is
plain files under RawContent/World/Heightmaps/, so swapping the terrain later is dropping in a different PNG of
any resolution and rerunning create_world.py; nothing else in the project knows how the terrain was made.
Pure numpy, no engine: run it with the engine's Python (numpy lives in Scripts/Authoring/.pylib, see
bootstrap-pylib.sh), or let create_world.py call it when the PNGs are missing.
UE_5.8/Engine/Binaries/ThirdParty/Python3/Win64/python.exe Scripts/Authoring/generate_heightmap.py [--seed N] [--size 4033]
D:/UE_5.8/Engine/Binaries/ThirdParty/Python3/Win64/python.exe Scripts/Authoring/generate_heightmap.py
... --seed 12 # another noise continent, manifest otherwise as is
... --source-file RawContent/World/Sources/dem.png --source-elevation 0 2400 # a real heightmap, this once
The shape, in metres, with the default scale in create_world.py (350 cm per quad, Z scale 500):
a continent with ragged coasts and sea around it, lowland plains, rolling hills, one or two mountain ranges
along a low-frequency band, thermal smoothing so slopes read as slopes, and a flat 200 m pad at the centre
for the player start. Weightmaps: base (grass) everywhere, layer 2 (rock) by slope, layer 3 by altitude.
The manifest names the source. Noise builds a continent with heightmap_noise.py. A file (16-bit PNG or raw
.r16 from any DEM tool) is cropped to a square, read as its own elevation range, resampled onto the world and
re-encoded into the world's range; the paint layers are then derived from the height exactly as for noise,
so swapping to a real heightmap is a manifest edit and a rerun, nothing else. Either way a flat spawn pad is
blended into the centre so the player starts stand on level ground.
"""
import argparse
import os
import struct
import sys
import zlib
import time
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, ".pylib"))
import numpy as np # noqa: E402
OUT_DIR = os.path.normpath(os.path.join(HERE, "..", "..", "RawContent", "World", "Heightmaps"))
SEA_LEVEL = 0.18 # fraction of the 16-bit range that is sea; create_world.py places the water plane here
PAD_RADIUS_FRACTION = 0.01 # flat spawn pad, as a fraction of the map width
import heightmap_erosion # noqa: E402
import heightmap_io # noqa: E402
import heightmap_noise # noqa: E402
from world_manifest import DERIVED_FILES, HEIGHTMAP_DIR, LAYER_FILES, MANIFEST_PATH, load_manifest # noqa: E402
LAYER_DEFAULTS = {
"rock_slope_start": 0.55, # rise over run where rock starts to show through the meadow (about 29 degrees)
"rock_slope_full": 1.05, # and where it is all rock (about 46 degrees)
"high_altitude_start_m": 1100, # where the high rock layer starts
"high_altitude_full_m": 1650, # and where it has taken over
"breakup_m": 18, # noise added to the altitude before the rules, so boundaries are not contour lines
"wear_rock_start": 0.35, # scraped bedrock (wear map, 0..1) reads as rock from here
"ridge_rock": 0.6, # how much convex curvature (ridges, shoulders) adds rock
"deposit_softens": 0.7, # how much laid-down sediment (deposit map, 0..1) takes rock away: fans and basins are meadow
}
def write_png(path, data):
"""Greyscale PNG, 8 or 16 bit from the array dtype. Row filter 0, one zlib stream."""
if data.dtype == np.uint16:
depth, payload = 16, data.astype(">u2")
else:
depth, payload = 8, data.astype(np.uint8)
height, width = data.shape
raw = b"".join(b"\x00" + payload[y].tobytes() for y in range(height))
def chunk(kind, body):
return struct.pack(">I", len(body)) + kind + body + struct.pack(">I", zlib.crc32(kind + body) & 0xFFFFFFFF)
ihdr = struct.pack(">IIBBBBB", width, height, depth, 0, 0, 0, 0)
with open(path, "wb") as f:
f.write(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", zlib.compress(raw, 6)) + chunk(b"IEND", b""))
def source_height_metres(manifest, size):
"""The world's height in metres, size x size, from whichever source the manifest names."""
source = manifest.source
kind = source.get("kind", "noise")
quad_m = manifest.quad_cm / 100.0
if kind == "noise":
seed = int(source.get("seed", 7))
print(f"noise source, seed {seed}")
return heightmap_noise.generate_metres(size, seed, quad_m, manifest.sea_level_m)
if kind == "file":
path = manifest.resolve(source["path"])
print(f"file source {path}")
values = heightmap_io.read_heightmap(path, source.get("width"))
if source.get("flip_y", False):
values = values[::-1]
values = heightmap_io.center_crop_square(values)
elevation = source.get("elevation_m", {"min": manifest.elevation_min_m, "max": manifest.elevation_max_m})
low, high = float(elevation["min"]), float(elevation["max"])
metres = low + values.astype(np.float32) / 65535.0 * (high - low)
print(f" {values.shape[1]}x{values.shape[0]} samples spanning {low:g}..{high:g} m, resampled to {size}x{size}")
metres = heightmap_io.resample(metres, size)
smooth = int(source.get("smooth_passes", 0))
if smooth > 0:
metres = heightmap_noise.box_blur(metres, smooth)
return metres
raise ValueError(f"{manifest.path}: unknown source kind {kind!r}; use 'noise' or 'file'")
def smoothstep(t):
return t * t * (3.0 - 2.0 * t)
def value_noise(size, cells, rng):
"""One octave: a random lattice of cells x cells, smoothly interpolated to size x size. Tileable enough."""
lattice = rng.random((cells + 1, cells + 1), dtype=np.float32)
coords = np.linspace(0.0, cells, size, endpoint=False, dtype=np.float32)
i = np.floor(coords).astype(np.int32)
t = smoothstep(coords - i)
i1 = np.minimum(i + 1, cells)
top = lattice[i[:, None], i[None, :]] * (1 - t[None, :]) + lattice[i[:, None], i1[None, :]] * t[None, :]
bottom = lattice[i1[:, None], i[None, :]] * (1 - t[None, :]) + lattice[i1[:, None], i1[None, :]] * t[None, :]
return top * (1 - t[:, None]) + bottom * t[:, None]
def fbm(size, rng, base_cells=4, octaves=8, gain=0.5, ridged=False):
total = np.zeros((size, size), dtype=np.float32)
amplitude, cells, norm = 1.0, base_cells, 0.0
for _ in range(octaves):
n = value_noise(size, cells, rng)
if ridged:
n = 1.0 - np.abs(n * 2.0 - 1.0)
n = n * n
total += n * amplitude
norm += amplitude
amplitude *= gain
cells *= 2
return total / norm
def box_blur(h, passes):
for _ in range(passes):
padded = np.pad(h, 1, mode="edge")
h = (padded[:-2, 1:-1] + padded[2:, 1:-1] + padded[1:-1, :-2] + padded[1:-1, 2:] + h) / 5.0
return h.astype(np.float32)
def thermal_smooth(h, passes, talus):
"""Cheap erosion: where a cell is much higher than a neighbour, move a little material downhill."""
for _ in range(passes):
padded = np.pad(h, 1, mode="edge")
for dy, dx in ((0, 1), (0, -1), (1, 0), (-1, 0)):
neighbour = padded[1 + dy:1 + dy + h.shape[0], 1 + dx:1 + dx + h.shape[1]]
diff = h - neighbour
move = np.where(diff > talus, (diff - talus) * 0.25, 0.0).astype(np.float32)
h -= move
return h
def generate(size, seed):
rng = np.random.default_rng(seed)
def apply_spawn_pad(metres, manifest):
"""A flat disc at the centre for the player starts, blended into the terrain over a second radius."""
if manifest.spawn_pad_m <= 0:
return metres
size = metres.shape[0]
radius = manifest.spawn_pad_m / manifest.side_m
y, x = np.mgrid[0:size, 0:size].astype(np.float32) / (size - 1)
# Continent: a radial falloff with a ragged, noise-warped edge, so the coast is not a circle.
cx, cy = 0.5 + (rng.random() - 0.5) * 0.15, 0.5 + (rng.random() - 0.5) * 0.15
radius = np.sqrt(((x - cx) * 1.05) ** 2 + ((y - cy) * 0.95) ** 2)
coast_warp = (fbm(size, rng, base_cells=3, octaves=5) - 0.5) * 0.35
continent = np.clip(1.0 - (radius + coast_warp) / 0.55, 0.0, 1.0)
continent = smoothstep(np.clip(continent * 1.6, 0.0, 1.0))
plains = fbm(size, rng, base_cells=6, octaves=4) * 0.06
hills = fbm(size, rng, base_cells=12, octaves=6, gain=0.5) * 0.22
# Mountain ranges: ridged noise, masked by a low-frequency band so they come as ranges, not everywhere.
range_band = fbm(size, rng, base_cells=3, octaves=3)
range_mask = smoothstep(np.clip((range_band - 0.47) / 0.2, 0.0, 1.0))
mountains = fbm(size, rng, base_cells=10, octaves=8, gain=0.5, ridged=True) * range_mask
land = 0.05 + plains + hills * (0.4 + 0.6 * continent) + mountains * 0.75
height = SEA_LEVEL + continent * land
# The sea floor keeps a little shape so the shore is not a hard step.
sea_floor = SEA_LEVEL - 0.03 - (1.0 - continent) * 0.04 + plains * 0.3
height = np.where(continent > 0.02, height, np.maximum(sea_floor, 0.0)).astype(np.float32)
height = np.maximum(height, sea_floor.astype(np.float32))
height = thermal_smooth(height, passes=6, talus=0.0025)
# A flat pad at the centre for the player start, blended into the terrain around it.
pad_radius = PAD_RADIUS_FRACTION
pad_dist = np.sqrt((x - 0.5) ** 2 + (y - 0.5) ** 2)
pad_weight = smoothstep(np.clip(1.0 - (pad_dist - pad_radius) / pad_radius, 0.0, 1.0))
pad_height = max(float(height[size // 2, size // 2]), SEA_LEVEL + 0.06)
height = height * (1 - pad_weight) + pad_height * pad_weight
height = np.clip(height, 0.0, 1.0)
# Weightmaps from the finished shape. Slope is per quad in height-range units; the thresholds are guesses
# to be tuned by eye in the editor.
gy, gx = np.gradient(box_blur(height, 3))
slope = np.sqrt(gx * gx + gy * gy) * size
rock = smoothstep(np.clip((slope - 1.8) / 1.6, 0.0, 1.0))
high = smoothstep(np.clip((height - 0.5) / 0.16, 0.0, 1.0)) * (1.0 - rock * 0.5)
base = np.clip(1.0 - rock - high, 0.0, 1.0)
total = base + rock + high
weights = [np.rint(w / total * 255.0).astype(np.uint8) for w in (base, rock, high)]
return np.rint(height * 65535.0).astype(np.uint16), weights
dist = np.sqrt((x - 0.5) ** 2 + (y - 0.5) ** 2)
weight = heightmap_noise.smoothstep(np.clip(1.0 - (dist - radius) / radius, 0.0, 1.0))
centre = float(metres[size // 2, size // 2])
pad_height = max(centre, manifest.sea_level_m + 150.0) # never a pad in the sea
return (metres * (1 - weight) + pad_height * weight).astype(np.float32)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--seed", type=int, default=7)
parser.add_argument("--size", type=int, default=4033, help="vertices per side; 4033 fits 32x32 components of 126 quads")
parser.add_argument("--out", default=OUT_DIR)
args = parser.parse_args()
def derived_maps(metres, maps, manifest):
"""The erosion's leftovers squashed to [0, 1]: flow (log scaled), wear, deposit, and a curvature map with
0.5 flat, convex above, concave below."""
curve = heightmap_erosion.curvature(metres, manifest.quad_cm / 100.0)
scale = max(float(np.percentile(np.abs(curve), 99.0)), 1e-6)
return {
"flow": heightmap_erosion.to_unit(maps["flow"], 99.5, log_scale=True),
"wear": heightmap_erosion.to_unit(maps["wear"], 99.0),
"deposit": heightmap_erosion.to_unit(maps["deposit"], 99.0),
"curvature": np.clip(0.5 + curve / scale * 0.5, 0.0, 1.0).astype(np.float32),
}
def derive_layers(metres, derived, manifest, rng):
"""Rocky Meadows' three paint layers from the finished height and what the erosion left: meadow everywhere,
rock by slope, on scraped bedrock and on convex ridges, less rock where sediment was laid down, high rock
by altitude. Returns {layer name: uint8 weightmap}, the three summing to 255."""
rules = {**LAYER_DEFAULTS, **manifest.layers}
quad_m = manifest.quad_cm / 100.0
size = metres.shape[0]
breakup = (heightmap_noise.fbm(size, rng, base_cells=24, octaves=4) - 0.5) * 2.0 * float(rules["breakup_m"])
gy, gx = np.gradient(heightmap_noise.box_blur(metres, 2), quad_m)
slope = np.sqrt(gx * gx + gy * gy)
slope_breakup = breakup / float(rules["breakup_m"]) * 0.12 if rules["breakup_m"] else 0.0
rock = heightmap_noise.smoothstep(np.clip(
(slope + slope_breakup - rules["rock_slope_start"]) / (rules["rock_slope_full"] - rules["rock_slope_start"]), 0.0, 1.0))
scraped = heightmap_noise.smoothstep(np.clip((derived["wear"] - rules["wear_rock_start"]) / (1.0 - rules["wear_rock_start"]), 0.0, 1.0))
convex = np.clip((derived["curvature"] - 0.5) * 2.0, 0.0, 1.0) * np.clip(slope / rules["rock_slope_start"], 0.0, 1.0)
rock = np.maximum(rock, np.maximum(scraped * 0.9, convex * rules["ridge_rock"]))
rock = rock * (1.0 - rules["deposit_softens"] * derived["deposit"] * (slope < rules["rock_slope_full"]))
high = heightmap_noise.smoothstep(np.clip(
(metres + breakup - rules["high_altitude_start_m"]) / (rules["high_altitude_full_m"] - rules["high_altitude_start_m"]), 0.0, 1.0))
high = high * (1.0 - rock * 0.5)
meadow = np.clip(1.0 - rock - high, 0.0, 1.0)
total = np.maximum(meadow + rock + high, 1e-6)
# The pack's names are not what they sound like: Base_Layer is its rock, Layer_02 its grass, Layer_03 its
# high rock (read from the textures its layer functions sample).
weights = {"Base_Layer": rock, "Layer_02": meadow, "Layer_03": high}
return {name: np.rint(w / total * 255.0).astype(np.uint8) for name, w in weights.items()}
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--manifest", default=MANIFEST_PATH)
parser.add_argument("--seed", type=int, help="override the noise seed for this run")
parser.add_argument("--source-file", help="use this heightmap file for this run instead of the manifest's source")
parser.add_argument("--source-elevation", nargs=2, type=float, metavar=("MIN_M", "MAX_M"),
help="what 0 and 65535 mean in the source file, in metres")
parser.add_argument("--out", default=HEIGHTMAP_DIR)
args = parser.parse_args(argv)
manifest = load_manifest(args.manifest)
if args.source_file:
manifest.source = {"kind": "file", "path": args.source_file}
if args.source_elevation:
manifest.source["elevation_m"] = {"min": args.source_elevation[0], "max": args.source_elevation[1]}
elif args.seed is not None:
manifest.source = {"kind": "noise", "seed": args.seed}
print(manifest.describe())
size = manifest.vertices_per_side
started = time.time()
metres = source_height_metres(manifest, size)
print(f"uplift {metres.min():.0f}..{metres.max():.0f} m in {time.time() - started:.0f} s; eroding")
metres, maps = heightmap_erosion.erode(metres, manifest.quad_cm / 100.0, manifest.sea_level_m, manifest.erosion)
metres = apply_spawn_pad(metres, manifest)
clipped = float(((metres < manifest.elevation_min_m) | (metres > manifest.elevation_max_m)).mean()) * 100.0
metres = np.clip(metres, manifest.elevation_min_m, manifest.elevation_max_m)
height = np.rint(manifest.metres_to_value(metres)).clip(0, 65535).astype(np.uint16)
derived = derived_maps(metres, maps, manifest)
layers = derive_layers(metres, derived, manifest, np.random.default_rng(int(manifest.source.get("seed", 0)) + 1))
os.makedirs(args.out, exist_ok=True)
height, weights = generate(args.size, args.seed)
write_png(os.path.join(args.out, "L_World_Height.png"), height)
for name, data in zip(("Base_Layer", "Layer_02", "Layer_03"), weights):
write_png(os.path.join(args.out, f"L_World_{name}.png"), data)
land = float((height > SEA_LEVEL * 65535).mean()) * 100.0
print(f"seed {args.seed}: {args.size}x{args.size}, {land:.0f}% land, written to {args.out}")
heightmap_io.write_png(os.path.join(args.out, "L_World_Height.png"), height)
for name, data in layers.items():
heightmap_io.write_png(os.path.join(args.out, LAYER_FILES[name]), data)
for name, data in derived.items():
heightmap_io.write_png(os.path.join(args.out, DERIVED_FILES[name]), np.rint(data * 255.0).astype(np.uint8))
land = float((metres > manifest.sea_level_m).mean()) * 100.0
print(f"height {metres.min():.0f}..{metres.max():.0f} m, {land:.0f}% above sea level, {clipped:.2f}% clipped to the range; "
f"layers meadow {layers['Layer_02'].mean() / 255 * 100:.0f}% rock {layers['Base_Layer'].mean() / 255 * 100:.0f}% "
f"high {layers['Layer_03'].mean() / 255 * 100:.0f}%; {time.time() - started:.0f} s; written to {args.out}")
if __name__ == "__main__":
+251
View File
@@ -0,0 +1,251 @@
"""Geological passes over a heightmap in metres, numpy only: particle hydraulic erosion, thermal weathering with
an angle of repose, strata hardness, and the derivative maps (flow, wear, deposition) they leave behind.
Fractal noise gives pillowy hills; these passes give drainage, V-valleys, alluvial fans, scree aprons and rock
shelves. Applied by generate_heightmap.py to whatever the manifest's source produced, noise or file.
Units inside: heights are in cell widths (metres over the cell size), so a slope of 1.0 is 45 degrees and the
droplet constants mean the same thing at any resolution. The hydraulic pass runs twice: on a downsampled map
(coarse cells, long droplet lives) for the valleys, then at full resolution (short lives) for the gullies;
the coarse result is applied to the full map as a delta, so the fine detail underneath survives.
Droplets are simulated in vectorised batches: a batch of tens of thousands takes one step together, reading
the map as it was at the start of the step and scattering its erosion and deposits back with np.add.at. Two
droplets in the same cell in the same step do not see each other; at these densities that is invisible.
"""
import time
import numpy as np
import heightmap_io
import heightmap_noise
DEFAULTS = {
"enabled": True,
"coarse_factor": 4, # the coarse pass runs on the map downsampled by this
"coarse_droplets": 800000,
"coarse_lifetime": 120, # steps, one cell each: 120 coarse cells is 1.7 km of path at 4x on 3.5 m quads
"fine_droplets": 3000000,
"fine_lifetime": 40,
"thermal_passes": 24,
"talus_deg": 35.0, # angle of repose
"inertia": 0.1,
"capacity": 2.0, # sediment a droplet can carry, in cell-heights per unit of slope, speed and water
"max_load": 2.0, # cell-heights: the most one droplet carries, so the mound it can leave where it stops is bounded
"min_slope": 0.01,
"deposit_rate": 0.2,
"erode_rate": 0.2,
"evaporation": 0.02,
"gravity": 4.0,
"strata_period_m": 160.0, # vertical period of the hard and soft bands
"strata_contrast": 0.6, # 0 is uniform rock, 1 is hard bands that barely erode next to soft ones that melt
"max_change": 0.2, # cell-heights one droplet may cut or fill in one step; batches of droplets share cells, so this is the brake
"max_speed": 5.0,
"min_erode_slope": 0.25, # below this slope (about 14 degrees) water deposits but barely cuts: lowland soil holds, so meadows stay meadows
"fine_scale": 0.5, # the fine pass cuts at this fraction of the coarse pass's rates: gullies, not trenches, at 3.5 m cells
"batch": 200000, # droplets stepping together, at most one per 40 cells of the map
"seed": 11,
}
class Hardness:
"""Rock hardness in [0, 1] as a function of position and elevation: horizontal strata with a slow tilt and a
slow change of rock type across the map. Erosion is scaled by (1 - hardness), so hard bands hold shelves."""
def __init__(self, size, rng, period_cells, contrast):
self.period = max(float(period_cells), 1e-3)
self.contrast = float(contrast)
self.tilt = heightmap_noise.fbm(size, rng, base_cells=3, octaves=3, gain=0.5).astype(np.float32)
self.kind = heightmap_noise.fbm(size, rng, base_cells=2, octaves=3, gain=0.5).astype(np.float32)
def at(self, ix, iy, height):
band = 0.5 + 0.5 * np.sin(2.0 * np.pi * (height / self.period + self.tilt[iy, ix] * 2.0))
return np.clip(0.5 + self.contrast * (band - 0.5) * (0.4 + 0.8 * self.kind[iy, ix]), 0.05, 0.95).astype(np.float32)
BRUSH = ((0, 0, 0.36), (0, 1, 0.12), (0, -1, 0.12), (1, 0, 0.12), (-1, 0, 0.12),
(1, 1, 0.04), (1, -1, 0.04), (-1, 1, 0.04), (-1, -1, 0.04)) # offsets (dy, dx) and weights summing to 1
def sample(h, px, py):
"""Bilinear height and gradient at float positions; the caller keeps px, py inside [0, size - 2]."""
x0 = px.astype(np.int32)
y0 = py.astype(np.int32)
fx = px - x0
fy = py - y0
h00 = h[y0, x0]
h10 = h[y0, x0 + 1]
h01 = h[y0 + 1, x0]
h11 = h[y0 + 1, x0 + 1]
gx = (h10 - h00) * (1 - fy) + (h11 - h01) * fy
gy = (h01 - h00) * (1 - fx) + (h11 - h10) * fx
hc = h00 * (1 - fx) * (1 - fy) + h10 * fx * (1 - fy) + h01 * (1 - fx) * fy + h11 * fx * fy
return hc, gx, gy, x0, y0, fx, fy
def hydraulic(h, rng, droplets, lifetime, cfg, hardness, spawn_mask, maps, sea_cells=-1e9):
"""Particle erosion in place on h (cell units). maps: flow, wear, deposit arrays of h's shape, accumulated.
A droplet that reaches water below `sea_cells` drops its whole load there and ends: the sea is a sink,
and river mouths get their fans."""
size = h.shape[0]
ys, xs = np.nonzero(spawn_mask)
if xs.size == 0:
return
inertia, capacity_factor = cfg["inertia"], cfg["capacity"]
min_slope, deposit_rate, erode_rate = cfg["min_slope"], cfg["deposit_rate"], cfg["erode_rate"]
evaporation, gravity = cfg["evaporation"], cfg["gravity"]
max_change, max_speed, max_load = float(cfg["max_change"]), float(cfg["max_speed"]), float(cfg["max_load"])
min_erode_slope = max(float(cfg["min_erode_slope"]), 1e-6)
batch = max(min(int(cfg["batch"]), size * size // 40), 1000)
limit = size - 2.001
done = 0
while done < droplets:
n = min(batch, droplets - done)
done += n
pick = rng.integers(0, xs.size, n)
px = np.clip(xs[pick] + rng.random(n, dtype=np.float32), 1.0, limit).astype(np.float32)
py = np.clip(ys[pick] + rng.random(n, dtype=np.float32), 1.0, limit).astype(np.float32)
dx = np.zeros(n, dtype=np.float32)
dy = np.zeros(n, dtype=np.float32)
speed = np.ones(n, dtype=np.float32)
water = np.ones(n, dtype=np.float32)
sediment = np.zeros(n, dtype=np.float32)
for _ in range(lifetime):
if px.size == 0:
break
hc, gx, gy, x0, y0, fx, fy = sample(h, px, py)
dx = dx * inertia - gx * (1 - inertia)
dy = dy * inertia - gy * (1 - inertia)
length = np.hypot(dx, dy)
moving = length > 1e-9
safe = np.where(moving, length, 1.0)
dx = np.where(moving, dx / safe, 0.0).astype(np.float32)
dy = np.where(moving, dy / safe, 0.0).astype(np.float32)
nx = px + dx
ny = py + dy
inside = moving & (nx >= 1.0) & (nx <= limit) & (ny >= 1.0) & (ny <= limit)
hn = sample(h, np.clip(nx, 1.0, limit), np.clip(ny, 1.0, limit))[0]
dh = np.where(inside, hn - hc, 0.0).astype(np.float32)
slope = np.maximum(-dh, min_slope)
capacity = np.minimum(slope * speed * water * capacity_factor, max_load)
hard = hardness.at(x0, y0, hc) if hardness is not None else 0.0
holds = np.clip(np.hypot(gx, gy) / min_erode_slope, 0.0, 1.0) ** 2 # flat ground resists cutting
deposit = np.where(dh > 0.0, np.minimum(dh, sediment),
np.where(sediment > capacity, (sediment - capacity) * deposit_rate, 0.0))
erode = np.where((dh <= 0.0) & (sediment <= capacity),
np.minimum((capacity - sediment) * erode_rate, -dh) * (1.0 - hard) * holds, 0.0)
into_sea = inside & (hn < sea_cells)
deposit = np.where(into_sea, sediment, np.minimum(deposit, max_change)).astype(np.float32)
erode = np.where(into_sea, 0.0, np.minimum(erode, max_change)).astype(np.float32)
# Cuts go through a 3x3 brush: a one-cell footprint leaves every path as a rill one cell wide, which
# reads as brush strokes. Deposits land on the droplet's own bilinear cell: spread through the brush,
# a pit's rim rises faster than its floor, the pit never fills, and every droplet that drains into it
# adds to the rim until there is a mound.
for oy, ox, weight in BRUSH:
np.add.at(h, (y0 + oy, x0 + ox), -erode * weight)
np.add.at(h, (y0, x0), deposit * (1 - fx) * (1 - fy))
np.add.at(h, (y0, x0 + 1), deposit * fx * (1 - fy))
np.add.at(h, (y0 + 1, x0), deposit * (1 - fx) * fy)
np.add.at(h, (y0 + 1, x0 + 1), deposit * fx * fy)
np.add.at(maps["flow"], (y0, x0), water)
np.add.at(maps["wear"], (y0, x0), erode)
np.add.at(maps["deposit"], (y0, x0), deposit)
sediment = sediment + erode - deposit
speed = np.minimum(np.sqrt(np.maximum(0.0, speed * speed - dh * gravity)), max_speed).astype(np.float32) # downhill is faster
water = water * (1.0 - evaporation)
alive = inside & ~into_sea & (water > 0.001)
px, py, dx, dy = nx[alive], ny[alive], dx[alive], dy[alive]
speed, water, sediment = speed[alive], water[alive], sediment[alive]
DIRECTIONS = ((0, 1, 1.0), (0, -1, 1.0), (1, 0, 1.0), (-1, 0, 1.0),
(1, 1, np.sqrt(2.0)), (1, -1, np.sqrt(2.0)), (-1, 1, np.sqrt(2.0)), (-1, -1, np.sqrt(2.0)))
def thermal(h, passes, talus):
"""Mass-conserving thermal weathering: where a cell stands above a neighbour by more than the angle of
repose allows, half the excess slides down, shared among the lower neighbours. Cliffs keep a face, and
scree builds at their feet. h in cell units, talus is tan(angle of repose)."""
size = h.shape[0]
def neighbour(padded, dy, dx):
return padded[1 + dy:1 + dy + size, 1 + dx:1 + dx + size]
for _ in range(passes):
start = h.copy()
padded = np.pad(start, 1, mode="edge")
worst = np.zeros(h.shape, dtype=np.float32)
total = np.zeros(h.shape, dtype=np.float32)
for dy, dx, dist in DIRECTIONS:
excess = np.maximum(start - neighbour(padded, dy, dx) - talus * dist, 0.0)
worst = np.maximum(worst, excess)
total += excess
# A cell sheds half of its largest excess per pass, split among its lower neighbours in proportion to
# how far each is below the angle of repose. Never more than half, so slopes settle without inverting.
scale = np.where(total > 0.0, 0.5 * worst / np.maximum(total, 1e-9), 0.0).astype(np.float32)
for dy, dx, dist in DIRECTIONS:
move = np.maximum(start - neighbour(padded, dy, dx) - talus * dist, 0.0) * scale
h -= move
h[max(dy, 0):size + min(dy, 0), max(dx, 0):size + min(dx, 0)] += move[max(-dy, 0):size + min(-dy, 0), max(-dx, 0):size + min(-dx, 0)]
return h
def erode(metres, quad_m, sea_level_m, settings, log=print):
"""The whole sequence on a map in metres. Returns (metres, maps) where maps holds flow, wear and deposit at
the map's resolution, in cell-height units accumulated over both passes."""
cfg = {**DEFAULTS, **(settings or {})}
size = metres.shape[0]
maps = {name: np.zeros((size, size), dtype=np.float32) for name in ("flow", "wear", "deposit")}
if not cfg["enabled"]:
return metres, maps
rng = np.random.default_rng(int(cfg["seed"]))
talus = float(np.tan(np.radians(cfg["talus_deg"])))
started = time.time()
factor = int(cfg["coarse_factor"])
if factor > 1 and cfg["coarse_droplets"] > 0:
coarse = heightmap_io.block_mean(metres, factor)
cell_m = quad_m * factor
hc = (coarse / cell_m).astype(np.float32)
hardness = Hardness(hc.shape[0], rng, cfg["strata_period_m"] / cell_m, cfg["strata_contrast"])
coarse_maps = {name: np.zeros(hc.shape, dtype=np.float32) for name in maps}
thermal(hc, max(cfg["thermal_passes"] // 4, 1), talus)
hydraulic(hc, rng, int(cfg["coarse_droplets"]), int(cfg["coarse_lifetime"]), cfg, hardness, coarse > sea_level_m + 2.0, coarse_maps,
sea_cells=sea_level_m / cell_m)
thermal(hc, max(cfg["thermal_passes"] // 2, 1), talus)
delta = hc * cell_m - coarse
metres = (metres + heightmap_io.resample(delta, size)).astype(np.float32)
for name in maps:
maps[name] += heightmap_io.resample(coarse_maps[name], size) * factor
log(f" coarse erosion at {hc.shape[0]}x{hc.shape[0]}: {cfg['coarse_droplets']} droplets, "
f"largest cut {-delta.min():.0f} m, largest fill {delta.max():.0f} m, {time.time() - started:.0f} s")
hf = (metres / quad_m).astype(np.float32)
before = hf.copy()
hardness = Hardness(size, rng, cfg["strata_period_m"] / quad_m, cfg["strata_contrast"])
fine_cfg = {**cfg, "erode_rate": cfg["erode_rate"] * cfg["fine_scale"], "max_change": cfg["max_change"] * cfg["fine_scale"]}
hydraulic(hf, rng, int(cfg["fine_droplets"]), int(cfg["fine_lifetime"]), fine_cfg, hardness, metres > sea_level_m + 2.0, maps,
sea_cells=sea_level_m / quad_m)
thermal(hf, int(cfg["thermal_passes"]), talus)
delta = (hf - before) * quad_m
log(f" fine erosion at {size}x{size}: {cfg['fine_droplets']} droplets, {cfg['thermal_passes']} thermal passes at "
f"{cfg['talus_deg']:g} deg, largest cut {-delta.min():.0f} m, largest fill {delta.max():.0f} m, {time.time() - started:.0f} s total")
return (hf * quad_m).astype(np.float32), maps
def curvature(metres, quad_m):
"""Laplacian of the lightly blurred height, in metres per cell squared: positive on ridges and convex
shoulders, negative in gullies and sediment traps."""
h = heightmap_noise.box_blur(metres, 2)
padded = np.pad(h, 1, mode="edge")
lap = (padded[:-2, 1:-1] + padded[2:, 1:-1] + padded[1:-1, :-2] + padded[1:-1, 2:] - 4.0 * h)
return (lap / quad_m).astype(np.float32)
def to_unit(values, percentile=99.0, log_scale=False):
"""A map squashed into [0, 1] for painting and for an 8-bit PNG."""
v = np.log1p(np.maximum(values, 0.0)) if log_scale else np.maximum(values, 0.0)
top = float(np.percentile(v, percentile))
return np.clip(v / max(top, 1e-6), 0.0, 1.0).astype(np.float32)
+184
View File
@@ -0,0 +1,184 @@
"""Reading, writing and resampling heightmaps with nothing but numpy, so the authoring scripts run on the
engine's own Python (which has no PIL). Greyscale PNG in 8 or 16 bit, raw 16-bit little-endian (.r16 / .raw,
what World Machine, Gaea and the engine's own exporter write), bilinear resampling and a centred square crop:
enough to take a real heightmap from any of the usual sources and put it on the landscape.
"""
import math
import struct
import zlib
import numpy as np
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
def write_png(path, data):
"""Greyscale PNG, 8 or 16 bit from the array dtype. Row filter 0, one zlib stream."""
if data.dtype == np.uint16:
depth, payload = 16, data.astype(">u2")
else:
depth, payload = 8, data.astype(np.uint8)
height, width = data.shape
raw = b"".join(b"\x00" + payload[y].tobytes() for y in range(height))
def chunk(kind, body):
return struct.pack(">I", len(body)) + kind + body + struct.pack(">I", zlib.crc32(kind + body) & 0xFFFFFFFF)
ihdr = struct.pack(">IIBBBBB", width, height, depth, 0, 0, 0, 0)
with open(path, "wb") as f:
f.write(PNG_SIGNATURE + chunk(b"IHDR", ihdr) + chunk(b"IDAT", zlib.compress(raw, 6)) + chunk(b"IEND", b""))
def read_png_header(path):
"""(width, height, bit_depth, colour_type) without decoding the image."""
with open(path, "rb") as f:
head = f.read(8 + 8 + 13)
if head[:8] != PNG_SIGNATURE or head[12:16] != b"IHDR":
raise ValueError(f"{path}: not a PNG")
width, height, depth, colour_type = struct.unpack(">IIBB", head[16:26])
return width, height, depth, colour_type
def _unfilter_sequential(filter_type, row, prev, bpp):
"""Average and Paeth depend on the byte just decoded, so they go pixel by pixel. Rare in practice; a
4081x4081 16-bit file with every row Paeth-filtered takes some tens of seconds, once, on import."""
out = bytearray(row)
n = len(out)
if filter_type == 3:
for i in range(n):
left = out[i - bpp] if i >= bpp else 0
out[i] = (out[i] + ((left + prev[i]) >> 1)) & 0xFF
else:
for i in range(n):
if i >= bpp:
a, c = out[i - bpp], prev[i - bpp]
else:
a, c = 0, 0
b = prev[i]
p = a + b - c
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
if pa <= pb and pa <= pc:
predictor = a
elif pb <= pc:
predictor = b
else:
predictor = c
out[i] = (out[i] + predictor) & 0xFF
return out
def read_png(path):
"""The first channel of a non-interlaced PNG as a 2D uint8 or uint16 array (greyscale, grey+alpha, RGB
and RGBA are accepted; palette and interlaced files are not)."""
with open(path, "rb") as f:
blob = f.read()
if blob[:8] != PNG_SIGNATURE:
raise ValueError(f"{path}: not a PNG")
pos, idat, ihdr = 8, [], None
while pos + 8 <= len(blob):
length, kind = struct.unpack(">I4s", blob[pos:pos + 8])
body = blob[pos + 8:pos + 8 + length]
pos += 12 + length
if kind == b"IHDR":
ihdr = struct.unpack(">IIBBBBB", body)
elif kind == b"IDAT":
idat.append(body)
elif kind == b"IEND":
break
if ihdr is None:
raise ValueError(f"{path}: no IHDR")
width, height, depth, colour_type, _, _, interlace = ihdr
channels = {0: 1, 2: 3, 4: 2, 6: 4}.get(colour_type)
if channels is None or depth not in (8, 16) or interlace != 0:
raise ValueError(f"{path}: unsupported PNG (colour type {colour_type}, {depth} bit, interlace {interlace}); "
"use a non-interlaced 8 or 16 bit greyscale or RGB file")
bytes_per_sample = depth // 8
bpp = channels * bytes_per_sample
stride = width * bpp
data = zlib.decompress(b"".join(idat))
if len(data) != height * (stride + 1):
raise ValueError(f"{path}: PNG data is {len(data)} bytes, expected {height * (stride + 1)}")
rows = np.empty((height, stride), dtype=np.uint8)
prev = np.zeros(stride, dtype=np.uint8)
for y in range(height):
start = y * (stride + 1)
filter_type = data[start]
row = np.frombuffer(data, dtype=np.uint8, count=stride, offset=start + 1)
if filter_type == 0:
out = row.copy()
elif filter_type == 1:
out = (np.cumsum(row.reshape(width, bpp), axis=0, dtype=np.uint64) & 0xFF).astype(np.uint8).reshape(stride)
elif filter_type == 2:
out = ((row.astype(np.uint16) + prev) & 0xFF).astype(np.uint8)
elif filter_type in (3, 4):
out = np.frombuffer(bytes(_unfilter_sequential(filter_type, bytes(row), bytes(prev), bpp)), dtype=np.uint8)
else:
raise ValueError(f"{path}: bad PNG filter {filter_type} on row {y}")
rows[y] = out
prev = rows[y]
dtype = ">u2" if depth == 16 else np.uint8
samples = rows.reshape(height, width * channels * bytes_per_sample).view(dtype).reshape(height, width, channels)
first = samples[:, :, 0]
return first.astype(np.uint16) if depth == 16 else first.astype(np.uint8)
def read_r16(path, width=None):
"""Raw 16-bit little-endian samples, square unless a width is given."""
values = np.fromfile(path, dtype="<u2")
if width is None:
width = math.isqrt(len(values))
if width * width != len(values):
raise ValueError(f"{path}: {len(values)} samples is not a square; give the width in the manifest")
if len(values) % width != 0:
raise ValueError(f"{path}: {len(values)} samples do not divide by width {width}")
return values.reshape(len(values) // width, width).astype(np.uint16)
def read_heightmap(path, width=None):
"""Any supported file as a 2D uint16 array with the full 0..65535 range (8-bit files are widened)."""
lower = path.lower()
if lower.endswith(".png"):
values = read_png(path)
return values.astype(np.uint16) * 257 if values.dtype == np.uint8 else values
if lower.endswith((".r16", ".raw")):
return read_r16(path, width)
raise ValueError(f"{path}: unknown heightmap format; use 16-bit PNG or raw .r16")
def center_crop_square(values):
height, width = values.shape
side = min(height, width)
y0, x0 = (height - side) // 2, (width - side) // 2
return values[y0:y0 + side, x0:x0 + side]
def block_mean(values, factor):
"""Downsample by an integer factor with a box filter, trimming the edge that does not divide."""
height, width = values.shape
height, width = height // factor * factor, width // factor * factor
trimmed = values[:height, :width].astype(np.float32)
return trimmed.reshape(height // factor, factor, width // factor, factor).mean(axis=(1, 3))
def resample(values, size):
"""Bilinear resample of a 2D array to size x size, box-filtered first when shrinking by 2x or more."""
source = values.astype(np.float32)
factor = min(source.shape) // size
if factor >= 2:
source = block_mean(source, factor)
src_h, src_w = source.shape
if (src_h, src_w) == (size, size):
return source
ys = np.linspace(0.0, src_h - 1, size, dtype=np.float32)
xs = np.linspace(0.0, src_w - 1, size, dtype=np.float32)
y0 = np.floor(ys).astype(np.int64)
x0 = np.floor(xs).astype(np.int64)
y1 = np.minimum(y0 + 1, src_h - 1)
x1 = np.minimum(x0 + 1, src_w - 1)
ty = (ys - y0)[:, None]
tx = (xs - x0)[None, :]
top = source[np.ix_(y0, x0)] * (1 - tx) + source[np.ix_(y0, x1)] * tx
bottom = source[np.ix_(y1, x0)] * (1 - tx) + source[np.ix_(y1, x1)] * tx
return (top * (1 - ty) + bottom * ty).astype(np.float32)
+167
View File
@@ -0,0 +1,167 @@
"""The noise heightmap source: a seeded continent in metres, and the small numpy toolkit (value noise, fBm,
domain warping, cellular crest lines, blur) that generate_heightmap.py also uses to derive the paint layers.
The shape, in metres: a continent with ragged coasts and sea around it, meadow lowlands, rolling hills, and
mountain ranges that run as long warped chains over about two fifths of the land, foothills included, with
ridged crests up to the manifest's ceiling. This is the uplift only; heightmap_erosion.py weathers and carves
it afterwards. Rocky Meadows is the look: meadow between the ranges, rock on them. This is the placeholder
until a real heightmap replaces it in the manifest; nothing downstream can tell the difference.
"""
import numpy as np
def smoothstep(t):
return t * t * (3.0 - 2.0 * t)
def value_noise(size, cells, rng):
"""One octave on the regular grid: a random lattice of cells x cells, smoothly interpolated to size x size."""
lattice = rng.random((cells + 1, cells + 1), dtype=np.float32)
coords = np.linspace(0.0, cells, size, endpoint=False, dtype=np.float32)
i = np.floor(coords).astype(np.int32)
t = smoothstep(coords - i)
i1 = np.minimum(i + 1, cells)
top = lattice[i[:, None], i[None, :]] * (1 - t[None, :]) + lattice[i[:, None], i1[None, :]] * t[None, :]
bottom = lattice[i1[:, None], i[None, :]] * (1 - t[None, :]) + lattice[i1[:, None], i1[None, :]] * t[None, :]
return top * (1 - t[:, None]) + bottom * t[:, None]
def sample_lattice(lattice, u, v):
"""One octave at arbitrary coordinates: smooth interpolation of a periodic lattice at (u, v) in cell units,
any float arrays of one shape. Periodic, so warped or stretched coordinates never run off the edge."""
cells = lattice.shape[0]
i0 = np.floor(u).astype(np.int32)
j0 = np.floor(v).astype(np.int32)
tu = smoothstep(u - i0)
tv = smoothstep(v - j0)
i0 %= cells
j0 %= cells
i1 = (i0 + 1) % cells
j1 = (j0 + 1) % cells
top = lattice[j0, i0] * (1 - tu) + lattice[j0, i1] * tu
bottom = lattice[j1, i0] * (1 - tu) + lattice[j1, i1] * tu
return top * (1 - tv) + bottom * tv
def fbm(size, rng, base_cells=4, octaves=8, gain=0.5, ridged=False):
"""Fractional Brownian motion in [0, 1] on the regular grid: octaves of value noise, each twice as fine
and `gain` as strong."""
total = np.zeros((size, size), dtype=np.float32)
amplitude, cells, norm = 1.0, base_cells, 0.0
for _ in range(octaves):
n = value_noise(size, cells, rng)
if ridged:
n = 1.0 - np.abs(n * 2.0 - 1.0)
n = n * n
total += n * amplitude
norm += amplitude
amplitude *= gain
cells *= 2
return total / norm
def fbm_at(u, v, rng, base_cells=4, octaves=8, gain=0.5, ridged=False):
"""fBm sampled at map coordinates (u, v), where 0..1 spans the map once; anything outside wraps. Feed it
warped or anisotropic coordinates and the noise bends and stretches with them."""
total = np.zeros(u.shape, dtype=np.float32)
amplitude, cells, norm = 1.0, base_cells, 0.0
for _ in range(octaves):
lattice = rng.random((cells, cells), dtype=np.float32)
n = sample_lattice(lattice, u * cells, v * cells)
if ridged:
n = 1.0 - np.abs(n * 2.0 - 1.0)
n = n * n
total += n * amplitude
norm += amplitude
amplitude *= gain
cells *= 2
return total / norm
def normalised(a):
return (a - a.min()) / max(float(a.max() - a.min()), 1e-6)
def cellular_edges(u, v, rng, cells=12, jitter=0.9):
"""Worley cellular noise, F2 - F1 through periodic jittered feature points, mapped so the borders between
cells read 1 and the interiors 0: a network of thin, branching crest lines. Sampled at map coordinates
like fbm_at, so warped coordinates bend the network."""
points = rng.random((cells, cells, 2), dtype=np.float32) * jitter + (1.0 - jitter) * 0.5
su = u * cells
sv = v * cells
i0 = np.floor(su).astype(np.int32)
j0 = np.floor(sv).astype(np.int32)
fu = (su - i0).astype(np.float32)
fv = (sv - j0).astype(np.float32)
f1 = np.full(u.shape, np.inf, dtype=np.float32)
f2 = f1.copy()
for dj in (-1, 0, 1):
for di in (-1, 0, 1):
ci = (i0 + di) % cells
cj = (j0 + dj) % cells
d = np.hypot(points[cj, ci, 0] + di - fu, points[cj, ci, 1] + dj - fv)
closer = d < f1
f2 = np.where(closer, f1, np.minimum(f2, d))
f1 = np.where(closer, d, f1)
edge = 1.0 - np.clip((f2 - f1) / 0.6, 0.0, 1.0)
return (edge * edge).astype(np.float32)
def box_blur(h, passes):
for _ in range(passes):
padded = np.pad(h, 1, mode="edge")
h = (padded[:-2, 1:-1] + padded[2:, 1:-1] + padded[1:-1, :-2] + padded[1:-1, 2:] + h) / 5.0
return h.astype(np.float32)
def generate_metres(size, seed, quad_m, sea_level_m=0.0, land_height_m=2560.0):
"""A size x size continent in metres above `sea_level_m`, before erosion: the uplift. The crests approach
`land_height_m` above the sea; the sea floor lies 30 to 180 m below it, shaped so the shore is not a step.
On steepness: each octave of noise contributes a slope of about amplitude over wavelength, so with gain 0.5
every octave is as steep as the last and eight of them stack into cliffs everywhere. The gains here keep
the meadows gentle; the ranges are meant to be steep and the erosion pass gives them their faces.
Measure the result with a slope histogram before tuning by eye.
"""
rng = np.random.default_rng(seed)
y, x = np.mgrid[0:size, 0:size].astype(np.float32) / (size - 1)
# Continent: a radial falloff with a ragged, noise-warped edge, so the coast is not a circle.
cx, cy = 0.5 + (rng.random() - 0.5) * 0.15, 0.5 + (rng.random() - 0.5) * 0.15
radius = np.sqrt(((x - cx) * 1.05) ** 2 + ((y - cy) * 0.95) ** 2)
coast_warp = (fbm(size, rng, base_cells=3, octaves=5, gain=0.45) - 0.5) * 0.35
continent = np.clip(1.0 - (radius + coast_warp) / 0.55, 0.0, 1.0)
continent = smoothstep(np.clip(continent * 1.6, 0.0, 1.0))
# A low-frequency warp field bends everything that follows, so ridges curve and ranges are not blobs.
warp_x = (fbm(size, rng, base_cells=3, octaves=3, gain=0.5) - 0.5) * 0.16
warp_y = (fbm(size, rng, base_cells=3, octaves=3, gain=0.5) - 0.5) * 0.16
plains = fbm(size, rng, base_cells=6, octaves=4, gain=0.45) * 0.05
hills = fbm_at(x + warp_x * 0.5, y + warp_y * 0.5, rng, base_cells=5, octaves=5, gain=0.45) * 0.18
# Ranges. An elongated, warped band says where they run: stretched across its grain so they come as long
# chains, thresholded by percentile so they and their foothills cover about two fifths of the map whatever
# the seed. Ridged noise through the same warp gives them their crests; the gamma keeps the flanks massive.
angle = float(rng.uniform(0.0, np.pi))
along = (x - 0.5) * np.cos(angle) + (y - 0.5) * np.sin(angle)
across = -(x - 0.5) * np.sin(angle) + (y - 0.5) * np.cos(angle)
band = fbm_at(0.5 + along * 0.7 + warp_x, 0.5 + across * 2.2 + warp_y, rng, base_cells=3, octaves=3, gain=0.5)
band_lo, band_hi = np.percentile(band, [58.0, 86.0])
range_mask = smoothstep(np.clip((band - band_lo) / max(band_hi - band_lo, 1e-6), 0.0, 1.0))
ridges = normalised(fbm_at(x + warp_x, y + warp_y, rng, base_cells=5, octaves=6, gain=0.42, ridged=True))
# Cellular edges through a stronger warp: a light touch of branching crest lines where cells meet. Kept
# light on purpose: at 0.3 the ranges became a honeycomb of polygon walls with flat floors (2026-09-17).
crests = cellular_edges(x + warp_x * 1.4, y + warp_y * 1.4, rng, cells=14, jitter=0.95)
mountains = np.power(0.88 * ridges + 0.12 * crests, 0.8) * range_mask
# Ground detail at the scale of a few quads, a few metres tall: texture, not terrain.
detail = (fbm(size, rng, base_cells=200, octaves=3, gain=0.5) - 0.5) * 2.0 * 4.0
land = 0.04 + plains + hills * (0.4 + 0.6 * continent) + mountains * 1.0
height = continent * land * land_height_m + detail * continent
sea_floor = (-0.03 - (1.0 - continent) * 0.04 + plains * 0.3) * land_height_m
height = np.where(continent > 0.02, height, sea_floor).astype(np.float32)
height = np.maximum(height, sea_floor.astype(np.float32))
# Weathering and erosion are heightmap_erosion.py's job; this is the raw uplift.
return (height + sea_level_m).astype(np.float32)
+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)
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Builds the terrain generator (Docs/Terrain.md) to Tools/Terrain/bin/terrain.exe.
#
# Go is not in the repository and not in the engine's toolchain, so this is the one place that says so out
# loud when it is missing. Everything else about the generator is engine-free and needs nothing installed.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT/Tools/Terrain"
if ! command -v go >/dev/null 2>&1; then
echo "build-terrain: no 'go' on PATH." >&2
echo " The generator needs a Go toolchain (1.22 or newer, for math/rand/v2). https://go.dev/dl/" >&2
echo " Take the windows/amd64 build: a 386 toolchain caps the process near 2 GB, and one float32" >&2
echo " field at the full 7141 grid is 204 MB, of which the detail passes hold several at once." >&2
exit 1
fi
echo "go $(go version | awk '{print $3}' | sed 's/^go//') ($(go env GOOS)/$(go env GOARCH))"
if [ "$(go env GOARCH)" = "386" ]; then
echo "build-terrain: WARNING - this is a 32-bit toolchain. The full-resolution run will not fit." >&2
fi
gofmt -l . | (! grep .) || { echo "build-terrain: gofmt would change the files above" >&2; exit 1; }
go vet ./...
go test ./...
mkdir -p bin
go build -o bin/terrain.exe ./cmd/terrain
echo "built $ROOT/Tools/Terrain/bin/terrain.exe"