workshop rework
This commit is contained in:
1 parent
a3a80dfa16
commit
fb39e290c0
115 files changed
+1865
-497
No files matched your search
@@ -0,0 +1,252 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user