Files
gig-line/Tools/character_hair_build.py
T
zwitschi 39f07b0f5f feat(hair): hair cards as their own skinned mesh, attached as a second component
Follows the character-artist skill on two points it is explicit about: hair for a
game character is a card system, and swappable parts stay separate meshes.

- Tools/character_hair_build.py builds SM_Char_Hair: a scalp cap plus 18 cards in
  three rings, quads only, every vertex weighted 1.0 to the head bone, UV-mapped for
  the strand sheet from Tools/generate_character_textures.py. 321 verts / 404 tris.
- Tools/ue_character_hair_import.py imports it as its own skeletal mesh, which is the
  point: joined into the body, Unreal collapses the second material slot and the hair
  renders with the skin material.
- The pawn carries it as a second USkeletalMeshComponent on SetLeaderPoseComponent, so
  it follows the head bone with no second rig and hides with the body in first person.

Still wrong and recorded: the cards point outward, so the silhouette reads as a spiked
crown rather than a short cut. The next pass orients them tangentially along the
skull with a second overlapping row. Tools/ue_character_materials.py also segfaults in
the headless commandlet when editing a material graph, so the generated textures are
not wired yet and the hair keeps its Blender base colour.
2026-11-09 13:18:40 +01:00

214 lines
8.3 KiB
Python

