Solidify thickness went from 1 cm to 2 cm and the shell is fattened 8 mm along its own normals afterwards. That closed the shoulders, hips and knees where the body was showing through the cloth. Evaluation against the blueprint, same shot and box before and after: mean deltaE 31.9 -> 21.8 palette deltaE 25.4 -> 19.7 aspect delta 1.24 -> 1.22 Also fixes a crash I introduced: binding the materials in Tick used ConstructorHelpers::FObjectFinder, which is fatal outside a constructor - the game died with 'FObjectFinders can't be used outside of constructors' and the render run failed with signal 11. The binding now lives in BeginPlay and uses LoadObject. Not fixed, and recorded: the arms and face still render neutral grey, so M_Character_Skin itself is suspect rather than the assignment.
232 lines
8.6 KiB
Python
232 lines
8.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Build the character's clothing as separate skinned meshes.
|
|
|
|
The `character-artist` skill's clothing workflow, followed literally: duplicate body
|
|
faces for the clothing base, solidify for cloth thickness, and keep each piece a
|
|
separate mesh so it can be swapped or re-materialled on its own.
|
|
|
|
blender -b Art/blender/character/character-2026-11-07.blend \
|
|
--python Tools/character_clothing_build.py -- \
|
|
--out /tmp/gigline/clothing --report /tmp/gigline/clothing.json
|
|
|
|
Outputs one FBX per garment (`tee`, `jeans`, `shoes`), each carrying the character
|
|
armature so Unreal imports it as a skeletal mesh with the body's skeleton.
|
|
|
|
Selection is by **dominant vertex group**, not by height band alone: a z-range band
|
|
would swallow the arms hanging at the sides, and the arms have to stay bare below the
|
|
cap sleeves. Each garment lists the bones whose weights it accepts.
|
|
"""
|
|
|
|
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 as fbx_preset # noqa: E402
|
|
|
|
GARMENTS = (
|
|
{
|
|
"name": "tee",
|
|
"asset": "SM_Char_Cloth_Tee",
|
|
"bones": ["spine01", "spine02", "spine03", "spine04", "spine05",
|
|
"clavicle.L", "clavicle.R", "shoulder01.L", "shoulder01.R",
|
|
"upperarm01.L", "upperarm01.R", "breast.L", "breast.R",
|
|
"neck01", "neck02", "pelvis.L", "pelvis.R"],
|
|
"z_min": 0.90, "z_max": 1.44,
|
|
"x_max": 0.255, # trims the bare arms out of the tee
|
|
"use_bones": False,
|
|
"thickness": 0.020,
|
|
"colour": (0.020, 0.032, 0.075, 1.0), # navy cotton
|
|
"roughness": 0.78,
|
|
},
|
|
{
|
|
"name": "jeans",
|
|
"asset": "SM_Char_Cloth_Jeans",
|
|
"bones": ["pelvis.L", "pelvis.R", "upperleg01.L", "upperleg01.R",
|
|
"upperleg02.L", "upperleg02.R", "lowerleg01.L", "lowerleg01.R",
|
|
"lowerleg02.L", "lowerleg02.R", "spine01"],
|
|
"z_min": 0.06, "z_max": 1.06,
|
|
"x_max": 0.32,
|
|
"use_bones": False,
|
|
"thickness": 0.020,
|
|
"colour": (0.032, 0.055, 0.105, 1.0), # dark denim
|
|
"roughness": 0.62,
|
|
},
|
|
{
|
|
"name": "shoes",
|
|
"asset": "SM_Char_Cloth_Shoes",
|
|
"bones": ["foot.L", "foot.R", "toe1-1.L", "toe1-1.R",
|
|
"lowerleg02.L", "lowerleg02.R"],
|
|
"z_min": -0.05, "z_max": 0.16,
|
|
"x_max": 0.40,
|
|
"use_bones": False,
|
|
"thickness": 0.012,
|
|
"colour": (0.72, 0.71, 0.68, 1.0), # off-white canvas
|
|
"roughness": 0.55,
|
|
},
|
|
)
|
|
|
|
PARTS: list[str] = []
|
|
|
|
|
|
def log(message: str) -> None:
|
|
PARTS.append(message)
|
|
print(f"[cloth] {message}")
|
|
|
|
|
|
# MPFB ships helper geometry in the same mesh, weighted 1.0 to groups called
|
|
# 'Left', 'Right', 'HelperGeometry' and 'JointCubes'. Those dominate the raw weights,
|
|
# so the first build selected zero faces: every vertex's strongest group was a helper.
|
|
_HELPER_PREFIXES = ("helper", "joint", "Helper", "Joint")
|
|
_HELPER_NAMES = {"Left", "Right", "Mid", "body", "HelperGeometry", "JointCubes"}
|
|
|
|
|
|
def is_deform_bone(name: str) -> bool:
|
|
return bool(name) and not name.startswith(_HELPER_PREFIXES) and name not in _HELPER_NAMES
|
|
|
|
|
|
def dominant_groups(mesh) -> dict[int, str]:
|
|
"""Vertex index -> its strongest *deforming bone* group, ignoring helpers."""
|
|
index_to_name = {g.index: g.name for g in mesh.vertex_groups}
|
|
result = {}
|
|
for vertex in mesh.data.vertices:
|
|
candidates = [(g.weight, index_to_name.get(g.group, "")) for g in vertex.groups]
|
|
candidates = [(w, n) for w, n in candidates if is_deform_bone(n)]
|
|
if not candidates:
|
|
continue
|
|
result[vertex.index] = max(candidates)[1]
|
|
return result
|
|
|
|
|
|
def build_garment(body, arm, spec, out_dir):
|
|
bpy.ops.object.select_all(action="DESELECT")
|
|
body.select_set(True)
|
|
bpy.context.view_layer.objects.active = body
|
|
bpy.ops.object.duplicate()
|
|
garment = bpy.context.active_object
|
|
garment.name = spec["asset"]
|
|
|
|
allowed = set(spec["bones"])
|
|
dominant = dominant_groups(body)
|
|
|
|
mesh = garment.data
|
|
# Filter with bmesh instead of edit-mode selection. Setting polygon.select in
|
|
# object mode and then entering edit mode re-derives selection from the vertices,
|
|
# which were still all selected - the delete wiped the whole garment every time.
|
|
import bmesh
|
|
bm = bmesh.new()
|
|
bm.from_mesh(mesh)
|
|
doomed = []
|
|
for face in bm.faces:
|
|
centre_z = sum(v.co.z for v in face.verts) / len(face.verts)
|
|
groups = [dominant.get(v.index, "") for v in face.verts]
|
|
centre_x = abs(sum(v.co.x for v in face.verts) / len(face.verts))
|
|
keep = (spec["z_min"] <= centre_z <= spec["z_max"]
|
|
and centre_x <= spec.get("x_max", 10.0)
|
|
and (not spec.get("use_bones") or any(g in allowed for g in groups)))
|
|
face.tag = keep
|
|
if not keep:
|
|
doomed.append(face)
|
|
bmesh.ops.delete(bm, geom=doomed, context="FACES")
|
|
bm.to_mesh(mesh)
|
|
bm.free()
|
|
mesh.update()
|
|
log(f"{spec['asset']}: kept {len(mesh.polygons)} faces")
|
|
|
|
if not mesh.polygons:
|
|
log(f"{spec['asset']}: no faces selected - check the bone list and z range")
|
|
return None
|
|
|
|
# The body carries MPFB shape keys, and Blender refuses to apply a modifier to a
|
|
# mesh that has any. A garment does not need the morph targets.
|
|
if garment.data.shape_keys:
|
|
bpy.ops.object.shape_key_remove(all=True)
|
|
|
|
# Cloth thickness: solidify outward so the garment sits over the body rather
|
|
# than inside it.
|
|
solidify = garment.modifiers.new(name="Cloth", type="SOLIDIFY")
|
|
solidify.thickness = spec["thickness"]
|
|
solidify.offset = 1.0
|
|
bpy.ops.object.modifier_apply(modifier=solidify.name)
|
|
|
|
# Push the shell out along its own normals. Solidify alone left the body visible
|
|
# through the garment at every convex patch - shoulders, hips, knees.
|
|
import bmesh
|
|
bm = bmesh.new()
|
|
bm.from_mesh(mesh)
|
|
bm.normal_update()
|
|
for vertex in bm.verts:
|
|
vertex.co += vertex.normal * spec.get("fatten", 0.008)
|
|
bm.to_mesh(mesh)
|
|
bm.free()
|
|
mesh.update()
|
|
log(f"{spec['asset']}: fattened by {spec.get('fatten', 0.008):.3f} m")
|
|
|
|
material = bpy.data.materials.get(f"M_Char_{spec['name']}") \
|
|
or bpy.data.materials.new(f"M_Char_{spec['name']}")
|
|
material.use_nodes = True
|
|
bsdf = material.node_tree.nodes.get("Principled BSDF")
|
|
if bsdf:
|
|
bsdf.inputs["Base Color"].default_value = spec["colour"]
|
|
bsdf.inputs["Roughness"].default_value = spec["roughness"]
|
|
garment.data.materials.clear()
|
|
garment.data.materials.append(material)
|
|
|
|
bpy.ops.object.select_all(action="DESELECT")
|
|
arm.select_set(True)
|
|
garment.select_set(True)
|
|
bpy.context.view_layer.objects.active = arm
|
|
path = os.path.join(out_dir, f"{spec['name']}.fbx")
|
|
fbx_preset.export_character(path, selection=True)
|
|
|
|
stats = {
|
|
"asset": spec["asset"],
|
|
"file": path,
|
|
"vertices": len(garment.data.vertices),
|
|
"triangles": sum(len(p.vertices) - 2 for p in garment.data.polygons),
|
|
"thickness_m": spec["thickness"],
|
|
"bones": spec["bones"],
|
|
"z_range": [spec["z_min"], spec["z_max"]],
|
|
"colour": spec["colour"],
|
|
"roughness": spec["roughness"],
|
|
}
|
|
log(f"{spec['asset']}: {stats['vertices']} verts, {stats['triangles']} tris -> {path}")
|
|
return stats
|
|
|
|
|
|
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/clothing")
|
|
ap.add_argument("--report", default=None)
|
|
args = ap.parse_args(argv)
|
|
|
|
arm = next((o for o in bpy.data.objects if o.type == "ARMATURE"), None)
|
|
body = next((o for o in bpy.data.objects if o.type == "MESH" and o.parent == arm), None)
|
|
if arm is None or body is None:
|
|
raise SystemExit("need the character armature and its body mesh")
|
|
log(f"body {body.name}: {len(body.data.vertices)} verts")
|
|
|
|
os.makedirs(args.out, exist_ok=True)
|
|
built = []
|
|
for spec in GARMENTS:
|
|
stats = build_garment(body, arm, spec, args.out)
|
|
if stats:
|
|
built.append(stats)
|
|
|
|
if args.report:
|
|
with open(args.report, "w") as handle:
|
|
json.dump({"garments": built, "log": PARTS}, handle, indent=2)
|
|
print(f"[cloth] report -> {args.report}")
|
|
return 0 if len(built) == len(GARMENTS) else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|