Add eye whites and pupils to every base and archive the 0.9.9 milestone
Full build / Import + headless tests (push) Successful in 43s
Full build / Full desktop build + optional release (push) Skipped

This commit is contained in:
zwitschi committed 2026-11-06 18:44:39 +01:00
1 parent c70a523f0f
commit a0dbf75806
22 files changed
+129 -34

No files matched your search

+1
View File
@@ -14,6 +14,7 @@ Semantic Versioning.
- Hands, head gear and carried items were re-fitted to the new characters: hand items sit in the
palm instead of floating at the wrist, the lanyard moved up to the sternum, back gear moved up to
shoulder-blade height, and the clipboard is carried by its top edge.
- Eyes now have whites and pupils instead of flat dark rectangles.
- Every NPC now has a visible mouth; the Quaternius heads shipped without one, so the faces read as
brow-and-eye masks.
- Fixed the modular Worker characters: the guard, delivery, firefighter and crowd roles had holes
+4
View File
@@ -157,6 +157,10 @@ canonical implemented-features list in `docs/development-status.md`.
donor material renamed to the freed name lands as `Eye.001` and every `m.name == "Eye"` rule
silently misses it. Purge the datablocks (`bpy.data.materials.remove(m)` when `m.users == 0`) or
match on `_base_name()`.
- **A duplicated detail face has to be offset along its own normal.** Copying the eye quad into a
pupil and nudging it in world `-Y` vanished: the eye plane is tilted, so the "front" nudge went
backwards and the pupil sat inside the sclera. Offsetting along `face.normal * 0.0032` fixed it.
Also scale the depth axis when shrinking a copy, or it flattens onto the parent plane.
- **Blender node colours are linear; Godot tonemaps them.** A mouth lip at `0.16` linear rendered as
salmon/mauve. Divide by roughly 3–4 versus the intended sRGB value: the hair uses 0.027 linear,
the lip 0.055/0.018/0.02.
+3 -2
View File
@@ -801,8 +801,9 @@ accepted on 2026-11-06; the review page and its sheets were then retired.
they clear the 0.228 m hair shell. The cap now reads as a cap and both eyes *and* brows show.
- [x] Hair poking through head gear: fixed by the same lift + cap/hood scale; the firefighter
helmet dome covers the hair, and the cap reads as a cap rather than a visor.
- [ ] The eyes are single-colour quads on every base; a light sclera + pupil texture on the `Eye`
slot would finish the face at portrait distance.
- [x] Eyes now have a sclera and a pupil, done as geometry rather than a texture: the existing eye
quads became the light sclera and a shrunken copy pushed along the sclera's own normal became
the dark pupil (`_add_quaternius_eye_whites()`). Two extra draw calls per head, no new UV work.
- [x] Review page re-created for the second pass, then retired after the owner accepted the roster
(2026-11-06). The durable evidence is `docs/3d-model-review.md`, the measurements in
`docs/blender-assets.md`, and the `capture_npc_gear.gd` / `capture_npc_baseline.gd` scripts.
+40 -32
View File
@@ -404,10 +404,11 @@
"cloth",
"accessory",
"Eyebrows",
"Eye",
"sclera",
"hair",
"pants",
"mouth"
"mouth",
"pupil"
],
"mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_casual.glb",
@@ -424,8 +425,8 @@
"accessory",
"hair"
],
"triangles": 6218,
"vertices": 13012
"triangles": 6242,
"vertices": 13100
},
"npc_coat": {
"base": "hoodie",
@@ -448,10 +449,11 @@
"cloth",
"accessory",
"Eyebrows",
"Eye",
"sclera",
"hair",
"pants",
"mouth"
"mouth",
"pupil"
],
"mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_coat.glb",
@@ -467,8 +469,8 @@
"accessory",
"hair"
],
"triangles": 6218,
"vertices": 13012
"triangles": 6242,
"vertices": 13100
},
"npc_crowd_a": {
"base": "casual",
@@ -575,10 +577,11 @@
"cloth",
"accessory",
"Eyebrows",
"Eye",
"sclera",
"hair",
"pants",
"mouth"
"mouth",
"pupil"
],
"mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_crowd_c.glb",
@@ -593,8 +596,8 @@
"accessory",
"hair"
],
"triangles": 6218,
"vertices": 13012
"triangles": 6242,
"vertices": 13100
},
"npc_crowd_d": {
"base": "worker",
@@ -620,9 +623,10 @@
"Moustache",
"pants",
"Brows",
"Eye.001",
"sclera",
"hair",
"mouth"
"mouth",
"pupil"
],
"mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_crowd_d.glb",
@@ -637,8 +641,8 @@
"accessory",
"hair"
],
"triangles": 6266,
"vertices": 13222
"triangles": 6290,
"vertices": 13188
},
"npc_dress": {
"base": "casual",
@@ -705,9 +709,10 @@
"Moustache",
"pants",
"Brows",
"Eye.001",
"sclera",
"hair",
"mouth"
"mouth",
"pupil"
],
"mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_firefighter.glb",
@@ -721,8 +726,8 @@
"accessory",
"hair"
],
"triangles": 6266,
"vertices": 13222
"triangles": 6290,
"vertices": 13188
},
"npc_guard": {
"base": "worker",
@@ -748,9 +753,10 @@
"Moustache",
"pants",
"Brows",
"Eye.001",
"sclera",
"hair",
"mouth"
"mouth",
"pupil"
],
"mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_guard.glb",
@@ -765,8 +771,8 @@
"accessory",
"hair"
],
"triangles": 6266,
"vertices": 13222
"triangles": 6290,
"vertices": 13188
},
"npc_hoodie": {
"base": "hoodie",
@@ -789,10 +795,11 @@
"cloth",
"accessory",
"Eyebrows",
"Eye",
"sclera",
"hair",
"pants",
"mouth"
"mouth",
"pupil"
],
"mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_hoodie.glb",
@@ -808,8 +815,8 @@
"accessory",
"hair"
],
"triangles": 6218,
"vertices": 13012
"triangles": 6242,
"vertices": 13100
},
"npc_jacket": {
"base": "casual",
@@ -879,9 +886,10 @@
"Moustache",
"pants",
"Brows",
"Eye.001",
"sclera",
"hair",
"mouth"
"mouth",
"pupil"
],
"mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_vest.glb",
@@ -895,8 +903,8 @@
"accessory",
"hair"
],
"triangles": 6266,
"vertices": 13222
"triangles": 6290,
"vertices": 13188
},
"palm_tree": {
"bounds_godot": {
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 72 KiB

+5
View File
@@ -176,6 +176,11 @@ colour texture. The current `_apply_outfit_materials()` sets
`albedo_color = WHITE` on any slot it does not recognise, and these keep their
colour through that. When integrating, tint only the five contract slots.
**Face detail.** None of the three Quaternius heads ships a mouth or eye whites, so the build adds
both: `_add_quaternius_mouth()` places a lip block on the measured skin surface, and
`_add_quaternius_eye_whites()` renames the eye quads to `sclera` and adds a shrunken `pupil` copy in
front of each. Both are fixed-colour (not tint slots), so they survive the role palettes.
**Head gear fit.** Head gear is authored for the shipped Quaternius head:
`HEAD_GEAR_SCALE = 0.64` (0.201 m base head against the 0.315 m procedural one) and
`HEAD_GEAR_LIFT = 0.07` (its centre sits higher). The procedural fallback therefore wears slightly
+76
View File
@@ -1663,6 +1663,81 @@ def _add_quaternius_mouth(obj):
print(f" mouth placed at y {front + 0.003:+.3f} z {height:.3f}")
def _add_quaternius_eye_whites(obj):
"""Give the eye quads a sclera and a pupil.
Every base ships one dark quad per eye, so the face reads as two dark
rectangles at portrait distance. The quads are rewired instead of textured: the
existing faces become the sclera (light), and a shrunken copy in front of each
becomes the pupil (dark). Two draw calls per head, no new UV work.
"""
eye = next((i for i, m in enumerate(obj.data.materials)
if m is not None and _base_name(m.name).lower() == "eye"), None)
if eye is None:
return
sclera = obj.data.materials[eye]
sclera.name = "sclera"
sclera.use_nodes = True
bsdf = sclera.node_tree.nodes.get("Principled BSDF")
if bsdf is not None:
# Linear values: 0.62 reads as an off-white sclera, not a glowing one.
bsdf.inputs["Base Color"].default_value = (0.62, 0.60, 0.55, 1.0)
bsdf.inputs["Roughness"].default_value = 0.45
sclera.use_backface_culling = False
pupil = bpy.data.materials.new("pupil")
pupil.use_nodes = True
pupil_bsdf = pupil.node_tree.nodes.get("Principled BSDF")
if pupil_bsdf is not None:
pupil_bsdf.inputs["Base Color"].default_value = (0.012, 0.010, 0.014, 1.0)
pupil_bsdf.inputs["Roughness"].default_value = 0.35
# A single-sided pupil can vanish depending on the exported winding; the extra
# draw cost on two 0.02 m quads is irrelevant.
pupil.use_backface_culling = False
# Collect the eye faces before adding anything, then inset a pupil per face.
faces = [p for p in obj.data.polygons if p.material_index == eye]
if not faces:
return
obj.data.materials.append(pupil)
pupil_index = len(obj.data.materials) - 1
bm = bmesh.new()
bm.from_mesh(obj.data)
bm.faces.ensure_lookup_table()
# Hold face *references*: adding vertices invalidates the index tables.
targets = [bm.faces[i] for i in range(len(bm.faces))
if bm.faces[i].material_index == eye]
# Shrink toward the *eye* centre, not each triangle's own centre: the quad is two
# triangles, and per-face insets produced two offset wedges instead of a pupil.
eye_centre = {}
for face in targets:
side = 1 if face.calc_center_median().x >= 0.0 else -1
eye_centre.setdefault(side, []).append(face.calc_center_median())
side_centre = {side: sum(points, Vector()) / len(points)
for side, points in eye_centre.items()}
new_faces = []
for face in targets:
centre = side_centre[1 if face.calc_center_median().x >= 0.0 else -1]
# Push the pupil in front of the sclera so it cannot z-fight, then shrink it
# about its own centre. Blender -Y is the face, so "in front" is -Y.
# Push the pupil out along the sclera's own normal: offsetting in world -Y
# only worked while the eye plane was axis-aligned, and this one is tilted.
nudge = face.normal.normalized() * 0.0032
verts = []
for vert in face.verts:
offset = vert.co - centre
verts.append(bm.verts.new(centre
+ Vector((offset.x * 0.74,
offset.y * 0.74,
offset.z * 0.70))
+ nudge))
new_face = bm.faces.new(verts)
new_face.material_index = pupil_index
new_faces.append(new_face)
bm.to_mesh(obj.data)
bm.free()
print(f" eye whites: {len(faces)} sclera quads, {len(new_faces)} pupil quads")
def _base_name(name):
"""Strip Blender's `.001` duplicate suffix."""
head, sep, tail = name.rpartition(".")
@@ -2075,6 +2150,7 @@ def _quaternius_tpose_glb(base, overrides):
if source.get("donor_head"):
_transplant_worker_head(obj)
_add_quaternius_mouth(obj)
_add_quaternius_eye_whites(obj)
_quaternius_slot_map(obj, slots)
# Upstream UVs are degenerate (every loop at 0,0), so an AO bake would write