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

Binary file not shown.
+3 -1
View File
@@ -13,7 +13,9 @@ Actionable task list for this project. This file is the single, central place fo
### Vehicle production - active - session-5598ce7e-27e3-4c64-bb49-c04a4a31ee58 (branch `vehicle-0.3.0`)
- [A] Model one vehicle in Blender with collision meshes; export to Unreal. **Model done** (`Tools/vehicle_build.py` → `Art/blender/vehicle/gigline-sedan.blend`, preview in `Art/blender/vehicle/preview.png`): 4.72 x 1.80 x 1.44 m, 2.78 m wheelbase, 1.62 m track, 0.33 m wheels, 252 triangles, four `UCX_` convex hulls. Dimensions come from real sedan figures and are logged to the report, so the vehicle config can read them rather than restating them. Remaining: import into Unreal (`0.3.0` next step).
- [A] Model one vehicle in Blender with collision meshes; export to Unreal. **Model done** (`Tools/vehicle_build.py` → `Art/blender/vehicle/gigline-sedan.blend`, preview in `Art/blender/vehicle/preview.png`): 4.72 x 1.80 x 1.44 m, 2.78 m wheelbase, 1.62 m track, 0.33 m wheels, 252 triangles, four `UCX_` convex hulls. Dimensions come from real sedan figures and are logged to the report, so the vehicle config can read them rather than restating them. - **Imported into Unreal** (`Tools/ue_vehicle_import.py` → `/Game/Hollywood/Vehicle`), split per the vehicle-artist object split: `SM_Vehicle_Body` (3 slots, 4.76 x 1.76 x 1.28 m), `SM_Vehicle_Glass`, `SM_Vehicle_Light_Front`, `SM_Vehicle_Light_Rear`, `SM_Vehicle_Mirrors`, and one `SM_Vehicle_Wheel` (0.22 x 0.66 x 0.66 m) reused four times. The body FBX carries the `UCX_` hulls as simple collision.
- **LODs cut for real:** decimating the duplicate gives LOD1 at 38 triangles and LOD2 at 14, against 132 for LOD0. The first attempt duplicated the shell unchanged and produced 84-triangle LODs that saved nothing - worth remembering as the failure mode of a "LOD" step nobody measures.
- **LOD group wiring is open:** `StaticMeshEditorSubsystem.import_lod` is unavailable in a headless commandlet (`get_editor_subsystem` returns None), so the LOD meshes import as their own assets and the group still needs an editor session.
- [ ] Set up Chaos Vehicle with wheel and physics configuration.
- [ ] Implement enter/exit interaction.
- [ ] Build a closed driving route with boundaries.
+1
View File
@@ -95,6 +95,7 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b
| `character_match.py` | Compares a rendered character against a blueprint: figure aspect, fill and coverage deltas, mean CIE76 colour distance, dominant-palette distance, and a side-by-side montage. | `--render`, `--blueprint`, `--render-box`, `--out`, `--montage` | `python3 Tools/character_match.py --render Screenshots/demo/review-demo-third-person-front.png --blueprint Art/characters/blueprints/front.png --render-box 0.42,0.46,0.56,0.97 --montage /tmp/gigline/match.png` |
| `generate_character_textures.py` | Procedural skin-pore and hair-strand textures with sidecars; no model involved. | `--out` | `python3 Tools/generate_character_textures.py --out Art/textures/generated` |
| `vehicle_build.py` | Models the drivable sedan: three-tier body, four wheels, lamps and bumpers, plus four `UCX_` convex collision hulls; saves the .blend, exports an FBX with the `ue_metric_v1` preset, and writes a report with every dimension the vehicle config needs. | `--out`, `--fbx`, `--report` | `blender -b --factory-startup --python Tools/vehicle_build.py -- --out Art/blender/vehicle/gigline-sedan.blend --fbx /tmp/gigline/vehicle/sedan.fbx` |
| `ue_vehicle_import.py` | Imports the sedan's six systems into `/Game/Hollywood/Vehicle` as separate static meshes, plus the two LOD meshes; the body carries the `UCX_` hulls as simple collision. | (no arguments; reads `/tmp/gigline/vehicle`) | `UnrealEditor-Cmd <uproject> -run=pythonscript -script=Tools/ue_vehicle_import.py -nullrhi -unattended -nopause` |
## Tests
+118
View File
@@ -0,0 +1,118 @@
# Import the sedan systems as separate static meshes (0.3.0).
#
# blender -b --factory-startup --python Tools/vehicle_build.py -- \
# --out Art/blender/vehicle/gigline-sedan.blend --fbx /tmp/gigline/vehicle/sedan.fbx
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/ue_vehicle_import.py -nullrhi -unattended -nopause -stdout
#
# One asset per system, per the vehicle-artist skill's object split: body, glass, front
# and rear lights, mirrors, and one wheel reused four times. The body FBX also carries
# the UCX_ hulls, which Unreal consumes as simple collision.
#
# Result file: /tmp/gigline/vehicle-import.txt
import os
import unreal
DEST = "/Game/Hollywood/Vehicle"
SOURCE = "/tmp/gigline/vehicle"
SYSTEMS = (
("body", "SM_Vehicle_Body"),
("glass", "SM_Vehicle_Glass"),
("light_front", "SM_Vehicle_Light_Front"),
("light_rear", "SM_Vehicle_Light_Rear"),
("mirrors", "SM_Vehicle_Mirrors"),
("wheel", "SM_Vehicle_Wheel"),
)
RESULT = "/tmp/gigline/vehicle-import.txt"
lines = []
def log(msg):
lines.append(str(msg))
unreal.log(str(msg))
def main():
for source, name in SYSTEMS:
path = os.path.join(SOURCE, f"{source}.fbx")
if not os.path.exists(path):
log(f"{name}: missing source {path}")
lines.append("VERDICT: FAIL")
return
task = unreal.AssetImportTask()
task.filename = path
task.destination_path = DEST
task.destination_name = name
task.automated = True
task.save = True
task.replace_existing = True
ui = unreal.FbxImportUI()
ui.import_mesh = True
ui.import_as_skeletal = False
ui.import_materials = True
ui.import_textures = False
ui.static_mesh_import_data.combine_meshes = True
ui.static_mesh_import_data.generate_lightmap_u_vs = True
ui.static_mesh_import_data.auto_generate_collision = True
ui.static_mesh_import_data.import_uniform_scale = 1.0
task.options = ui
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
mesh = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{name}")
if mesh is None:
log(f"{name}: MISSING after import")
lines.append("VERDICT: FAIL")
return
bounds = mesh.get_bounding_box() if hasattr(mesh, "get_bounding_box") else None
size = ""
if bounds:
extent = bounds.max - bounds.min
size = f"{extent.x/100:.2f} x {extent.y/100:.2f} x {extent.z/100:.2f} m"
mats = mesh.get_editor_property("static_materials")
log(f"{name}: imported, {len(mats)} material slot(s), bounds {size}")
# Wire the two LOD meshes into the body's LOD group. import_lod is the supported
# path; copying bytes between assets is not, and the LOD group is what the engine
# uses to pick by screen size.
# LOD meshes are imported as their own assets. Wiring them into the body's LOD group
# needs StaticMeshEditorSubsystem.import_lod, which is unavailable in this headless
# commandlet (get_editor_subsystem returns None), so the group is a follow-up.
for source, name in (("body_lod1", "SM_Vehicle_Body_LOD1"),
("body_lod2", "SM_Vehicle_Body_LOD2")):
path = os.path.join(SOURCE, f"{source}.fbx")
if not os.path.exists(path):
log(f"{name}: missing {path}")
continue
task = unreal.AssetImportTask()
task.filename = path
task.destination_path = DEST
task.destination_name = name
task.automated = True
task.save = True
task.replace_existing = True
ui = unreal.FbxImportUI()
ui.import_mesh = True
ui.import_materials = True
ui.static_mesh_import_data.combine_meshes = True
task.options = ui
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
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")
lines.append("VERDICT: PASS")
try:
main()
except Exception as exc: # noqa: BLE001
import traceback
log(f"FAILED: {type(exc).__name__}: {exc}")
log(traceback.format_exc())
lines.append("VERDICT: FAIL")
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
with open(RESULT, "w") as f:
f.write("\n".join(lines) + "\n")
+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: