#!/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, along, across, normal, width, height, lift, segments=4): """A card that lies ALONG the scalp rather than sticking out of it. `along` is the direction the hair falls over the skull (the downward tangent at the anchor), `across` the card's width direction and `normal` the scalp normal. The first version passed a fixed global up and an outward vector, which made every card stand away from the head - the spiked crown in the review shot. """ 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 w = width * (1.0 - 0.65 * t) # Follow the skull, then lift a little at the tip so the cut has volume. base = anchor + along * (height * t) + normal * (lift * math.sin(t * math.pi * 0.7)) left = base - across * w right = base + across * 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)] down = Vector((0.0, 0.0, -1.0)) # Two rows: an inner row that covers the crown and an outer row that overlaps it # down to the hairline. Overlap is what stops the cards reading as separate spikes. rings = ( # count, radius scale, anchor height, length, width, lift (7, 0.55, 0.80, 0.085, 0.30, 0.25), (9, 0.72, 0.62, 0.070, 0.26, 0.18), (10, 0.86, 0.42, 0.055, 0.24, 0.12), ) index = 0 for ring, (count, radius_scale, height_scale, length, width_scale, lift_scale) in enumerate(rings): for i in range(count): angle = math.tau * (i + 0.5 * ring) / count horizontal = Vector((math.cos(angle), math.sin(angle), 0.0)) normal = (horizontal * radius_scale + Vector((0.0, 0.0, height_scale))).normalized() anchor = Vector((centre.x, centre.y, centre.z)) + normal * radius # Downward tangent: the part of -Z that lies in the scalp's tangent plane. along = (down - normal * down.dot(normal)).normalized() across = along.cross(normal).normalized() pieces.append(card(f"HairCard_{index}", anchor, along, across, normal, width=radius * width_scale, height=radius * length * 10.0, lift=radius * lift_scale)) 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())