The axis_forward:X / Blender-Z axle fix changed the reference pose but not the drive (-2737 cm/s), and axis_forward:X also alters the shared character preset. Revert it. The Chaos solver drives the wheel about the vehicle's Y (not the bone), and the drive force is positive yet the car moves backward - the inversion is in the engine torque / drive-force sign. Next: GetEngineTorque() sign or a SetupVehicle override.
418 lines
18 KiB
Python
418 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""Model a drivable sedan in Blender: body, wheels, collision and LOD.
|
|
|
|
blender -b --factory-startup --python Tools/blender/vehicle_build.py -- \
|
|
--out Art/blender/vehicle/gigline-sedan.blend \
|
|
--fbx /tmp/gigline/vehicle/sedan.fbx \
|
|
--report /tmp/gigline/vehicle/report.json
|
|
|
|
Scale is metres, sourced from real sedan dimensions: 4.72 m long, 1.83 m wide,
|
|
1.44 m tall, 2.78 m wheelbase. Wheels are separate objects so Chaos can attach
|
|
PhysicsAssets-lite wheel references and so the wheel radius the vehicle config needs is
|
|
read from the mesh rather than typed in twice.
|
|
|
|
Collision is `UCX_`-prefixed convex boxes per panel group, which is what the
|
|
`collision-proxy` skill asks for and what Unreal's `ue_metric_v1` preset expects: one
|
|
hull for the body, one per wheel-less corner trimmed, and a floor slab. Simplified
|
|
hulls are deliberate — a car does not need per-panel collision for driving.
|
|
|
|
Every measurement is logged and written to the report, so the vehicle config values
|
|
(wheelbase, track, radius) can be read from the build instead of guessed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import math
|
|
import os
|
|
import sys
|
|
|
|
import bpy
|
|
from mathutils import Vector
|
|
|
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common
|
|
sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement)
|
|
import fbx_export_preset # noqa: E402
|
|
|
|
# Real sedan reference: length, width, height, wheelbase, track, wheel radius.
|
|
DIMS = {
|
|
"length": 4.72,
|
|
"width": 1.80,
|
|
"height": 1.44,
|
|
"wheelbase": 2.78,
|
|
"track": 1.62,
|
|
"wheel_radius": 0.33,
|
|
"wheel_width": 0.22,
|
|
"ride_height": 0.16,
|
|
}
|
|
|
|
PARTS: list[str] = []
|
|
|
|
|
|
def log(message: str) -> None:
|
|
PARTS.append(message)
|
|
print(f"[vehicle] {message}")
|
|
|
|
|
|
def material(name, colour, roughness=0.45, metallic=0.0):
|
|
mat = bpy.data.materials.get(name) or bpy.data.materials.new(name)
|
|
mat.use_nodes = True
|
|
bsdf = mat.node_tree.nodes.get("Principled BSDF")
|
|
if bsdf:
|
|
bsdf.inputs["Base Color"].default_value = (*colour, 1.0)
|
|
bsdf.inputs["Roughness"].default_value = roughness
|
|
bsdf.inputs["Metallic"].default_value = metallic
|
|
return mat
|
|
|
|
|
|
def box(name, centre, size, mat):
|
|
bpy.ops.mesh.primitive_cube_add(size=1.0, location=centre)
|
|
obj = bpy.context.active_object
|
|
obj.name = name
|
|
obj.scale = size
|
|
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
|
|
obj.data.materials.append(mat)
|
|
return obj
|
|
|
|
|
|
def build_body(mats):
|
|
"""Three-tier silhouette: sill slab, bonnet and boot shoulders, cabin greenhouse.
|
|
|
|
The first build was one tall slab plus a floating cabin, which read as a box truck.
|
|
A sedan reads from the step between the bonnet and the roof, so the volumes are
|
|
stacked instead: a slab to beltline height, a lower bonnet in front and boot behind,
|
|
and the cabin only over the middle.
|
|
"""
|
|
L, W, H = DIMS["length"], DIMS["width"], DIMS["height"]
|
|
ride = DIMS["ride_height"]
|
|
belt = 0.98 # beltline height above ground
|
|
roof = H # 1.44
|
|
parts = []
|
|
|
|
# Sill slab: full length, narrow enough that the wheels stand proud of it.
|
|
parts.append(box("Body_Sill", (0.0, 0.0, ride + (belt - ride) * 0.5),
|
|
(L, 1.56, belt - ride), mats["paint"]))
|
|
# Bonnet: front 1.55 m, from beltline to a lower bonnet top.
|
|
parts.append(box("Body_Bonnet", (L * 0.5 - 0.78, 0.0, (belt + 1.06) * 0.5),
|
|
(1.56, W * 0.94, 1.06 - belt), mats["paint"]))
|
|
# Boot: rear 1.05 m, slightly lower than the bonnet.
|
|
parts.append(box("Body_Boot", (-L * 0.5 + 0.52, 0.0, (belt + 1.02) * 0.5),
|
|
(1.04, W * 0.94, 1.02 - belt), mats["paint"]))
|
|
# Cabin: the greenhouse, between bonnet and boot, narrower than the sills.
|
|
parts.append(box("Body_Cabin", (-0.02, 0.0, (1.06 + roof) * 0.5),
|
|
(2.02, W * 0.86, roof - 1.06), mats["paint"]))
|
|
|
|
# Glazing, inset a couple of centimetres so it reads as glass, not paint.
|
|
parts.append(box("Glass_Windscreen", (0.96, 0.0, 1.24), (0.05, W * 0.78, 0.30), mats["glass"]))
|
|
parts.append(box("Glass_Rear", (-1.00, 0.0, 1.24), (0.05, W * 0.76, 0.28), mats["glass"]))
|
|
for side in (-1.0, 1.0):
|
|
parts.append(box(f"Glass_Side_{'L' if side > 0 else 'R'}",
|
|
(-0.02, side * W * 0.43, 1.24), (1.76, 0.02, 0.26), mats["glass"]))
|
|
|
|
# Lamps, grille, bumpers, mirrors.
|
|
for side in (-1.0, 1.0):
|
|
parts.append(box(f"Lamp_Front_{'L' if side > 0 else 'R'}",
|
|
(L * 0.5 - 0.03, side * W * 0.32, 0.94), (0.05, 0.28, 0.12), mats["lamp"]))
|
|
parts.append(box(f"Lamp_Rear_{'L' if side > 0 else 'R'}",
|
|
(-L * 0.5 + 0.03, side * W * 0.32, 0.94), (0.04, 0.26, 0.10), mats["tail"]))
|
|
parts.append(box(f"Mirror_{'L' if side > 0 else 'R'}",
|
|
(0.80, side * (W * 0.5 + 0.05), 1.18), (0.08, 0.12, 0.07), mats["trim"]))
|
|
parts.append(box("Grille", (L * 0.5 - 0.02, 0.0, 0.80), (0.04, W * 0.48, 0.12), mats["trim"]))
|
|
for front in (1.0, -1.0):
|
|
parts.append(box(f"Bumper_{'F' if front > 0 else 'R'}",
|
|
(front * (L * 0.5 - 0.06), 0.0, ride + 0.14),
|
|
(0.16, W * 0.90, 0.20), mats["trim"]))
|
|
log(f"body: {len(parts)} parts, {L} x {W} x {H} m, beltline {belt} m")
|
|
return parts
|
|
|
|
|
|
def build_wheels(mats):
|
|
r, w = DIMS["wheel_radius"], DIMS["wheel_width"]
|
|
half_base = DIMS["wheelbase"] * 0.5
|
|
half_track = DIMS["track"] * 0.5
|
|
wheels = []
|
|
for name, x, y in (("Wheel_FL", half_base, half_track),
|
|
("Wheel_FR", half_base, -half_track),
|
|
("Wheel_RL", -half_base, half_track),
|
|
("Wheel_RR", -half_base, -half_track)):
|
|
bpy.ops.mesh.primitive_cylinder_add(vertices=20, radius=r, depth=w,
|
|
location=(x, y, r), rotation=(0.0, math.pi / 2, 0.0))
|
|
wheel = bpy.context.active_object
|
|
wheel.name = name
|
|
bpy.ops.object.transform_apply(location=False, rotation=True, scale=False)
|
|
wheel.data.materials.append(mats["tyre"])
|
|
wheels.append(wheel)
|
|
log(f"wheels: 4 at r={r} m, width {w} m, wheelbase {DIMS['wheelbase']} m, track {DIMS['track']} m")
|
|
return wheels
|
|
|
|
|
|
def build_collision(mats):
|
|
"""One convex hull per major volume: body, cabin, and the four wheel arches.
|
|
|
|
Unreal reads `UCX_<mesh>_NN` names; the exporter ships them as separate meshes and
|
|
the import preset turns them into simple collision.
|
|
"""
|
|
L, W, H = DIMS["length"], DIMS["width"], DIMS["height"]
|
|
ride = DIMS["ride_height"]
|
|
hulls = [
|
|
box("UCX_Sedan_Body_01", (0.0, 0.0, ride + 0.40), (L * 0.98, W * 0.98, 0.70), mats["collision"]),
|
|
box("UCX_Sedan_Cabin_01", (-0.12, 0.0, ride + 0.94), (L * 0.44, W * 0.86, 0.42), mats["collision"]),
|
|
box("UCX_Sedan_BumperF_01", (L * 0.47, 0.0, ride + 0.24), (0.24, W * 0.94, 0.30), mats["collision"]),
|
|
box("UCX_Sedan_BumperR_01", (-L * 0.47, 0.0, ride + 0.24), (0.24, W * 0.94, 0.30), mats["collision"]),
|
|
]
|
|
for hull in hulls:
|
|
hull.hide_render = True
|
|
hull.display_type = "WIRE"
|
|
log(f"collision: {len(hulls)} convex hulls (body, cabin, two bumpers), hidden from render")
|
|
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__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("--out", default="Art/blender/vehicle/gigline-sedan.blend")
|
|
ap.add_argument("--fbx", default="/tmp/gigline/vehicle/sedan.fbx")
|
|
ap.add_argument("--report", default="/tmp/gigline/vehicle/report.json")
|
|
args = ap.parse_args(argv)
|
|
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
mats = {
|
|
"paint": material("M_Vehicle_Paint", (0.42, 0.05, 0.05), 0.28, 0.35),
|
|
"glass": material("M_Vehicle_Glass", (0.06, 0.08, 0.09), 0.12),
|
|
"tyre": material("M_Vehicle_Tyre", (0.02, 0.02, 0.02), 0.85),
|
|
"trim": material("M_Vehicle_Trim", (0.04, 0.04, 0.04), 0.55),
|
|
"lamp": material("M_Vehicle_Lamp", (0.85, 0.82, 0.70), 0.18),
|
|
"tail": material("M_Vehicle_Tail", (0.45, 0.03, 0.03), 0.25),
|
|
"collision": material("M_Vehicle_Collision", (0.2, 0.2, 0.2), 0.9),
|
|
}
|
|
|
|
body = build_body(mats)
|
|
wheels = build_wheels(mats)
|
|
hulls = build_collision(mats)
|
|
|
|
# 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:
|
|
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))
|
|
log(f"saved {args.out}")
|
|
|
|
# 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(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}")
|
|
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
|
|
# 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)
|
|
|
|
# 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": shell_triangles,
|
|
"collision_hulls": [h.name for h in hull_join],
|
|
"wheels": wheel_names,
|
|
"systems": {key: {"object": system_names.get(key), "triangles": per_system.get(key, 0),
|
|
"fbx": exported.get(key)}
|
|
for key in system_names},
|
|
"wheel_triangles": wheel_triangles,
|
|
"lods": lod_paths,
|
|
"log": PARTS,
|
|
}
|
|
if args.report:
|
|
os.makedirs(os.path.dirname(os.path.abspath(args.report)), exist_ok=True)
|
|
with open(args.report, "w") as handle:
|
|
json.dump(report, handle, indent=2)
|
|
print(f"[vehicle] report -> {args.report}")
|
|
print(f"[vehicle] {triangles} triangles, {len(hull_join)} hulls")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|