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:
1 parent
a72bf85b2e
commit
02cadd4964
20 files changed
+268
-13
No files matched your search
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user