sync
This commit is contained in:
1 parent
2a2cea4fd0
commit
3a9b791a6a
8 files changed
+788
-207
No files matched your search
@@ -0,0 +1,225 @@
|
||||
"""Generate turntable Level Sequences (and optionally queue Movie Render Queue jobs).
|
||||
|
||||
Run this from Unreal's Output Log with::
|
||||
|
||||
exec(open(r".../Content/Python/generate_blueprint_turntable.py").read())
|
||||
generate_turntables("/Game/Blueprints/BP_MyAsset")
|
||||
|
||||
The script creates/saves a small presentation map, so the bindings in the
|
||||
sequences and MRQ jobs are reproducible. Open any generated sequence in
|
||||
Sequencer to show the normal camera, camera-cut and transform tracks.
|
||||
"""
|
||||
import unreal
|
||||
|
||||
|
||||
# Project-specific defaults. They can be overridden in generate_turntables().
|
||||
DEFAULT_OUTPUT_ROOT = "/Game/Sequences/Turntables"
|
||||
DEFAULT_STAGE_MAP = "/Game/Maps/TurntableStage"
|
||||
DEFAULT_RENDER_DIRECTORY = "{project_dir}/Saved/TurntableRenders"
|
||||
FPS = 30
|
||||
TURN_DURATION_SECONDS = 5.0
|
||||
QUEUE_RENDER_IMMEDIATELY = False # True starts MRQ immediately after queueing.
|
||||
RENDER_ALL_SKELETON_ANIMATIONS = False
|
||||
|
||||
|
||||
def _log(message):
|
||||
unreal.log("[Turntable] " + message)
|
||||
|
||||
|
||||
def _asset_name(path):
|
||||
return path.rsplit("/", 1)[-1].split(".")[0]
|
||||
|
||||
|
||||
def _ensure_folder(path):
|
||||
if not unreal.EditorAssetLibrary.does_directory_exist(path):
|
||||
unreal.EditorAssetLibrary.make_directory(path)
|
||||
|
||||
|
||||
def _create_or_open_stage(stage_map):
|
||||
"""Open the dedicated map; creating it only on the first run."""
|
||||
if unreal.EditorAssetLibrary.does_asset_exist(stage_map):
|
||||
unreal.EditorLoadingAndSavingUtils.load_map(stage_map)
|
||||
else:
|
||||
_ensure_folder(stage_map.rsplit("/", 1)[0])
|
||||
unreal.EditorLevelLibrary.new_level(stage_map)
|
||||
unreal.EditorLevelLibrary.save_current_level()
|
||||
|
||||
|
||||
def _find_skeletal_components(actor):
|
||||
components = actor.get_components_by_class(unreal.SkeletalMeshComponent)
|
||||
return [component for component in components if component.get_editor_property("skeletal_mesh")]
|
||||
|
||||
|
||||
def _animations_set_on_blueprint(actor):
|
||||
"""Return AnimSequences explicitly selected on components in the Blueprint.
|
||||
|
||||
Anim Blueprints are procedural at runtime and do not expose a finite list of
|
||||
clips, so they are deliberately not guessed here. A component using
|
||||
"Use Animation Asset" is exported as one sequence per assigned clip.
|
||||
"""
|
||||
animations = []
|
||||
for component in _find_skeletal_components(actor):
|
||||
try:
|
||||
animation_data = component.get_editor_property("animation_data")
|
||||
animation = animation_data.get_editor_property("anim_to_play")
|
||||
if animation and animation not in animations:
|
||||
animations.append(animation)
|
||||
except Exception:
|
||||
pass
|
||||
return animations
|
||||
|
||||
|
||||
def _all_skeleton_animations(actor):
|
||||
"""Optional broad mode: render every AnimSequence sharing the actor skeleton."""
|
||||
skeletons = set()
|
||||
for component in _find_skeletal_components(actor):
|
||||
mesh = component.get_editor_property("skeletal_mesh")
|
||||
skeleton = mesh.get_editor_property("skeleton") if mesh else None
|
||||
if skeleton:
|
||||
skeletons.add(skeleton.get_path_name())
|
||||
|
||||
animations = []
|
||||
registry = unreal.AssetRegistryHelpers.get_asset_registry()
|
||||
for data in registry.get_assets_by_class("AnimSequence", True):
|
||||
animation = data.get_asset()
|
||||
if animation and animation.get_editor_property("skeleton").get_path_name() in skeletons:
|
||||
animations.append(animation)
|
||||
return animations
|
||||
|
||||
|
||||
def _set_transform_keys(track, frames, start_location, start_rotation):
|
||||
section = track.add_section()
|
||||
section.set_range(0, frames)
|
||||
channels = section.get_channels()
|
||||
# Translation XYZ, rotation XYZ, scale XYZ. Key all channels explicitly so
|
||||
# the result remains readable/editable in Sequencer.
|
||||
values = [start_location.x, start_location.y, start_location.z,
|
||||
start_rotation.roll, start_rotation.pitch, start_rotation.yaw,
|
||||
1.0, 1.0, 1.0]
|
||||
for index, value in enumerate(values):
|
||||
channels[index].add_key(unreal.FrameNumber(0), value)
|
||||
end_value = start_rotation.yaw + 360.0 if index == 5 else value
|
||||
channels[index].add_key(unreal.FrameNumber(frames), end_value)
|
||||
|
||||
|
||||
def _add_camera(sequence, camera, frames):
|
||||
camera_binding = sequence.add_possessable(camera)
|
||||
cut_track = sequence.add_master_track(unreal.MovieSceneCameraCutTrack)
|
||||
cut = cut_track.add_section()
|
||||
cut.set_range(0, frames)
|
||||
# Camera cuts require a MovieSceneObjectBindingID, not the binding proxy.
|
||||
cut.set_camera_binding_id(sequence.get_binding_id(camera_binding))
|
||||
|
||||
|
||||
def _add_animation_track(sequence, actor, animation, frames):
|
||||
"""Bind the matching skeletal component and play its animation clip."""
|
||||
for component in _find_skeletal_components(actor):
|
||||
mesh = component.get_editor_property("skeletal_mesh")
|
||||
skeleton = mesh.get_editor_property("skeleton") if mesh else None
|
||||
if skeleton != animation.get_editor_property("skeleton"):
|
||||
continue
|
||||
binding = sequence.add_possessable(component)
|
||||
track = binding.add_track(unreal.MovieSceneSkeletalAnimationTrack)
|
||||
section = track.add_section()
|
||||
section.set_range(0, frames)
|
||||
params = section.get_editor_property("params")
|
||||
params.set_editor_property("animation", animation)
|
||||
section.set_editor_property("params", params)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _make_sequence(sequence_name, output_folder, actor, camera, animation=None):
|
||||
tools = unreal.AssetToolsHelpers.get_asset_tools()
|
||||
existing_path = output_folder + "/" + sequence_name
|
||||
if unreal.EditorAssetLibrary.does_asset_exist(existing_path):
|
||||
unreal.EditorAssetLibrary.delete_asset(existing_path)
|
||||
sequence = tools.create_asset(sequence_name, output_folder, unreal.LevelSequence,
|
||||
unreal.LevelSequenceFactoryNew())
|
||||
frames = max(1, round(FPS * max(TURN_DURATION_SECONDS,
|
||||
animation.get_play_length() if animation else 0.0)))
|
||||
sequence.set_display_rate(unreal.FrameRate(FPS, 1))
|
||||
sequence.set_playback_start(0)
|
||||
sequence.set_playback_end(frames)
|
||||
|
||||
actor_binding = sequence.add_possessable(actor)
|
||||
transform_track = actor_binding.add_track(unreal.MovieScene3DTransformTrack)
|
||||
_set_transform_keys(transform_track, frames, actor.get_actor_location(), actor.get_actor_rotation())
|
||||
_add_camera(sequence, camera, frames)
|
||||
if animation:
|
||||
_add_animation_track(sequence, actor, animation, frames)
|
||||
|
||||
unreal.EditorAssetLibrary.save_asset(sequence.get_path_name())
|
||||
return sequence
|
||||
|
||||
|
||||
def _queue_render(sequence, stage_map, directory):
|
||||
subsystem = unreal.get_editor_subsystem(unreal.MoviePipelineQueueSubsystem)
|
||||
queue = subsystem.get_queue()
|
||||
job = queue.allocate_new_job(unreal.MoviePipelineExecutorJob)
|
||||
job.job_name = sequence.get_name()
|
||||
job.sequence = unreal.SoftObjectPath(sequence.get_path_name())
|
||||
job.map = unreal.SoftObjectPath(stage_map)
|
||||
config = job.get_configuration()
|
||||
output = config.find_or_add_setting_by_class(unreal.MoviePipelineOutputSetting)
|
||||
output.output_directory = unreal.DirectoryPath(directory)
|
||||
output.file_name_format = "{job_name}/{frame_number}"
|
||||
output.output_resolution = unreal.IntPoint(1920, 1080)
|
||||
config.find_or_add_setting_by_class(unreal.MoviePipelineImageSequenceOutput_PNG)
|
||||
return subsystem
|
||||
|
||||
|
||||
def generate_turntables(blueprint_path, output_root=DEFAULT_OUTPUT_ROOT,
|
||||
stage_map=DEFAULT_STAGE_MAP, render_directory=DEFAULT_RENDER_DIRECTORY,
|
||||
render_immediately=QUEUE_RENDER_IMMEDIATELY,
|
||||
render_all_skeleton_animations=RENDER_ALL_SKELETON_ANIMATIONS):
|
||||
"""Create a turntable plus one turntable per explicitly assigned animation.
|
||||
|
||||
`blueprint_path` must be an asset path, e.g. /Game/Blueprints/BP_Robot.
|
||||
The return value is the generated LevelSequence list.
|
||||
"""
|
||||
blueprint = unreal.load_asset(blueprint_path)
|
||||
if not blueprint or not isinstance(blueprint, unreal.Blueprint):
|
||||
raise RuntimeError("Expected a Blueprint asset path, got: " + blueprint_path)
|
||||
|
||||
bp_name = _asset_name(blueprint_path)
|
||||
# A separate stage per asset prevents other turntable actors from appearing
|
||||
# in the same render. Supplying a custom stage_map preserves that exact map.
|
||||
if stage_map == DEFAULT_STAGE_MAP:
|
||||
stage_map = stage_map + "_" + bp_name
|
||||
_create_or_open_stage(stage_map)
|
||||
folder = output_root + "/" + bp_name
|
||||
_ensure_folder(folder)
|
||||
|
||||
# Re-running the script replaces only its own presentation actors in the
|
||||
# selected stage map. This keeps repeated demos deterministic.
|
||||
generated_labels = {"TT_" + bp_name, "TT_Camera_" + bp_name}
|
||||
for existing_actor in unreal.EditorLevelLibrary.get_all_level_actors():
|
||||
if existing_actor.get_actor_label() in generated_labels:
|
||||
unreal.EditorLevelLibrary.destroy_actor(existing_actor)
|
||||
|
||||
actor = unreal.EditorLevelLibrary.spawn_actor_from_object(blueprint, unreal.Vector(0, 0, 0))
|
||||
actor.set_actor_label("TT_" + bp_name)
|
||||
origin, extent = actor.get_actor_bounds(False)
|
||||
distance = max(extent.x, extent.y, extent.z) * 3.0 + 150.0
|
||||
camera = unreal.EditorLevelLibrary.spawn_actor_from_class(
|
||||
unreal.CineCameraActor, unreal.Vector(origin.x - distance, origin.y, origin.z), unreal.Rotator(0, 0, 0))
|
||||
camera.set_actor_label("TT_Camera_" + bp_name)
|
||||
camera.set_actor_rotation((origin - camera.get_actor_location()).rotation(), False)
|
||||
camera.get_cine_camera_component().set_editor_property("current_focal_length", 50.0)
|
||||
|
||||
sequences = [_make_sequence("LS_TT_" + bp_name, folder, actor, camera)]
|
||||
animations = _all_skeleton_animations(actor) if render_all_skeleton_animations else _animations_set_on_blueprint(actor)
|
||||
for animation in animations:
|
||||
name = "LS_TT_{0}_{1}".format(bp_name, animation.get_name())
|
||||
sequences.append(_make_sequence(name, folder, actor, camera, animation))
|
||||
|
||||
unreal.EditorLevelLibrary.save_current_level()
|
||||
subsystem = None
|
||||
for sequence in sequences:
|
||||
subsystem = _queue_render(sequence, stage_map, render_directory)
|
||||
if render_immediately and subsystem:
|
||||
subsystem.render_queue_with_executor(unreal.MoviePipelinePIEExecutor)
|
||||
|
||||
_log("Created {0} sequence(s) and queued {0} PNG job(s) for {1}.".format(len(sequences), bp_name))
|
||||
return sequences
|
||||
@@ -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():
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Run inside Unreal (Python console / Execute Python Script).
|
||||
|
||||
Same pipeline as generate_procedural_terrain.py, but the height field comes
|
||||
from an actual image (e.g. DemoAssets/poland_shaded.png) instead of procedural
|
||||
noise. Reads heightmap_config.json (next to this script).
|
||||
|
||||
By default darker pixels = higher elevation and brighter pixels = lower, to
|
||||
match shaded-relief style maps (set "invert": false in the config for the
|
||||
opposite, standard-heightmap convention where white = high).
|
||||
|
||||
If the image has an alpha channel, pixels with alpha below "alpha_threshold"
|
||||
(0-1, default 0.5) are cut out of the terrain mesh entirely (real holes, not
|
||||
just low ground) and are excluded from foliage scatter/placement - useful for
|
||||
a heightmap shaped like a country/island silhouette with a transparent
|
||||
background.
|
||||
|
||||
Run import_environment_assets.py first so the mesh paths referenced in the
|
||||
config actually exist. Set the HEIGHTMAP_CONFIG env var to point at a
|
||||
different config file.
|
||||
"""
|
||||
import os, json, unreal
|
||||
import terrain_common as tc
|
||||
from png_reader import decode_png
|
||||
|
||||
CONFIG_PATH = os.environ.get(
|
||||
"HEIGHTMAP_CONFIG",
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "heightmap_config.json"),
|
||||
)
|
||||
|
||||
|
||||
class HeightmapField:
|
||||
def __init__(self, cfg):
|
||||
image_path = cfg["image_path"]
|
||||
print(f"Decoding heightmap image: {image_path}")
|
||||
width, height, channels, pixels = decode_png(image_path)
|
||||
print(f" {width}x{height}, {channels} channel(s)")
|
||||
|
||||
self.width = width
|
||||
self.height_px = height
|
||||
self.invert = cfg.get("invert", True) # darker = higher, by default (shaded-relief convention)
|
||||
self.height_scale = cfg["height_scale"]
|
||||
self.size_x = cfg["size_x"]
|
||||
self.size_y = cfg["size_y"]
|
||||
self.alpha_threshold = cfg.get("alpha_threshold", 0.5)
|
||||
self.exclude_white = cfg.get("exclude_white", True)
|
||||
self.white_threshold = cfg.get("white_threshold", 0.98)
|
||||
self._lum = self._build_luminance(pixels, width, height, channels)
|
||||
self._alpha = self._build_alpha(pixels, width, height, channels)
|
||||
self._min_channel = self._build_min_channel(pixels, width, height, channels)
|
||||
|
||||
@staticmethod
|
||||
def _build_luminance(pixels, width, height, channels):
|
||||
count = width * height
|
||||
lum = [0.0] * count
|
||||
if channels <= 2:
|
||||
for i in range(count):
|
||||
lum[i] = pixels[i * channels] / 255.0
|
||||
else:
|
||||
for i in range(count):
|
||||
o = i * channels
|
||||
lum[i] = (0.299 * pixels[o] + 0.587 * pixels[o + 1] + 0.114 * pixels[o + 2]) / 255.0
|
||||
return lum
|
||||
|
||||
@staticmethod
|
||||
def _build_min_channel(pixels, width, height, channels):
|
||||
"""Per-pixel min(R,G,B) (or the sole channel, if grayscale) in [0,1] -
|
||||
used to detect "fully white" fill pixels regardless of luminance
|
||||
formula quirks (e.g. pure yellow has high luminance but isn't white)."""
|
||||
count = width * height
|
||||
arr = [0.0] * count
|
||||
if channels <= 2:
|
||||
for i in range(count):
|
||||
arr[i] = pixels[i * channels] / 255.0
|
||||
else:
|
||||
for i in range(count):
|
||||
o = i * channels
|
||||
arr[i] = min(pixels[o], pixels[o + 1], pixels[o + 2]) / 255.0
|
||||
return arr
|
||||
|
||||
@staticmethod
|
||||
def _build_alpha(pixels, width, height, channels):
|
||||
# Only grayscale+alpha (2) and RGBA (4) carry an alpha channel; anything
|
||||
# else is fully opaque, so there is nothing to mask out.
|
||||
if channels not in (2, 4):
|
||||
return None
|
||||
count = width * height
|
||||
alpha = [0.0] * count
|
||||
last = channels - 1
|
||||
for i in range(count):
|
||||
alpha[i] = pixels[i * channels + last] / 255.0
|
||||
return alpha
|
||||
|
||||
def _world_to_image(self, x, y):
|
||||
# Map world (x, y) in [-size/2, size/2] to continuous image space, with
|
||||
# +Y world mapped toward row 0 (image top), matching a north-up map.
|
||||
u = (x + self.size_x / 2.0) / self.size_x
|
||||
v = (y + self.size_y / 2.0) / self.size_y
|
||||
u = min(max(u, 0.0), 1.0)
|
||||
v = min(max(v, 0.0), 1.0)
|
||||
return u * (self.width - 1), (1.0 - v) * (self.height_px - 1)
|
||||
|
||||
def _sample_bilinear(self, channel_array, x, y):
|
||||
px, py = self._world_to_image(x, y)
|
||||
x0, y0 = int(px), int(py)
|
||||
x1, y1 = min(x0 + 1, self.width - 1), min(y0 + 1, self.height_px - 1)
|
||||
fx, fy = px - x0, py - y0
|
||||
|
||||
def S(xx, yy):
|
||||
return channel_array[yy * self.width + xx]
|
||||
|
||||
s00, s10, s01, s11 = S(x0, y0), S(x1, y0), S(x0, y1), S(x1, y1)
|
||||
s0 = s00 + (s10 - s00) * fx
|
||||
s1 = s01 + (s11 - s01) * fx
|
||||
return s0 + (s1 - s0) * fy
|
||||
|
||||
def height(self, x, y):
|
||||
lum = self._sample_bilinear(self._lum, x, y)
|
||||
if self.invert:
|
||||
lum = 1.0 - lum
|
||||
return lum * self.height_scale
|
||||
|
||||
def normal(self, x, y, step=200.0):
|
||||
return tc.finite_difference_normal(self.height, x, y, step)
|
||||
|
||||
def is_masked(self, x, y):
|
||||
if self._alpha is not None and self._sample_bilinear(self._alpha, x, y) < self.alpha_threshold:
|
||||
return True
|
||||
if self.exclude_white and self._sample_bilinear(self._min_channel, x, y) >= self.white_threshold:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
with open(CONFIG_PATH, "r") as f:
|
||||
config = json.load(f)
|
||||
|
||||
terrain_cfg = config["terrain"]
|
||||
field = HeightmapField(terrain_cfg)
|
||||
base_folder = "HeightmapTerrain"
|
||||
|
||||
print("Building terrain mesh from heightmap...")
|
||||
static_mesh, bounds = tc.build_terrain_mesh(terrain_cfg, field)
|
||||
|
||||
loc = terrain_cfg["location"]
|
||||
origin = unreal.Vector(loc[0], loc[1], loc[2])
|
||||
tc.spawn_terrain_actor(static_mesh, origin, base_folder, "HeightmapTerrain")
|
||||
|
||||
print("Scattering foliage layers...")
|
||||
for layer_cfg in config.get("scatter_layers", []):
|
||||
tc.scatter_layer(layer_cfg, field, bounds, origin, base_folder)
|
||||
|
||||
explicit_entries = config.get("explicit_placements", [])
|
||||
if explicit_entries:
|
||||
print("Placing explicit manifest entries...")
|
||||
tc.place_explicit(explicit_entries, field, origin, base_folder)
|
||||
|
||||
print("Done.")
|
||||
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"terrain": {
|
||||
"image_path": "C:\\Users\\koleg\\Documents\\Unreal Projects\\ExampleProject\\DemoAssets\\poland_shaded.png",
|
||||
"invert": true,
|
||||
"alpha_threshold": 0.5,
|
||||
"location": [0.0, 0.0, 0.0],
|
||||
"size_x": 20000.0,
|
||||
"size_y": 16580.0,
|
||||
"resolution": 128,
|
||||
"height_scale": 3000.0,
|
||||
"dest_path": "/Game/ImportedAssets/Terrain",
|
||||
"asset_name": "SM_HeightmapTerrain"
|
||||
},
|
||||
"scatter_layers": [
|
||||
{
|
||||
"name": "Trees",
|
||||
"meshes": [
|
||||
"/Game/ImportedAssets/Trees/SM_tree01",
|
||||
"/Game/ImportedAssets/Trees/SM_tree02",
|
||||
"/Game/ImportedAssets/Trees/SM_tree03",
|
||||
"/Game/ImportedAssets/Trees/SM_tree04"
|
||||
],
|
||||
"count": 300,
|
||||
"min_height": 0.0,
|
||||
"max_height": 1500.0,
|
||||
"max_slope_degrees": 30.0,
|
||||
"min_scale": 0.85,
|
||||
"max_scale": 1.3,
|
||||
"align_to_normal": true,
|
||||
"min_spacing": 250.0,
|
||||
"seed": 7
|
||||
},
|
||||
{
|
||||
"name": "Rocks",
|
||||
"meshes": [
|
||||
"/Game/ImportedAssets/Rocks/SM_rock1",
|
||||
"/Game/ImportedAssets/Rocks/SM_rock2",
|
||||
"/Game/ImportedAssets/Rocks/SM_rock3"
|
||||
],
|
||||
"count": 150,
|
||||
"min_height": 800.0,
|
||||
"max_height": 3000.0,
|
||||
"max_slope_degrees": 50.0,
|
||||
"min_scale": 0.5,
|
||||
"max_scale": 2.0,
|
||||
"align_to_normal": true,
|
||||
"min_spacing": 200.0,
|
||||
"seed": 42
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Minimal pure-Python PNG decoder (stdlib only: zlib + struct).
|
||||
|
||||
Supports 8-bit, non-interlaced grayscale / grayscale+alpha / RGB / RGBA PNGs -
|
||||
which covers ordinary exported heightmap/relief images. Deliberately does not
|
||||
depend on Pillow or any other package, since Unreal's embedded Python has no
|
||||
pip packages installed by default.
|
||||
"""
|
||||
import struct, zlib
|
||||
|
||||
_CHANNELS_BY_COLOR_TYPE = {0: 1, 2: 3, 4: 2, 6: 4} # 3 (palette) is unsupported
|
||||
|
||||
|
||||
def _paeth(a, b, c):
|
||||
p = a + b - c
|
||||
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
|
||||
if pa <= pb and pa <= pc:
|
||||
return a
|
||||
if pb <= pc:
|
||||
return b
|
||||
return c
|
||||
|
||||
|
||||
def decode_png(path):
|
||||
"""Returns (width, height, channels, pixels) where pixels is a flat
|
||||
bytearray of width*height*channels bytes, row-major, top row first."""
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
if data[:8] != b"\x89PNG\r\n\x1a\n":
|
||||
raise ValueError(f"Not a PNG file: {path}")
|
||||
|
||||
width = height = bit_depth = color_type = interlace = None
|
||||
idat = bytearray()
|
||||
pos = 8
|
||||
while pos < len(data):
|
||||
length = struct.unpack(">I", data[pos:pos + 4])[0]
|
||||
ctype = data[pos + 4:pos + 8]
|
||||
chunk = data[pos + 8:pos + 8 + length]
|
||||
if ctype == b"IHDR":
|
||||
width, height, bit_depth, color_type, _comp, _filt, interlace = struct.unpack(">IIBBBBB", chunk)
|
||||
elif ctype == b"IDAT":
|
||||
idat.extend(chunk)
|
||||
elif ctype == b"IEND":
|
||||
break
|
||||
pos += 8 + length + 4 # length + type + data + crc
|
||||
|
||||
if bit_depth != 8:
|
||||
raise ValueError(f"Only 8-bit PNGs are supported (got bit depth {bit_depth}) - re-export as 8-bit: {path}")
|
||||
if interlace != 0:
|
||||
raise ValueError(f"Interlaced PNGs are not supported - re-export as non-interlaced: {path}")
|
||||
if color_type not in _CHANNELS_BY_COLOR_TYPE:
|
||||
raise ValueError(f"Unsupported PNG color type {color_type} (palette?) - re-export as grayscale/RGB/RGBA: {path}")
|
||||
channels = _CHANNELS_BY_COLOR_TYPE[color_type]
|
||||
|
||||
raw = zlib.decompress(bytes(idat))
|
||||
stride = width * channels
|
||||
pixels = bytearray(width * height * channels)
|
||||
|
||||
prev_row = bytearray(stride)
|
||||
row_start = 0
|
||||
for y in range(height):
|
||||
filter_type = raw[row_start]
|
||||
row = bytearray(raw[row_start + 1: row_start + 1 + stride])
|
||||
row_start += 1 + stride
|
||||
|
||||
if filter_type == 1: # Sub
|
||||
for i in range(channels, stride):
|
||||
row[i] = (row[i] + row[i - channels]) & 0xFF
|
||||
elif filter_type == 2: # Up
|
||||
for i in range(stride):
|
||||
row[i] = (row[i] + prev_row[i]) & 0xFF
|
||||
elif filter_type == 3: # Average
|
||||
for i in range(stride):
|
||||
a = row[i - channels] if i >= channels else 0
|
||||
row[i] = (row[i] + ((a + prev_row[i]) >> 1)) & 0xFF
|
||||
elif filter_type == 4: # Paeth
|
||||
for i in range(stride):
|
||||
a = row[i - channels] if i >= channels else 0
|
||||
b = prev_row[i]
|
||||
c = prev_row[i - channels] if i >= channels else 0
|
||||
row[i] = (row[i] + _paeth(a, b, c)) & 0xFF
|
||||
# filter_type == 0 (None): no-op
|
||||
|
||||
pixels[y * stride:(y + 1) * stride] = row
|
||||
prev_row = row
|
||||
|
||||
return width, height, channels, pixels
|
||||
@@ -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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 11 MiB |
@@ -78,6 +78,10 @@
|
||||
{
|
||||
"Name": "EditorScriptingUtilities",
|
||||
"Enabled": true
|
||||
},
|
||||
{
|
||||
"Name": "MovieRenderPipeline",
|
||||
"Enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user