This commit is contained in:
Vysyr committed 2026-09-05 13:33:04 +02:00
1 parent 2a2cea4fd0
commit 3a9b791a6a
8 files changed
+788 -207

No files matched your search

+9 -206
View File
@@ -12,17 +12,18 @@ terrain_config.json actually exist.
Set the TERRAIN_CONFIG env var to point at a different config file, e.g. to
run terrain_config_variants_demo.json without touching the default config.
See generate_terrain_from_heightmap.py for the same pipeline driven by a real
heightmap image instead of procedural noise - both share terrain_common.py.
"""
import os, json, math, random, unreal
import terrain_common as tc
CONFIG_PATH = os.environ.get(
"TERRAIN_CONFIG",
os.path.join(os.path.dirname(os.path.abspath(__file__)), "terrain_config_variants_demo.json"),
)
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
# ---------------------------------------------------------------------------
# Fractal value noise (pure Python, no engine dependency) -------------------
@@ -86,206 +87,10 @@ class FractalHeightField:
return (total / max_amp) * self.height_scale
def normal(self, x, y, step=200.0):
h_l = self.height(x - step, y)
h_r = self.height(x + step, y)
h_d = self.height(x, y - step)
h_u = self.height(x, y + step)
# tangents along X and Y, cross product gives the surface normal.
tangent_x = unreal.Vector(2 * step, 0.0, h_r - h_l)
tangent_y = unreal.Vector(0.0, 2 * step, h_u - h_d)
nx = tangent_x.y * tangent_y.z - tangent_x.z * tangent_y.y
ny = tangent_x.z * tangent_y.x - tangent_x.x * tangent_y.z
nz = tangent_x.x * tangent_y.y - tangent_x.y * tangent_y.x
length = math.sqrt(nx * nx + ny * ny + nz * nz) or 1.0
return unreal.Vector(nx / length, ny / length, nz / length)
return tc.finite_difference_normal(self.height, x, y, step)
# ---------------------------------------------------------------------------
# Terrain mesh ----------------------------------------------------------------
# ---------------------------------------------------------------------------
def build_terrain_mesh(cfg, field):
steps = max(2, cfg["resolution"])
mesh = unreal.DynamicMesh()
primitive_options = unreal.GeometryScriptPrimitiveOptions()
mesh = unreal.GeometryScript_Primitives.append_rectangle_xy(
mesh, primitive_options, unreal.Transform(),
dimension_x=cfg["size_x"], dimension_y=cfg["size_y"],
steps_width=steps, steps_height=steps,
)
mesh, position_list, _has_gaps = unreal.GeometryScript_MeshQueries.get_all_vertex_positions(mesh, False)
positions = unreal.GeometryScript_List.convert_vector_list_to_array(position_list)
min_x = min_y = float("inf")
max_x = max_y = float("-inf")
displaced = []
for p in positions:
z = field.height(p.x, p.y)
displaced.append(unreal.Vector(p.x, p.y, z))
min_x, max_x = min(min_x, p.x), max(max_x, p.x)
min_y, max_y = min(min_y, p.y), max(max_y, p.y)
new_list = unreal.GeometryScript_List.convert_array_to_vector_list(displaced)
mesh = unreal.GeometryScript_MeshEdits.set_all_mesh_vertex_positions(mesh, new_list)
mesh = unreal.GeometryScript_Normals.set_per_vertex_normals(mesh)
dest_path = cfg["dest_path"]
asset_name = cfg["asset_name"]
asset_path = f"{dest_path}/{asset_name}"
if unreal.EditorAssetLibrary.does_asset_exist(asset_path):
unreal.EditorAssetLibrary.delete_asset(asset_path)
create_options = unreal.GeometryScriptCreateNewStaticMeshAssetOptions()
static_mesh, _outcome = unreal.GeometryScript_NewAssetUtils.create_new_static_mesh_asset_from_mesh(
mesh, asset_path, create_options,
)
unreal.EditorAssetLibrary.save_asset(static_mesh.get_path_name())
bounds = (min_x, max_x, min_y, max_y)
return static_mesh, bounds
_mesh_bounds_cache = {}
def mesh_local_bounds(mesh):
path = mesh.get_path_name()
if path not in _mesh_bounds_cache:
box = mesh.get_bounding_box()
_mesh_bounds_cache[path] = (box.min, box.max)
return _mesh_bounds_cache[path]
def ground_offset_for(mesh, quat, scale_vec):
"""World-space Z distance from the actor's pivot down to the lowest point of
its (scaled, rotated) mesh, so the base can be planted exactly on the ground
no matter where the source asset's pivot was authored, or how it's tilted."""
box_min, box_max = mesh_local_bounds(mesh)
min_z = None
for x in (box_min.x, box_max.x):
for y in (box_min.y, box_max.y):
for z in (box_min.z, box_max.z):
corner = unreal.Vector(x * scale_vec.x, y * scale_vec.y, z * scale_vec.z)
world_z = quat.rotate_vector(corner).z
if min_z is None or world_z < min_z:
min_z = world_z
return -min_z
def align_and_yaw_quat(normal, yaw_degrees, align_to_normal):
"""Random yaw about the object's own up axis, then (optionally) tilt that
up axis to match the terrain normal - in that order, since tilting first
would rotate the yaw axis out from under the random spin."""
yaw_quat = unreal.Rotator(roll=0.0, pitch=0.0, yaw=yaw_degrees).quaternion()
if not align_to_normal:
return yaw_quat
align_quat = unreal.Vector(0.0, 0.0, 1.0).find_quat_between_normals(normal)
return align_quat * yaw_quat
# ---------------------------------------------------------------------------
# Foliage scatter --------------------------------------------------------------
# ---------------------------------------------------------------------------
def scatter_layer(layer_cfg, field, bounds, origin, base_folder):
min_x, max_x, min_y, max_y = bounds
rng = random.Random(layer_cfg.get("seed", 0))
meshes = [unreal.load_asset(path) for path in layer_cfg["meshes"]]
meshes = [m for m in meshes if m is not None]
if not meshes:
print(f" [{layer_cfg['name']}] no valid meshes found, skipping")
return 0
max_slope_cos = math.cos(math.radians(layer_cfg.get("max_slope_degrees", 90.0)))
min_spacing = layer_cfg.get("min_spacing", 0.0)
min_spacing_sqr = min_spacing * min_spacing
placed_points = []
spawned = 0
max_attempts = layer_cfg["count"] * 25
attempts = 0
while spawned < layer_cfg["count"] and attempts < max_attempts:
attempts += 1
x = rng.uniform(min_x, max_x)
y = rng.uniform(min_y, max_y)
if min_spacing_sqr > 0.0 and any((x - px) ** 2 + (y - py) ** 2 < min_spacing_sqr for px, py in placed_points):
continue
z = field.height(x, y)
if not (layer_cfg["min_height"] <= z <= layer_cfg["max_height"]):
continue
normal = field.normal(x, y)
if normal.z < max_slope_cos:
continue
placed_points.append((x, y))
mesh = rng.choice(meshes)
scale = rng.uniform(layer_cfg["min_scale"], layer_cfg["max_scale"])
scale_vec = unreal.Vector(scale, scale, scale)
yaw = rng.uniform(0.0, 360.0)
quat = align_and_yaw_quat(normal, yaw, layer_cfg.get("align_to_normal", False))
rotation = quat.rotator()
ground_offset = ground_offset_for(mesh, quat, scale_vec)
location = unreal.Vector(origin.x + x, origin.y + y, origin.z + z + ground_offset)
actor = actor_subsystem.spawn_actor_from_object(mesh, location, rotation)
actor.set_actor_scale3d(scale_vec)
actor.set_folder_path(f"{base_folder}/{layer_cfg['name']}")
actor.set_actor_label(f"{layer_cfg['name']}_{spawned:04d}")
spawned += 1
print(f" [{layer_cfg['name']}] placed {spawned}/{layer_cfg['count']} ({attempts} attempts)")
return spawned
def place_explicit(entries, field, origin, base_folder):
"""Places an exact, hand-authored (or externally-generated) list of instances.
Unlike scatter_layer's density/slope rules, this reproduces a literal
manifest byte-for-byte: the same JSON always yields the same named
landmarks at the same coordinates - useful for hero props, quest markers,
or a layout exported from an external design tool, none of which a
rule-driven scatter (ours or a graph-based tool like PCG) is meant to pin down.
"""
placed = 0
for entry in entries:
mesh = unreal.load_asset(entry["mesh"])
if mesh is None:
print(f" [explicit] missing mesh asset: {entry['mesh']}")
continue
x, y, z_offset = entry.get("location", [0.0, 0.0, 0.0])
scale = entry.get("scale", 1.0)
if isinstance(scale, list):
scale_vec = unreal.Vector(*scale)
else:
scale_vec = unreal.Vector(scale, scale, scale)
roll, pitch, yaw = entry.get("rotation", [0.0, 0.0, 0.0])
rotation = unreal.Rotator(roll=roll, pitch=pitch, yaw=yaw)
quat = rotation.quaternion()
z = z_offset
if entry.get("snap_to_terrain", True):
z += field.height(x, y) + ground_offset_for(mesh, quat, scale_vec)
location = unreal.Vector(origin.x + x, origin.y + y, origin.z + z)
actor = actor_subsystem.spawn_actor_from_object(mesh, location, rotation)
actor.set_actor_scale3d(scale_vec)
actor.set_folder_path(f"{base_folder}/Explicit")
actor.set_actor_label(entry.get("name", f"Explicit_{placed:04d}"))
placed += 1
print(f" [explicit] placed {placed}/{len(entries)}")
return placed
def generate_tile(config, terrain_cfg, tile_label):
"""Builds one terrain + its scatter layers + its explicit placements, all
under a dedicated Outliner folder so multiple tiles can coexist in one level.
@@ -294,20 +99,18 @@ def generate_tile(config, terrain_cfg, tile_label):
base_folder = f"ProceduralTerrain/{tile_label}"
print(f"[{tile_label}] building terrain mesh (seed={terrain_cfg['noise']['seed']})...")
static_mesh, bounds = build_terrain_mesh(terrain_cfg, field)
static_mesh, bounds = tc.build_terrain_mesh(terrain_cfg, field)
loc = terrain_cfg["location"]
origin = unreal.Vector(loc[0], loc[1], loc[2])
terrain_actor = actor_subsystem.spawn_actor_from_object(static_mesh, origin, unreal.Rotator(0, 0, 0))
terrain_actor.set_folder_path(base_folder)
terrain_actor.set_actor_label(f"Terrain_{tile_label}")
tc.spawn_terrain_actor(static_mesh, origin, base_folder, f"Terrain_{tile_label}")
for layer_cfg in config.get("scatter_layers", []):
scatter_layer(layer_cfg, field, bounds, origin, base_folder)
tc.scatter_layer(layer_cfg, field, bounds, origin, base_folder)
explicit_entries = config.get("explicit_placements", [])
if explicit_entries:
place_explicit(explicit_entries, field, origin, base_folder)
tc.place_explicit(explicit_entries, field, origin, base_folder)
def main():