Files
2026-10-31 17:03:41 +01:00

253 lines
11 KiB
Python

"""Shared by generate_procedural_terrain.py and generate_terrain_from_heightmap.py.
Everything here is agnostic to where the height values come from - it only
needs a "field" object with:
field.height(x, y) -> float
field.normal(x, y) -> unreal.Vector (unit length)
so noise-driven and heightmap-driven terrains can reuse the exact same mesh
building, ground-snapping, and scatter/placement logic.
"""
import math, random, unreal
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
def finite_difference_normal(height_fn, x, y, step=200.0):
"""Surface normal from four height samples around (x, y). `step` should be
large enough to average over small-scale bumps - otherwise high-frequency
noise/heightmap detail produces exaggerated per-instance tilt."""
h_l = height_fn(x - step, y)
h_r = height_fn(x + step, y)
h_d = height_fn(x, y - step)
h_u = height_fn(x, y + step)
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)
# ---------------------------------------------------------------------------
# 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)
is_masked = getattr(field, "is_masked", None)
if is_masked is not None:
masked_vertex_ids = {vid for vid, p in enumerate(positions) if is_masked(p.x, p.y)}
if masked_vertex_ids:
mesh, triangle_list, _has_tri_gaps = unreal.GeometryScript_MeshQueries.get_all_triangle_indices(mesh, False)
triangles = unreal.GeometryScript_List.convert_triangle_list_to_array(triangle_list)
triangles_to_delete = [
tid for tid, tri in enumerate(triangles)
if tri.x in masked_vertex_ids or tri.y in masked_vertex_ids or tri.z in masked_vertex_ids
]
if triangles_to_delete:
index_list = unreal.GeometryScript_List.convert_array_to_index_list(triangles_to_delete)
mesh, num_deleted = unreal.GeometryScript_MeshEdits.delete_triangles_from_mesh(mesh, index_list)
print(f" cut {num_deleted} triangle(s) out of masked/transparent regions")
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
def spawn_terrain_actor(static_mesh, origin, base_folder, label):
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(label)
return terrain_actor
# ---------------------------------------------------------------------------
# Ground-snapping / alignment ------------------------------------------------
# ---------------------------------------------------------------------------
_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
is_masked = getattr(field, "is_masked", None)
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 is_masked is not None and is_masked(x, y):
continue
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.
"""
is_masked = getattr(field, "is_masked", None)
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])
if is_masked is not None and is_masked(x, y):
print(f" [explicit] {entry.get('name', '?')} falls in a masked/transparent region, skipping")
continue
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