feat(vehicle): split into systems, add real LODs, import to Unreal

Follows the vehicle-artist object split, which the first build violated: body, glass,
front and rear lights, mirrors and a single wheel mesh are now separate objects and
separate assets in /Game/Hollywood/Vehicle. A single merged mesh across all systems is
what the skill forbids for a game vehicle, and it also meant glass could not be made
transparent or lights emissive independently.

LODs decimate rather than duplicate: LOD1 38 triangles, LOD2 14, against 132 for LOD0.
The first pass copied the shell unchanged and produced 84-triangle 'LODs' that saved
nothing.

Imports verified with bounds: body 4.76 x 1.76 x 1.28 m, wheel 0.22 x 0.66 x 0.66 m,
which match the modelled dimensions.

Open, recorded: LOD group wiring needs StaticMeshEditorSubsystem.import_lod, which is
unavailable in a headless commandlet, so the LOD meshes sit as their own assets for now.
This commit is contained in:
zwitschi committed 2026-11-09 14:46:02 +01:00
1 parent a72bf85b2e
commit 02cadd4964
20 files changed
+268 -13

No files matched your search

+99 -10
View File
@@ -191,16 +191,55 @@ def main(argv=None) -> int:
wheels = build_wheels(mats)
hulls = build_collision(mats)
# Join the visible panels into one export mesh; wheels stay separate objects so the
# vehicle setup can reference them.
bpy.ops.object.select_all(action="DESELECT")
# Object split per the vehicle-artist skill: body, glass, lights and wheels stay
# separate so each can be swapped, made transparent or made emissive on its own. A
# single merged mesh is exactly what the skill forbids for a game vehicle.
systems = {}
for part in body:
part.select_set(True)
bpy.context.view_layer.objects.active = body[0]
bpy.ops.object.join()
shell = bpy.context.active_object
shell.name = "SM_Vehicle_Sedan"
name = part.name
if name.startswith("Glass_"):
key = "glass"
elif name.startswith("Lamp_"):
key = "light_front"
elif name.startswith("Lamp_Rear") or name.startswith("Lamp_Rear_"):
key = "light_rear"
elif name.startswith("Mirror_"):
key = "mirrors"
else:
key = "body"
systems.setdefault(key, []).append(part)
# Lamps were named lamp_front/lamp_rear by prefix; separate them properly.
rear = [p for p in systems.pop("light_front", []) if "Rear" in p.name]
front = [p for p in systems.get("body", []) if False]
# Collect every lamp again from the body list by name.
lamps = [p for p in body if p.name.startswith("Lamp_")]
systems["light_front"] = [p for p in lamps if "Rear" not in p.name]
systems["light_rear"] = [p for p in lamps if "Rear" in p.name]
systems["body"] = [p for p in body if not p.name.startswith(
("Glass_", "Lamp_", "Mirror_"))]
joined = {}
for key, parts in systems.items():
if not parts:
continue
bpy.ops.object.select_all(action="DESELECT")
for part in parts:
part.select_set(True)
bpy.context.view_layer.objects.active = parts[0]
if len(parts) > 1:
bpy.ops.object.join()
obj = bpy.context.active_object
obj.name = {
"body": "SM_Vehicle_Body",
"glass": "SM_Vehicle_Glass",
"light_front": "SM_Vehicle_Light_Front",
"light_rear": "SM_Vehicle_Light_Rear",
"mirrors": "SM_Vehicle_Mirrors",
}[key]
joined[key] = obj
shell = joined["body"]
hull_join = hulls
log("systems: " + ", ".join(f"{k}={len(v)} parts" for k, v in systems.items()))
os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True)
bpy.ops.wm.save_as_mainfile(filepath=os.path.abspath(args.out))
@@ -209,17 +248,62 @@ def main(argv=None) -> int:
# Export: shell + collision hulls in one file, wheels in their own file so Chaos
# can bind them to wheel bones or use them as visual wheels.
os.makedirs(os.path.dirname(os.path.abspath(args.fbx)), exist_ok=True)
exp_dir = os.path.dirname(os.path.abspath(args.fbx))
exported = {}
for key, obj in joined.items():
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
path = os.path.join(exp_dir, f"{key}.fbx")
fbx_export_preset.export_static(path, selection=True)
exported[key] = path
# Body carries the collision hulls so the hulls and the shell land in one asset,
# which is how Unreal wires UCX_ meshes to their mesh.
bpy.ops.object.select_all(action="DESELECT")
shell.select_set(True)
for hull in hull_join:
hull.select_set(True)
bpy.context.view_layer.objects.active = shell
fbx_export_preset.export_static(args.fbx, selection=True)
log(f"exported {args.fbx}")
fbx_export_preset.export_static(os.path.join(exp_dir, "body.fbx"), selection=True)
# One wheel asset, reused four times: Chaos binds the same wheel mesh per bone.
bpy.ops.object.select_all(action="DESELECT")
wheels[0].select_set(True)
bpy.context.view_layer.objects.active = wheels[0]
fbx_export_preset.export_static(os.path.join(exp_dir, "wheel.fbx"), selection=True)
exported["wheel"] = os.path.join(exp_dir, "wheel.fbx")
log(f"exported {len(exported)} system FBX files to {exp_dir}")
triangles = sum(len(p.vertices) - 2 for p in shell.data.polygons)
per_system = {key: sum(len(p.vertices) - 2 for p in obj.data.polygons)
for key, obj in joined.items()}
for hull in hull_join:
triangles += sum(len(p.vertices) - 2 for p in hull.data.polygons)
# LODs per the vehicle-artist targets: LOD1 drops mirrors and lamp detail, LOD2 is a
# body-plus-glass shell. They are separate objects here and get wired into the
# body's LOD group at import time.
lod_paths = {}
for index, keep_glass, ratio in ((1, True, 0.55), (2, False, 0.25)):
lod_key = f"body_lod{index}"
bpy.ops.object.select_all(action="DESELECT")
shell.select_set(True)
if keep_glass and "glass" in joined:
joined["glass"].select_set(True)
bpy.context.view_layer.objects.active = shell
bpy.ops.object.duplicate()
lod = bpy.context.active_object
lod.name = f"SM_Vehicle_Body_LOD{index}"
# Decimate for real: the first version duplicated the shell unchanged, so both
# LODs came out at 84 triangles - an LOD that saves nothing.
decimate = lod.modifiers.new(name="LOD", type="DECIMATE")
decimate.ratio = ratio
bpy.ops.object.modifier_apply(modifier=decimate.name)
lod_path = os.path.join(exp_dir, f"{lod_key}.fbx")
fbx_export_preset.export_static(lod_path, selection=True)
lod_paths[lod_key] = lod_path
lod.hide_set(False)
log(f"{lod_key}: exported, {sum(len(p.vertices) - 2 for p in lod.data.polygons)} triangles")
bpy.data.objects.remove(lod, do_unlink=True)
report = {
"asset": "SM_Vehicle_Sedan",
"dimensions_m": DIMS,
@@ -227,6 +311,11 @@ def main(argv=None) -> int:
"triangles": triangles,
"collision_hulls": [h.name for h in hull_join],
"wheels": [w.name for w in wheels],
"systems": {key: {"object": obj.name, "triangles": per_system.get(key, 0),
"fbx": exported.get(key)}
for key, obj in joined.items()},
"wheel_triangles": sum(len(p.vertices) - 2 for p in wheels[0].data.polygons),
"lods": lod_paths,
"log": PARTS,
}
if args.report: