"""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