Add MakeHuman head hair/body prototype and 12-variant rollout plan
This commit is contained in:
1 parent
958d3ae0ff
commit
b297c4543f
9 files changed
+269
-89
No files matched your search
+138
-24
@@ -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"]])
|
||||
|
||||
Reference in new issue
Block a user