Add MakeHuman head hair/body prototype and 12-variant rollout plan

This commit is contained in:
zwitschi committed 2026-11-04 18:08:03 +01:00
1 parent 958d3ae0ff
commit b297c4543f
9 files changed
+269 -89

No files matched your search

+138 -24
View File
@@ -276,13 +276,17 @@ def material(name, textured_solid=False):
check_existing=True)
albedo.image.colorspace_settings.name = "sRGB"
links.new(albedo.outputs["Color"], bsdf.inputs["Base Color"])
normal_tex = nodes.new("ShaderNodeTexImage")
normal_tex.image = bpy.data.images.load(
str(NPC_TEX_DIR / f"{name}_normal.png"), check_existing=True)
normal_tex.image.colorspace_settings.name = "Non-Color"
nmap = nodes.new("ShaderNodeNormalMap")
links.new(normal_tex.outputs["Color"], nmap.inputs["Color"])
links.new(nmap.outputs["Normal"], bsdf.inputs["Normal"])
# The hair normal map was authored against the old cap UVs. Reusing it
# on the skull-conforming MakeHuman cap produces large dark tangent
# patches, so hair keeps its albedo but takes flat shading normals.
if name != "hair":
normal_tex = nodes.new("ShaderNodeTexImage")
normal_tex.image = bpy.data.images.load(
str(NPC_TEX_DIR / f"{name}_normal.png"), check_existing=True)
normal_tex.image.colorspace_settings.name = "Non-Color"
nmap = nodes.new("ShaderNodeNormalMap")
links.new(normal_tex.outputs["Color"], nmap.inputs["Color"])
links.new(nmap.outputs["Normal"], bsdf.inputs["Normal"])
rough = {"skin": 0.6, "cloth": 0.86, "pants": 0.92,
"accessory": 0.5, "hair": 0.7}[name]
bsdf.inputs["Roughness"].default_value = rough
@@ -550,22 +554,21 @@ def _skin_body(spec):
"guard": 0.05}.get(layer, 0.0)
leg = 1.0 + {"responder": 0.1, "dress": -0.1}.get(layer, 0.0)
nodes = [ # (pos, radius_x, radius_y)
((0, 0, 0.80), 0.168 * w, 0.116 * d), # 0 pelvis
((0, 0, 0.96), 0.146 * w * bulk, 0.103 * d * bulk), # 1 waist
((0, 0.0, 1.18), 0.168 * w * bulk, 0.114 * d * bulk), # 2 chest
((0, 0.0, 1.355), 0.170 * w * bulk, 0.108 * d * bulk), # 3 upper chest
# A slightly fuller neck base and a lower shoulder root give the
# head/neck/shoulder line a trapezius slope instead of a thin post on
# a hanger. The head and collar still sit above this band.
((0, 0, 1.43), 0.074, 0.072), # 4 neck base
((0, -0.005, 1.53), 0.060, 0.060), # 5 neck top
((0, 0, 0.80), 0.158 * w, 0.112 * d), # 0 pelvis
((0, 0, 0.96), 0.142 * w * bulk, 0.101 * d * bulk), # 1 waist
((0, 0.0, 1.18), 0.155 * w * bulk, 0.110 * d * bulk), # 2 chest
((0, 0.0, 1.335), 0.158 * w * bulk, 0.105 * d * bulk), # 3 upper chest
# Narrow neck and a lower, sloped shoulder root. The old wide neck base
# and horizontal shoulder shelf made the accepted head read as small.
((0, 0, 1.42), 0.060, 0.060), # 4 neck base
((0, -0.005, 1.53), 0.054, 0.054), # 5 neck top
]
edges = [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5)]
for s in (-1.0, 1.0):
base = len(nodes)
nodes += [
((0.20 * s * w, 0, 1.37), 0.080 * t * sleeve, 0.074 * t * sleeve), # shoulder
((0.27 * s * w, 0, 1.23), 0.058 * t * sleeve, 0.058 * t * sleeve), # upper arm
((0.19 * s * w, 0, 1.345), 0.070 * t * sleeve, 0.068 * t * sleeve), # shoulder
((0.27 * s * w, 0, 1.22), 0.054 * t * sleeve, 0.055 * t * sleeve), # upper arm
((0.275 * s * w, 0.005, 1.06), 0.046 * t * sleeve, 0.048 * t * sleeve), # elbow
((0.275 * s * w, -0.01, 0.78), 0.034 * t, 0.030 * t), # wrist
]
@@ -591,7 +594,7 @@ def _skin_body(spec):
sub = obj.modifiers.new("Sub", "SUBSURF")
sub.levels = 2
dec = obj.modifiers.new("Decimate", "DECIMATE")
dec.ratio = 0.26
dec.ratio = 0.21
dec.use_symmetry = True
dec.symmetry_axis = "X"
_apply_all(obj)
@@ -716,6 +719,102 @@ def _hair_cap(name, mat, front_z, back_z, radius=0.163, center=(0, 0.008, 1.645)
return _finish(_link(name, bm), mat, smooth=True, sharp_angle=70)
def _hair_curve(style):
"""Hairline endpoints and skull offset for a hair style."""
if style == "buzz":
return 1.734, 1.608, 1.035
return 1.714, 1.588, 1.055
def _trim_hidden_scalp(head, style):
"""Delete the scalp that sits under the hair cap to free triangle budget.
The cap is a scaled copy of the skull, so any head face whose scaled
vertices all fall outside the hairline sphere is fully covered. Keeping
boundary-touching faces leaves a safety band under the hair rim; the open
hole is never visible and the accepted face geometry is untouched.
"""
front_z, back_z, scale = _hair_curve(style)
center = Vector((0.0, 0.0, 1.665))
cutter_z = (front_z + back_z) * 0.5 - 0.396
bm = bmesh.new()
bm.from_mesh(head.data)
def covered(p):
q = center + (p - center) * scale
return (q.x * q.x + (q.y + 0.41) ** 2
+ (q.z - cutter_z) ** 2) > 0.56 ** 2
doomed = [f for f in bm.faces if all(covered(v.co) for v in f.verts)]
if doomed:
bmesh.ops.delete(bm, geom=doomed, context="FACES")
loose = [v for v in bm.verts if not v.link_faces]
if loose:
bmesh.ops.delete(bm, geom=loose, context="VERTS")
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
bm.to_mesh(head.data)
bm.free()
def _hair_from_head(source, style, mat_hair):
"""Build the hair cap as a solid shell derived from the skull.
A separate hair ellipsoid cannot follow the broad MakeHuman crown, and an
open, face-deleted copy produces a sawtooth hairline. Scaling the closed
head shell outwards and boolean-cutting the face with a large front sphere
gives a watertight cap whose curved hairline is smooth from every angle.
"""
front_z, back_z, scale = _hair_curve(style)
cap = source.copy()
cap.data = source.data.copy()
cap.name = "HairCap"
bpy.context.scene.collection.objects.link(cap)
center = Vector((0.0, 0.0, 1.665))
for v in cap.data.vertices:
v.co = center + (v.co - center) * scale
# The cutter is a big sphere in front of the face: its upper surface rises
# toward the brow and falls toward the nape, which is the hairline curve.
mid_z = (front_z + back_z) * 0.5
cutter_z = mid_z - 0.396
bpy.ops.mesh.primitive_uv_sphere_add(segments=32, ring_count=20, radius=0.56,
location=(0.0, -0.41, cutter_z))
cutter = bpy.context.object
bpy.context.view_layer.objects.active = cap
mod = cap.modifiers.new("Hairline", "BOOLEAN")
mod.operation = "DIFFERENCE"
mod.solver = "EXACT"
mod.object = cutter
bpy.ops.object.modifier_apply(modifier=mod.name)
bpy.data.objects.remove(cutter, do_unlink=True)
# The boolean result is still head-density. Hair needs silhouette, not
# pores, and the character budget has to stay under 16k triangles.
dec = cap.modifiers.new("HairReduce", "DECIMATE")
dec.ratio = 0.34
dec.use_collapse_triangulate = True
bpy.ops.object.modifier_apply(modifier=dec.name)
bm = bmesh.new()
bm.from_mesh(cap.data)
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
bm.to_mesh(cap.data)
bm.free()
cap.data.materials.clear()
cap.data.materials.append(mat_hair)
cap.data.shade_smooth()
cap.data.set_sharp_from_angle(angle=math.radians(55))
parts = [cap]
if style == "long":
parts.append(sphere("HairBack", 0.095, (0, 0.060, 1.560), mat_hair,
seg=14, rings=9, scale=(0.98, 0.55, 1.55)))
for s in (-1.0, 1.0):
parts.append(sphere("Lock", 0.048, (0.089 * s, -0.012, 1.585),
mat_hair, seg=8, rings=6,
scale=(0.55, 0.85, 2.05)))
if style == "bun":
parts.append(sphere("Bun", 0.060, (0, 0.100, 1.775), mat_hair,
seg=12, rings=8, scale=(1.0, 0.9, 0.95)))
return parts
def _hair(style, mat_hair, broad=False):
parts = []
if style == "buzz":
@@ -1005,7 +1104,15 @@ def _makehuman_head(mats, spec):
target = remap.get(mat.name.split(".")[0])
if target is not None:
head.data.materials[index] = target
return [head]
# Bridge the 1.48-1.53 m neck gap between the MakeHuman cut and the
# procedurally built torso. The plug sits inside both shells, so only
# the visible band reads as neck and no seam ring reaches the surface.
plug = cyl("NeckPlug", 0.062, 0.052, 0.095, (0, 0.002, 1.500), skin,
seg=12, scale=(1.10, 1.0, 1.0))
hair_style = "buzz" if spec["layer"] == "hood" else spec["hair"]
hair_parts = _hair_from_head(head, hair_style, hair)
_trim_hidden_scalp(head, hair_style)
return [head, plug] + hair_parts
# The old decimated MakeHuman "hair" scalp slot reads as shaggy shards;
# collapse the head to one skin slot so the join does not collide with the
@@ -1245,7 +1352,8 @@ def build_character(name, head="face"):
else:
head_parts = _face(mats, spec)
hair_style = spec["hair"] if spec["layer"] != "hood" else "buzz"
head_parts += _hair(hair_style, mats["hair"])
if head != "mh":
head_parts += _hair(hair_style, mats["hair"])
head = join(head_parts, "head")
_auto_weights(head, layout, rigid=lambda p: "Head")
@@ -1811,10 +1919,10 @@ def build_prop(name):
# Build + render entry points (inside Blender)
# ---------------------------------------------------------------------------
def _build_one(category, name):
def _build_one(category, name, head="face"):
reset_scene()
if category == "npc":
objs = build_character(name)
objs = build_character(name, head=head)
elif category == "npc_gear":
objs = build_gear(name)
else:
@@ -2764,6 +2872,9 @@ def main(argv=None):
for cmd in ("build", "render"):
s = sub.add_parser(cmd)
s.add_argument("--only", default="")
if cmd == "build":
s.add_argument("--head", default="face", choices=("face", "proto", "mh"),
help="character head source (default: face)")
a = sub.add_parser("audit")
a.add_argument("--json", action="store_true")
fit = sub.add_parser("fit")
@@ -2854,7 +2965,10 @@ def main(argv=None):
file=sys.stderr)
return 2
for cat, name in _selected(args.only):
(_build_one if args.cmd == "build" else _render_one)(cat, name)
if args.cmd == "build":
_build_one(cat, name, head=args.head)
else:
_render_one(cat, name)
if args.cmd == "build":
results = [audit_asset(c, n) for c, n in catalogue()]
write_manifest([r for r in results if r["vertices"]])