feat(clothing): tee, jeans and shoes as separate skinned meshes

Follows the character-artist clothing workflow literally: duplicate the body's own
faces for the garment base, solidify for cloth thickness, and keep each piece a
separate mesh. Attached as leader-pose components beside the hair, so no second rig.

- Tools/character_clothing_build.py builds three garments from the body mesh, trimmed
  geometrically (height band plus a width limit) instead of by dominant bone: the bone
  filter left holes wherever a face's strongest bone was outside the allowed list.
- Tools/ue_character_clothing_import.py imports them as CharacterTee / CharacterJeans /
  CharacterShoes, each arriving with its own single material slot.
- The pawn attaches all three, hidden with the body in first person.

Three traps recorded in MEMORY.md, each of which cost a build: MPFB weights its helper
geometry to groups that dominate every vertex, Blender refuses to apply a modifier to a
mesh with shape keys, and object-mode polygon selection is discarded on entering edit
mode - the delete wiped the whole garment.

Known defects, in TODO.md: the garments have coverage holes at shoulders, hips and
knees, and the hair reads light and wispy since its material was repointed to the
masked strand sheet.
This commit is contained in:
zwitschi committed 2026-11-09 13:41:39 +01:00
1 parent 85a8437cbc
commit 86e9c5b693
50 files changed
+408 -46

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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -117,6 +117,30 @@ AGigLineCharacter::AGigLineCharacter()
HairMesh->SetLeaderPoseComponent(GetMesh());
}
// Garments: tee, jeans, shoes. Each is its own mesh with its own single material,
// attached to the capsule and posed from the body so there is no second rig.
static const TCHAR* GarmentPaths[] = {
TEXT("/Game/Test/Character/CharacterTee.CharacterTee"),
TEXT("/Game/Test/Character/CharacterJeans.CharacterJeans"),
TEXT("/Game/Test/Character/CharacterShoes.CharacterShoes"),
};
for (const TCHAR* Path : GarmentPaths)
{
USkeletalMeshComponent* Piece = CreateDefaultSubobject<USkeletalMeshComponent>(
FName(*FString::Printf(TEXT("Cloth_%s"), *FPaths::GetBaseFilename(Path))));
Piece->SetupAttachment(GetCapsuleComponent());
Piece->SetCollisionEnabled(ECollisionEnabled::NoCollision);
Piece->SetHiddenInGame(true);
Piece->SetRelativeLocation(FVector(0.0f, 0.0f, -87.5f));
Piece->SetRelativeRotation(FRotator(0.0f, -90.0f, 0.0f));
if (USkeletalMesh* Mesh = LoadObject<USkeletalMesh>(nullptr, Path))
{
Piece->SetSkeletalMesh(Mesh);
Piece->SetLeaderPoseComponent(GetMesh());
}
Clothing.Add(Piece);
}
// Third-person boom. bDoCollisionTest is the camera-collision feature: the arm
// shortens when something is between the pivot and the desired camera position.
ThirdPersonArm = CreateDefaultSubobject<USpringArmComponent>(TEXT("ThirdPersonArm"));
@@ -826,6 +850,13 @@ void AGigLineCharacter::ToggleCamera()
{
HairMesh->SetHiddenInGame(!bThirdPerson);
}
for (USkeletalMeshComponent* Piece : Clothing)
{
if (Piece)
{
Piece->SetHiddenInGame(!bThirdPerson);
}
}
if (bThirdPerson)
{
UpdateBodyAnimation();
@@ -166,6 +166,16 @@ protected:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Body")
TObjectPtr<USkeletalMeshComponent> HairMesh;
/**
* Garments, each its own skeletal mesh on a leader pose component.
*
* The character-artist skill keeps clothing separate so a piece can be swapped or
* re-materialled on its own; it also sidesteps the material-slot collapse that
* makes joined multi-material meshes arrive with one slot.
*/
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Body")
TArray<TObjectPtr<USkeletalMeshComponent>> Clothing;
UPROPERTY()
TObjectPtr<UAnimSequence> IdleAnimation;
+3
View File
@@ -505,3 +505,6 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
- **Segmenting the character out of a review shot does not work by colour.** The sky band, the pale wall and the dark pavement are each larger and more saturated than the body, so every size-based rule picked a backdrop blob and the first report measured the whole frame. `Tools/character_match.py` now takes an explicit `--render-box`; the proper fix is a background plate rendered without the character.
- **The report has to separate the failures it measures.** Aspect, fill, coverage and two colour distances are reported apart on purpose: an absolute similarity number between a photoreal sheet and a low-detail body would mean nothing. The first real comparison (aspect Δ1.24, mean ΔE 31.9) was dominated by the model being **unclothed** while the blueprint wears a tee and jeans.
- **`SetMaterial(0, ...)` in the pawn constructor does not visibly land** on the body mesh: `M_Character_Skin` is built and assigned, yet the render stays neutral grey. Either the slot does not exist yet at that point or the assignment is dropped; `BeginPlay` is the next thing to try.
- **MPFB vertex groups are polluted by helper geometry.** The body mesh weights its helper cubes to groups named `Left`, `Right`, `HelperGeometry` and `JointCubes` at 1.0, so a "strongest group" lookup returns a helper for most vertices - the first clothing build selected **zero** faces. Filter helper names out before asking which bone a vertex belongs to.
- **Blender refuses to apply a modifier to a mesh with shape keys.** The body carries ten; a garment copy must clear them (`shape_key_remove(all=True)`) before solidify can be applied.
- **Edit-mode selection reverts to vertex selection.** Setting `polygon.select` in object mode and then entering edit mode re-derives selection from the vertices, which were all still selected - deleting "the unselected faces" wiped the entire garment. Filter with `bmesh.ops.delete(context="FACES")` instead.
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.
+3 -1
View File
@@ -22,7 +22,9 @@ Actionable task list for this project. This file is the single, central place fo
- **The card layout now hugs the skull.** Each card is oriented by the scalp normal at its anchor — the length runs along the downward tangent, the width across it, and a small lift at the tip gives the cut volume. Three overlapping rows (7/9/10 cards, 26 total, 468 tris) replaced the single outward-pointing ring that read as a spiked crown. Overlap is what stops cards reading as separate spikes.
- **Skin still needs work.** `M_Character_Skin` (constant tone 0.62/0.42/0.33, roughness 0.58) is built and the pawn calls `SetMaterial(0, ...)`, but the body still renders neutral grey in review shots, so the assignment is not taking. The baked `T_Character_Diffuse` cannot be used either: sampling it renders the body black, so the MPFB mesh's UVs do not map onto that bake. Next: verify the slot index after `SetSkeletalMesh`, or assign in `BeginPlay` instead of the constructor.
- **Character blueprint and evaluation tools exist.** `Tools/comfyui_character_blueprint.py` generates a four-view sheet (front/side/back/head) from one description with fixed seeds; `Tools/character_match.py` compares a render against it and reports aspect, fill, coverage, mean CIE76 and palette distance plus a side-by-side montage. First result against `Screenshots/demo/review-demo-third-person-front.png`: **aspect delta 1.24, fill delta 0.12, mean ΔE 31.9, palette ΔE 25.4** — and the montage shows why: the model is a **nude body**, the blueprint is clothed (navy tee, jeans, sneakers).
- **Clothing is the largest visible gap** and is not in this patch's scope: the body carries no garments, so every colour and silhouette metric is dominated by the missing clothes rather than by skin or hair tuning.
- **Clothing exists now** (`Tools/character_clothing_build.py` + `Tools/ue_character_clothing_import.py`): tee, jeans and shoes as three separate skinned meshes, built from the body's own faces per the character-artist workflow (duplicate faces, solidify for thickness, one mesh per piece), imported as `CharacterTee` / `CharacterJeans` / `CharacterShoes` and attached as leader-pose components. The navy tee, dark jeans and off-white shoes read correctly at review distance.
- **The garments have coverage holes.** Grey skin shows through at the shoulders, hips and knees. Solidify offset (1 cm) is not enough where the body is convex, and trimming by x-width removes faces the body then shows through. Next: raise the offset, or grow the garment along its own normals after the solidify.
- **Hair reads light and wispy** since its material instance was repointed to `M_Character_Hair_Mat` (masked strand sheet): the alpha is cutting the cards into separate hairs and the base colour is being lifted by the texture. Either darken the texture or drive colour from a constant and use the sheet only as an opacity mask.
- **Figure segmentation is explicit, not automatic.** Auto-detection failed on the review shots: the backdrop (sky band, pale wall, dark pavement) is larger and more saturated than the subject, so every size-based rule picked a backdrop blob. `--render-box` takes the figure box as fractions of the frame. A background plate rendered without the character would make this automatic.
- `Tools/ue_character_materials.py` (wiring the generated textures into the skin and hair materials) **segfaults in the headless commandlet** when it edits the material graph. Not wired; the hair keeps the dark base colour it carries from Blender. Needs either an editor-session run or a material asset built from scratch instead of edited in place.
+218
View File
@@ -0,0 +1,218 @@
#!/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.235, # trims the bare arms out of the tee
"use_bones": False,
"thickness": 0.009,
"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.30,
"use_bones": False,
"thickness": 0.010,
"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)
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())
+71
View File
@@ -0,0 +1,71 @@
# Import the garment meshes as separate skeletal meshes (0.2.2).
#
# blender -b Art/blender/character/character-2026-11-07.blend \
# --python Tools/character_clothing_build.py -- --out /tmp/gigline/clothing
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
# -script=<repo>/Tools/ue_character_clothing_import.py -nullrhi -unattended -nopause -stdout
#
# Each garment is its own asset, per the character-artist skill's rule that clothing
# stays separate so it can be swapped or re-materialled. The pawn attaches each one as
# a component using the body as the leader pose.
#
# Result file: /tmp/gigline/character-clothing-import.txt
import unreal
DEST = "/Game/Test/Character"
SOURCE = "/tmp/gigline/clothing"
GARMENTS = (("tee", "CharacterTee"), ("jeans", "CharacterJeans"), ("shoes", "CharacterShoes"))
RESULT = "/tmp/gigline/character-clothing-import.txt"
lines = []
def log(msg):
lines.append(str(msg))
unreal.log(str(msg))
def main():
for source, name in GARMENTS:
task = unreal.AssetImportTask()
task.filename = f"{SOURCE}/{source}.fbx"
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 = True
ui.import_animations = False
ui.import_materials = True
ui.import_textures = False
ui.skeletal_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
mats = mesh.get_editor_property("materials")
log(f"{name}: imported, {len(mats)} slot(s): "
+ ", ".join((m.get_editor_property("material_interface").get_name()
if m.get_editor_property("material_interface") else "None")
for m in mats))
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")