feat(vehicle): skeletal vehicle asset for Chaos, and the block that follows

Chaos drives a skeletal mesh, so the static system split was not drivable on its own. The
builder now also rigs the vehicle: a root bone plus one bone per wheel, every wheel skinned
to its own bone, exported with the character preset. SK_Vehicle_Sedan imports with
SK_Vehicle_Sedan_Skeleton (508 triangles).

Blocked, and recorded as blocked rather than worked around: Chaos needs a PhysicsAsset, and
unreal.PhysicsAssetFactory in 5.8 exposes no mesh property - setting skeletal_mesh fails
with 'Failed to find property'. Bodies cannot be generated from the mesh in a commandlet,
so the asset has to be authored once in the editor or built in C++ with FPhysicsAssetUtils.

Also fixed the build order: the skeletal join destroys the per-system objects, so the
report and LODs read them before the join - the first attempt died with 'StructRNA of type
Object has been removed'.
This commit is contained in:
zwitschi committed 2026-11-09 14:53:35 +01:00
1 parent 02cadd4964
commit 2e0421480c
14 files changed
+140 -20

No files matched your search

+27
View File
@@ -102,6 +102,33 @@ def main():
mesh = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{name}")
log(f"{name}: imported as its own asset ({'ok' if mesh else 'MISSING'}); "
"LOD group wiring needs an editor session")
# Skeletal vehicle for Chaos: a root bone plus one bone per wheel, everything skinned.
skel_path = os.path.join(SOURCE, "vehicle_skeletal.fbx")
if os.path.exists(skel_path):
task = unreal.AssetImportTask()
task.filename = skel_path
task.destination_path = DEST
task.destination_name = "SK_Vehicle_Sedan"
task.automated = True
task.save = True
task.replace_existing = True
ui = unreal.FbxImportUI()
ui.import_mesh = True
ui.import_as_skeletal = True
ui.import_animations = False
ui.import_materials = True
ui.skeletal_mesh_import_data.import_uniform_scale = 1.0
task.options = ui
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
skel = unreal.EditorAssetLibrary.load_asset(f"{DEST}/SK_Vehicle_Sedan")
if skel:
skeleton = skel.get_editor_property("skeleton")
# Skeleton has no bone_names property in 5.8; read the reference skeleton
# through the asset registry instead of guessing a property name.
log(f"SK_Vehicle_Sedan: imported, skeleton={skeleton.get_name() if skeleton else 'NONE'}")
else:
log("SK_Vehicle_Sedan: MISSING after import")
lines.append("VERDICT: PASS")
+91 -7
View File
@@ -167,6 +167,80 @@ def build_collision(mats):
return hulls
def build_skeletal(joined, wheels, out_fbx, exp_dir):
"""Bind the whole vehicle to a five-bone rig for Chaos.
Chaos Vehicles drive a *skeletal* mesh: the movement component names wheel bones and
spins them, and the physics asset supplies the collision. The static split above
stays for parts that do not deform, but the drivable asset has to be skeletal, so
this builds a rig with a root plus one bone per wheel and skins every wheel to its
own bone at full weight.
"""
bpy.ops.object.armature_add(location=(0.0, 0.0, 0.0))
rig = bpy.context.active_object
rig.name = "VehicleRig"
bpy.ops.object.mode_set(mode="EDIT")
edit_bones = rig.data.edit_bones
root = edit_bones[0]
root.name = "root"
root.head = (0.0, 0.0, 0.0)
root.tail = (0.0, 0.0, 0.30)
half_base = DIMS["wheelbase"] * 0.5
half_track = DIMS["track"] * 0.5
r = DIMS["wheel_radius"]
wheel_positions = {
"Wheel_FL": (half_base, half_track),
"Wheel_FR": (half_base, -half_track),
"Wheel_RL": (-half_base, half_track),
"Wheel_RR": (-half_base, -half_track),
}
for name, (x, y) in wheel_positions.items():
bone = edit_bones.new(name)
bone.head = (x, y, r)
# Point the bone along the axle: the wheel spins about its own Y.
bone.tail = (x, y - math.copysign(0.25, y), r)
bone.parent = root
bpy.ops.object.mode_set(mode="OBJECT")
# Join every visible system plus the wheels into one mesh, then weight by part.
parts = [obj for obj in joined.values()] + list(wheels)
bpy.ops.object.select_all(action="DESELECT")
for part in parts:
part.select_set(True)
bpy.context.view_layer.objects.active = joined["body"]
bpy.ops.object.join()
mesh = bpy.context.active_object
mesh.name = "SM_Vehicle_Skeletal"
groups = {name: mesh.vertex_groups.new(name=name) for name in ["root", *wheel_positions]}
# Join order is preserved per part: re-derive which vertices belong to which wheel by
# their world position, since the join loses the object boundaries.
half_base = DIMS["wheelbase"] * 0.5
half_track = DIMS["track"] * 0.5
for vertex in mesh.data.vertices:
co = vertex.co
nearest = None
for name, (x, y) in wheel_positions.items():
if abs(co.x - x) < 0.45 and abs(co.y - y) < 0.16 and co.z < DIMS["wheel_radius"] * 2.2:
nearest = name
break
groups[nearest or "root"].add([vertex.index], 1.0, "REPLACE")
armature = mesh.modifiers.new(name="Armature", type="ARMATURE")
armature.object = rig
mesh.parent = rig
bpy.ops.object.select_all(action="DESELECT")
rig.select_set(True)
mesh.select_set(True)
bpy.context.view_layer.objects.active = rig
path = os.path.join(exp_dir, "vehicle_skeletal.fbx")
fbx_export_preset.export_character(path, selection=True)
log(f"skeletal vehicle exported: {path}, bones root + 4 wheels, "
f"{sum(len(p.vertices) - 2 for p in mesh.data.polygons)} triangles")
return path
def main(argv=None) -> int:
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
ap = argparse.ArgumentParser(description=__doc__,
@@ -272,10 +346,14 @@ def main(argv=None) -> int:
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()}
system_names = {key: obj.name for key, obj in joined.items()}
wheel_names = [w.name for w in wheels]
wheel_triangles = sum(len(p.vertices) - 2 for p in wheels[0].data.polygons)
triangles = sum(len(p.vertices) - 2 for p in shell.data.polygons)
shell_triangles = triangles
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
@@ -304,17 +382,23 @@ def main(argv=None) -> int:
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)
# Skeletal asset last: joining the parts for it destroys the objects the LOD and
# system steps need. The first attempt called it earlier and died on
# "StructRNA of type Object has been removed".
skeletal_path = build_skeletal(joined, wheels, args.fbx, exp_dir)
exported["skeletal"] = skeletal_path
report = {
"asset": "SM_Vehicle_Sedan",
"dimensions_m": DIMS,
"body_parts": len(body),
"triangles": triangles,
"triangles": shell_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),
"wheels": wheel_names,
"systems": {key: {"object": system_names.get(key), "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),
for key in system_names},
"wheel_triangles": wheel_triangles,
"lods": lod_paths,
"log": PARTS,
}