""" Unreal editor script: dumps the Modular SciFi Season 2 Starter Bundle (/Game/ModSciInteriors, /Game/ModSci_Engineer, /Game/ModSci_EngiProps) out of the project so the kit can be assembled in Blender. Adapted from models/anchorage/build/ue_export_kit.py (Season 1): several content roots instead of one. READ-ONLY for the project: nothing is saved, no asset is changed. Run (build/ue_export_kit.ps1 does this): UnrealEditor-Cmd.exe TheDescent.uproject -run=pythonscript -script="" -unattended -nullrhi Writes under KIT_OUT (default models/vessel-interior/kit_export): meshes//.fbx source mesh (welded, highest LOD), no collision meshes.json per mesh: bounds (cm), material slots, triangle count materials.json instances: parent + effective parameter values; base materials: parameters + graph textures//.../.png every texture a material of the pack refers to (full size) textures.json per texture: size, sRGB, compression, file maps/.json the pack's demo maps: every actor with its world matrix, meshes, lights, decals Coordinates in the JSON are Unreal's own: cm, left-handed, matrices as four rows (X, Y, Z axis, then origin). Steps can be limited with KIT_DO=meshes,materials,textures,maps (comma separated). """ import json import os import sys import time import traceback import unreal sys.dont_write_bytecode = True OUT = os.environ.get("KIT_OUT", "D:/HADAL - The Descent/models/vessel-interior/kit_export").replace("\\", "/") ROOTS = [s.strip().rstrip("/") for s in os.environ.get( "KIT_ROOTS", "/Game/ModSciInteriors,/Game/ModSci_Engineer,/Game/ModSci_EngiProps").split(",") if s.strip()] DO = set(s.strip() for s in os.environ.get("KIT_DO", "meshes,materials,textures,maps").split(",") if s.strip()) ONLY_MAPS = [s.strip() for s in os.environ.get("KIT_MAPS", "").split(",") if s.strip()] SKIP_TEXTURE_PACKS = () T0 = time.time() mel = unreal.MaterialEditingLibrary PROBLEMS = [] def log(msg): unreal.log("[kit] +%6.1fs %s" % (time.time() - T0, msg)) def problem(msg): PROBLEMS.append(msg) unreal.log_warning("[kit] PROBLEM: " + msg) def write_json(rel, data): path = OUT + "/" + rel os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as f: json.dump(data, f, indent=1, sort_keys=True) log("wrote %s" % rel) def pack_of(path): """'/Game/ModSciInteriors/Meshes/SM_Wall_A' -> 'ModSciInteriors'""" parts = path.strip("/").split("/") return parts[1] if len(parts) > 1 else "" def obj_path(o): """Package path of an asset ('/Game/x/y'), or None.""" if o is None: return None try: return o.get_path_name().split(".")[0] except Exception: # noqa: BLE001 return str(o) def vec(v): return [v.x, v.y, v.z] def col(c): return [c.r, c.g, c.b, c.a] def mat_rows(t): m = t.to_matrix() return [[p.x, p.y, p.z, p.w] for p in (m.x_plane, m.y_plane, m.z_plane, m.w_plane)] def to_json(v, depth=0): if v is None or isinstance(v, (bool, int, float, str)): return v if isinstance(v, unreal.Object): return {"obj": v.get_path_name()} if isinstance(v, unreal.LinearColor): return col(v) if isinstance(v, unreal.Color): return [v.r, v.g, v.b, v.a] if isinstance(v, (unreal.Vector, unreal.Vector4)) or type(v).__name__ in ("Vector3f", "Vector4f"): try: return [v.x, v.y, v.z] + ([v.w] if hasattr(v, "w") else []) except Exception: # noqa: BLE001 return str(v) if type(v).__name__ in ("Vector2D", "Vector2f"): return [v.x, v.y] if isinstance(v, (list, tuple, unreal.Array)) and depth < 3: return [to_json(x, depth + 1) for x in v] return str(v) def get(o, prop, default=None): try: return o.get_editor_property(prop) except Exception: # noqa: BLE001 return default # --------------------------------------------------------------------------------------------------------------- # assets # --------------------------------------------------------------------------------------------------------------- def list_assets(): ar = unreal.AssetRegistryHelpers.get_asset_registry() ar.scan_paths_synchronous(ROOTS, True) by_class = {} for root in ROOTS: for ad in ar.get_assets_by_path(root, recursive=True): cls = str(ad.asset_class_path.asset_name) by_class.setdefault(cls, []).append(str(ad.package_name)) for k in by_class: by_class[k].sort() log("assets under %s: %s" % (", ".join(ROOTS), ", ".join("%s x%d" % (k, len(v)) for k, v in sorted(by_class.items())))) return by_class # --------------------------------------------------------------------------------------------------------------- # meshes # --------------------------------------------------------------------------------------------------------------- def export_meshes(paths): info = {} n_exported = 0 for i, path in enumerate(paths): try: sm = unreal.load_asset(path) if sm is None: problem("could not load mesh " + path) continue name = path.split("/")[-1] rel = "meshes/%s/%s.fbx" % (pack_of(path), name) out = OUT + "/" + rel bb = sm.get_bounding_box() slots = [] for s in sm.static_materials: slots.append({"slot": str(s.material_slot_name), "material": obj_path(s.material_interface)}) rec = { "fbx": rel, "min": vec(bb.min), "max": vec(bb.max), "slots": slots, "tris": sm.get_num_triangles(0), "verts": sm.get_num_vertices(0), "lods": sm.get_num_lods(), } try: bs = get(sm, "body_setup") rec["collision_trace_flag"] = str(get(bs, "collision_trace_flag")) if bs else None except Exception: # noqa: BLE001 pass info[path] = rec if os.path.isfile(out) and os.path.getsize(out) > 0: continue os.makedirs(os.path.dirname(out), exist_ok=True) task = unreal.AssetExportTask() task.object = sm task.filename = out task.automated = True task.prompt = False task.replace_identical = True task.exporter = unreal.StaticMeshExporterFBX() opts = unreal.FbxExportOption() for prop, val in (("ascii", False), ("force_front_x_axis", False), ("vertex_color", True), ("level_of_detail", False), ("collision", False), ("export_source_mesh", True), ("bake_material_inputs", unreal.FbxMaterialBakeMode.DISABLED), ("fbx_export_compatibility", unreal.FbxExportCompatibility.FBX_2020)): try: opts.set_editor_property(prop, val) except Exception as e: # noqa: BLE001 if i == 0: problem("FbxExportOption.%s: %s" % (prop, e)) task.options = opts if unreal.Exporter.run_asset_export_task(task) and os.path.isfile(out): n_exported += 1 else: problem("FBX export failed: " + path) except Exception as e: # noqa: BLE001 problem("mesh %s: %s" % (path, e)) unreal.log_warning(traceback.format_exc()) if (i + 1) % 40 == 0: log("meshes %d / %d" % (i + 1, len(paths))) write_json("meshes.json", info) log("meshes: %d described, %d newly exported" % (len(info), n_exported)) return info # --------------------------------------------------------------------------------------------------------------- # materials # --------------------------------------------------------------------------------------------------------------- _EXPR_BASE = set(dir(unreal.MaterialExpression)) ROOT_PROPS = ("MP_BASE_COLOR", "MP_METALLIC", "MP_SPECULAR", "MP_ROUGHNESS", "MP_EMISSIVE_COLOR", "MP_OPACITY", "MP_OPACITY_MASK", "MP_NORMAL", "MP_AMBIENT_OCCLUSION", "MP_WORLD_POSITION_OFFSET", "MP_REFRACTION", "MP_SUBSURFACE_COLOR") # Most expression properties are EditAnywhere without a Blueprint flag, so Python does not list them in dir(): # they are asked for by name. EXPR_PROPS = ("parameter_name", "default_value", "group", "r", "g", "b", "a", "constant", "const_a", "const_b", "const_alpha", "coordinate_index", "u_tiling", "v_tiling", "un_mirror_u", "un_mirror_v", "texture", "material_function", "const_exponent", "fraction", "luminance_factors", "speed_x", "speed_y", "exponent", "base_reflect_fraction", "min_default", "max_default", "clamp_mode", "input_name", "output_name", "preview_value", "input_type", "sampler_type", "sampler_source", "const_coordinate", "period", "desc", "height_ratio", "reference_plane", "default_opacity", "opacity", "fade_distance_default", "depth_fade_distance", "normalize", "scale", "bias", "const_scale", "const_bias", "time_period", "ignore_pause", "const_base", "const_min", "const_max", "tiling", "default_r", "default_g", "default_b", "default_a", "channel", "mip_value_mode", "const_mip_value", "automatic_view_mip_bias", "sub_u_vs", "attribute", "coordinate_scale", "world_position_shader_offset") def expr_props(e): out = {} for name in dir(e): if name.startswith("_") or name in _EXPR_BASE: continue try: v = getattr(e, name) except Exception: # noqa: BLE001 continue if callable(v): continue out[name] = to_json(v) for name in EXPR_PROPS: if name in out: continue try: out[name] = to_json(e.get_editor_property(name)) except Exception: # noqa: BLE001 pass return out def out_name(e, inp): try: r = mel.get_input_node_output_name_for_material_expression(e, inp) if isinstance(r, tuple): return str(r[-1]) return None if r is None else str(r) except Exception: # noqa: BLE001 return None def dump_graph(owner, exprs, inputs_of, functions_seen): nodes = {} for e in exprs: if e is None: continue node = {"class": e.get_class().get_name(), "props": expr_props(e)} try: names = [str(n) for n in mel.get_material_expression_input_names(e)] except Exception: # noqa: BLE001 names = [] try: ins = list(inputs_of(owner, e)) except Exception: # noqa: BLE001 ins = [] conns = [] for k, inp in enumerate(ins): nm = names[k] if k < len(names) else str(k) conns.append({"input": nm, "from": inp.get_name() if inp else None, "output": out_name(e, inp) if inp else None}) node["inputs"] = conns try: node["outputs"] = [str(n) for n in mel.get_material_expression_output_names(e)] except Exception: # noqa: BLE001 pass fn = get(e, "material_function") if node["class"] == "MaterialExpressionMaterialFunctionCall" else None if fn is not None: functions_seen[fn.get_path_name()] = fn nodes[e.get_name()] = node return nodes def dump_base_material(mat, functions_seen): rec = {"type": "Material"} for prop in ("blend_mode", "shading_model", "two_sided", "material_domain", "opacity_mask_clip_value", "decal_blend_mode", "translucency_lighting_mode"): rec[prop] = to_json(get(mat, prop)) params = {"scalars": {}, "vectors": {}, "textures": {}, "switches": {}} for n in mel.get_scalar_parameter_names(mat): params["scalars"][str(n)] = mel.get_material_default_scalar_parameter_value(mat, n) for n in mel.get_vector_parameter_names(mat): params["vectors"][str(n)] = col(mel.get_material_default_vector_parameter_value(mat, n)) for n in mel.get_texture_parameter_names(mat): params["textures"][str(n)] = obj_path(mel.get_material_default_texture_parameter_value(mat, n)) for n in mel.get_static_switch_parameter_names(mat): params["switches"][str(n)] = mel.get_material_default_static_switch_parameter_value(mat, n) rec["params"] = params try: rec["used_textures"] = sorted(set(obj_path(t) for t in mel.get_used_textures(mat) if t)) except Exception: # noqa: BLE001 rec["used_textures"] = [] try: rec["graph"] = dump_graph(mat, mel.get_material_expressions(mat), mel.get_inputs_for_material_expression, functions_seen) roots = {} for p in ROOT_PROPS: mp = getattr(unreal.MaterialProperty, p, None) if mp is None: continue node = mel.get_material_property_input_node(mat, mp) if node is not None: roots[p] = {"from": node.get_name(), "output": str(mel.get_material_property_input_node_output_name(mat, mp))} rec["roots"] = roots except Exception as e: # noqa: BLE001 problem("graph of %s: %s" % (mat.get_path_name(), e)) return rec def dump_instance(mi): rec = {"type": "MaterialInstanceConstant", "parent": obj_path(get(mi, "parent"))} try: rec["base"] = obj_path(mi.get_base_material()) except Exception: # noqa: BLE001 rec["base"] = None rec["overrides"] = to_json(get(mi, "base_property_overrides")) try: rec["blend_mode"] = to_json(mi.get_blend_mode()) except Exception: # noqa: BLE001 pass params = {"scalars": {}, "vectors": {}, "textures": {}, "switches": {}} for n in mel.get_scalar_parameter_names(mi): params["scalars"][str(n)] = mel.get_material_instance_scalar_parameter_value(mi, n) for n in mel.get_vector_parameter_names(mi): params["vectors"][str(n)] = col(mel.get_material_instance_vector_parameter_value(mi, n)) for n in mel.get_texture_parameter_names(mi): params["textures"][str(n)] = obj_path(mel.get_material_instance_texture_parameter_value(mi, n)) for n in mel.get_static_switch_parameter_names(mi): params["switches"][str(n)] = mel.get_material_instance_static_switch_parameter_value(mi, n) rec["params"] = params # which of them the instance itself sets (the rest come from the parent) own = {} for prop, key in (("scalar_parameter_values", "scalars"), ("vector_parameter_values", "vectors"), ("texture_parameter_values", "textures")): try: own[key] = sorted(str(p.parameter_info.name) for p in mi.get_editor_property(prop)) except Exception: # noqa: BLE001 own[key] = [] rec["own"] = own return rec def dump_materials(by_class): out = {} functions_seen = {} bases = by_class.get("Material", []) insts = by_class.get("MaterialInstanceConstant", []) for i, path in enumerate(bases): try: out[path] = dump_base_material(unreal.load_asset(path), functions_seen) except Exception as e: # noqa: BLE001 problem("material %s: %s" % (path, e)) unreal.log_warning(traceback.format_exc()) log("base materials: %d" % len(bases)) for i, path in enumerate(insts): try: out[path] = dump_instance(unreal.load_asset(path)) except Exception as e: # noqa: BLE001 problem("instance %s: %s" % (path, e)) if (i + 1) % 100 == 0: log("instances %d / %d" % (i + 1, len(insts))) # material functions the graphs call (several levels deep) funcs = {} todo = dict(functions_seen) while todo: fpath, fn = todo.popitem() if fpath in funcs: continue seen_here = {} try: funcs[fpath] = {"graph": dump_graph(fn, mel.get_material_function_expressions(fn), mel.get_inputs_for_material_function_expression, seen_here)} except Exception as e: # noqa: BLE001 funcs[fpath] = {"error": str(e)} for k, v in seen_here.items(): if k not in funcs: todo[k] = v write_json("materials.json", {"materials": out, "functions": funcs}) return out # --------------------------------------------------------------------------------------------------------------- # textures # --------------------------------------------------------------------------------------------------------------- def export_textures(materials): wanted = set() for rec in materials.values(): for t in rec.get("params", {}).get("textures", {}).values(): if t: wanted.add(t) for t in rec.get("used_textures", []): if t: wanted.add(t) wanted = sorted(t for t in wanted if not any(s in t for s in SKIP_TEXTURE_PACKS)) info = {} n_exported = 0 for i, path in enumerate(wanted): try: tex = unreal.load_asset(path) if tex is None or not isinstance(tex, unreal.Texture2D): info[path] = {"skipped": type(tex).__name__} continue if any(path.startswith(r + "/") for r in ROOTS): rel = "textures/" + path[len("/Game/"):] + ".png" else: rel = "textures/_external/" + path.strip("/").replace("/", "__") + ".png" out = OUT + "/" + rel rec = { "file": rel, "size": [tex.blueprint_get_size_x(), tex.blueprint_get_size_y()], "srgb": bool(get(tex, "srgb")), "compression": str(get(tex, "compression_settings")), "flip_green": bool(get(tex, "flip_green_channel")), "lod_group": str(get(tex, "lod_group")), } info[path] = rec if os.path.isfile(out) and os.path.getsize(out) > 0: continue os.makedirs(os.path.dirname(out), exist_ok=True) task = unreal.AssetExportTask() task.object = tex task.filename = out task.automated = True task.prompt = False task.replace_identical = True task.exporter = unreal.TextureExporterPNG() ok = unreal.Exporter.run_asset_export_task(task) and os.path.isfile(out) if not ok: out_tga = out[:-4] + ".tga" task.filename = out_tga task.exporter = unreal.TextureExporterTGA() ok = unreal.Exporter.run_asset_export_task(task) and os.path.isfile(out_tga) if ok: rec["file"] = rel[:-4] + ".tga" if ok: n_exported += 1 else: rec["failed"] = True problem("texture export failed: " + path) except Exception as e: # noqa: BLE001 problem("texture %s: %s" % (path, e)) if (i + 1) % 50 == 0: log("textures %d / %d" % (i + 1, len(wanted))) write_json("textures.json", info) log("textures: %d described, %d newly exported" % (len(info), n_exported)) # --------------------------------------------------------------------------------------------------------------- # demo maps # --------------------------------------------------------------------------------------------------------------- LIGHT_PROPS = ("intensity", "light_color", "attenuation_radius", "source_radius", "soft_source_radius", "source_length", "inner_cone_angle", "outer_cone_angle", "source_width", "source_height", "barn_door_angle", "barn_door_length", "intensity_units", "temperature", "use_temperature", "cast_shadows", "mobility", "indirect_lighting_intensity", "volumetric_scattering_intensity", "use_inverse_squared_falloff", "light_falloff_exponent") def dump_map(map_path): les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem) eas = unreal.get_editor_subsystem(unreal.EditorActorSubsystem) if not les.load_level(map_path): problem("could not load map " + map_path) return actors = [] counts = {} for a in eas.get_all_level_actors(): if not unreal.SystemLibrary.is_valid(a): continue try: cls = a.get_class().get_name() counts[cls] = counts.get(cls, 0) + 1 rec = { "name": a.get_name(), "label": a.get_actor_label(), "class": cls, "class_path": a.get_class().get_path_name(), "folder": str(a.get_folder_path()), "level": a.get_outer().get_outer().get_name() if a.get_outer() and a.get_outer().get_outer() else "", "matrix": mat_rows(a.get_actor_transform()), "hidden": bool(a.is_hidden_ed()), "hidden_in_game": bool(get(a, "hidden")), } att = a.get_attach_parent_actor() if att: rec["parent"] = att.get_name() meshes = [] for c in a.get_components_by_class(unreal.StaticMeshComponent): sm = c.static_mesh if sm is None: continue m = { "mesh": obj_path(sm), "matrix": mat_rows(c.get_world_transform()), "materials": [obj_path(x) for x in c.get_materials()], "visible": bool(c.is_visible()), "cast_shadow": bool(get(c, "cast_shadow", True)), "comp_class": c.get_class().get_name(), } if isinstance(c, unreal.InstancedStaticMeshComponent): inst = [] for k in range(c.get_instance_count()): t = c.get_instance_transform(k, True) if t is not None: inst.append(mat_rows(t)) m["instances"] = inst meshes.append(m) if meshes: rec["meshes"] = meshes lights = [] for c in a.get_components_by_class(unreal.LightComponentBase): l = {"comp_class": c.get_class().get_name(), "matrix": mat_rows(c.get_world_transform()), "visible": bool(c.is_visible())} for p in LIGHT_PROPS: v = get(c, p, "__none__") if v != "__none__": l[p] = to_json(v) lights.append(l) if lights: rec["lights"] = lights decals = [] for c in a.get_components_by_class(unreal.DecalComponent): decals.append({"material": obj_path(c.get_decal_material()), "size": vec(get(c, "decal_size")), "matrix": mat_rows(c.get_world_transform()), "sort_order": get(c, "sort_order", 0)}) if decals: rec["decals"] = decals cams = [] for c in a.get_components_by_class(unreal.CameraComponent): cams.append({"matrix": mat_rows(c.get_world_transform()), "fov": get(c, "field_of_view")}) if cams: rec["cameras"] = cams for c in a.get_components_by_class(unreal.ExponentialHeightFogComponent): rec["fog"] = {p: to_json(get(c, p)) for p in ("fog_density", "fog_height_falloff", "fog_inscattering_luminance", "fog_inscattering_color", "volumetric_fog", "start_distance")} if cls == "PostProcessVolume": s = get(a, "settings") if s is not None: pp = {} for p in ("auto_exposure_bias", "auto_exposure_min_brightness", "auto_exposure_max_brightness", "bloom_intensity", "vignette_intensity", "color_saturation", "color_contrast", "color_gain", "color_gamma", "white_temp", "white_tint", "film_slope", "film_toe", "ambient_occlusion_intensity", "scene_color_tint"): over = get(s, "override_" + p) if over: pp[p] = to_json(get(s, p)) rec["post"] = pp rec["unbound"] = bool(get(a, "unbound")) actors.append(rec) except Exception as e: # noqa: BLE001 problem("actor %s in %s: %s" % (a.get_name(), map_path, e)) name = map_path.split("/")[-1] write_json("maps/%s__%s.json" % (pack_of(map_path), name), {"map": map_path, "actors": actors, "counts": counts}) log("map %s: %d actors (%s)" % (name, len(actors), ", ".join("%s x%d" % (k, v) for k, v in sorted(counts.items(), key=lambda kv: -kv[1])[:8]))) # --------------------------------------------------------------------------------------------------------------- def main(): log("out: %s roots: %s steps: %s" % (OUT, ",".join(ROOTS), ",".join(sorted(DO)))) os.makedirs(OUT, exist_ok=True) by_class = list_assets() write_json("assets.json", by_class) if "meshes" in DO: export_meshes(by_class.get("StaticMesh", [])) materials = None if "materials" in DO: materials = dump_materials(by_class) if "textures" in DO: if materials is None: with open(OUT + "/materials.json", "r", encoding="utf-8") as f: materials = json.load(f)["materials"] export_textures(materials) if "maps" in DO: maps = by_class.get("World", []) if ONLY_MAPS: maps = [m for m in maps if m.split("/")[-1] in ONLY_MAPS] for m in maps: try: dump_map(m) except Exception as e: # noqa: BLE001 problem("map %s: %s" % (m, e)) unreal.log_warning(traceback.format_exc()) write_json("problems.json", PROBLEMS) log("problems: %d" % len(PROBLEMS)) try: main() log("DONE") except Exception as exc: # noqa: BLE001 unreal.log_error("[kit] FAIL: %s" % (exc,)) unreal.log_error(traceback.format_exc()) sys.exit(1)