diff --git a/CHANGELOG.md b/CHANGELOG.md index a7c7d0e..053af7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/MEMORY.md b/MEMORY.md index ed18746..df38f1e 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -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. diff --git a/TODO.md b/TODO.md index 4538be3..993821c 100644 --- a/TODO.md +++ b/TODO.md @@ -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. diff --git a/assets/models/blender/manifest.json b/assets/models/blender/manifest.json index 0a9c47d..1509a37 100644 --- a/assets/models/blender/manifest.json +++ b/assets/models/blender/manifest.json @@ -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": { diff --git a/assets/models/blender/npc/npc_casual.glb b/assets/models/blender/npc/npc_casual.glb index 79a39bb..707033a 100644 Binary files a/assets/models/blender/npc/npc_casual.glb and b/assets/models/blender/npc/npc_casual.glb differ diff --git a/assets/models/blender/npc/npc_casual_ao.png b/assets/models/blender/npc/npc_casual_ao.png index 2948cfe..9c79099 100644 Binary files a/assets/models/blender/npc/npc_casual_ao.png and b/assets/models/blender/npc/npc_casual_ao.png differ diff --git a/assets/models/blender/npc/npc_coat.glb b/assets/models/blender/npc/npc_coat.glb index 79a39bb..707033a 100644 Binary files a/assets/models/blender/npc/npc_coat.glb and b/assets/models/blender/npc/npc_coat.glb differ diff --git a/assets/models/blender/npc/npc_coat_ao.png b/assets/models/blender/npc/npc_coat_ao.png index 2948cfe..9c79099 100644 Binary files a/assets/models/blender/npc/npc_coat_ao.png and b/assets/models/blender/npc/npc_coat_ao.png differ diff --git a/assets/models/blender/npc/npc_crowd_c.glb b/assets/models/blender/npc/npc_crowd_c.glb index 79a39bb..707033a 100644 Binary files a/assets/models/blender/npc/npc_crowd_c.glb and b/assets/models/blender/npc/npc_crowd_c.glb differ diff --git a/assets/models/blender/npc/npc_crowd_c_ao.png b/assets/models/blender/npc/npc_crowd_c_ao.png index 2948cfe..9c79099 100644 Binary files a/assets/models/blender/npc/npc_crowd_c_ao.png and b/assets/models/blender/npc/npc_crowd_c_ao.png differ diff --git a/assets/models/blender/npc/npc_crowd_d.glb b/assets/models/blender/npc/npc_crowd_d.glb index 365fe78..3481da9 100644 Binary files a/assets/models/blender/npc/npc_crowd_d.glb and b/assets/models/blender/npc/npc_crowd_d.glb differ diff --git a/assets/models/blender/npc/npc_crowd_d_ao.png b/assets/models/blender/npc/npc_crowd_d_ao.png index 9b040c6..806a0b9 100644 Binary files a/assets/models/blender/npc/npc_crowd_d_ao.png and b/assets/models/blender/npc/npc_crowd_d_ao.png differ diff --git a/assets/models/blender/npc/npc_firefighter.glb b/assets/models/blender/npc/npc_firefighter.glb index 365fe78..3481da9 100644 Binary files a/assets/models/blender/npc/npc_firefighter.glb and b/assets/models/blender/npc/npc_firefighter.glb differ diff --git a/assets/models/blender/npc/npc_firefighter_ao.png b/assets/models/blender/npc/npc_firefighter_ao.png index 9b040c6..806a0b9 100644 Binary files a/assets/models/blender/npc/npc_firefighter_ao.png and b/assets/models/blender/npc/npc_firefighter_ao.png differ diff --git a/assets/models/blender/npc/npc_guard.glb b/assets/models/blender/npc/npc_guard.glb index 365fe78..3481da9 100644 Binary files a/assets/models/blender/npc/npc_guard.glb and b/assets/models/blender/npc/npc_guard.glb differ diff --git a/assets/models/blender/npc/npc_guard_ao.png b/assets/models/blender/npc/npc_guard_ao.png index 9b040c6..806a0b9 100644 Binary files a/assets/models/blender/npc/npc_guard_ao.png and b/assets/models/blender/npc/npc_guard_ao.png differ diff --git a/assets/models/blender/npc/npc_hoodie.glb b/assets/models/blender/npc/npc_hoodie.glb index 79a39bb..707033a 100644 Binary files a/assets/models/blender/npc/npc_hoodie.glb and b/assets/models/blender/npc/npc_hoodie.glb differ diff --git a/assets/models/blender/npc/npc_hoodie_ao.png b/assets/models/blender/npc/npc_hoodie_ao.png index 2948cfe..9c79099 100644 Binary files a/assets/models/blender/npc/npc_hoodie_ao.png and b/assets/models/blender/npc/npc_hoodie_ao.png differ diff --git a/assets/models/blender/npc/npc_vest.glb b/assets/models/blender/npc/npc_vest.glb index e0db1c8..aee966a 100644 Binary files a/assets/models/blender/npc/npc_vest.glb and b/assets/models/blender/npc/npc_vest.glb differ diff --git a/assets/models/blender/npc/npc_vest_ao.png b/assets/models/blender/npc/npc_vest_ao.png index 9b040c6..806a0b9 100644 Binary files a/assets/models/blender/npc/npc_vest_ao.png and b/assets/models/blender/npc/npc_vest_ao.png differ diff --git a/docs/blender-assets.md b/docs/blender-assets.md index 2e4aeb1..7782f11 100644 --- a/docs/blender-assets.md +++ b/docs/blender-assets.md @@ -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 diff --git a/tools/blender_assets.py b/tools/blender_assets.py index 5534c89..bbfd8f5 100644 --- a/tools/blender_assets.py +++ b/tools/blender_assets.py @@ -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