feat(tex): ComfyUI tool, procedural character textures, hair cap behind a flag
Tools/comfyui_generate.py drives the local ComfyUI instance for generated textures and writes a sidecar with model, prompt, seed and workflow, so a generated asset can be regenerated or rejected instead of being an unexplained PNG. Models list and generation both verified against 127.0.0.1:8188. Stable Diffusion is not usable for flat skin swatches, and that is worth recording: three attempts produced an anatomical close-up twice and a leather-scale surface once. Skin detail is generated procedurally instead - tileable octave noise for pores, plus a strand sheet for hair - in Tools/generate_character_textures.py, with sidecars. Hair geometry exists (cap conforming to the head, weighted to the head bone, second material slot in Blender) but Unreal's import collapses the second material slot, so the cap renders with the skin material and reads as a bald skull. It is behind --hair and off; the next step is a separate skeletal mesh attached as a second component.
This commit is contained in:
1 parent
89a9c59747
commit
54c579ab1a
19 files changed
+494
-5
No files matched your search
@@ -127,6 +127,54 @@ def log(msg):
|
||||
print(f"[anim] {msg}")
|
||||
|
||||
|
||||
HAIR_MATERIAL = "M_Character_Hair"
|
||||
|
||||
|
||||
def add_hair_cap(arm, mesh):
|
||||
"""Add a short-hair cap conforming to the head, skinned to the head bone.
|
||||
|
||||
The character exported from MPFB is bald, and a bald head reads as a mannequin
|
||||
before anything else does. A cap plus a strand texture is enough at review
|
||||
distance; hair cards and a groomed cut are a later pass.
|
||||
"""
|
||||
head = arm.data.bones.get("head")
|
||||
if head is None or mesh is None:
|
||||
log("hair: no head bone or no mesh - skipped")
|
||||
return None
|
||||
|
||||
centre = (head.head_local + head.tail_local) * 0.5
|
||||
radius = max(0.085, (head.tail_local - head.head_local).length * 0.62)
|
||||
bpy.ops.mesh.primitive_uv_sphere_add(segments=24, ring_count=14, radius=radius,
|
||||
location=(centre.x, centre.y, centre.z + 0.012))
|
||||
cap = bpy.context.active_object
|
||||
cap.name = "HairCap"
|
||||
# Keep the upper hemisphere plus a little: a full sphere reads as a helmet.
|
||||
cut_z = centre.z - radius * 0.12
|
||||
bpy.ops.object.mode_set(mode="EDIT")
|
||||
bpy.ops.mesh.select_all(action="SELECT")
|
||||
bpy.ops.mesh.bisect(plane_co=(0.0, 0.0, cut_z), plane_no=(0.0, 0.0, 1.0),
|
||||
clear_inner=True, use_fill=False)
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
# Slightly ovoid, wider than deep, sitting back on the skull.
|
||||
cap.scale = (1.06, 1.10, 0.92)
|
||||
|
||||
hair_mat = bpy.data.materials.get(HAIR_MATERIAL) or bpy.data.materials.new(HAIR_MATERIAL)
|
||||
hair_mat.use_nodes = True
|
||||
bsdf = hair_mat.node_tree.nodes.get("Principled BSDF")
|
||||
if bsdf:
|
||||
bsdf.inputs["Base Color"].default_value = (0.055, 0.038, 0.030, 1.0)
|
||||
bsdf.inputs["Roughness"].default_value = 0.42
|
||||
cap.data.materials.append(hair_mat)
|
||||
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
cap.select_set(True)
|
||||
mesh.select_set(True)
|
||||
bpy.context.view_layer.objects.active = mesh
|
||||
bpy.ops.object.join()
|
||||
log(f"hair: cap added at head centre, radius {radius:.3f} m, material {HAIR_MATERIAL}")
|
||||
return mesh
|
||||
|
||||
|
||||
def clear_animation(arm):
|
||||
"""Drop any pre-existing actions so a rerun does not stack clips."""
|
||||
for action in list(bpy.data.actions):
|
||||
@@ -317,6 +365,10 @@ def main(argv=None) -> int:
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--out", default="/tmp/gigline/character-anim.fbx")
|
||||
ap.add_argument("--report", default=None)
|
||||
# Hair is built but not exported yet: Unreal's import collapses the second
|
||||
# material slot, so the cap renders with the skin material and reads as a bald
|
||||
# skull. Off until the hair is a separate asset (see TODO.md).
|
||||
ap.add_argument("--hair", action="store_true")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
arm = next((o for o in bpy.data.objects if o.type == "ARMATURE"), None)
|
||||
@@ -332,6 +384,18 @@ def main(argv=None) -> int:
|
||||
idle = clip_idle(arm, CLIPS["Idle"])
|
||||
walk = clip_walk(arm, CLIPS["Walk"])
|
||||
|
||||
# Hair before export so the cap is part of the skinned mesh; a separate asset
|
||||
# would need its own bone attachment and a second material assignment.
|
||||
verts_before = len(mesh.data.vertices) if mesh else 0
|
||||
if args.hair:
|
||||
add_hair_cap(arm, mesh)
|
||||
else:
|
||||
log("hair: skipped (--hair to include; blocked on material slots, see TODO.md)")
|
||||
if mesh and len(mesh.data.vertices) > verts_before:
|
||||
group = mesh.vertex_groups.get("head") or mesh.vertex_groups.new(name="head")
|
||||
group.add(list(range(verts_before, len(mesh.data.vertices))), 1.0, "REPLACE")
|
||||
log(f"hair: {len(mesh.data.vertices) - verts_before} vertices weighted to 'head'")
|
||||
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
# Export armature + mesh together: the preset's character flags carry the
|
||||
# bake_anim settings that make Unreal create AnimSequences.
|
||||
|
||||
Reference in new issue
Block a user