Files
UnrealPrototyping/Scripts/Authoring/dump_level.py
T

161 lines
8.0 KiB
Python

"""Dumps the actors of one or more levels to JSON, so a reference level (a pack's demo map) can be read without
opening it in the editor: class, label, transform, and the properties that matter for lighting, sky, fog,
post-processing and landscape. Written for reading Elite_RockyMeadows' demo maps while building L_World.
UnrealEditor-Cmd.exe Salty.uproject -run=pythonscript -script="Scripts/Authoring/dump_level.py /Game/Elite_RockyMeadows/Maps/Rocky_Meadows_01"
Levels come from the script arguments, else from the SALTY_DUMP_LEVELS environment variable (semicolon
separated), else the pack's first demo map. Output: Saved/Authoring/<LevelName>.json, one per level.
"""
import json
import os
import sys
import unreal
HERE = os.path.dirname(os.path.abspath(__file__))
OUT_DIR = os.path.normpath(os.path.join(HERE, "..", "..", "Saved", "Authoring"))
DEFAULT_LEVELS = ["/Game/Elite_RockyMeadows/Maps/Rocky_Meadows_01"]
# Component class name -> editor properties worth reading. Missing ones are skipped, so the list can be generous.
COMPONENT_PROPERTIES = {
"DirectionalLightComponent": [
"intensity", "light_color", "use_temperature", "temperature", "light_function_material", "light_function_scale",
"light_function_fade_distance", "atmosphere_sun_light", "cast_shadows", "dynamic_shadow_distance_movable_light",
"dynamic_shadow_distance_stationary_light", "num_dynamic_shadow_cascades", "cascade_distribution_exponent",
"cascade_transition_fraction", "shadow_distance_fadeout_fraction", "light_source_angle", "shadow_bias",
"volumetric_scattering_intensity", "cast_cloud_shadows", "cast_volumetric_shadow", "indirect_lighting_intensity",
"mobility", "specular_scale",
],
"SkyLightComponent": [
"intensity", "light_color", "source_type", "cubemap", "cubemap_resolution", "source_cubemap_angle", "real_time_capture",
"lower_hemisphere_color", "sky_distance_threshold", "cast_shadows", "volumetric_scattering_intensity",
"indirect_lighting_intensity", "occlusion_max_distance", "mobility",
],
"ExponentialHeightFogComponent": [
"fog_density", "fog_height_falloff", "second_fog_data", "fog_inscattering_luminance", "skybox_inscattering_color_cubemap",
"fog_inscattering_luminance_scale", "directional_inscattering_luminance", "directional_inscattering_exponent",
"directional_inscattering_start_distance", "fog_max_opacity", "start_distance", "end_distance", "fog_cutoff_distance",
"volumetric_fog", "volumetric_fog_scattering_distribution", "volumetric_fog_albedo", "volumetric_fog_emissive",
"volumetric_fog_extinction_scale", "volumetric_fog_distance", "volumetric_fog_start_distance",
"volumetric_fog_near_fade_in_distance", "volumetric_fog_static_lighting_scattering_intensity",
],
"StaticMeshComponent": ["static_mesh", "cast_shadow", "visible"],
"SkyAtmosphereComponent": ["rayleigh_scattering_scale", "mie_scattering_scale", "aerial_pespective_view_distance_scale"],
"VolumetricCloudComponent": ["layer_bottom_altitude", "layer_height", "material"],
}
ACTOR_PROPERTIES = {
"Landscape": ["landscape_material", "landscape_hole_material", "component_size_quads", "subsection_size_quads", "num_subsections",
"static_lighting_lod", "lod_distribution_setting", "lod0_distribution_setting", "lod0_screen_size",
"streaming_distance_multiplier", "collision_mip_level", "nanite_lod_index", "enable_nanite", "target_display_order"],
"LandscapeStreamingProxy": ["landscape_material", "component_size_quads", "subsection_size_quads", "num_subsections"],
"PostProcessVolume": ["unbound", "enabled", "priority", "blend_weight"],
"Actor": ["hidden"],
}
def encode(value):
"""A JSON-able rendering of a Python-wrapped Unreal value."""
if value is None or isinstance(value, (bool, int, float, str)):
return value
if isinstance(value, unreal.Object):
return value.get_path_name()
if isinstance(value, (unreal.Name, unreal.Text)):
return str(value)
if isinstance(value, (unreal.Vector, unreal.Rotator, unreal.LinearColor, unreal.Color, unreal.Vector2D, unreal.IntPoint)):
return list(value.to_tuple())
if isinstance(value, unreal.Array):
return [encode(v) for v in value]
return str(value)
def read_properties(obj, names):
out = {}
for name in names:
try:
out[name] = encode(obj.get_editor_property(name))
except Exception:
pass
return out
def overridden_post_process(settings):
"""The FPostProcessSettings fields whose override flag is set, with their values."""
out = {}
for name in dir(settings):
if not name.startswith("override_"):
continue
try:
if not settings.get_editor_property(name):
continue
field = name[len("override_"):]
out[field] = encode(settings.get_editor_property(field))
except Exception:
pass
return out
def dump_actor(actor):
class_name = actor.get_class().get_name()
record = {
"class": class_name,
"label": actor.get_actor_label(),
"location": list(actor.get_actor_location().to_tuple()),
"rotation": list(actor.get_actor_rotation().to_tuple()), # the tuple order is (roll, pitch, yaw)
"scale": list(actor.get_actor_scale3d().to_tuple()),
}
for key, names in ACTOR_PROPERTIES.items():
if key == "Actor" or class_name.startswith(key):
record.update(read_properties(actor, names))
if class_name.startswith("PostProcessVolume"):
try:
record["settings"] = overridden_post_process(actor.get_editor_property("settings"))
except Exception as error:
record["settings_error"] = str(error)
components = {}
for component in actor.get_components_by_class(unreal.ActorComponent):
component_class = component.get_class().get_name()
for key, names in COMPONENT_PROPERTIES.items():
if component_class == key or component_class.startswith(key):
entry = read_properties(component, names)
if isinstance(component, unreal.SceneComponent):
entry["relative_location"] = list(component.get_editor_property("relative_location").to_tuple())
entry["relative_rotation"] = list(component.get_editor_property("relative_rotation").to_tuple())
entry["relative_scale3d"] = list(component.get_editor_property("relative_scale3d").to_tuple())
if isinstance(component, unreal.MeshComponent):
entry["materials"] = [encode(m) for m in component.get_materials()]
components[f"{component_class}:{component.get_name()}"] = entry
if class_name.startswith("Landscape"):
try:
components["landscape_component_count"] = len(actor.get_editor_property("landscape_components"))
except Exception:
pass
record["components"] = components
return record
def dump_level(level_path):
unreal.log(f"dump_level: loading {level_path}")
level_subsystem = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
if not level_subsystem.load_level(level_path):
unreal.log_error(f"dump_level: could not load {level_path}")
return
actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
actors = [dump_actor(actor) for actor in actor_subsystem.get_all_level_actors()]
os.makedirs(OUT_DIR, exist_ok=True)
out_path = os.path.join(OUT_DIR, level_path.rsplit("/", 1)[-1] + ".json")
with open(out_path, "w", encoding="utf-8") as f:
json.dump({"level": level_path, "actors": actors}, f, indent=2)
unreal.log(f"dump_level: {len(actors)} actors of {level_path} written to {out_path}")
def main():
levels = [a for a in sys.argv[1:] if a.startswith("/")]
if not levels and os.environ.get("SALTY_DUMP_LEVELS"):
levels = [p for p in os.environ["SALTY_DUMP_LEVELS"].split(";") if p]
for level in levels or DEFAULT_LEVELS:
dump_level(level)
main()