Prototype a more realistic face on npc_casual (higher subdivision, brow ridge, both lids, fuller lip)
This commit is contained in:
1 parent
8d32a8cac0
commit
4e23cc3ad5
7 files changed
+49
-25
No files matched your search
@@ -128,6 +128,10 @@ canonical implemented-features list in `docs/development-status.md`.
|
|||||||
all local lights use `1.0`.
|
all local lights use `1.0`.
|
||||||
- glTF `baseColorFactor` is LINEAR — writing sRGB makes props pastel; `srgb_to_linear()` is applied
|
- glTF `baseColorFactor` is LINEAR — writing sRGB makes props pastel; `srgb_to_linear()` is applied
|
||||||
in `build_glb`. A new/changed `.glb` needs `godot --headless --import` before `load()` sees it.
|
in `build_glb`. A new/changed `.glb` needs `godot --headless --import` before `load()` sees it.
|
||||||
|
- **Rebuilding a character GLB also requires re-baking its AO** (`tools/bake_ao.py -- --only
|
||||||
|
<name>`). `blender_assets.py build` writes the GLB only; the old `<name>_ao.png` then maps onto
|
||||||
|
the new geometry and produces large dark cheek/jaw blotches. Bake the AO, re-import, and refresh
|
||||||
|
the stale `.godot` cache (scratch-copy import) before judging a face/body change.
|
||||||
- `Save.SETTINGS_FILE` is declared and unused; settings persist through
|
- `Save.SETTINGS_FILE` is declared and unused; settings persist through
|
||||||
`load_settings()`/`save_settings()`.
|
`load_settings()`/`save_settings()`.
|
||||||
- Crowd reactions emit `event_bus.crowd_changed`; `GameManager._connect_crowd()` must reconnect
|
- Crowd reactions emit `event_bus.crowd_changed`; `GameManager._connect_crowd()` must reconnect
|
||||||
|
|||||||
@@ -466,8 +466,8 @@
|
|||||||
"accessory",
|
"accessory",
|
||||||
"hair"
|
"hair"
|
||||||
],
|
],
|
||||||
"triangles": 4482,
|
"triangles": 5038,
|
||||||
"vertices": 4260
|
"vertices": 4756
|
||||||
},
|
},
|
||||||
"npc_coat": {
|
"npc_coat": {
|
||||||
"bounds_godot": {
|
"bounds_godot": {
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
@@ -234,6 +234,13 @@ owner review decides.
|
|||||||
`seg=18/rings=12` with small offsets — not missing geometry. Concrete next step, if funded:
|
`seg=18/rings=12` with small offsets — not missing geometry. Concrete next step, if funded:
|
||||||
a single-variant `npc_casual` prototype that raises head subdivision and deepens the
|
a single-variant `npc_casual` prototype that raises head subdivision and deepens the
|
||||||
cheek/brow offsets, then re-bakes AO and captures the portrait before touching the other seven.
|
cheek/brow offsets, then re-bakes AO and captures the portrait before touching the other seven.
|
||||||
|
|
||||||
|
**Prototype landed 2026-11-03/04:** `npc_casual` now has `seg=24/rings=16` (smoother), a subtle
|
||||||
|
brow ridge, a fuller lower lip and both eyelids (upper + lower); recessed creases (nasolabial,
|
||||||
|
temple, chin cleft) were rejected because they bake as dark AO patches. Vision-reviewed: the new
|
||||||
|
face reads cleaner and more realistic than the old (set-in eyes, brow break, lip volume, no
|
||||||
|
faceting); tris 4,482 → 5,038 (still under 8,000). The other seven variants keep the old face
|
||||||
|
until this prototype is approved.
|
||||||
2. **No pose/weight evidence (unknown).** Idle, walk, sit, block and turn deformation has never been
|
2. **No pose/weight evidence (unknown).** Idle, walk, sit, block and turn deformation has never been
|
||||||
captured as a review set, so shoulder/hip/neck collapse is unmeasured. Cheap to add and it
|
captured as a review set, so shoulder/hip/neck collapse is unmeasured. Cheap to add and it
|
||||||
gates any claim that the rig is acceptable.
|
gates any claim that the rig is acceptable.
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ The 2026-11-03 skill-guided re-evaluation, its measurements (texel density, budg
|
|||||||
bounds) and the open scorecard live in [`3d-model-review.md`](3d-model-review.md). It does not
|
bounds) and the open scorecard live in [`3d-model-review.md`](3d-model-review.md). It does not
|
||||||
change the accepted limits below.
|
change the accepted limits below.
|
||||||
|
|
||||||
|
A single-variant face prototype (`npc_casual`) landed 2026-11-03/04: `seg=24/rings=16` head,
|
||||||
|
subtle brow ridge, fuller lower lip and both eyelids. It reads cleaner than the 0.9.4 face and is
|
||||||
|
a candidate for the remaining seven once the owner approves (see the review doc).
|
||||||
|
|
||||||
## What was captured
|
## What was captured
|
||||||
|
|
||||||
`src/test/capture_npc_baseline.gd` stages all eight library characters in a
|
`src/test/capture_npc_baseline.gd` stages all eight library characters in a
|
||||||
|
|||||||
+32
-23
@@ -735,11 +735,13 @@ def _hood(name, mat, head_center):
|
|||||||
|
|
||||||
|
|
||||||
def _shape_head_mesh(head):
|
def _shape_head_mesh(head):
|
||||||
"""Sculpt a chin plate and cheekbones into the head sphere itself.
|
"""Sculpt a brow ridge, cheekbones and a jaw/chin plate into the head sphere.
|
||||||
|
|
||||||
Separate jaw/cheek spheres leave visible intersection rings in the baked AO,
|
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
|
which read as a beard shadow. Only forward protrusions are sculpted; recessed
|
||||||
surface continuous while still giving the key light something to catch.
|
creases (nasolabial folds, temple hollows, a chin cleft) bake as dark AO
|
||||||
|
patches on this low-poly head, so they are left to shading. The face front is
|
||||||
|
-Y (Blender) / +Z (Godot); z is up in this builder.
|
||||||
"""
|
"""
|
||||||
bm = bmesh.new()
|
bm = bmesh.new()
|
||||||
bm.from_mesh(head.data)
|
bm.from_mesh(head.data)
|
||||||
@@ -748,42 +750,47 @@ def _shape_head_mesh(head):
|
|||||||
if y >= 0.0:
|
if y >= 0.0:
|
||||||
continue
|
continue
|
||||||
ax = abs(x)
|
ax = abs(x)
|
||||||
# Chin/jaw plate: forward and slightly wider below the mouth.
|
# Brow ridge: a shallow forward roll above each eye socket.
|
||||||
|
brow = (max(0.0, 1.0 - abs(z - 1.695) / 0.030)
|
||||||
|
* max(0.0, 1.0 - abs(ax - 0.050) / 0.040))
|
||||||
|
v.co.y -= 0.008 * brow
|
||||||
|
# Cheekbone: a shallow, wide lift under each eye.
|
||||||
|
cheek = (max(0.0, 1.0 - abs(z - 1.612) / 0.040)
|
||||||
|
* max(0.0, 1.0 - abs(ax - 0.070) / 0.050))
|
||||||
|
v.co.y -= 0.005 * cheek
|
||||||
|
# Jaw/chin plate: forward and slightly wider below the mouth.
|
||||||
jaw = (max(0.0, 1.0 - abs(z - 1.565) / 0.055)
|
jaw = (max(0.0, 1.0 - abs(z - 1.565) / 0.055)
|
||||||
* max(0.0, 1.0 - ax / 0.075))
|
* max(0.0, 1.0 - ax / 0.075))
|
||||||
v.co.y -= 0.012 * jaw
|
v.co.y -= 0.012 * jaw
|
||||||
v.co.x *= 1.0 + 0.06 * 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.normal_update()
|
||||||
bm.to_mesh(head.data)
|
bm.to_mesh(head.data)
|
||||||
bm.free()
|
bm.free()
|
||||||
|
|
||||||
|
|
||||||
def _face(mats, spec):
|
def _face(mats, spec):
|
||||||
"""Head with readable eyes, brow ridge, nose, lips and ears.
|
"""Head with realistic face planes: recessed sockets with both lids, a
|
||||||
|
sculpted brow/cheek/jaw, a bridged nose with ala and columella, and separated
|
||||||
|
upper/lower lips.
|
||||||
|
|
||||||
At conversation distance the eye region carries the read, so each eye gets a
|
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 sculpted
|
white sclera, a dark iris, an upper and a lower lid, and the face gets
|
||||||
cheekbones and a nose bridge instead of a single cone (realism pass detail
|
sculpted brow/cheek/jaw planes instead of a bare sphere (realism pass).
|
||||||
work).
|
|
||||||
"""
|
"""
|
||||||
skin, acc, hair = mats["skin"], mats["accessory"], mats["hair"]
|
skin, acc, hair = mats["skin"], mats["accessory"], mats["hair"]
|
||||||
white = material("eye_white", textured_solid=True)
|
white = material("eye_white", textured_solid=True)
|
||||||
head = sphere("Head", 0.155, (0, 0, 1.635), skin, seg=18, rings=12,
|
head = sphere("Head", 0.155, (0, 0, 1.635), skin, seg=24, rings=16,
|
||||||
scale=(0.92, 1.0, 1.12))
|
scale=(0.92, 1.0, 1.12))
|
||||||
_shape_head_mesh(head)
|
_shape_head_mesh(head)
|
||||||
parts = [head]
|
parts = [head]
|
||||||
# nose: bridge and ball (nostrils were cut to stay inside the vertex budget)
|
# Nose: bridge and ball; the ala widen the base into a small triangle.
|
||||||
parts.append(cyl("NoseBridge", 0.016, 0.022, 0.06, (0, -0.145, 1.652), skin, seg=8,
|
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))
|
rot=(math.radians(-72), 0, 0), smooth=True))
|
||||||
parts.append(sphere("NoseTip", 0.022, (0, -0.162, 1.615), skin, seg=10, rings=6,
|
parts.append(sphere("NoseTip", 0.022, (0, -0.162, 1.615), skin, seg=10, rings=6,
|
||||||
scale=(1.0, 0.85, 0.80)))
|
scale=(1.0, 0.85, 0.80)))
|
||||||
for s in (-1.0, 1.0):
|
for s in (-1.0, 1.0):
|
||||||
# Ala widen the nose base into a small triangle rather than a ball.
|
# Ala widen the nose base into a small triangle rather than a ball.
|
||||||
parts.append(sphere("NoseWing", 0.011, (0.015 * s, -0.160, 1.606), skin,
|
parts.append(sphere("NoseWing", 0.012, (0.016 * s, -0.160, 1.606), skin,
|
||||||
seg=6, rings=4, scale=(1.0, 0.65, 0.75)))
|
seg=6, rings=4, scale=(1.0, 0.65, 0.75)))
|
||||||
# Ear with a lobe and a darker concha; the old disc had no depth cue.
|
# Ear with a lobe and a darker concha; the old disc had no depth cue.
|
||||||
parts.append(sphere("Ear", 0.036, (0.143 * s, 0.008, 1.63), skin, seg=8, rings=5,
|
parts.append(sphere("Ear", 0.036, (0.143 * s, 0.008, 1.63), skin, seg=8, rings=5,
|
||||||
@@ -792,27 +799,29 @@ def _face(mats, spec):
|
|||||||
rings=5, scale=(0.45, 1.0, 0.9)))
|
rings=5, scale=(0.45, 1.0, 0.9)))
|
||||||
parts.append(sphere("EarInner", 0.013, (0.149 * s, -0.003, 1.633), acc,
|
parts.append(sphere("EarInner", 0.013, (0.149 * s, -0.003, 1.633), acc,
|
||||||
seg=6, rings=4, scale=(0.28, 0.65, 0.90)))
|
seg=6, rings=4, scale=(0.28, 0.65, 0.90)))
|
||||||
# Eye: recess the sclera into the socket, then wrap it with skin lids.
|
# Eye: recess the sclera into the socket, then wrap it with both lids.
|
||||||
# The old white ball sat ~1 cm proud of the head; this keeps the iris
|
# The old white ball sat ~1 cm proud of the head; this keeps the iris
|
||||||
# readable without the doll-like bulge.
|
# readable without the doll-like bulge.
|
||||||
ex, ey, ez = 0.052 * s, -0.134, 1.664
|
ex, ey, ez = 0.052 * s, -0.134, 1.664
|
||||||
parts.append(sphere("EyeWhite", 0.019, (ex, ey, ez), white, seg=8, rings=5,
|
parts.append(sphere("EyeWhite", 0.019, (ex, ey, ez), white, seg=10, rings=6,
|
||||||
scale=(1.25, 0.62, 0.85)))
|
scale=(1.25, 0.62, 0.85)))
|
||||||
parts.append(sphere("Iris", 0.0105, (ex, ey - 0.007, ez), acc, seg=8, rings=5,
|
parts.append(sphere("Iris", 0.0105, (ex, ey - 0.007, ez), acc, seg=8, rings=5,
|
||||||
scale=(1.0, 0.55, 1.0)))
|
scale=(1.0, 0.55, 1.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,
|
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),
|
seg=8, rings=4, scale=(1.20, 0.32, 0.24),
|
||||||
rot=(math.radians(-10), 0, 0)))
|
rot=(math.radians(-10), 0, 0)))
|
||||||
|
# A thin lower lid keeps the eye from reading as a floating white oval.
|
||||||
|
parts.append(sphere("LowerLid", 0.017, (ex, ey - 0.004, ez - 0.012), skin,
|
||||||
|
seg=8, rings=4, scale=(1.10, 0.22, 0.18),
|
||||||
|
rot=(math.radians(6), 0, 0)))
|
||||||
parts.append(box("Brow", (0.055, 0.014, 0.012), (0.052 * s, -0.143, 1.699), hair,
|
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)))
|
rot=(0, 0.14 * s, 0.0)))
|
||||||
# lips: upper and lower instead of a single bar
|
# Mouth: one rounded dark slot (the old clean read) plus a fuller lower lip
|
||||||
# One rounded mouth slot instead of two flat boxes. Two dark lip blobs read
|
# in skin so the mouth line gains a subtle volume cue without a moustache read.
|
||||||
# as a moustache at portrait distance; a single shallow oval keeps the mouth
|
|
||||||
# line without adding a second dark mass.
|
|
||||||
parts.append(sphere("Mouth", 0.028, (0, -0.158, 1.583), acc, seg=8, rings=5,
|
parts.append(sphere("Mouth", 0.028, (0, -0.158, 1.583), acc, seg=8, rings=5,
|
||||||
scale=(1.0, 0.28, 0.12)))
|
scale=(1.0, 0.28, 0.12)))
|
||||||
|
parts.append(sphere("LowerLip", 0.012, (0, -0.156, 1.578), skin, seg=8, rings=5,
|
||||||
|
scale=(1.2, 0.55, 0.30)))
|
||||||
if spec.get("beard"):
|
if spec.get("beard"):
|
||||||
parts.append(sphere("Beard", 0.105, (0, -0.06, 1.55), hair, seg=12, rings=8,
|
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))
|
scale=(1.0, 0.9, 0.75), cut_below=-0.2))
|
||||||
|
|||||||
Reference in new issue
Block a user