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 - 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 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. 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 - Every NPC now has a visible mouth; the Quaternius heads shipped without one, so the faces read as
brow-and-eye masks. brow-and-eye masks.
- Fixed the modular Worker characters: the guard, delivery, firefighter and crowd roles had holes - 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 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 silently misses it. Purge the datablocks (`bpy.data.materials.remove(m)` when `m.users == 0`) or
match on `_base_name()`. 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 - **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, 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. 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. 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 - [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. 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` - [x] Eyes now have a sclera and a pupil, done as geometry rather than a texture: the existing eye
slot would finish the face at portrait distance. 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 - [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 (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. `docs/blender-assets.md`, and the `capture_npc_gear.gd` / `capture_npc_baseline.gd` scripts.
+40 -32
View File
@@ -404,10 +404,11 @@
"cloth", "cloth",
"accessory", "accessory",
"Eyebrows", "Eyebrows",
"Eye", "sclera",
"hair", "hair",
"pants", "pants",
"mouth" "mouth",
"pupil"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_casual.glb", "path": "assets/models/blender/npc/npc_casual.glb",
@@ -424,8 +425,8 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 6218, "triangles": 6242,
"vertices": 13012 "vertices": 13100
}, },
"npc_coat": { "npc_coat": {
"base": "hoodie", "base": "hoodie",
@@ -448,10 +449,11 @@
"cloth", "cloth",
"accessory", "accessory",
"Eyebrows", "Eyebrows",
"Eye", "sclera",
"hair", "hair",
"pants", "pants",
"mouth" "mouth",
"pupil"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_coat.glb", "path": "assets/models/blender/npc/npc_coat.glb",
@@ -467,8 +469,8 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 6218, "triangles": 6242,
"vertices": 13012 "vertices": 13100
}, },
"npc_crowd_a": { "npc_crowd_a": {
"base": "casual", "base": "casual",
@@ -575,10 +577,11 @@
"cloth", "cloth",
"accessory", "accessory",
"Eyebrows", "Eyebrows",
"Eye", "sclera",
"hair", "hair",
"pants", "pants",
"mouth" "mouth",
"pupil"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_crowd_c.glb", "path": "assets/models/blender/npc/npc_crowd_c.glb",
@@ -593,8 +596,8 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 6218, "triangles": 6242,
"vertices": 13012 "vertices": 13100
}, },
"npc_crowd_d": { "npc_crowd_d": {
"base": "worker", "base": "worker",
@@ -620,9 +623,10 @@
"Moustache", "Moustache",
"pants", "pants",
"Brows", "Brows",
"Eye.001", "sclera",
"hair", "hair",
"mouth" "mouth",
"pupil"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_crowd_d.glb", "path": "assets/models/blender/npc/npc_crowd_d.glb",
@@ -637,8 +641,8 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 6266, "triangles": 6290,
"vertices": 13222 "vertices": 13188
}, },
"npc_dress": { "npc_dress": {
"base": "casual", "base": "casual",
@@ -705,9 +709,10 @@
"Moustache", "Moustache",
"pants", "pants",
"Brows", "Brows",
"Eye.001", "sclera",
"hair", "hair",
"mouth" "mouth",
"pupil"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_firefighter.glb", "path": "assets/models/blender/npc/npc_firefighter.glb",
@@ -721,8 +726,8 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 6266, "triangles": 6290,
"vertices": 13222 "vertices": 13188
}, },
"npc_guard": { "npc_guard": {
"base": "worker", "base": "worker",
@@ -748,9 +753,10 @@
"Moustache", "Moustache",
"pants", "pants",
"Brows", "Brows",
"Eye.001", "sclera",
"hair", "hair",
"mouth" "mouth",
"pupil"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_guard.glb", "path": "assets/models/blender/npc/npc_guard.glb",
@@ -765,8 +771,8 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 6266, "triangles": 6290,
"vertices": 13222 "vertices": 13188
}, },
"npc_hoodie": { "npc_hoodie": {
"base": "hoodie", "base": "hoodie",
@@ -789,10 +795,11 @@
"cloth", "cloth",
"accessory", "accessory",
"Eyebrows", "Eyebrows",
"Eye", "sclera",
"hair", "hair",
"pants", "pants",
"mouth" "mouth",
"pupil"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_hoodie.glb", "path": "assets/models/blender/npc/npc_hoodie.glb",
@@ -808,8 +815,8 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 6218, "triangles": 6242,
"vertices": 13012 "vertices": 13100
}, },
"npc_jacket": { "npc_jacket": {
"base": "casual", "base": "casual",
@@ -879,9 +886,10 @@
"Moustache", "Moustache",
"pants", "pants",
"Brows", "Brows",
"Eye.001", "sclera",
"hair", "hair",
"mouth" "mouth",
"pupil"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_vest.glb", "path": "assets/models/blender/npc/npc_vest.glb",
@@ -895,8 +903,8 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 6266, "triangles": 6290,
"vertices": 13222 "vertices": 13188
}, },
"palm_tree": { "palm_tree": {
"bounds_godot": { "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 `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. 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 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_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 `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}") 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): def _base_name(name):
"""Strip Blender's `.001` duplicate suffix.""" """Strip Blender's `.001` duplicate suffix."""
head, sep, tail = name.rpartition(".") head, sep, tail = name.rpartition(".")
@@ -2075,6 +2150,7 @@ def _quaternius_tpose_glb(base, overrides):
if source.get("donor_head"): if source.get("donor_head"):
_transplant_worker_head(obj) _transplant_worker_head(obj)
_add_quaternius_mouth(obj) _add_quaternius_mouth(obj)
_add_quaternius_eye_whites(obj)
_quaternius_slot_map(obj, slots) _quaternius_slot_map(obj, slots)
# Upstream UVs are degenerate (every loop at 0,0), so an AO bake would write # Upstream UVs are degenerate (every loop at 0,0), so an AO bake would write