Files
security-game/docs/3d-model-review.md
T
zwitschi 44dba010a7
Full build / Import + headless tests (push) Successful in 42s
Full build / Full desktop build + optional release (push) Skipped
Prepare the head-prototype owner sign-off sheet (before/after at 1.85m)
2026-11-04 14:47:25 +01:00

466 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 3D model review (2026-11-03)
Status: **baseline measured; scorecard open.** This is the working document for the unversioned
`3D model re-evaluation` track in [`TODO.md`](../TODO.md). It records what was measured, what the
Blender skill guidance says the targets are, the ranked candidate gaps, and the decisions that
still need the project owner. Nothing here re-opens the accepted 0.9.4 limits by itself.
Related documents: [`npc-visual-review.md`](npc-visual-review.md) (accepted 0.9.4 limits and
before/after sheets), [`npc-visual-review.md`](npc-visual-review.md#proportion-audit) (proportion guide),
[`blender-assets.md`](blender-assets.md) (asset contract), [`asset-tooling.md`](asset-tooling.md)
(commands).
## Method and skill routing
The review is screenshot- and measurement-backed, following `qa-review` (screenshot set, defect
severity, explicit verdict) rather than an opinion pass:
| Family | Skills loaded | Criteria taken from them |
| --- | --- | --- |
| Routing and pipeline | `blender-director` | Plan → blockout → detail → validate → export; MCP steps translated to headless flatpak runs |
| Characters | `character-artist`, `retopology`, `rigging` | Eye/mouth/jaw/shoulder loops, quad-dominant deformation surfaces, 3-loop joints, no triangles on primary deformers |
| UVs and textures | `uv-workflow`, `texture-workflow`, `materials` | Texel-density tiers (hero 512–1024, standard 256–512, background 128–256 px/m), consistent density, no overlapping UVs |
| Budgets and structure | `asset-optimization`, `lod-pipeline` | Triangle/vertex budgets, naming, LOD tiers |
| Props, gear, foliage | `prop-artist`, `hard-surface`, `vegetation-artist` | Silhouette read, bevel/chamfer language, collision proxies, carded foliage |
| Look and verdict | `lookdev`, `qa-review` | Day/night beauty captures, ship / ship-with-notes / no-ship verdict |
Project contract and gates stay authoritative; see `AGENTS.md` §Local Blender skills. Blender MCP
is optional interactive tooling (see [`asset-tooling.md#optional-interactive-blender-mcp`](asset-tooling.md#optional-interactive-blender-mcp)); this review's evidence
came from the headless `tools/blender_assets.py` / `tools/npc_pipeline.py` pipelines plus the
Godot capture harnesses, so it remains reproducible without MCP.
## What was captured and measured (2026-11-03)
| Evidence | Command | Result |
| --- | --- | --- |
| Contract audit | `python3 tools/blender_assets.py audit --json` | 46/46 assets clean; library 59,012 tris |
| Imported-GLB inspection | `blender_assets.py inspect` (all 46) | All clean; transforms applied, helpers ignored |
| Gear fit | `python3 tools/blender_assets.py fit --json` | Head width 0.315 m on all eight; clothing radius 0.190–0.260 m |
| Proportions | `python3 tools/npc_proportions.py report --json` | All eight inside the stylized guide |
| Texel density | `python3 tools/blender_assets.py texels --json` | New measurement, table below |
| Neutral baseline | `capture_npc_baseline.gd` → `tmp/model-baseline` (48 frames) + `npc_baseline.py sheet` | 48 frames, none blank; sheet in `tmp/` only |
| Final context | `capture_npc_review.gd` → `tmp/model-review` (96 frames) | 96 frames, none blank; raw frames stay local |
| Performance | `smoke_npc_budget.gd` | 24 avatars, 168 meshes, 152,464 tris, 6.94 ms of 50 ms |
Raw captures and JSON stay in the gitignored `tmp/`; committed review images remain the
`docs/blender_previews/sheet_*.jpg` set from 0.9.4/0.9.5 until a fix actually lands.
## Character measurements
Contract numbers from `audit` + `inspect`; density from `texels` (256² slot maps, authored and
with the runtime 4× fabric repeat).
| Character | Tris / verts | Height / feet | Materials | Authored td (px/m) | Fabric effective (px/m) | Verdict |
| --- | --- | --- | --- | --- | --- | --- |
| `npc_casual` | 5,038 / 4,756 | 1.824 / +0.000 | 7 | 106.8–122.2 | 429–440 | Contract pass; face prototype |
| `npc_jacket` | 5,428 / 5,071 | 1.824 / +0.000 | 7 | 100.3–113.7 | 409–413 | Contract pass; face prototype |
| `npc_coat` | 6,030 / 5,802 | 1.824 / +0.000 | 7 | 75.3–81.4 | 307–310 | Contract pass; face prototype |
| `npc_vest` | 5,536 / 5,375 | 1.819 / +0.000 | 9 | 85.5–99.3 | 362–366 | Contract pass; face prototype |
| `npc_dress` | 5,456 / 5,128 | 1.825 / +0.000 | 7 | 81.9–89.0 | 338–339 | Contract pass; face prototype |
| `npc_hoodie` | 5,860 / 5,492 | 1.856 / +0.000 | 7 | 89.5–98.6 | 359–364 | Contract pass; face prototype |
| `npc_firefighter` | 5,900 / 5,902 | 1.819 / +0.000 | 9 | 64.0–75.1 | 287–290 | Contract pass; face prototype (helmet shadow hides the lower lip) |
| `npc_guard` | 5,602 / 5,684 | 1.819 / +0.000 | 11 | 90.0–100.8 | 374–374 | Contract pass; face prototype |
| `npc_crowd_a` | 5,200 / 4,734 | 1.825 / +0.000 | 7 | 107–120 | 431–435 | Contract pass; crowd prototype |
| `npc_crowd_b` | 4,734 / 4,353 | 1.809 / +0.000 | 7 | 107–118 | 434–439 | Contract pass; crowd prototype |
| `npc_crowd_c` | 5,674 / 5,440 | 1.824 / +0.000 | 7 | 76–83 | 317 | Contract pass; crowd prototype |
| `npc_crowd_d` | 5,814 / 5,300 | 1.856 / +0.000 | 7 | 92–106 | 377–381 | Contract pass; crowd prototype |
| `npc_base` (fallback) | 1,204 / 2,488 | 1.836 / +0.000 | 5 | 70.1–72.9 | 4× on fabric | Contract pass; compact fallback under the 16,000/16,000 cap |
Blender-side `inspect` counts can differ by ±1 vertex from the glTF accessor count in `audit`
(normal-split rounding); both are recorded and neither changes a verdict.
Readings:
- Every character is inside 16,000/16,000 with the farthest well below the new cap and 5,494
verts (69%), so there is headroom for targeted topology detail without a cap change.
- Surface area tracks clothing volume: the firefighter coat is the largest body (4.28 m²) and has
the lowest authored density; `npc_casual` is the smallest (2.44 m²) and the highest.
- **Authored density (64–122 px/m) sits below the skill's background tier (128–256 px/m).** The
runtime tint controls most of the look: `skin`, `hair`, `accessory` and the fixed detail slots
are neutral maps tinted at runtime, not painted skin, so density mostly controls subtle
weave/noise. Fabric is effective 287–440 px/m (standard tier) because of the 4× repeat. This is
a recorded note, not a defect, until a portrait shows texture starvation.
- Material counts 7–11 per character are fixed detail slots (eyes, rubber, hivis, reflective,
leather, metal), not extra tint slots.
## Baseline inventory (2026-11-04)
Recorded for the Blender NPC model overhaul track: the exact starting point before the local
part pipeline is built. `npc_base.blend` is the frozen fallback; the 12 characters are assembled
procedurally by `tools/blender_assets.py`; the MakeHuman prototypes live under `tmp/npc-research/`
and are not the shipped path.
### `assets/models/npc/npc_base.blend` (frozen fallback)
- Mesh `npc_base`: **652 verts / 642 tris**, five material slots in order
`pants, cloth, skin, hair, accessory`.
- Armature: **14 bones** — `Hips, Spine, Neck, Head, ShoulderL/R, ElbowL/R, HandL/R, HipL/R, KneeL/R`
(the required 13 core bones plus the `Hips` root).
- It is the compact fallback, not the prototype host; the overhaul must not absorb prototype
detail into it unless a separate contract decision approves it.
### 12 generated character GLBs
`audit` counts (glTF accessor; Blender `inspect` can differ ±1 on normal-split rounding):
| Character | Tris | Verts | | Character | Tris | Verts |
| --- | ---: | ---: | --- | --- | ---: | ---: |
| `npc_casual` | 5,034 | 4,607 | | `npc_firefighter` | 5,888 | 5,785 |
| `npc_jacket` | 5,432 | 5,225 | | `npc_guard` | 5,670 | 5,671 |
| `npc_coat` | 6,046 | 5,645 | | `npc_crowd_a` | 5,284 | 4,908 |
| `npc_vest` | 5,532 | 5,195 | | `npc_crowd_b` | 4,812 | 4,550 |
| `npc_dress` | 5,540 | 5,073 | | `npc_crowd_c` | 5,690 | 5,510 |
| `npc_hoodie` | 5,864 | 5,325 | | `npc_crowd_d` | 5,810 | 5,444 |
All well under the 16,000/16,000 cap; the largest is `npc_coat` at 38% of the tri budget, so
head/face/hand detail has ample headroom.
### Procedural builders (current geometry locations)
Coordinates are Blender metres, Z-up; face front is −Y / Godot +Z; `s` = ±1 side, `w` = build
width scale.
- **Head** — `_face` + `_shape_head_mesh`: `sphere(Head, 0.155, (0, 0, 1.635), seg=24, rings=16,
scale=(0.92, 1.0, 1.12))`. Forward-only sculpt (brow at z 1.695, cheek at z 1.612, jaw/chin at
z 1.565); recessed creases rejected because they bake as dark AO patches.
- **Eyes** — sclera `sphere(0.019, (0.052s, −0.134, 1.664), scale=(1.25, 0.62, 0.85))`;
iris `sphere(0.0105, (0.052s, −0.141, 1.664), scale=(1.0, 0.55, 1.0))`; upper lid at z +0.014,
lower lid at z −0.012; brow box at (0.052s, −0.143, 1.699).
- **Nose** — bridge `cyl(0.016→0.022, (0, −0.145, 1.652))`, tip `sphere(0.022, (0, −0.162, 1.615))`,
wings `sphere(0.012, (0.016s, −0.160, 1.606))`.
- **Mouth** — slot `sphere(0.028, (0, −0.158, 1.583), scale=(1.0, 0.28, 0.12))`, lower lip
`sphere(0.012, (0, −0.156, 1.578))`.
- **Ears** — `sphere(0.036, (0.143s, 0.008, 1.63), scale=(0.42, 1.0, 1.35))` + lobe + inner concha.
- **Hands** — `_hands_and_shoes`: palm `sphere(0.046, (0.275·s·w, −0.012, 0.762),
scale=(0.80, 0.62, 0.88))`; four fingers base (…, 0.778) → tips z 0.701–0.714; thumb
(…, 0.778 → 0.760 → 0.738). No finger bones; open palm + `HAND_ITEM_FORWARD` only.
### UV layout
- Five tint slots (`skin, hair, accessory, cloth, pants`) at 256² each; fixed detail slots
(`eye_white`, rubber, hivis, reflective, leather, metal) are the extra material slots.
- Authored texel density 64–122 px/m (below the background tier 128–256), fabric effective
287–440 px/m via the 4× repeat; runtime tint carries the look. This is a recorded note until a
portrait shows texture starvation.
### MakeHuman prototypes (not shipped; `tmp/npc-research/makehuman/`)
| File | Verts / tris | Bones | Slots | Note |
| --- | ---: | ---: | --- | --- |
| `makehuman_body.glb` | 19,320 / 6,444 | 0 | `skin_proto` | CC0 intake, unskinned, 1.82 m grounded |
| `makehuman_retargeted.glb` | 7,062 / 6,444 | 14 | 5 tint | arm-down + contract rig, deltoid warp remains |
| `makehuman_final.glb` | 6,651 / 6,407 | 14 | 8 (incl. `skin.001`, `eye_white`, `rubber`) | project head/hands/shoes swapped; duplicate `skin.001` |
The "Icosphere" the inspector lists alongside these GLBs is the glTF importer's
bone-display helper (a `pose_bone.custom_shape`), not part of the asset; `inspect_glb` already
excludes it from measurements. The real cleanup for `makehuman_final.glb` is the duplicate
`skin.001` material and the manual deltoid retopology.
## Overhaul acceptance criteria (measurable)
Defined 2026-11-04 for the local part pipeline. A prototype must pass every applicable item
before it is rolled out to the other variants; the bar is the stylized low-poly target, not
photorealism.
### Face (one-variant prototype first)
Capture `front` (yaw 0°), `three_quarter` (yaw 35°) and `profile` (yaw 90°) at **1.5 m** and
**1.85 m** (32° FOV), matching `capture_npc_baseline.gd` (a new 1.5 m portrait capture is added
for this). Pass conditions:
- **Eyes** — sclera + iris + both lids distinct at 1.85 m; at 1.5 m the iris is a readable dark
dot against the white sclera, not a floating oval; no socket ring or lid seam.
- **Mouth** — the mouth line and lower-lip volume read at both distances; no moustache-like
shadow from a floating dark slot.
- **Nose** — bridge, tip and ala planes read in three-quarter and profile; no nose-wing seam or
inverted normal at the base.
- **Ears** — concha depth + lobe read in profile; not a flat disc.
- **Jaw/cheek/chin** — front and profile show a jaw transition and cheek/chin planes; no faceted
plane seams, inverted normals or z-fighting.
- **Silhouette** — front/three-quarter/profile agree; face direction is unambiguous.
- **AO** — the 256² bake stays clean: no recessed crease baking into a dark patch.
### Hands (one-variant prototype first)
Capture an **open palm** and a **relaxed grip** at **0.8 m** (`--view=hand` plus a new grip
capture). Pass conditions:
- **Open palm** — four fingers and a thumb read as a hand, not a mitten; at least two of the four
finger gaps are readable.
- **Grip** — the palm plus curled fingers silhouette reads; no finger-through-palm clipping.
- **Gear contact** — phone, cup, clipboard, camera, flashlight, wand and radio contact the palm
at 0.8 m with `HAND_ITEM_FORWARD` unchanged; no floating or hand-through-item clipping.
### Contract (both, every prototype)
- 16,000 triangles / 16,000 glTF vertices per character; gear stays at 1,500.
- Optional extra bones are allowed; the required 13 core bones and their rest positions must not
move; the `npc_base` mesh node name and the five tint slots stay unchanged.
### Measurement gates
`blender_assets.py inspect`/`audit`/`fit`, `npc_proportions.py`, the 24-NPC budget, Godot + Python
tests, the 48-frame baseline and the 96-frame pose/context review all stay authoritative; the new
face/hands captures are the visual bar for this track.
### Working-set layout
Part sources and preview GLBs live under `tmp/npc-parts/<variant>/` (gitignored by the root
`tmp/` rule); only the assembled character GLBs, AO/maps, the manifest and the review sheets are
committed. No new committed `.blend` source tree is added without a docs/ADR decision — the
frozen `npc_base.blend` stays the only tracked `.blend`.
### Head prototype (in progress 2026-11-04)
`tools/npc_parts.py build --part head_proto` is the first connected-shell attempt: a seg-48
head sphere is displaced (not floating spheres) into eye sockets with an orbital rim, a nose
bridge/tip/ala, upper/lower lip volumes, and extruded ears with a concha recess; the eyeballs
stay separate. Local renders live under `tmp/npc-parts/npc_casual/head_proto_*.png`.
Iteration log (vision-judged, profile view):
1. Initial displacement too subtle — read as a "ball with props".
2. Nose + ear visible in profile but the cranium still read as a "ball with a nose".
3. Added face-plane flatten + jaw/chin taper; taper read, but "~90% sphere".
4. Stronger flatten + nose + chin; nose now reads, jawline still dissolves into the skull.
5. Fixed an ear-displacement sign bug (the ± side loop cancelled the helix); the ear now
protrudes (x ±0.155 vs the ±0.139 shell) but reads as a soft blob.
Current state (prototype built, ~3/5 vision read): the nose, mouth notch, chin bump and ear bulge
all read; the centre-profile measurement confirms the relief is objective — nose tip −0.192,
brow −0.150, chin −0.149, jaw step −0.11, forehead −0.126→−0.078. The remaining polish (a flatter
forehead, a defined ear helix/lobe, a jawline that steps to the neck) is folded into the §3.2
variant comparison; the neck is part of `_skin_body`, so the standalone head always lacks it.
### Head subdivision comparison (2026-11-04)
Three `head_proto` variants at UV-sphere segments 24/48/64 were rendered at 1.85 m and 1.5 m in
front/three-quarter/profile and judged side by side:
| Variant | Verts / tris | 1.85 m | 1.5 m | Verdict |
| --- | ---: | --- | --- | --- |
| s24 | 670 / 1,168 | lumpy, nose/lips melt into the chin | jagged silhouette, no mouth | reject |
| s48 | 1,558 / 2,944 | eyes/brows/nose/lips read cleanly | features resolve cleanly | **pick** |
| s64 | 2,470 / 4,768 | visually identical to s48 | indistinguishable from s48 | reject (wasted verts) |
The chosen s48 is the `head_proto` default; the rejected s24/s64 stay local under
`tmp/npc-parts/`. A before/after sign-off sheet (current `_face` vs s48, 1.85 m
front/three-quarter/profile) is
[`blender_previews/sheet_head_proto_before_after.jpg`](blender_previews/sheet_head_proto_before_after.jpg).
## Scorecard skeleton
Verdicts use `qa-review` semantics: **P0** (blocker: wrong scale, broken normals, budget fail),
**P1** (major: silhouette/readability), **P2** (minor), **accept** (recorded limit). `—` means not
yet reviewed; this document is the place to fill it.
### Characters
| Character | Silhouette | Face @1.85 m | Hands/grip | Weights/pose | Cloth/hair | LOD | Verdict |
| --- | --- | --- | --- | --- | --- | --- | --- |
| *(pose captured 2026-11-04: see "Pose evidence" below)* | | | | | | | |
| `npc_casual` | accept (0.9.4) | accept (face prototype) | accept (open palm) | fixed (wrist/hand + hip/knee) | accept | accept (two-quad impostor) | pending owner |
| `npc_jacket` | accept | accept (face prototype) | accept | fixed (wrist/hand + hip/knee) | accept | accept | pending owner |
| `npc_coat` | accept | accept (face prototype) | accept | fixed (wrist/hand + hip/knee) | accept (hem 0.76 stride) | accept | pending owner |
| `npc_vest` | accept | accept (face prototype) | accept | fixed (wrist/hand + hip/knee) | accept | accept | pending owner |
| `npc_dress` | accept; performer props dominate | accept (face prototype) | accept | fixed (wrist/hand + hip/knee) | accept | accept | pending owner |
| `npc_hoodie` | accept (hooded criterion) | accept (lower face only) | accept | fixed (wrist/hand + hip/knee) | accept | accept | pending owner |
| `npc_firefighter` | accept | accept (face prototype) | accept | fixed (wrist/hand + hip/knee) | accept | accept | pending owner |
| `npc_guard` | accept | accept (face prototype) | accept | fixed (wrist/hand + hip/knee) | accept | accept | pending owner |
| `npc_crowd_a` | accept | accept (face prototype) | accept | fixed (wrist/hand + hip/knee) | accept | accept | crowd prototype |
| `npc_crowd_b` | accept | accept (face prototype) | accept | fixed (wrist/hand + hip/knee) | accept (beard) | accept | crowd prototype |
| `npc_crowd_c` | accept | accept (face prototype) | accept | fixed (wrist/hand + hip/knee) | accept (coat hem) | accept | crowd prototype |
| `npc_crowd_d` | accept | accept (face prototype) | accept | fixed (wrist/hand + hip/knee) | accept | accept | crowd prototype |
### Gear (18)
| Gear | Attach | Tris | Verdict |
| --- | --- | --- | --- |
| `cap` | head | 328 | — (fit measured, visual pending) |
| `hood` | head | 804 | — |
| `sunglasses` | head | 164 | accept (0.9.4 clearance) |
| `firefighter_helmet` | head | 672 | accept (0.9.4 + 3 cm runtime offset) |
| `backpack` | back | 380 | accept (0.9.4 strap refit) |
| `shoulder_bag` | back | 188 | — |
| `scba_tank` | back | 362 | — |
| `lanyard` | chest | 100 | accept (0.9.4 + 4 cm runtime offset) |
| `phone` | hand | 96 | accept (forward offset) |
| `cup` | hand | 188 | accept |
| `clipboard` | hand | 84 | accept |
| `umbrella` | hand | 134 | accept |
| `camera` | hand | 318 | accept |
| `protest_sign` | hand | 92 | accept (performer/protester read) |
| `flashlight` | hand | 168 | accept |
| `wand` | hand | 244 | accept |
| `radio` | hand | 220 | accept |
Gear total: 5,002 tris across 18 pieces (all far below the 1,500-per-piece cap).
`blender_assets.py fit` readings (2026-11-03): `shoulder_bag` is clear on both `npc_coat` and
`npc_dress` (the only back-gear clearance the fit test asserts); `sunglasses` sits inside the
head band at 0.64× head width. Three caveats worth recording, none of which changes a verdict:
`backpack` (npc_casual) and `scba_tank` (npc_firefighter) report `CLIP` because back gear is
measured against the **hip/skirt** radius, which is wider than the upper back where the bags
actually sit; `cap` and `firefighter_helmet` report `band=OUTSIDE` because they sit on the crown
above the 1.55–1.81 face/head band (expected); and the `lanyard` clip is corrected at runtime by
`GEAR_OFFSETS` (+4 cm forward). The unused `beanie` was retired on 2026-11-03 (removed from
`GEAR`, its builder, `npc_avatar.gd` and the manifest).
### Props (20 library + 6 generated fallbacks)
Library total 13,608 tris; `marquee` (2,804) and `street_tree` (2,592) are the two heaviest and are
the first candidates for a `vegetation-artist`/lookdev pass.
| Prop | Tris | Verdict |
| --- | --- | --- |
| `bollard` | 138 | accept (2026-11-03 flared base collar) |
| `bench` | 560 | — |
| `trash_bin` | 228 | — |
| `planter_box` | 1,324 | — |
| `newspaper_box` | 300 | accept (2026-11-03 recessed slot + door seam) |
| `fire_hydrant` | 610 | accept (2026-11-03 rebuild: bolted flange, bonnet cap nut, larger side + front outlets) |
| `street_lamp` | 428 | — |
| `street_tree` | 2,592 | P1 candidate (flat-shaded clumps) |
| `palm_tree` | 400 | P1 candidate (same foliage language) |
| `round_planter` | 1,240 | — |
| `a_frame_sign` | 212 | — |
| `wall_lantern` | 180 | — |
| `traffic_cone` | 210 | — |
| `crowd_barrier` | 388 | — |
| `stanchion` | 434 | — |
| `handrail` | 268 | — |
| `security_podium` | 184 | — |
| `double_door` | 380 | — |
| `marquee` | 2,804 | — (emissive check pending) |
| `sedan` | 928 | — |
Vision review 2026-11-03 (`tools/describe_image.py` on the prop gallery): `bench` and `trash_bin`
are ship-ready (minor chamfer polish only); `bollard` passes but leans on its reflective band
(add a base collar); `planter_box`/`round_planter` fail on the foliage — the `_shrub()` clumps
read as rocks, not plants (same foliage language gap, gated on the owner); `newspaper_box` is
borderline (the slot is a flat decal, wants a recessed inset + door seam); `fire_hydrant` is now
rebuilt (see row). No prop floats or sinks; shadow direction is consistent.
Generated fallbacks (`assets/models/generated/`): bench, bollard, fire_hydrant, newspaper_box,
planter_box, trash_bin. They are only used when the library prop is missing, so they are audited
for presence, not re-reviewed for art quality.
### Venues
| Venue | Measured (map-budget test) | Status |
| --- | --- | --- |
| Hollywood Boulevard | 58,884 tris / 409 meshes | shape-language review pending |
| The Joke Shop | 26,828 tris / 580 meshes | facade review landed: 6.0 m low hall, white signature band, tied-back curtains, west hotel tower; remaining gap = rounded west corner |
| TV Studio | — | shape-language review pending |
## Pose evidence (fixed 2026-11-04)
The weights/pose column can now be filled. The root cause was the harness: it never disabled the
avatar's `_physics_process`, so `_advance_fsm` + `_move_toward_target` reset `arrived`/velocity
every tick and `theater_pose()` reverted to idle. `capture_npc_pose.gd` now calls
`set_physics_process(false)`/`set_process(false)` after staging, and the pose sticks (sit vs idle
diff 5.63, walk vs idle 2.29, vision-judged as crouched). The earlier "moving the `Rig` child does
not render" probe was a red herring: it set the position in `_shoot()` in the same frame as
`root.get_texture().get_image()`.
`src/test/capture_npc_pose.gd` stages idle / walk-contact / walk-pass / seated for all twelve
variants (full-body + shoulder views).
| Check | Result |
| --- | --- |
| Pose reaches proxies | Seated pose logs `rig_y=-0.339`, `leg0=-1.357` |
| Pose reaches skeleton bones | `HipL`/`KneeL` bone rotations differ from rest; headless test `test_pose_reaches_the_skeleton_bones` locks it (suite 273/273) |
| Idle restores rest | Same test asserts the hip returns to its rest orientation |
| Skinning bound | One `Skeleton3D`, mesh bound to it (`same=true`); hiding `npc_base` removes the body |
| Bone rotations deform the mesh | Direct writes change rendered pixels: Spine 1,719 / HipL 1,052 / KneeL 810 / ElbowL 745 / Head 671 / ShoulderL 640 sampled pixels |
| Bone translation deforms the mesh | A +0.5 m Head offset moves the head in frame |
| Standalone window probes render the pose | Three variants (avatar under `root`, under a `Node3D` stage, created mid-`_process`) render the seated crouch (diff ~2,200-2,360 samples) |
| Offscreen capture renders the pose | A `SubViewport` + `RenderingServer.force_draw()` probe renders the crouch reliably (diff 602 at +2/+6/+12 frames), including a re-created second avatar |
| The harness renders the pose | **No** in its current form: same stage, same pose, rest frames. One ordering (`_frame_view()` immediately before `force_draw()`) produced posed walk frames once, then regressed to rest/garbage on the next run |
The rig, the animation math and the offscreen render path are all sound. A 2026-11-03 re-attempt
with the recipe (SubViewport + `force_draw()` + `force_update_all_bone_transforms()` + disabled
process/physics) still produced byte-identical rest frames, and a decisive probe narrowed the
failure further: teleporting the **avatar** changes the render (diff 10), but moving the **`Rig`
child** (the GLB model's parent) does not (diff 0). The skinned mesh therefore does not follow its
`Rig` Node3D transform, so the rig lowering never reaches the rendered model — the defect is
upstream of camera/`force_draw()` ordering. Next attempt must answer why the skinned model ignores
the `Rig` node, or stage poses via the known-good `capture_npc_review.gd` fallback.
Working recipe (verified by the probes; keep it when rebuilding):
1. Create a `SubViewport` (1280x720, `own_world_3d = true`, `UPDATE_ALWAYS`) in `_initialize`.
2. Build the studio (environment, key/fill light, ground, camera) and one avatar inside it.
3. Wait 2-3 frames so the skinned mesh binds.
4. Apply the pose with `_animate()` ticks, with the avatar's process/physics disabled.
5. Call `RenderingServer.force_draw()`, then `viewport.get_texture().get_image()`.
6. Free the avatar and repeat for the next pose; a re-created avatar also rendered correctly in a probe.
## Ranked candidate gaps
Ordered by expected player-visible gain per unit of work. None is approved for fixing yet; the
owner review decides.
1. **Face plane structure at portrait distance (P1, all eight).** The neutral sheet shows flat,
faceted cheek/brow planes and slit-like eyes at 1.85 m. `character-artist`/`retopology` guidance
asks for concentric eye loops and a 3-loop mouth. This is the largest readability gap and also
the most expensive (head rebuild + re-UV + re-AO per variant). Needs a targeted single-variant
prototype before committing to eight.
The eight library characters already carry the 0.9.4 face detail in
`tools/blender_assets.py._face()`: sculpted chin plate + cheekbones (`_shape_head_mesh`),
socketed sclera + thin upper lid, nose bridge/tip/wings, a rounded mouth and ear concha
(the plain sphere head in `tools/npc_pipeline.py` is the frozen `npc_base` fallback only).
So this gap is a readability/scale question on the existing sculpt — the head is
`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 16,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.
3. **Foliage language (P1, `street_tree`, `palm_tree`, `planter_box`, `round_planter`).** Flat-shaded
clumps against an otherwise readable set, already deferred once. `vegetation-artist` carded
clusters would fix it inside the existing triangle budget.
4. **Texel density below the background tier (note).** 64–122 px/m authored vs the skill's 128–256
background tier. Fabric is already standard-tier at runtime. Decide: accept because the maps are
neutral tinted detail, or raise only `skin`/`hair` to 512² for portrait distance.
5. **`npc_firefighter` density floor (P2).** 64 px/m with the largest coat area; split the coat UV
shell or raise the map size before the next character pass, whichever the density decision says.
6. **Hands at conversation distance (accept, revisit only if face work opens the budget).** Open
palm with `HAND_ITEM_FORWARD`; no finger bones exist, so a curl rig is a contract decision, not
a modelling tidy-up.
7. **Gear never reviewed as silhouette language (P2).** 18 pieces pass fit/contract but have no
prop-artist verdict (the unused `beanie` was retired 2026-11-03).
8. **Venue shape language (P2).** Hollywood and the Joke Shop have budget data but no
`environment-artist`/`archviz` review; the TV Studio has no recorded map-budget number in this
document yet.
## qa-review sign-off (2026-11-04)
All eight library characters **ship with notes**. Face realism landed on all eight (`seg=24/rings=16`,
brow ridge, both eyelids, fuller lower lip), hands/grip and cloth/hair are accepted as recorded, gear
fit is measured with its caveats, and the props are reviewed (hydrant/bollard/newspaper_box rebuilt,
foliage two-toned). The only unresolved column is **weights/pose** (blocked on the capture harness),
and the full carded-cluster foliage pass is the one remaining owner-gated P1. No P0 remains.
## Decisions needed from the owner
- Accept the measured texel density as a stylized limitation, or fund a skin/hair resolution pass.
- Approve or reject a single-variant face/eye-loop prototype (and pick which variant to prototype
on) before any eight-character rebuild.
- Decide whether the pose/weight capture is required before the crowd-variant roster.
- Confirm foliage rebuild priority versus the map track (both touch the same street trees).
## Next actions
Tracked in [`TODO.md`](../TODO.md) `3D model re-evaluation`: the remaining `A. Baseline…` item is
this owner review; `B`/`C`/`D` carry the per-family work, and `E` adds the pose/weight,
collider-coverage and scorecard-coverage gates this document found missing.