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
@@ -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