Add the MakeHuman-head character head source (head=mh) with project eyes/brows/mouth and CC0 attribution
Full build / Import + headless tests (push) Successful in 53s
Full build / Full desktop build + optional release (push) Skipped

This commit is contained in:
zwitschi committed 2026-11-04 15:18:49 +01:00
1 parent 632ec16aaa
commit cdd2e02564
5 files changed
+96 -10

No files matched your search

+9
View File
@@ -40,6 +40,15 @@ kept outside version control.
The CC-BY preview images are included only as development evidence. Any future The CC-BY preview images are included only as development evidence. Any future
shipped derivative of CesiumMan or RiggedFigure must keep the attribution above. shipped derivative of CesiumMan or RiggedFigure must keep the attribution above.
### MakeHuman head source
The decimated head used by the character head prototype is derived from the
MakeHuman / MPFB base mesh, **CC0** (`assets/models/blender_src/makehuman_head.glb`;
source `tmp/npc-research/makehuman/base.obj` with its bundled
`LICENSE.md`/`LICENSE.ASSETS.md`). It is retargeted, decimated and re-weighted by
project scripts; the exported character GLBs remain project assets. See ADR-0015
and `docs/npc-model-sourcing.md`.
The venue geometry is authored procedurally in `src/game/world_builder.gd` as a The venue geometry is authored procedurally in `src/game/world_builder.gd` as a
stylized reconstruction. The Joke Shop is a fictional stand-in for the real stylized reconstruction. The Joke Shop is a fictional stand-in for the real
Sunset Strip block; the game does not claim endorsement by, or an exact replica Sunset Strip block; the game does not claim endorsement by, or an exact replica
Binary file not shown.
+76 -2
View File
@@ -965,6 +965,72 @@ def _head_proto(mats, spec, subdiv=48):
return parts return parts
def _makehuman_head(mats, spec):
"""MakeHuman head shape plus the project's eye/mouth/brow pieces.
The AI-judged head rework (2026-11-04) picked the decimated MakeHuman head
over the displacement shell: it keeps the nose/chin/jaw landmarks at 1,573
tris. The MakeHuman mesh has no eyeballs, so the project's sclera/iris/lid/
brow pieces are added at the MakeHuman landmarks (eyes ≈ ±0.052, −0.160,
1.664) for readability. Blender Z-up, face front −Y.
"""
skin, acc, hair = mats["skin"], mats["accessory"], mats["hair"]
white = material("eye_white", textured_solid=True)
src = SRC_DIR / "makehuman_head.glb"
before = {o.name for o in bpy.context.scene.objects}
bpy.ops.import_scene.gltf(filepath=str(src))
# Drop the imported armature / glTF neutral bone; build_character re-weights
# the head to the contract rig.
for a in list(bpy.context.scene.objects):
if a.type == "ARMATURE" and a.name not in before:
bpy.data.objects.remove(a, do_unlink=True)
# Pick the newly imported mesh, not the already-built body.
head = next((o for o in bpy.context.scene.objects
if o.type == "MESH" and o.name not in before
and o.data.materials), None)
if head is None:
raise SystemExit(f"no head mesh in {src}")
head.name = "HeadShell"
# The decimated MakeHuman "hair" scalp slot reads as shaggy shards; collapse
# the head to one skin slot so the join does not collide with the body's
# cloth/pants slots (the project `_hair` cap provides hair later).
head.data.materials.clear()
head.data.materials.append(skin)
# Clean the ragged neck cut left by the Head-group extraction: bisect below
# the jaw and cap the hole so the head ends on a clean solid underside.
bm = bmesh.new()
bm.from_mesh(head.data)
bmesh.ops.bisect_plane(bm, geom=bm.verts[:] + bm.edges[:] + bm.faces[:],
plane_co=(0, 0, 1.50), plane_no=(0, 0, -1),
clear_outer=True)
boundary = [e for e in bm.edges if e.is_boundary]
if boundary:
bmesh.ops.holes_fill(bm, edges=boundary)
bm.to_mesh(head.data)
bm.free()
parts = [head]
for s in (-1.0, 1.0):
# The MakeHuman socket is a deep cavity (floor ≈ −0.105); keep the
# sclera recessed so it never breaks the face silhouette.
ex, ey, ez = 0.052 * s, -0.125, 1.664
parts.append(sphere("EyeWhite", 0.019, (ex, ey, ez), white, seg=10,
rings=6, scale=(1.25, 0.62, 0.85)))
parts.append(sphere("Iris", 0.0105, (ex, ey - 0.007, ez), acc, seg=8,
rings=5, scale=(1.0, 0.55, 1.0)))
parts.append(sphere("UpperLid", 0.020, (ex, -0.135, ez + 0.014),
skin, seg=8, rings=4, scale=(1.20, 0.32, 0.24),
rot=(math.radians(-10), 0, 0)))
parts.append(sphere("LowerLid", 0.017, (ex, -0.135, ez - 0.012),
skin, seg=8, rings=4, scale=(1.10, 0.22, 0.18),
rot=(math.radians(6), 0, 0)))
parts.append(box("Brow", (0.055, 0.014, 0.012), (0.052 * s, -0.165, 1.705),
hair, rot=(0, 0.14 * s, 0.0)))
# A subtle mouth line on top of the MakeHuman lips for readability.
parts.append(sphere("Mouth", 0.028, (0, -0.165, 1.58), acc, seg=8, rings=5,
scale=(1.0, 0.28, 0.12)))
return parts
def _hands_and_shoes(mats, spec): def _hands_and_shoes(mats, spec):
skin = mats["skin"] skin = mats["skin"]
shoe = mats["accessory"] shoe = mats["accessory"]
@@ -1122,7 +1188,10 @@ def _clothing(spec, mats):
return parts return parts
def build_character(name, head_proto=False): def build_character(name, head="face"):
"""Build one character. `head` picks the head source: "face" (current
primitive face), "proto" (connected-shell displacement prototype) or
"mh" (decimated MakeHuman head + project eyes/brows/mouth)."""
spec = dict(CHARACTERS[name]) spec = dict(CHARACTERS[name])
if name == "npc_hoodie": if name == "npc_hoodie":
spec["beard"] = True spec["beard"] = True
@@ -1137,7 +1206,12 @@ def build_character(name, head_proto=False):
_assign_body_slots(body, spec, body_mats) _assign_body_slots(body, spec, body_mats)
_auto_weights(body, layout) _auto_weights(body, layout)
head_parts = _head_proto(mats, spec) if head_proto else _face(mats, spec) if head == "mh":
head_parts = _makehuman_head(mats, spec)
elif head == "proto":
head_parts = _head_proto(mats, spec)
else:
head_parts = _face(mats, spec)
hair_style = spec["hair"] if spec["layer"] != "hood" else "buzz" hair_style = spec["hair"] if spec["layer"] != "hood" else "buzz"
head_parts += _hair(hair_style, mats["hair"]) head_parts += _hair(hair_style, mats["hair"])
head = join(head_parts, "head") head = join(head_parts, "head")
+10 -7
View File
@@ -54,7 +54,7 @@ PROTOTYPE = "npc_casual" # canonical one-variant prototype
# The four buildable parts. `head` is the bare head shell; `face` is the shell # The four buildable parts. `head` is the bare head shell; `face` is the shell
# plus nose/eyes/mouth/ears; `hand_l`/`hand_r` are the left/right hand geometry. # plus nose/eyes/mouth/ears; `hand_l`/`hand_r` are the left/right hand geometry.
PART_NAMES = ("head", "face", "hand_l", "hand_r", "head_proto", PART_NAMES = ("head", "face", "hand_l", "hand_r", "head_proto",
"hand_l_proto", "hand_r_proto") "hand_l_proto", "hand_r_proto", "head_mh")
_HAND_PREFIXES = ("Palm", "Finger", "Fingertip", "Thumb", "ThumbTip") _HAND_PREFIXES = ("Palm", "Finger", "Fingertip", "Thumb", "ThumbTip")
@@ -109,6 +109,8 @@ def build_part(name, variant, out, subdiv=48):
obj = ba._hand_proto(mats, spec, side=-1.0) obj = ba._hand_proto(mats, spec, side=-1.0)
elif name == "hand_r_proto": elif name == "hand_r_proto":
obj = ba._hand_proto(mats, spec, side=1.0) obj = ba._hand_proto(mats, spec, side=1.0)
elif name == "head_mh":
obj = ba.join(ba._makehuman_head(mats, spec), "head_mh")
else: else:
side = -1.0 if name == "hand_l" else 1.0 side = -1.0 if name == "hand_l" else 1.0
hs = ba._hands_and_shoes(mats, spec) hs = ba._hands_and_shoes(mats, spec)
@@ -164,14 +166,15 @@ def extract_part(name, variant, out):
return _extract_group(mesh, group, out) return _extract_group(mesh, group, out)
def assemble(variant, out, head_proto=False): def assemble(variant, out, head="face"):
"""Assemble a variant (currently rebuilds the procedural character). """Assemble a variant (currently rebuilds the procedural character).
This proves the end-to-end wiring; once parts are approved, this swaps the This proves the end-to-end wiring; once parts are approved, this swaps the
new part GLBs in instead of calling ``build_character`` directly. Pass new part GLBs in instead of calling ``build_character`` directly. Pass
``head_proto=True`` to build with the connected-shell prototype head. ``head="mh"`` or ``head="proto"`` to build with the MakeHuman or prototype
head source.
""" """
objs = ba.build_character(variant, head_proto=head_proto) objs = ba.build_character(variant, head=head)
mesh = next(o for o in objs if o.type == "MESH") mesh = next(o for o in objs if o.type == "MESH")
arm = next(o for o in objs if o.type == "ARMATURE") arm = next(o for o in objs if o.type == "ARMATURE")
out = Path(out) out = Path(out)
@@ -279,8 +282,8 @@ def main(argv=None):
asm = sub.add_parser("assemble") asm = sub.add_parser("assemble")
asm.add_argument("--variant", default=PROTOTYPE) asm.add_argument("--variant", default=PROTOTYPE)
asm.add_argument("--out", required=True) asm.add_argument("--out", required=True)
asm.add_argument("--head-proto", action="store_true", asm.add_argument("--head", default="face", choices=("face", "proto", "mh"),
help="use the connected-shell prototype head") help="head source (face | proto | mh)")
insp = sub.add_parser("inspect") insp = sub.add_parser("inspect")
insp.add_argument("--file", required=True) insp.add_argument("--file", required=True)
@@ -316,7 +319,7 @@ def main(argv=None):
extract_part(args.part, args.variant, args.out) extract_part(args.part, args.variant, args.out)
print(f"extracted {args.part} -> {args.out}") print(f"extracted {args.part} -> {args.out}")
elif args.cmd == "assemble": elif args.cmd == "assemble":
assemble(args.variant, args.out, head_proto=args.head_proto) assemble(args.variant, args.out, head=args.head)
print(f"assembled {args.variant} -> {args.out}") print(f"assembled {args.variant} -> {args.out}")
elif args.cmd == "inspect": elif args.cmd == "inspect":
inspect(args.file) inspect(args.file)
+1 -1
View File
@@ -12,7 +12,7 @@ class CliTests(unittest.TestCase):
self.assertEqual( self.assertEqual(
set(npc_parts.PART_NAMES), set(npc_parts.PART_NAMES),
{"head", "face", "hand_l", "hand_r", "head_proto", {"head", "face", "hand_l", "hand_r", "head_proto",
"hand_l_proto", "hand_r_proto"}, "hand_l_proto", "hand_r_proto", "head_mh"},
) )
def test_blender_commands_fail_cleanly_without_bpy(self): def test_blender_commands_fail_cleanly_without_bpy(self):