Add eye whites and pupils to every base and archive the 0.9.9 milestone
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 72 KiB |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||