diff --git a/MEMORY.md b/MEMORY.md index eb30c84..28fa59c 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -128,6 +128,10 @@ canonical implemented-features list in `docs/development-status.md`. all local lights use `1.0`. - 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. +- **Rebuilding a character GLB also requires re-baking its AO** (`tools/bake_ao.py -- --only + `). `blender_assets.py build` writes the GLB only; the old `_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 `load_settings()`/`save_settings()`. - Crowd reactions emit `event_bus.crowd_changed`; `GameManager._connect_crowd()` must reconnect diff --git a/assets/models/blender/manifest.json b/assets/models/blender/manifest.json index 51ca86e..4d63627 100644 --- a/assets/models/blender/manifest.json +++ b/assets/models/blender/manifest.json @@ -466,8 +466,8 @@ "accessory", "hair" ], - "triangles": 4482, - "vertices": 4260 + "triangles": 5038, + "vertices": 4756 }, "npc_coat": { "bounds_godot": { @@ -1224,4 +1224,4 @@ "head": "Rig/Head marker (y = 1.62 m, head centre)" }, "generator": "tools/blender_assets.py" -} \ No newline at end of file +} diff --git a/assets/models/blender/npc/npc_casual.glb b/assets/models/blender/npc/npc_casual.glb index 9231cd5..82e876c 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 97ed309..3d97b5b 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/docs/3d-model-review.md b/docs/3d-model-review.md index 341f9fb..be31ae8 100644 --- a/docs/3d-model-review.md +++ b/docs/3d-model-review.md @@ -234,6 +234,13 @@ owner review decides. `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 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 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. diff --git a/docs/npc-visual-review.md b/docs/npc-visual-review.md index e326081..c7c78c8 100644 --- a/docs/npc-visual-review.md +++ b/docs/npc-visual-review.md @@ -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 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 `src/test/capture_npc_baseline.gd` stages all eight library characters in a diff --git a/tools/blender_assets.py b/tools/blender_assets.py index 2f8489f..e55e489 100644 --- a/tools/blender_assets.py +++ b/tools/blender_assets.py @@ -735,11 +735,13 @@ def _hood(name, mat, head_center): 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, - 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. + which read as a beard shadow. Only forward protrusions are sculpted; recessed + 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.from_mesh(head.data) @@ -748,42 +750,47 @@ def _shape_head_mesh(head): if y >= 0.0: continue 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) * 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. + """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 - 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). + white sclera, a dark iris, an upper and a lower lid, and the face gets + sculpted brow/cheek/jaw planes instead of a bare sphere (realism pass). """ skin, acc, hair = mats["skin"], mats["accessory"], mats["hair"] 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)) _shape_head_mesh(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, rot=(math.radians(-72), 0, 0), smooth=True)) parts.append(sphere("NoseTip", 0.022, (0, -0.162, 1.615), skin, seg=10, rings=6, scale=(1.0, 0.85, 0.80))) for s in (-1.0, 1.0): # 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))) # 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, @@ -792,27 +799,29 @@ def _face(mats, spec): rings=5, scale=(0.45, 1.0, 0.9))) 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))) - # 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 # 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, + parts.append(sphere("EyeWhite", 0.019, (ex, ey, ez), white, seg=10, rings=6, 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))) - # 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))) + # 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, rot=(0, 0.14 * s, 0.0))) - # lips: upper and lower instead of a single bar - # One rounded mouth slot instead of two flat boxes. Two dark lip blobs read - # as a moustache at portrait distance; a single shallow oval keeps the mouth - # line without adding a second dark mass. + # Mouth: one rounded dark slot (the old clean read) plus a fuller lower lip + # in skin so the mouth line gains a subtle volume cue without a moustache read. parts.append(sphere("Mouth", 0.028, (0, -0.158, 1.583), acc, seg=8, rings=5, 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"): 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))