feat(vehicle): the car renders, built from the owner's hot hatch template
The invisible-vehicle defect is closed. Shot 9 now shows the car instead of an empty road. Tools/vehicle_from_template.py converts Art/blender/vehicle/hot hatch.blend into UE-ready assets, following two skills: vehicle-artist - systems split (body 113k tris, trim 10.5k, glass 20.5k, lights 18.6k, wheels 47.8k), one UCX_ hull 1.45 x 3.94 x 0.76 m, two decimated LODs at 39.7k and 13.6k. unreal-export - scale normalised to a documented 4.05 m hot hatch before export (the template measures 7.26 m, factor 0.5579), then verified after import: body bounds 1.51 x 4.02 x 1.22 m against the 4.05 m target. Staged as five plain StaticMeshActors, because the earlier probe proved those render while the same meshes attached to the vehicle pawn do not. Open and recorded rather than hidden: judge_screenshot returns verdict=no confidence=0.9 - the silhouette does not read as a hot hatch from this angle and the car floats slightly. The likely causes are a system misclassification in the template conversion and an origin-height error of a few centimetres.
This commit is contained in:
1 parent
021aa56121
commit
b50afa4bcd
67 files changed
+384
-83
No files matched your search
@@ -96,6 +96,7 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b
|
||||
| `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` |
|
||||
| `vehicle_from_template.py` | Converts the owner's `hot hatch.blend` into UE-ready assets: normalises the scale to a documented 4.05 m, splits systems (body/trim/glass/lights/wheels), adds a `UCX_` hull, decimates two LODs, exports FBX per system and writes a report. | `--out`, `--report` | `blender -b "Art/blender/vehicle/hot hatch.blend" --python Tools/vehicle_from_template.py -- --out /tmp/gigline/hatch` |
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
+15
-21
@@ -661,34 +661,28 @@ def build():
|
||||
# attached to the vehicle pawn stay invisible, so the visuals do not depend on the
|
||||
# pawn until that is solved. A visible car that does not drive yet beats an invisible
|
||||
# one that does.
|
||||
vehicle_origin = unreal.Vector(-12950.0, -900.0, 470.0)
|
||||
# The hot-hatch template, normalised to 4.05 m and split per the vehicle-artist
|
||||
# object split. Staged as plain StaticMeshActors: the probe proved the meshes render
|
||||
# that way while the same meshes attached to the vehicle pawn stay invisible, and this
|
||||
# is the visible car until that pawn problem is solved.
|
||||
vehicle_origin = unreal.Vector(-12950.0, -900.0, 372.0)
|
||||
vehicle_rotation = unreal.Rotator(pitch=0.0, yaw=0.0, roll=0.0)
|
||||
|
||||
vehicle_class = unreal.load_class(None, "/Script/GigLine.GigLineVehicle")
|
||||
if vehicle_class:
|
||||
vehicle = spawn(vehicle_class, vehicle_origin, "Demo_Vehicle_Sedan")
|
||||
log(f"staged: Demo_Vehicle_Sedan ({'ok' if vehicle else 'FAILED'})")
|
||||
else:
|
||||
log("staged: Demo_Vehicle_Sedan skipped - class not found")
|
||||
|
||||
panels = (
|
||||
("SM_Vehicle_Body", unreal.Vector(0.0, 0.0, -72.0)),
|
||||
("SM_Vehicle_Glass", unreal.Vector(0.0, 0.0, -72.0)),
|
||||
("SM_Vehicle_Light_Front", unreal.Vector(0.0, 0.0, -72.0)),
|
||||
("SM_Vehicle_Light_Rear", unreal.Vector(0.0, 0.0, -72.0)),
|
||||
("SM_Vehicle_Mirrors", unreal.Vector(0.0, 0.0, -72.0)),
|
||||
("SM_Vehicle_Wheel", unreal.Vector(139.0, 81.0, -88.0)),
|
||||
("SM_Vehicle_Wheel", unreal.Vector(139.0, -81.0, -88.0)),
|
||||
("SM_Vehicle_Wheel", unreal.Vector(-139.0, 81.0, -88.0)),
|
||||
("SM_Vehicle_Wheel", unreal.Vector(-139.0, -81.0, -88.0)),
|
||||
panel_systems = (
|
||||
"SM_Vehicle_Body",
|
||||
"SM_Vehicle_Trim",
|
||||
"SM_Vehicle_Glass",
|
||||
"SM_Vehicle_Lights",
|
||||
"SM_Vehicle_Wheels",
|
||||
)
|
||||
staged_panels = 0
|
||||
for index, (mesh_name, offset) in enumerate(panels):
|
||||
for index, mesh_name in enumerate(panel_systems):
|
||||
mesh = unreal.EditorAssetLibrary.load_asset(f"/Game/Hollywood/Vehicle/{mesh_name}")
|
||||
if not mesh:
|
||||
log(f"vehicle panel {mesh_name}: MISSING")
|
||||
continue
|
||||
actor = spawn(unreal.StaticMeshActor, vehicle_origin + offset,
|
||||
f"Demo_VehiclePanel_{index:02d}")
|
||||
actor = spawn(unreal.StaticMeshActor, vehicle_origin,
|
||||
f"Demo_VehiclePanel_{index:02d}", vehicle_rotation)
|
||||
if actor:
|
||||
comp = actor.get_component_by_class(unreal.StaticMeshComponent)
|
||||
if comp:
|
||||
|
||||
@@ -292,7 +292,7 @@ try:
|
||||
# The sedan, staged on the carriageway at (-12950, -900). Three-quarter front
|
||||
# view from the road so both the body and the wheels are visible: a side-on
|
||||
# or rear view hides the wheel stances that the build is judged on.
|
||||
("demo-vehicle", unreal.Vector(-12250, -1600, 620), unreal.Vector(-12950, -900, 430), 50.0),
|
||||
("demo-vehicle", unreal.Vector(-12560, -1560, 560), unreal.Vector(-12950, -900, 420), 45.0),
|
||||
("demo-spawn-view", unreal.Vector(-13336, -3100, 620), unreal.Vector(-13336, 1300, 620), 48.0),
|
||||
# Street level, from the north sidewalk: the old camera sat at Y -400,
|
||||
# inside the facade body (which spans Y -1457..-200), so it looked at
|
||||
|
||||
@@ -16,14 +16,13 @@ import os
|
||||
import unreal
|
||||
|
||||
DEST = "/Game/Hollywood/Vehicle"
|
||||
SOURCE = "/tmp/gigline/vehicle"
|
||||
SOURCE = "/tmp/gigline/hatch"
|
||||
SYSTEMS = (
|
||||
("body", "SM_Vehicle_Body"),
|
||||
("trim", "SM_Vehicle_Trim"),
|
||||
("glass", "SM_Vehicle_Glass"),
|
||||
("light_front", "SM_Vehicle_Light_Front"),
|
||||
("light_rear", "SM_Vehicle_Light_Rear"),
|
||||
("mirrors", "SM_Vehicle_Mirrors"),
|
||||
("wheel", "SM_Vehicle_Wheel"),
|
||||
("lights", "SM_Vehicle_Lights"),
|
||||
("wheels", "SM_Vehicle_Wheels"),
|
||||
)
|
||||
RESULT = "/tmp/gigline/vehicle-import.txt"
|
||||
lines = []
|
||||
@@ -103,7 +102,8 @@ def main():
|
||||
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.
|
||||
# Skeletal vehicle for Chaos is not built from this template yet: the hatch is a
|
||||
# static model, and the drivable rig has to be authored around it.
|
||||
skel_path = os.path.join(SOURCE, "vehicle_skeletal.fbx")
|
||||
if os.path.exists(skel_path):
|
||||
task = unreal.AssetImportTask()
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Turn the provided hot-hatch .blend into UE-ready assets.
|
||||
|
||||
blender -b "Art/blender/vehicle/hot hatch.blend" \
|
||||
--python Tools/vehicle_from_template.py -- \
|
||||
--out /tmp/gigline/hatch --report /tmp/gigline/hatch/report.json
|
||||
|
||||
Follows two skills:
|
||||
|
||||
* **vehicle-artist** — one object per system (body, wheels, glass, lights, trim,
|
||||
mirrors), collision planned with the body, LODs planned before export.
|
||||
* **unreal-export** — scale confirmed with a known dimension, `SM_`/`UCX_` naming,
|
||||
LOD chain, and every asset measured after import.
|
||||
|
||||
The template is not to scale: its longest axis is 7.21 m, which no hot hatch is. The
|
||||
tool therefore normalises to a documented real length (default 4.05 m) and records the
|
||||
factor, rather than exporting whatever units the file happened to use. That is the
|
||||
"confirm scale with known-dimension test" step, done before the export instead of after
|
||||
the import.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import fbx_export_preset # noqa: E402
|
||||
|
||||
# A three-door hot hatch: 4.05 m long, 1.75 m wide, 1.42 m tall.
|
||||
TARGET_LENGTH_M = 4.05
|
||||
TARGET_WIDTH_M = 1.75
|
||||
TARGET_HEIGHT_M = 1.42
|
||||
|
||||
PARTS: list[str] = []
|
||||
|
||||
|
||||
def log(message: str) -> None:
|
||||
PARTS.append(message)
|
||||
print(f"[hatch] {message}")
|
||||
|
||||
|
||||
SYSTEM_RULES = (
|
||||
# (system, material substrings that identify it, object-name substrings)
|
||||
("glass", ("windows",), ("Cube.003",)),
|
||||
("lights", ("light", "reflector"), ("lights and grill", "Cylinder.003", "Cylinder.004")),
|
||||
("mirrors", ("mirror",), ("Cube.005",)),
|
||||
("wheels", ("rubber", "brake caliper", "black shiny metal"), ("Cylinder", "Cylinder.001")),
|
||||
("trim", ("plastic black", "pure black", "chrome", "black metal", "rough chrome"),
|
||||
("Cube.001", "Cube.002", "Cube.004", "Cube.006", "Cube.007", "Cylinder.002")),
|
||||
)
|
||||
|
||||
|
||||
def classify(obj):
|
||||
"""Assign an object to a system by its materials first, then by its name."""
|
||||
materials = [m.name.lower() for m in obj.data.materials if m]
|
||||
name = obj.name.lower()
|
||||
for system, materials_hint, names_hint in SYSTEM_RULES:
|
||||
if any(hint in name for hint in (n.lower() for n in names_hint)):
|
||||
return system
|
||||
if any(any(h in material for h in materials_hint) for material in materials):
|
||||
return system
|
||||
return "body"
|
||||
|
||||
|
||||
def normalise_scale(objects):
|
||||
"""Scale and centre the whole car so it matches the documented dimensions."""
|
||||
xs, ys, zs = [], [], []
|
||||
for obj in objects:
|
||||
for corner in obj.bound_box:
|
||||
world = obj.matrix_world @ __import__("mathutils").Vector(corner)
|
||||
xs.append(world.x)
|
||||
ys.append(world.y)
|
||||
zs.append(world.z)
|
||||
length = max(ys) - min(ys)
|
||||
width = max(xs) - min(xs)
|
||||
height = max(zs) - min(zs)
|
||||
log(f"template bounds: {length:.2f} x {width:.2f} x {height:.2f} m")
|
||||
factor = TARGET_LENGTH_M / length if length > 0.01 else 1.0
|
||||
log(f"scale factor {factor:.4f} (target length {TARGET_LENGTH_M} m, "
|
||||
f"was {length:.2f} m)")
|
||||
|
||||
for obj in objects:
|
||||
obj.scale = tuple(s * factor for s in obj.scale)
|
||||
obj.location = tuple(l * factor for l in obj.location)
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
# Drop the car onto Z 0 and centre it on the origin in X/Y.
|
||||
xs, ys, zs = [], [], []
|
||||
for obj in objects:
|
||||
for corner in obj.bound_box:
|
||||
world = obj.matrix_world @ __import__("mathutils").Vector(corner)
|
||||
xs.append(world.x)
|
||||
ys.append(world.y)
|
||||
zs.append(world.z)
|
||||
offset = __import__("mathutils").Vector(
|
||||
(-(min(xs) + max(xs)) / 2.0, -(min(ys) + max(ys)) / 2.0, -min(zs)))
|
||||
for obj in objects:
|
||||
obj.location = tuple(l + o for l, o in zip(obj.location, offset))
|
||||
bpy.context.view_layer.update()
|
||||
log(f"centred and grounded (offset {offset.x:.2f}, {offset.y:.2f}, {offset.z:.2f} m)")
|
||||
|
||||
|
||||
def join_system(objects, name):
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
for obj in objects:
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = objects[0]
|
||||
if len(objects) > 1:
|
||||
bpy.ops.object.join()
|
||||
joined = bpy.context.active_object
|
||||
joined.name = name
|
||||
return joined
|
||||
|
||||
|
||||
def add_collision(body):
|
||||
"""One convex box per major volume, `UCX_`-prefixed for Unreal."""
|
||||
hulls = []
|
||||
xs, ys, zs = [], [], []
|
||||
for corner in body.bound_box:
|
||||
world = body.matrix_world @ __import__("mathutils").Vector(corner)
|
||||
xs.append(world.x)
|
||||
ys.append(world.y)
|
||||
zs.append(world.z)
|
||||
length, width, height = max(ys) - min(ys), max(xs) - min(xs), max(zs) - min(zs)
|
||||
centre = ((min(xs) + max(xs)) / 2.0, (min(ys) + max(ys)) / 2.0,
|
||||
min(zs) + height * 0.5)
|
||||
bpy.ops.mesh.primitive_cube_add(size=1.0, location=centre)
|
||||
hull = bpy.context.active_object
|
||||
hull.name = "UCX_SM_Vehicle_Body_01"
|
||||
hull.scale = (width * 0.96, length * 0.98, height * 0.62)
|
||||
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
|
||||
hull.hide_render = True
|
||||
hulls.append(hull)
|
||||
log(f"collision: 1 hull {width*0.96:.2f} x {length*0.98:.2f} x {height*0.62:.2f} m")
|
||||
return hulls
|
||||
|
||||
|
||||
def decimate_copy(source, name, ratio):
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
source.select_set(True)
|
||||
bpy.context.view_layer.objects.active = source
|
||||
bpy.ops.object.duplicate()
|
||||
copy = bpy.context.active_object
|
||||
copy.name = name
|
||||
modifier = copy.modifiers.new(name="LOD", type="DECIMATE")
|
||||
modifier.ratio = ratio
|
||||
bpy.ops.object.modifier_apply(modifier=modifier.name)
|
||||
return copy
|
||||
|
||||
|
||||
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="/tmp/gigline/hatch")
|
||||
ap.add_argument("--report", default="/tmp/gigline/hatch/report.json")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
meshes = [o for o in bpy.data.objects if o.type == "MESH"]
|
||||
if not meshes:
|
||||
raise SystemExit("no meshes in the template")
|
||||
log(f"template: {len(meshes)} mesh objects, "
|
||||
f"{sum(len(o.data.vertices) for o in meshes)} vertices")
|
||||
|
||||
normalise_scale(meshes)
|
||||
|
||||
systems = {}
|
||||
for obj in meshes:
|
||||
systems.setdefault(classify(obj), []).append(obj)
|
||||
log("systems: " + ", ".join(f"{k}={len(v)} objects" for k, v in systems.items()))
|
||||
|
||||
os.makedirs(args.out, exist_ok=True)
|
||||
exported = {}
|
||||
joined = {}
|
||||
for system, objects in systems.items():
|
||||
name = {
|
||||
"body": "SM_Vehicle_Body",
|
||||
"glass": "SM_Vehicle_Glass",
|
||||
"lights": "SM_Vehicle_Lights",
|
||||
"mirrors": "SM_Vehicle_Mirrors",
|
||||
"wheels": "SM_Vehicle_Wheels",
|
||||
"trim": "SM_Vehicle_Trim",
|
||||
}[system]
|
||||
obj = join_system(objects, name)
|
||||
joined[system] = obj
|
||||
path = os.path.join(args.out, f"{system}.fbx")
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
fbx_export_preset.export_static(path, selection=True)
|
||||
exported[system] = path
|
||||
tris = sum(len(p.vertices) - 2 for p in obj.data.polygons)
|
||||
log(f"{name}: {tris} triangles -> {path}")
|
||||
|
||||
hulls = add_collision(joined["body"])
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
joined["body"].select_set(True)
|
||||
for hull in hulls:
|
||||
hull.select_set(True)
|
||||
bpy.context.view_layer.objects.active = joined["body"]
|
||||
fbx_export_preset.export_static(os.path.join(args.out, "body.fbx"), selection=True)
|
||||
log("body re-exported with its UCX hull")
|
||||
|
||||
lods = {}
|
||||
for index, ratio in ((1, 0.35), (2, 0.12)):
|
||||
lod = decimate_copy(joined["body"], f"SM_Vehicle_Body_LOD{index}", ratio)
|
||||
path = os.path.join(args.out, f"body_lod{index}.fbx")
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
lod.select_set(True)
|
||||
bpy.context.view_layer.objects.active = lod
|
||||
fbx_export_preset.export_static(path, selection=True)
|
||||
tris = sum(len(p.vertices) - 2 for p in lod.data.polygons)
|
||||
lods[f"body_lod{index}"] = {"path": path, "triangles": tris}
|
||||
log(f"SM_Vehicle_Body_LOD{index}: {tris} triangles")
|
||||
bpy.data.objects.remove(lod, do_unlink=True)
|
||||
|
||||
report = {
|
||||
"template": bpy.data.filepath,
|
||||
"target_dimensions_m": {"length": TARGET_LENGTH_M, "width": TARGET_WIDTH_M,
|
||||
"height": TARGET_HEIGHT_M},
|
||||
"systems": {system: {"object": joined[system].name,
|
||||
"triangles": sum(len(p.vertices) - 2
|
||||
for p in joined[system].data.polygons),
|
||||
"fbx": exported.get(system)}
|
||||
for system in joined},
|
||||
"lods": lods,
|
||||
"collision": [h.name for h in hulls],
|
||||
"log": PARTS,
|
||||
}
|
||||
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"[hatch] report -> {args.report}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in new issue
Block a user