The e2e asset check now completes: PASS on all six steps - empty maps 9 s, demo build 10 s, review build 9 s, captures 80 s, judge 388 s, review file. Four breakages from the folder move had to be fixed first: - ue_placement and ue_common are siblings of the scripts that import them, but the rewrite put only the tools root on sys.path, so every Unreal commandlet died with ModuleNotFoundError. Both paths are inserted now. - render_screenshots.sh resolved the repository root one level up, so commandlet paths became Tools/Tools/... and the capture step failed in 0 s. - the driver treated a result file left by an earlier run as success, which is how a ten-second 'demo build' passed without building. Result files are unlinked before each step and child output streams to a per-step log file rather than a pipe. - two editors on one project hang instead of failing, so the driver refuses to start while one is running. Also corrects ue_recreate_maps.py: LevelEditorSubsystem.new_level() refuses a path whose package is still in memory and delete_asset() leaves the file on disk, measured create->delete->create returning False. The step now loads each map, destroys every actor, saves, and reports the count it left (0), which is the property the builders actually need. Open question recorded in TODO.md: the rebuilt map holds 409 actors and 613 KB against 600 and 886 KB before, and the ~190 missing ones look like the facade-kit and theatre-trim placements that run as separate steps.
228 lines
9.2 KiB
Python
228 lines
9.2 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/blender/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/gen/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
|
|
|
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common
|
|
sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement)
|
|
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())
|