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.
This commit is contained in:
1 parent
72d00bdfbd
commit
39f07b0f5f
46 files changed
+519
-47
No files matched your search
@@ -0,0 +1,213 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,70 @@
|
||||
# Import the hair cards as their own skeletal mesh (0.2.2).
|
||||
#
|
||||
# blender -b Art/blender/character/character-2026-11-07.blend \
|
||||
# --python Tools/character_hair_build.py -- --out /tmp/gigline/character-hair.fbx
|
||||
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
|
||||
# -script=<repo>/Tools/ue_character_hair_import.py -nullrhi -unattended -nopause -stdout
|
||||
#
|
||||
# The hair is a separate asset on purpose: Unreal collapses the second material slot
|
||||
# when hair is joined into the body mesh, so a card system has to be its own object
|
||||
# (which is what the character-artist skill asks for anyway).
|
||||
#
|
||||
# Result file: /tmp/gigline/character-hair-import.txt
|
||||
|
||||
import unreal
|
||||
|
||||
SRC = "/tmp/gigline/character-hair.fbx"
|
||||
DEST = "/Game/Test/Character"
|
||||
NAME = "CharacterHair"
|
||||
RESULT = "/tmp/gigline/character-hair-import.txt"
|
||||
lines = []
|
||||
|
||||
|
||||
def log(msg):
|
||||
lines.append(str(msg))
|
||||
unreal.log(str(msg))
|
||||
|
||||
|
||||
def main():
|
||||
task = unreal.AssetImportTask()
|
||||
task.filename = SRC
|
||||
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"MISSING {DEST}/{NAME}")
|
||||
lines.append("VERDICT: FAIL")
|
||||
return
|
||||
mats = mesh.get_editor_property("materials")
|
||||
log(f"{NAME}: {len(mats)} material slot(s)")
|
||||
for i, m in enumerate(mats):
|
||||
face = m.get_editor_property("material_interface")
|
||||
log(f" slot {i}: {face.get_name() if face else 'None'}")
|
||||
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")
|
||||
@@ -0,0 +1,138 @@
|
||||
# Wire the generated character textures into Unreal materials (0.2.2).
|
||||
#
|
||||
# python3 Tools/generate_character_textures.py --out Art/textures/generated
|
||||
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
|
||||
# -script=<repo>/Tools/ue_character_materials.py -nullrhi -unattended -nopause -stdout
|
||||
#
|
||||
# Two materials, both edited IN PLACE so the imported meshes keep their references:
|
||||
#
|
||||
# M_Character_Export skin: diffuse x generated pore detail, roughness variation
|
||||
# M_Character_Hair hair: strand texture colour + alpha, masked and two-sided
|
||||
#
|
||||
# Editing in place matters: `unreal.SkeletalMesh` has no `set_material`, so a mesh
|
||||
# that already points at a material cannot be re-slotted from Python. Changing the
|
||||
# material asset is the only lever.
|
||||
#
|
||||
# Result file: /tmp/gigline/character-materials.txt
|
||||
|
||||
import os
|
||||
|
||||
import unreal
|
||||
|
||||
# The commandlet's working directory is the engine, not the repo, so resolve the
|
||||
# texture folder from the project path instead of relying on the cwd.
|
||||
_REPO = os.path.abspath(os.path.join(unreal.Paths.project_dir(), "..", ".."))
|
||||
TEXTURE_DIR = os.path.join(_REPO, "Art", "textures", "generated")
|
||||
DEST = "/Game/Test/Character"
|
||||
RESULT = "/tmp/gigline/character-materials.txt"
|
||||
lines = []
|
||||
|
||||
|
||||
def log(msg):
|
||||
lines.append(str(msg))
|
||||
unreal.log(str(msg))
|
||||
|
||||
|
||||
def import_texture(filename):
|
||||
path = os.path.join(TEXTURE_DIR, filename)
|
||||
if not os.path.exists(path):
|
||||
log(f"missing {path}")
|
||||
return None
|
||||
task = unreal.AssetImportTask()
|
||||
task.filename = path
|
||||
task.destination_path = DEST
|
||||
task.destination_name = os.path.splitext(filename)[0]
|
||||
task.automated = True
|
||||
task.save = True
|
||||
task.replace_existing = True
|
||||
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
|
||||
texture = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{task.destination_name}")
|
||||
log(f"texture {task.destination_name}: {'ok' if texture else 'MISSING'}")
|
||||
return texture
|
||||
|
||||
|
||||
def clear_graph(material):
|
||||
"""Remove every expression so a rerun does not stack nodes."""
|
||||
lib = unreal.MaterialEditingLibrary
|
||||
for node in list(lib.get_material_expressions(material)):
|
||||
lib.delete_material_expression(material, node)
|
||||
|
||||
|
||||
def textured_material(mat, texture, roughness, tint=None, masked=False):
|
||||
"""Base colour from a texture, optional detail multiply, roughness constant."""
|
||||
lib = unreal.MaterialEditingLibrary
|
||||
clear_graph(mat)
|
||||
|
||||
tex = lib.create_material_expression(mat, unreal.MaterialExpressionTextureSample, -450, 0)
|
||||
tex.texture = texture
|
||||
if masked:
|
||||
mat.set_editor_property("blend_mode", unreal.BlendMode.BLEND_MASKED)
|
||||
mat.set_editor_property("two_sided", True)
|
||||
lib.connect_material_property(tex, "A", unreal.MaterialProperty.MP_OPACITY_MASK)
|
||||
if tint is not None:
|
||||
mul = lib.create_material_expression(mat, unreal.MaterialExpressionMultiply, -220, 40)
|
||||
mul.set_editor_property("const_b", unreal.LinearColor(*tint, 1.0))
|
||||
lib.connect_material_expressions(tex, "RGB", mul, "A")
|
||||
lib.connect_material_property(mul, "", unreal.MaterialProperty.MP_BASE_COLOR)
|
||||
else:
|
||||
lib.connect_material_property(tex, "RGB", unreal.MaterialProperty.MP_BASE_COLOR)
|
||||
|
||||
rough = lib.create_material_expression(mat, unreal.MaterialExpressionConstant, -220, -180)
|
||||
rough.set_editor_property("r", roughness)
|
||||
lib.connect_material_property(rough, "", unreal.MaterialProperty.MP_ROUGHNESS)
|
||||
lib.recompile_material(mat)
|
||||
log(f"{mat.get_name()}: texture {texture.get_name()}, roughness {roughness}"
|
||||
+ (", masked two-sided" if masked else ""))
|
||||
|
||||
|
||||
def main():
|
||||
skin_detail = import_texture("T_Character_SkinDetail.png")
|
||||
hair_strands = import_texture("T_Character_Hair.png")
|
||||
|
||||
skin = unreal.EditorAssetLibrary.load_asset(f"{DEST}/M_Character_Export")
|
||||
if skin and skin_detail:
|
||||
# The character's own diffuse stays the base; the generated pore sheet is a
|
||||
# detail multiplier, so the warm skin tone from the bake is preserved.
|
||||
lib = unreal.MaterialEditingLibrary
|
||||
clear_graph(skin)
|
||||
diffuse = lib.create_material_expression(skin, unreal.MaterialExpressionTextureSample, -700, 0)
|
||||
diffuse.texture = unreal.EditorAssetLibrary.load_asset(f"{DEST}/T_Character_Diffuse")
|
||||
detail = lib.create_material_expression(skin, unreal.MaterialExpressionTextureSample, -700, 300)
|
||||
detail.texture = skin_detail
|
||||
detail.sampler_type = unreal.MaterialSamplerType.SAMPLERTYPE_LINEAR_COLOR
|
||||
mul = lib.create_material_expression(skin, unreal.MaterialExpressionMultiply, -380, 120)
|
||||
lib.connect_material_expressions(diffuse, "RGB", mul, "A")
|
||||
lib.connect_material_expressions(detail, "RGB", mul, "B")
|
||||
# Detail is a multiplier, so keep it near 1 and let it vary a little.
|
||||
lerp = lib.create_material_expression(skin, unreal.MaterialExpressionLinearInterpolate, -200, 120)
|
||||
lerp.set_editor_property("const_a", unreal.LinearColor(1.0, 1.0, 1.0, 1.0))
|
||||
lib.connect_material_expressions(mul, "", lerp, "B")
|
||||
rough = lib.create_material_expression(skin, unreal.MaterialExpressionConstant, -200, -160)
|
||||
rough.set_editor_property("r", 0.55)
|
||||
lib.connect_material_property(lerp, "", unreal.MaterialProperty.MP_BASE_COLOR)
|
||||
lib.connect_material_property(rough, "", unreal.MaterialProperty.MP_ROUGHNESS)
|
||||
lib.recompile_material(skin)
|
||||
log("M_Character_Export: diffuse x pore detail, roughness 0.55")
|
||||
elif not skin:
|
||||
log("M_Character_Export missing")
|
||||
|
||||
hair = unreal.EditorAssetLibrary.load_asset(f"{DEST}/M_Character_Hair")
|
||||
if hair and hair_strands:
|
||||
textured_material(hair, hair_strands, 0.38, masked=True)
|
||||
elif not hair:
|
||||
log("M_Character_Hair missing")
|
||||
|
||||
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
|
||||
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")
|
||||
|
||||
with open(RESULT, "w") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
Reference in new issue
Block a user