One directory with sixty scripts stopped being navigable, and the grouping is also the sandbox boundary:
Tools/unreal/ commandlets and the two map builders, with ue_common.py and ue_placement.py as the
shared imports (they need the engine)
Tools/blender/ builders, exporters, the FBX preset, the MPFB character pipeline, blender_screenshots
(they need the Blender Flatpak)
Tools/review/ the visual loop: render_screenshots.sh, judge_screenshot.py, hero_compare.py,
build_comparison_md.py, e2e_asset_check.py, hero_iterate.sh, visual_loop.py,
visual_targets.json
Tools/gen/ image and texture generation: the ComfyUI driver, deterministic texture writers,
reference fetching
Tools/input/ OS-level input injection
root _common.py, check-lfs.sh, addon_compat_probe.py, sibling_asset_audit.py, tests/
Every moved script now resolves the repository root as ../.. and puts the tools root plus its siblings on
sys.path, so 'import ue_common' still works from unreal/. The test suite bootstrap does the same for the five
folders. render_screenshots.sh takes the folder as a third argument to run_cmdlet, and the e2e driver,
hero_iterate.sh and the comparison generator were repointed.
51 README rows now link to their new location, the README gains a Layout section naming each folder's
sandbox, and the architecture notes and Blender README follow the moves.
Verified: 108 tests pass, prettier is clean, judge_screenshot.py and hero_compare.py run from their new
homes, and the e2e run was aborted mid-way (it had been rebuilding an empty DemoMap for 15 minutes) and is
being rerun against the new layout.
226 lines
9.1 KiB
Python
226 lines
9.1 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
|
|
|
|
sys.path.insert(0, os.path.dirname(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())
|