Recess NPC eyes and sculpt the jaw and cheeks
Full build / Import + headless tests (push) Successful in 38s
Full build / Full desktop build + optional release (push) Skipped

This commit is contained in:
zwitschi committed 2026-11-02 19:08:26 +01:00
1 parent 15ab8c686d
commit 03c355e7c1
26 files changed
+117 -70

No files matched your search

+2
View File
@@ -10,6 +10,8 @@ Semantic Versioning.
- Character necks are fuller and shoulders slope from the neck instead of
reading as a thin post on a hanger.
- Character eyes sit in the socket behind a thin upper lid, and the chin and
cheekbones are sculpted into the head instead of reading as separate spheres.
- Tourists no longer appear to carry two backpacks: the photo-pose camera bag is
hidden except while they are actually taking a picture.
- Backpack shoulder straps are now flattened bands that sit outside the chest
+5
View File
@@ -81,6 +81,11 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
1.37 m. A final triangulate + tiny-face delete in `build_character` removes zero-area triangles
that the new neck branch exposed in jacket/hoodie. All eight GLBs and AO maps were rebuilt;
`npc_proportions.py` still passes and `sheet_npc_baseline_compare-body.jpg` is the review image.
- **NPC face-depth pass (0.9.4, 2026-11-02):** recessed the sclera to `ey=-0.134` and added a
thin upper lid; removed the separate jaw/cheek spheres and instead sculpt front head vertices
(`_shape_head_mesh`) around a chin plate and cheekbones. This avoids dark AO intersection rings.
Moved lips forward to stay on the reshaped surface. Latest review sheet:
`sheet_npc_baseline_compare-face.jpg`; max character is now 4,754 tris / 4,574 verts.
- **UI flow smoke (2026-11-02):** `smoke_ui_flows.gd` boots the real scene and
exercises inspection allow/deny, normal vs emergency radio, Help filtering,
wand allow/deny and review return-to-menu. Keep this smoke in the 0.9.6
+7 -4
View File
@@ -99,10 +99,13 @@ and equipment fit.
### Head, face and hair
- [ ] Refine the head volume and jaw/cheek/chin proportions against the portrait concept; keep the
face front direction unambiguous for the focus cone and head-turn.
- [ ] Improve brow/eye-socket depth, eyelid/iris readability, nose bridge/tip, lips and ear shape
without turning the low-poly face into a noisy mesh.
- [x] Refine the head volume and jaw/cheek/chin proportions against the portrait concept; keep the
face front direction unambiguous for the focus cone and head-turn. (Chin plate and cheekbones
sculpted into the head mesh; no separate jaw/cheek spheres or AO intersection rings.)
- [x] Improve brow/eye-socket depth and eyelid/iris readability without turning the low-poly face
into a noisy mesh. (Recessed sclera, thin upper lid; `sheet_npc_baseline_compare-face.jpg`.)
- [ ] Refine the nose bridge/tip, lips and ear shape; keep the five tint slots and face direction
unchanged.
- [ ] Audit hair caps, fringes, side locks, buns and beard silhouettes for gaps, intersections and
obvious scalp seams; keep hair color in the tintable slot.
- [ ] Verify hats/hoods/sunglasses/helmets still clear the refined brow, ears and hair; update gear
+16 -16
View File
@@ -487,8 +487,8 @@
"accessory",
"hair"
],
"triangles": 3984,
"vertices": 3793
"triangles": 3760,
"vertices": 3556
},
"npc_coat": {
"bounds_godot": {
@@ -528,8 +528,8 @@
"accessory",
"hair"
],
"triangles": 4978,
"vertices": 4776
"triangles": 4754,
"vertices": 4574
},
"npc_dress": {
"bounds_godot": {
@@ -567,8 +567,8 @@
"accessory",
"hair"
],
"triangles": 4400,
"vertices": 4075
"triangles": 4176,
"vertices": 3863
},
"npc_firefighter": {
"bounds_godot": {
@@ -608,8 +608,8 @@
"accessory",
"hair"
],
"triangles": 4848,
"vertices": 4831
"triangles": 4624,
"vertices": 4487
},
"npc_guard": {
"bounds_godot": {
@@ -652,8 +652,8 @@
"accessory",
"hair"
],
"triangles": 4558,
"vertices": 4652
"triangles": 4334,
"vertices": 4456
},
"npc_hoodie": {
"bounds_godot": {
@@ -693,8 +693,8 @@
"accessory",
"hair"
],
"triangles": 4816,
"vertices": 4425
"triangles": 4592,
"vertices": 4187
},
"npc_jacket": {
"bounds_godot": {
@@ -734,8 +734,8 @@
"accessory",
"hair"
],
"triangles": 4384,
"vertices": 4130
"triangles": 4160,
"vertices": 3930
},
"npc_vest": {
"bounds_godot": {
@@ -775,8 +775,8 @@
"accessory",
"hair"
],
"triangles": 4496,
"vertices": 4271
"triangles": 4272,
"vertices": 4186
},
"palm_tree": {
"bounds_godot": {
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

+2 -2
View File
@@ -94,8 +94,8 @@ All eight use the `npc_base` contract, so each one can replace
`Materials.npc_slot()` tints them the same way it tints `npc_base`.
- budget ≤ 6000 triangles and ≤ 5000 vertices (glTF vertex count, after seam
splits; `CHAR_MAX_VERTS` in `tools/blender_assets.py`, raised from 4000 for
the 2026-11-01 face/hand pass). The current range is 3,984–4,978 triangles
and 3,793–4,831 vertices.
the 2026-11-01 face/hand pass). The current range is 3,760–4,754 triangles
and 3,556–4,574 vertices.
Construction: the torso and limbs are one continuous Skin-modifier mesh,
subdivided twice, decimated to 26 % with X symmetry, and cut at the waist, cuffs,
+1
View File
@@ -46,6 +46,7 @@ Committed contact sheets and local Blender renders for the character, gear and p
| <img src="sheet_npc_baseline.jpg" width="280" alt="sheet_npc_baseline.jpg"> | [`sheet_npc_baseline.jpg`](sheet_npc_baseline.jpg) |
| <img src="sheet_npc_baseline_compare-p1.jpg" width="280" alt="sheet_npc_baseline_compare-p1.jpg"> | [`sheet_npc_baseline_compare-p1.jpg`](sheet_npc_baseline_compare-p1.jpg) |
| <img src="sheet_npc_baseline_compare-body.jpg" width="280" alt="sheet_npc_baseline_compare-body.jpg"> | [`sheet_npc_baseline_compare-body.jpg`](sheet_npc_baseline_compare-body.jpg) |
| <img src="sheet_npc_baseline_compare-face.jpg" width="280" alt="sheet_npc_baseline_compare-face.jpg"> | [`sheet_npc_baseline_compare-face.jpg`](sheet_npc_baseline_compare-face.jpg) |
| <img src="sheet_npc_gear.jpg" width="280" alt="sheet_npc_gear.jpg"> | [`sheet_npc_gear.jpg`](sheet_npc_gear.jpg) |
| <img src="sheet_props.jpg" width="280" alt="sheet_props.jpg"> | [`sheet_props.jpg`](sheet_props.jpg) |
| <img src="shoulder_bag.png" width="280" alt="shoulder_bag.png"> | [`shoulder_bag.png`](shoulder_bag.png) |
Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

+1
View File
@@ -40,6 +40,7 @@ Implemented so far:
- Visual alignment: start/venue menu, pause, settings, shift review, radio, wand, inspection, timeline, HUD target chip/escalation bar, and radial menu now use or match their AI references in `assets/images/ui/`. The runtime art loader (`src/game/ui/art.gd`) falls back cleanly when a file is missing. The wand panel lists the guest's carried items, and multiline HUD/wand strings render real line breaks.
- NPC neck/shoulder transition (0.9.4 work): the body graph now uses a fuller neck base, a slightly tapered neck top and a lower shoulder root, giving the head/neck/shoulder line a trapezius slope instead of a thin post. The full character library was rebuilt with a final degenerate-face cleanup; the baseline comparison is `sheet_npc_baseline_compare-body.jpg`.
- NPC face-depth pass (0.9.4 work): sclera are recessed and capped by a thin upper lid, and the separate jaw/cheek spheres are replaced by a chin plate and cheekbones sculpted into the head mesh. The full library was rebuilt; the comparison is `sheet_npc_baseline_compare-face.jpg`.
- NPC proportion audit (0.9.4 work): `tools/npc_proportions.py` measures all eight GLBs against a stylized height/head/shoulder/hip/torso/leg/arm/foot guide. All variants pass; the head and broad-build silhouettes are the nearest upper bounds. Results are in [`npc-proportion-audit.md`](npc-proportion-audit.md).
- NPC P1 portrait fixes (0.9.4 work): tourists now hide their photo-pose camera bag outside `photo`/`film` poses, removing the duplicate-backpack silhouette; the backpack gear was rebuilt with flattened, forward chest straps and a fresh AO map. The before/after sheet is [`blender_previews/sheet_npc_baseline_compare-p1.jpg`](blender_previews/sheet_npc_baseline_compare-p1.jpg); the face and remaining clothing/hand work stay open.
- NPC visual baseline (0.9.4 work): `src/test/capture_npc_baseline.gd` captures all eight library characters from front, three-quarter, profile, back, full-body and crowd views in a neutral studio (48 frames). The criteria, measurements and ranked gaps are in [`npc-visual-review.md`](npc-visual-review.md); the committed sheet is [`blender_previews/sheet_npc_baseline.jpg`](blender_previews/sheet_npc_baseline.jpg). No model changes were made in this baseline step.
+35 -30
View File
@@ -1,10 +1,12 @@
# NPC visual baseline and acceptance criteria
Status: baseline captured 2026-11-02; the first P1 fixes were applied the same
day (tourist photo-prop visibility and backpack strap geometry). The face pass
and the remaining body/clothing work are still open. The baseline remains the
reference for the 0.9.4 NPC visual optimization; the post-fix comparison is
[`blender_previews/sheet_npc_baseline_compare-p1.jpg`](blender_previews/sheet_npc_baseline_compare-p1.jpg).
Status: baseline captured 2026-11-02. The first P1 fixes (tourist photo-prop
visibility, backpack straps), the neck/shoulder transition and the face-depth
pass are applied. Remaining body/clothing work is open; the baseline remains the
reference for 0.9.4. Comparison sheets:
[`sheet_npc_baseline_compare-p1.jpg`](blender_previews/sheet_npc_baseline_compare-p1.jpg),
[`sheet_npc_baseline_compare-body.jpg`](blender_previews/sheet_npc_baseline_compare-body.jpg)
and the latest [`sheet_npc_baseline_compare-face.jpg`](blender_previews/sheet_npc_baseline_compare-face.jpg).
## What was captured
@@ -98,20 +100,19 @@ From `python3 tools/blender_assets.py fit` and the manifest:
| Character | Tris | Verts | Head width | Clothing radius | Notable gear clearances |
| --- | ---: | ---: | ---: | ---: | --- |
| `npc_casual` | 3,984 | 3,793 | 0.315 m | 0.191 m (hip) | backpack x −0.041, radial −0.162 |
| `npc_jacket` | 4,384 | 4,130 | 0.315 m | 0.191 m (hip) | lanyard x −0.116, radial +0.023 (runtime +4 cm forward) |
| `npc_coat` | 4,978 | 4,776 | 0.315 m | 0.225 m (skirt) | sunglasses width ratio 0.641; bag x +0.037, radial −0.130 |
| `npc_vest` | 4,496 | 4,271 | 0.315 m | 0.191 m (hip) | cap width ratio 1.067 |
| `npc_dress` | 4,400 | 4,075 | 0.315 m | 0.260 m (skirt) | bag x +0.002, radial −0.095 |
| `npc_hoodie` | 4,816 | 4,425 | 0.315 m | 0.190 m (hip) | — |
| `npc_firefighter` | 4,848 | 4,831 | 0.315 m | 0.255 m (skirt) | helmet width ratio 1.651; SCBA x −0.105, radial −0.076 |
| `npc_guard` | 4,558 | 4,652 | 0.315 m | 0.194 m (hip) | cap width ratio 1.067 |
| `npc_casual` | 3,760 | 3,556 | 0.315 m | 0.191 m (hip) | backpack x −0.041, radial −0.162 |
| `npc_jacket` | 4,160 | 3,930 | 0.315 m | 0.191 m (hip) | lanyard x −0.116, radial +0.023 (runtime +4 cm forward) |
| `npc_coat` | 4,754 | 4,574 | 0.315 m | 0.225 m (skirt) | sunglasses width ratio 0.641; bag x +0.037, radial −0.130 |
| `npc_vest` | 4,272 | 4,186 | 0.315 m | 0.191 m (hip) | cap width ratio 1.067 |
| `npc_dress` | 4,176 | 3,863 | 0.315 m | 0.260 m (skirt) | bag x +0.002, radial −0.095 |
| `npc_hoodie` | 4,592 | 4,187 | 0.315 m | 0.190 m (hip) | — |
| `npc_firefighter` | 4,624 | 4,487 | 0.315 m | 0.255 m (skirt) | helmet width ratio 1.651; SCBA x −0.105, radial −0.076 |
| `npc_guard` | 4,334 | 4,456 | 0.315 m | 0.194 m (hip) | cap width ratio 1.067 |
The widest character is `npc_firefighter` at 4,848 triangles and 4,831 vertices, so
the current headroom is 1,152 triangles and 169 vertices per character. Any face
or hand refinement must come out of that budget or replace existing detail. The
refit backpack measures 380 triangles / 406 vertices; the character and gear
table above remains valid because the character meshes did not change.
The widest character is `npc_coat` at 4,754 triangles and 4,574 vertices, so the
current headroom is 1,246 triangles and 426 vertices per character. Any hand or
clothing refinement must come out of that budget or replace existing detail. The
refit backpack measures 380 triangles / 406 vertices.
## Baseline findings, ranked
@@ -124,11 +125,12 @@ table above remains valid because the character meshes did not change.
photo prop is now hidden outside `photo`/`film` poses
(`photo_prop_visible_for_pose()`), so it only appears when the character is
actually taking a picture; the after frame confirms one bag at rest.
2. **The face is the largest portrait gap.** The eyes, nose and lips are
readable but sit on a shallow, faceted head: the eye whites protrude, the
eyelid/cheek transition is missing, and the mouth is a dark slot. This is
intentionally stylized, but the head volume and eye socket are the first
things a player sees at conversation range.
2. **Face depth. Fixed 2026-11-02.** The sclera was recessed into the socket and
wrapped with a thin upper lid; the separate jaw/cheek spheres were replaced
by a chin plate and cheekbones sculpted into the head mesh. That removes the
dark intersection rings that read as a beard shadow and keeps the mouth on
the surface. The mouth bar and low-poly brows remain stylized, but the eye
socket and jaw/cheek transition now read as one head volume.
3. **Backpack straps read as rigid black bars. Refit 2026-11-02.** The straps
were thin round tubes whose chest/waist points sat inside the torso, so only
fragments showed from the front. They are now flattened bands whose front path
@@ -182,17 +184,20 @@ python3 tools/npc_baseline.py compare \
```
The comparison sheet places the before/after pair for each view side by side.
The first run is committed as
[`sheet_npc_baseline_compare-p1.jpg` and `sheet_npc_baseline_compare-body.jpg`](blender_previews/sheet_npc_baseline_compare-p1.jpg);
keep the baseline sheet as the before artifact and add a new numbered/after
sheet rather than replacing it.
The committed runs are
[`sheet_npc_baseline_compare-p1.jpg`](blender_previews/sheet_npc_baseline_compare-p1.jpg),
[`sheet_npc_baseline_compare-body.jpg`](blender_previews/sheet_npc_baseline_compare-body.jpg)
and [`sheet_npc_baseline_compare-face.jpg`](blender_previews/sheet_npc_baseline_compare-face.jpg).
Keep the baseline sheet as the before artifact and add a new comparison sheet
rather than replacing it.
## Next implementation order
1. Refine the head/eye socket/jaw volume without changing the face direction or
the five tint slots.
1. Improve the torso/hip transition and the remaining body-shape variation.
2. Add a conversation-distance hand/grip capture and fix hand geometry before
judging hand items.
3. Audit hems and footwear in walk/sit/block poses.
4. Re-run the 48-frame baseline, the 96-frame final-context review, the 24-NPC
4. Refine nose/lip/ear shapes only if the portrait pass still flags them after
the hands and clothing read cleanly.
5. Re-run the 48-frame baseline, the 96-frame final-context review, the 24-NPC
budget and `blender_assets.py audit/fit`; publish the next comparison sheet.
+48 -18
View File
@@ -728,23 +728,48 @@ def _hood(name, mat, head_center):
return _finish(_link(name, bm), mat, smooth=True, sharp_angle=60)
def _shape_head_mesh(head):
"""Sculpt a chin plate and cheekbones into the head sphere itself.
Separate jaw/cheek spheres leave visible intersection rings in the baked AO,
which read as a beard shadow. Moving the head's own front vertices keeps the
surface continuous while still giving the key light something to catch.
"""
bm = bmesh.new()
bm.from_mesh(head.data)
for v in bm.verts:
x, y, z = v.co
if y >= 0.0:
continue
ax = abs(x)
# Chin/jaw plate: forward and slightly wider below the mouth.
jaw = (max(0.0, 1.0 - abs(z - 1.565) / 0.055)
* max(0.0, 1.0 - ax / 0.075))
v.co.y -= 0.012 * jaw
v.co.x *= 1.0 + 0.06 * jaw
# Cheekbone: a shallow, wide lift under each eye.
cheek = (max(0.0, 1.0 - abs(z - 1.610) / 0.040)
* max(0.0, 1.0 - abs(ax - 0.072) / 0.050))
v.co.y -= 0.006 * cheek
bm.normal_update()
bm.to_mesh(head.data)
bm.free()
def _face(mats, spec):
"""Head with readable eyes, brow ridge, nose, lips and ears.
At conversation distance the eye region carries the read, so each eye gets a
white sclera, a dark iris and an upper lid, and the face gets cheekbones and
a nose bridge instead of a single cone (realism pass detail work).
white sclera, a dark iris and an upper lid, and the face gets sculpted
cheekbones and a nose bridge instead of a single cone (realism pass detail
work).
"""
skin, acc, hair = mats["skin"], mats["accessory"], mats["hair"]
white = material("eye_white", textured_solid=True)
parts = [sphere("Head", 0.155, (0, 0, 1.635), skin, seg=18, rings=12,
scale=(0.92, 1.0, 1.12))]
# jaw / chin and cheekbones soften the egg into a face
parts.append(sphere("Jaw", 0.10, (0, -0.04, 1.556), skin, seg=12, rings=8,
scale=(1.0, 1.0, 0.8)))
for s in (-1.0, 1.0):
parts.append(sphere("Cheek", 0.034, (0.076 * s, -0.104, 1.606), skin, seg=8,
rings=5, scale=(1.0, 0.7, 0.8)))
head = sphere("Head", 0.155, (0, 0, 1.635), skin, seg=18, rings=12,
scale=(0.92, 1.0, 1.12))
_shape_head_mesh(head)
parts = [head]
# nose: bridge and ball (nostrils were cut to stay inside the vertex budget)
parts.append(cyl("NoseBridge", 0.016, 0.022, 0.06, (0, -0.145, 1.652), skin, seg=8,
rot=(math.radians(-72), 0, 0), smooth=True))
@@ -756,20 +781,25 @@ def _face(mats, spec):
scale=(0.42, 1.0, 1.35)))
parts.append(sphere("EarLobe", 0.018, (0.142 * s, -0.005, 1.605), skin, seg=8,
rings=5, scale=(0.45, 1.0, 0.9)))
# eye: sclera, iris, upper lid and brow
ex, ey, ez = 0.052 * s, -0.138, 1.664
# Eye: recess the sclera into the socket, then wrap it with skin lids.
# The old white ball sat ~1 cm proud of the head; this keeps the iris
# readable without the doll-like bulge.
ex, ey, ez = 0.052 * s, -0.134, 1.664
parts.append(sphere("EyeWhite", 0.019, (ex, ey, ez), white, seg=8, rings=5,
scale=(1.25, 0.7, 0.85)))
parts.append(sphere("Iris", 0.0105, (ex, ey - 0.013, ez), acc, seg=8, rings=5,
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(box("Lid", (0.038, 0.010, 0.008), (ex, ey + 0.004, ez + 0.014),
skin, rot=(math.radians(-10), 0, 0)))
# One thin upper lid; no lower lid, which made the eyes read as
# half-closed slits at conversation distance.
parts.append(sphere("UpperLid", 0.020, (ex, ey - 0.006, ez + 0.014), skin,
seg=8, rings=4, scale=(1.20, 0.32, 0.24),
rot=(math.radians(-10), 0, 0)))
parts.append(box("Brow", (0.055, 0.014, 0.012), (0.052 * s, -0.143, 1.699), hair,
rot=(0, 0.14 * s, 0.0)))
# lips: upper and lower instead of a single bar
parts.append(box("LipUpper", (0.052, 0.012, 0.008), (0, -0.145, 1.588), acc,
parts.append(box("LipUpper", (0.052, 0.012, 0.008), (0, -0.156, 1.588), acc,
rot=(math.radians(6), 0, 0)))
parts.append(box("LipLower", (0.046, 0.012, 0.009), (0, -0.143, 1.578), acc))
parts.append(box("LipLower", (0.046, 0.012, 0.009), (0, -0.154, 1.578), acc))
if spec.get("beard"):
parts.append(sphere("Beard", 0.105, (0, -0.06, 1.55), hair, seg=12, rings=8,
scale=(1.0, 0.9, 0.75), cut_below=-0.2))