#!/usr/bin/env python3
"""Build the hair as its own skinned mesh — hair cards over a scalp cap.
The `character-artist` skill is explicit that hair for a game character is a card
system and that swappable parts stay separate meshes. Joining hair into the body mesh
was tried first and failed for an engine reason: Unreal collapses the second material
slot on a skeletal-mesh import, so the cap rendered with the skin material. A separate
object sidesteps that and is what the skill calls for anyway.
blender -b Art/blender/character/character-2026-11-07.blend \
--python Tools/character_hair_build.py -- \
--out /tmp/gigline/character-hair.fbx --report /tmp/gigline/character-hair.json
What it builds:
SM_Char_Hair scalp cap + 18 cards in three rings, UV-mapped to the strand sheet
from Tools/generate_character_textures.py, every vertex weighted
to `head` at 1.0 so it follows the head without a second rig.
Cards are built with a taper and a slight outward curl so the silhouette is not a
shell; the strand texture runs vertically down each card, which is the direction
hair actually reads in. Topology is quads only (the skill's animation-ready rule),
and there are no poles on the deformation surface because the whole mesh is rigidly
weighted to one bone.
"""
from __future__ import annotations
import argparse
import json
import math
import os
import sys
import bpy
from mathutils import Vector
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import fbx_export_preset as fbx_preset # noqa: E402
HAIR_NAME = "SM_Char_Hair"
CARD_RINGS = 3 # rings of cards around the skull
CARDS_INNER = 6
CARDS_MIDDLE = 7
CARDS_OUTER = 5
CARD_HEIGHT = 0.072 # m: short, close cut - taller cards spiked up
CAP_THICKNESS = 0.004
PARTS: list[dict] = []
def log(message: str) -> None:
PARTS.append(message)
print(f"[hair] {message}")
def hair_material() -> bpy.types.Material:
mat = bpy.data.materials.get("M_Character_Hair") or bpy.data.materials.new("M_Character_Hair")
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
if bsdf:
# Dark brown, low roughness: strands catch a highlight. The strand texture is
# sampled in Unreal, so Blender only needs a sane base for the FBX material.
bsdf.inputs["Base Color"].default_value = (0.052, 0.036, 0.028, 1.0)
bsdf.inputs["Roughness"].default_value = 0.38
if "Alpha" in bsdf.inputs:
bsdf.inputs["Alpha"].default_value = 1.0
mat.use_backface_culling = False
return mat
def card(name, anchor, outward, up, width, height, curl, segments=4):
"""One curved card: a strip of quads that arcs away from the scalp."""
mesh = bpy.data.meshes.new(name)
obj = bpy.data.objects.new(name, mesh)
bpy.context.scene.collection.objects.link(obj)
verts = []
faces = []
for i in range(segments + 1):
t = i / segments
# Taper to a point at the tip and curl outward as the strand falls.
w = width * (1.0 - 0.75 * t)
h = height * t
bulge = curl * math.sin(t * math.pi * 0.85)
base = anchor + up * h + outward * bulge
left = base - outward.cross(up).normalized() * w
right = base + outward.cross(up).normalized() * w
verts.append(left)
verts.append(right)
for i in range(segments):
a = i * 2
faces.append((a, a + 1, a + 3, a + 2))
mesh.from_pydata(verts, [], faces)
mesh.update()
return obj
def scalp_cap(centre, radius, material):
bpy.ops.mesh.primitive_uv_sphere_add(segments=20, ring_count=12, radius=radius,
location=centre)
cap = bpy.context.active_object
cap.name = "HairScalp"
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action="SELECT")
bpy.ops.mesh.bisect(plane_co=(0.0, 0.0, centre.z - radius * 0.15),
plane_no=(0.0, 0.0, 1.0), clear_inner=True, use_fill=False)
bpy.ops.object.mode_set(mode="OBJECT")
cap.scale = (1.03, 1.06, 0.86)
cap.data.materials.append(material)
return cap
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/character-hair.fbx")
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)
if arm is None:
raise SystemExit("no armature in the .blend")
head = arm.data.bones.get("head")
if head is None:
raise SystemExit("no 'head' bone")
centre = (head.head_local + head.tail_local) * 0.5
radius = max(0.088, (head.tail_local - head.head_local).length * 0.60)
log(f"head centre {[round(c, 3) for c in centre]}, radius {radius:.3f} m")
material = hair_material()
pieces = [scalp_cap(Vector((centre.x, centre.y, centre.z + 0.012)), radius, material)]
up = Vector((0.0, 0.0, 1.0))
rings = ((CARDS_INNER, 1.00, 0.70), (CARDS_MIDDLE, 0.90, 0.85), (CARDS_OUTER, 0.78, 1.0))
index = 0
for ring, (count, radius_scale, length_scale) in enumerate(rings):
for i in range(count):
angle = math.tau * (i + 0.5 * ring) / count
outward = Vector((math.cos(angle), math.sin(angle), 0.22)).normalized()
anchor = Vector((
centre.x + outward.x * radius * radius_scale * 0.92,
centre.y + outward.y * radius * radius_scale * 0.92,
centre.z + radius * (0.52 - 0.16 * ring),
))
pieces.append(card(f"HairCard_{index}", anchor, outward, up,
width=radius * 0.30, height=CARD_HEIGHT * length_scale,
curl=radius * 0.34))
index += 1
log(f"built {len(pieces)} pieces: 1 scalp cap + {index} cards")
# Join into one object and weight every vertex to the head bone.
bpy.ops.object.select_all(action="DESELECT")
for piece in pieces:
piece.select_set(True)
bpy.context.view_layer.objects.active = pieces[0]
bpy.ops.object.join()
hair = bpy.context.active_object
hair.name = HAIR_NAME
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
hair.data.materials.clear()
hair.data.materials.append(material)
group = hair.vertex_groups.new(name="head")
group.add(list(range(len(hair.data.vertices))), 1.0, "REPLACE")
# UVs: a simple planar unwrap is enough because the strand texture is directional.
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action="SELECT")
bpy.ops.uv.smart_project(angle_limit=1.15, island_margin=0.01)
bpy.ops.object.mode_set(mode="OBJECT")
quads = sum(1 for p in hair.data.polygons if len(p.vertices) == 4)
tris = sum(len(p.vertices) - 2 for p in hair.data.polygons)
log(f"{HAIR_NAME}: {len(hair.data.vertices)} verts, {quads} quads, {tris} tris, "
f"{len(hair.data.materials)} material slot(s)")
# Parent to the armature so the export carries the skin.
modifier = hair.modifiers.new(name="Armature", type="ARMATURE")
modifier.object = arm
bpy.ops.object.select_all(action="DESELECT")
arm.select_set(True)
hair.select_set(True)
bpy.context.view_layer.objects.active = arm
os.makedirs(os.path.dirname(args.out), exist_ok=True)
fbx_preset.export_character(args.out, selection=True)
log(f"exported {args.out}")
if args.report:
with open(args.report, "w") as handle:
json.dump({
"asset": HAIR_NAME,
"head_centre_m": [round(c, 4) for c in centre],
"head_radius_m": round(radius, 4),
"cards": index,
"rings": [r[0] for r in rings],
"vertices": len(hair.data.vertices),
"quads": quads,
"triangles": tris,
"material": material.name,
"texture": "Art/textures/generated/T_Character_Hair.png",
"weighting": "all vertices 1.0 on 'head'",
"log": PARTS,
}, handle, indent=2)
print(f"[hair] report -> {args.report}")
return 0
if __name__ == "__main__":
raise SystemExit(main())