Files
security-game/docs/assets/3d-model-review.md
T
zwitschi efa062833f
Full build / Import + headless tests (push) Successful in 46s
Full build / Full desktop build + optional release (push) Skipped
Give the Joke Shop a real queue line and thicken the rope barrier
2026-11-06 20:47:28 +01:00

36 KiB
Raw Blame History

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. 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-model-sourcing.md (sources, shipped roster and build steps), blender-assets.md (asset contract), asset-tooling.md (commands). The 0.9.4 procedural baseline this page was measured against no longer ships; its before/after sheets stay in blender_previews/.

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); 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 character baseline before the Quaternius migration. npc_base.blend is the frozen fallback; the 12 characters are currently assembled procedurally by tools/blender_assets.py. The active plan is now the CC0 Quaternius base bake (ADR-0017); this section and the MakeHuman notes below are the pre-migration record. The MPFB/MakeHuman development files were removed from tmp/ after the 2026-11-05 pivot.

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 (historical, not shipped; research files removed from tmp/)

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)

  • 24,000 triangles / 24,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.

AI-driven head rework (2026-11-04)

The head was reworked against an AI-generated target concept (front + profile, saved under tmp/npc-parts/head_concept_*.png) and judged with describe_image.py across several iterations. The displacement-on-sphere approach oscillates between "decorated egg" (too subtle) and "spiky wedges" (too aggressive) and settles at ~3/5 — the coarse s48 sphere cannot resolve the nose/lip/chin relief cleanly.

The judge then compared the displacement head against the decimated MakeHuman head (extracted from makehuman_retargeted.glb, 2,009 verts / 1,573 tris, saved as the local makehuman_head.glb) and returned a decisive result: ship the MakeHuman head — it keeps the nose/chin/jaw landmarks at a fraction of the cost, while the procedural head's "clean ellipse outline instantly screams procedural sphere".

Conclusion: the next head asset is the MakeHuman head shape (1,573 tris) plus the project's separate eye/mouth/brow pieces for readability, not the displacement shell. The displacement _head_proto stays local as a rejected variant.

Built (2026-11-04): _makehuman_head in blender_assets.py imports the committed assets/models/blender_src/makehuman_head.glb (CC0, attributed in CREDITS.md), collapses it to one skin slot, bisects + caps the ragged neck, and adds the project sclera/iris/lids/brows/mouth at the MakeHuman landmarks. build_character(name, head="mh") assembles it; the result passes inspect (14 bones, skinned, no issues). The judge reads the face ~3.5/5; remaining polish is welding the added eye/brow pieces into the head shell and a skull-conforming hair cap.

Standalone head re-review (2026-11-04): the separate assets/models/blender_src/makehuman_head.glb prototype (12,641 tris / 7,690 split verts, with a skin eyelid mask, recessed globes, flat iris/pupil discs and surface-anchored brows) was accepted by the owner as a basic human head. The automated vision gate still returns REJECT on the eye/lid region (globe depth, lid overlap, iris at three-quarter) as deferred polish. The assembled npc_casual --head mh now adds a skull-conforming hair cap, a hidden scalp trim and a narrowed neck/shoulder body: 14,753 tris / 12,823 seam-split verts / 14 bones / 9 materials, inspect clean. All 12 variants count 14,693–15,987 tris. See makehuman-head-review.md.

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.

Foliage language P1 — resolved 2026-11-06

The ranked P1 was that shrubs read as rocks: _shrub() stacked spheres, so the silhouette was smooth and the light response was a single facet per clump.

_leaf_card() replaces the stack: one low-poly inner mass for opacity, then 18–53 two-triangle quads scattered over the canopy shell by a jittered Fibonacci distribution, each rotated onto the canopy normal and tilted off it. No alpha texture, so the flat-colour library and the Compatibility renderer are untouched.

Prop Before After
planter_box 1,324 tris 818
round_planter 1,240 tris 808
street_tree 2,592 tris 1,506
Props total (20) 13,808 11,784

Lighting pass — flashlight measured 2026-11-06

capture_realism.gd --stages=late --flashlight renders the same frame with the lamp off and on, so the beam's contribution is a number instead of an impression. Mean luminance, 0–255:

Region Lamp off Old settings Shipped now
Aimed centre (facade) 13.5 13.6 29.7
Near pavement 3.7 3.7 11.7

The old flashlight (energy 4.2, 20 m range, 14° cone, aimed dead ahead) moved the frame by 0.1/255 — it was effectively off. The shipped values are 10 energy, 45 m, 24°, tilted 5° down: the beam lands on the door or person being checked and puts a pool on the pavement in front, without flooding the facade. Verified in both venues (Hollywood facade and the Joke Shop door).

Street-lamp pools, measured

A new lamp_late shot frames a street lamp so the pool under it can be sampled. The lamp is StreetLamp_* (named so set_night_mode can tune it apart from the sconce and marquee glows). Attenuation stays at 1.0 — the venue test requires physical falloff — so the pool is made by range and energy:

Setting Pool Gap Pool/gap
old: 16 m, energy 2.2 14.1 2.7 5.2
16 m, energy 4.0 21.1 6.5 3.3 (washed)
8.5 m, energy 6.5 11.5 1.3 8.9
10 m, energy 8.0 (shipped) 16.0 1.3 12.3

Mean luminance 0–255. The shipped values brighten the pool past the old setting while darkening the gap between poles, which is what "pools, not a wash" means numerically.

Event banners read as lit cloth (2026-11-06)

The banners were one flat emissive panel, so they measured the same luminance from the mounting bar to the hem (114/121/122/125 down the panel) and read as a painted red card rather than lit fabric. They are now four stacked strips with a night gradient (energies 2.6 / 2.2 / 1.8 / 1.4), carried per material through night_energy metadata so set_night_mode keeps the ratio instead of flattening it.

Before After
Vertical profile down a banner 114 → 125 (flat) 135 → 84
Clipping (p99 / pixels ≥ 250) 192 / 0.2 % 192 / 0.2 %
Daylight read uniform uniform (strips flush, no seam)

No clipping in either state, and the Compatibility renderer draws it with the same flat emissive material as before — the change is geometry plus per-material energy, not a new shader.

Set dressing — Joke Shop queue (2026-11-06)

The queue props were placed individually (stanchion at ±3, crowd_barrier at x 2.2, podium at 6.8), so no line read and the stanchions' 0.9 m velvet stubs pointed in unrelated directions. They are now one queue: four stanchions 0.9 m apart west of the guest approach lane, the check-in podium at the head, and the barricade on the curb side. The line has to sit outside PROP_EXCLUSIONS["guest approach lane"] (x ±1.4), which is what the placement validator enforces — the first attempt put the podium at x 0.4 and the test caught it.

Readability at 8 m drove the thickness pass: bike-rack frame 22 → 28 mm, bars 10 → 12 mm, velvet rope 18 → 22 mm. The west planter also moved 1.6 m clear of the palm canopy, which was clipping its lip.

Measured at night afterwards: the entrance walkway sits at 20.7/255 against 3.0 on the side pavement, so the door pool exists; the queue area itself is still unlit and stays queued as a follow-up.

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

  • Review the assembled makehuman-head-review.md prototype (hair cap and full-body silhouette) before rebuilding the 12 shipped character GLBs.

Tracked in 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.