Reorganize docs into category folders and drop the retired head-review page
Group the top-level docs into docs/project, docs/design, docs/assets and docs/process, each with its own README index. Delete the superseded docs/screens/head-review page and sheets. Rewrite every markdown link, absolute docs path and tool/test reference to the new locations, fix the pre-existing broken anchors and remove the dead Joke Shop photo-board link. check_docs OK, Python tooling 167/167.
This commit is contained in:
1 parent
045ed2a58f
commit
2ba40f3578
57 files changed
+406
-414
No files matched your search
@@ -0,0 +1,522 @@
|
||||
# 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-model-sourcing.md`](npc-model-sourcing.md) (sources, shipped roster and
|
||||
build steps), [`blender-assets.md`](blender-assets.md) (asset contract),
|
||||
[`asset-tooling.md`](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`](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](../adr/0017-quaternius-npc-base.md)); 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`](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`](../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 |
|
||||
|
||||
## 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`](makehuman-head-review.md) prototype (hair cap and full-body silhouette) before rebuilding the 12 shipped character GLBs.
|
||||
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Asset Documentation
|
||||
|
||||
Asset contracts, pipelines, sourcing and reviews. These pages own the measured evidence; the
|
||||
executable gates are `tools/blender_assets.py inspect|audit|fit`, `tools/npc_proportions.py` and
|
||||
the capture scripts.
|
||||
|
||||
- [`blender-assets.md`](blender-assets.md) — Blender library operation, the character/gear/prop
|
||||
contract, attach points and rebuild discipline.
|
||||
- [`asset-tooling.md`](asset-tooling.md) — image/model pipelines, the optional Blender MCP setup
|
||||
and the screenshot/vision gates.
|
||||
- [`npc-model-sourcing.md`](npc-model-sourcing.md) — character-source evaluation, licence rules,
|
||||
the Quaternius intake and the active NPC plan.
|
||||
- [`3d-model-review.md`](3d-model-review.md) — measurements, per-asset scorecard, ranked gaps and
|
||||
accepted limits.
|
||||
- [`makehuman-head-review.md`](makehuman-head-review.md) — the retained MakeHuman fallback head,
|
||||
its provenance and the review verdict.
|
||||
|
||||
Up: [`../README.md`](../README.md) · Product plan: [`../../PLAN.md`](../../PLAN.md) · Tasks:
|
||||
[`../../TODO.md`](../../TODO.md).
|
||||
@@ -0,0 +1,730 @@
|
||||
# Asset Pipeline and Tooling
|
||||
|
||||
Everything the game renders is produced or gated by the scripts below. Architecture-level contracts,
|
||||
data flow and failure fallbacks are summarized in
|
||||
[`architecture.md`](../project/architecture.md#content-and-asset-pipeline-architecture); this page is the
|
||||
operational reference. Development-only outputs (AI concept sheets, Blender sources, raw AI fabrics)
|
||||
are gitignored; the audited game assets are committed.
|
||||
|
||||
## Image Asset Tools
|
||||
|
||||
Install the Pillow dependency used by the existing texture generator and the asset tools:
|
||||
|
||||
```bash
|
||||
python3 -m pip install -r requirements-tools.txt
|
||||
```
|
||||
|
||||
Generate a role-sized image (generation requires `OPENROUTER_API_KEY`):
|
||||
|
||||
```bash
|
||||
python3 tools/asset_pipeline.py generate --prompt "weathered red brick" --role tile --output assets/textures/brick_extra.png
|
||||
```
|
||||
|
||||
Generated images are resized to the selected role profile. Non-square requests use
|
||||
the validated `openai/gpt-5-image-mini` source ratio and crop to the final role dimensions.
|
||||
Transparent glyphs can be recolored while preserving their alpha mask with
|
||||
`--foreground '#ffffff'`.
|
||||
|
||||
Crop uses the image center by default; `--x` and `--y` select an explicit crop origin. Resize defaults to aspect-preserving `contain`; use `--fit cover` to crop or `--fit stretch` to distort to the exact dimensions.
|
||||
|
||||
```bash
|
||||
python3 tools/asset_pipeline.py crop assets/textures/source.png assets/textures/crop.png --width 256 --height 128
|
||||
python3 tools/asset_pipeline.py resize assets/textures/source.png assets/images/scenes/scene.png --width 1920 --height 1080 --fit cover
|
||||
python3 tools/asset_pipeline.py seamless assets/textures/rough_tile.png assets/textures/tile.png
|
||||
```
|
||||
|
||||
Audit accepts image files or directories. `--role auto` infers common roles from paths and filenames; explicit roles include `tile`, `texture`, `icon`, `card`, `banner`, `backdrop`, `background`, `overlay`, `app_icon`, and `screenshot`.
|
||||
|
||||
```bash
|
||||
python3 tools/asset_pipeline.py audit assets/textures
|
||||
python3 tools/asset_pipeline.py audit assets/images/scenes --role background --json
|
||||
python3 -m unittest discover -s tools/tests -t tools -p 'test_*.py'
|
||||
```
|
||||
|
||||
The audit classifies measurable fit issues as `good` or `bad` based on role-specific dimensions, aspect ratio, alpha requirements, and tile-edge continuity. It lists perspective/projection as a visual review item because image semantics cannot be judged reliably by these deterministic checks.
|
||||
|
||||
## Prop models
|
||||
|
||||
Street furniture (bollards, benches, bins, planters, newspaper boxes, a hydrant)
|
||||
is real 3D geometry generated by `tools/model_pipeline.py` and written to
|
||||
`assets/models/generated/*.glb`. Godot imports each file as a `PackedScene`, and
|
||||
`WorldBuilder` instantiates it with a procedural fallback when the model or its
|
||||
import is missing, so the world still builds on a fresh checkout.
|
||||
|
||||
```bash
|
||||
# Print the catalogue (parts, triangle count, whether a model/concept exists)
|
||||
python3 tools/model_pipeline.py list
|
||||
|
||||
# Rebuild the models (deterministic, offline) and validate them
|
||||
python3 tools/model_pipeline.py build
|
||||
python3 tools/model_pipeline.py audit
|
||||
|
||||
# Optional: an image model supplies the colour palette per prop
|
||||
# (requires OPENROUTER_API_KEY; the repo .env is read automatically)
|
||||
python3 tools/model_pipeline.py concept
|
||||
python3 tools/model_pipeline.py concept --only bench --force
|
||||
```
|
||||
|
||||
Each prop is a small list of boxes and cylinders, so `build` needs no network and
|
||||
the output is byte-identical between runs. `concept` asks an image model for a
|
||||
studio-style reference; `dominant_colors` and `assign_roles` then map that image's
|
||||
colours onto material roles (darkest → metal, warm saturated → wood, light grey →
|
||||
stone, green → foliage). glTF stores colours in linear space, so the tool converts
|
||||
the sRGB palettes before writing them. Re-run `build` and `godot --headless
|
||||
--path . --import` after changing a prop. See
|
||||
[ADR-0012](../adr/0012-surface-materials-and-generated-props.md).
|
||||
|
||||
Surface materials share one definition in `src/game/materials.gd`: named
|
||||
roughness/metallic profiles per surface, plus the environment tune-up (ACES
|
||||
tonemapping, glow, fog, colour grading) that the phase lighting applies.
|
||||
|
||||
## NPC character model
|
||||
|
||||
NPCs are moving from the procedural capsule rig to a skinned, UV-mapped base
|
||||
character authored in Blender ([ADR-0013](../adr/0013-blender-authored-npc-characters.md)).
|
||||
The active base is the CC0 Quaternius modular character, baked to the runtime contract
|
||||
([ADR-0017](../adr/0017-quaternius-npc-base.md)).
|
||||
`tools/npc_pipeline.py` builds `assets/models/generated/npc_base.glb` plus the
|
||||
per-slot material maps. `NpcAvatar` instantiates that skinned model, tints its
|
||||
five material slots from the seed-driven outfit, and drives the bones from the
|
||||
existing animation code; the procedural rig remains the offline fallback (see
|
||||
[ADR-0013](../adr/0013-blender-authored-npc-characters.md) and
|
||||
[`blender-assets.md`](blender-assets.md)).
|
||||
|
||||
```bash
|
||||
# Print the frozen contract (slots, bone names, triangle budget)
|
||||
python3 tools/npc_pipeline.py list
|
||||
|
||||
# Rebuild the skinned base mesh + neutral maps in Blender and validate it
|
||||
flatpak run org.blender.Blender --background --python "$PWD/tools/npc_pipeline.py" -- build
|
||||
python3 tools/npc_pipeline.py audit
|
||||
|
||||
# Layer the AI clothing albedos (shirt/trousers) over the procedural weave, and
|
||||
# derive per-fabric outfit colours for the avatar palette
|
||||
python3 tools/npc_pipeline.py prepare
|
||||
python3 tools/npc_pipeline.py palette
|
||||
```
|
||||
|
||||
`prepare` resizes the AI fabric tiles (kept locally in `assets/textures/npc/ai/`,
|
||||
which is gitignored) onto the 256² `cloth`/`pants` albedos; `build` bakes them into
|
||||
the `.glb` and regenerates the procedural normal + packed ORM maps at the same
|
||||
256² size. Runtime fabric materials repeat the weave 4× (`NPC_FABRIC_UV_SCALE`)
|
||||
and skip the per-character baked AO, which otherwise magnified into large
|
||||
blotches; skin, hair and fixed gear keep AO. `palette` writes
|
||||
`assets/textures/npc/palette.json` using `model_pipeline.dominant_colors`.
|
||||
|
||||
## Blender asset library (loaded by the game)
|
||||
|
||||
`tools/blender_assets.py` models a larger library procedurally in Blender for the
|
||||
v0.6.0 integration: **8 skinned NPC characters** (casual, jacket, coat, hi-vis vest,
|
||||
dress, hoodie, firefighter, guard), each a drop-in for `npc_base.glb` (same bones,
|
||||
joint positions and tint slots); **15 gear pieces** authored at head/hand/back/chest
|
||||
attach points (caps, hood, helmet, backpack, SCBA tank, lanyard, phone, cup,
|
||||
clipboard, umbrella, camera, sign, flashlight, wand, radio, …); and **20 props**
|
||||
(octagonal bollard and bin, slatted bench, hydrant, news box, planters, street lamp,
|
||||
street and palm trees, A-frame sign, wall lantern, cone, crowd barrier, stanchion,
|
||||
handrail, podium, double door, marquee, sedan).
|
||||
|
||||
`NpcAvatar` selects a character by role/seed, tints the five contract slots, and
|
||||
attaches gear at the documented points; `WorldBuilder` loads the library props
|
||||
with colliders/avoidance and drives their emissive parts from the day/night
|
||||
cycle. The single `npc_base.glb` and primitive props remain offline fallbacks.
|
||||
Contract and integration notes: [`blender-assets.md`](blender-assets.md).
|
||||
|
||||
Sourcing and migration plan: [`npc-model-sourcing.md`](npc-model-sourcing.md) records the
|
||||
2026-11-05 direction (ADR-0017). Quaternius Ultimate Modular (CC0) is the primary NPC mesh
|
||||
and animation source, baked to the existing 13-core contract; the procedural generator is
|
||||
the fallback. Kenney/Khronos/MakeHuman remain evaluated references, not active bases.
|
||||
|
||||
| Characters | Props |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
```bash
|
||||
flatpak run org.blender.Blender --background --python "$PWD/tools/blender_assets.py" -- build
|
||||
flatpak run org.blender.Blender --background --python "$PWD/tools/blender_assets.py" -- build --head mh
|
||||
flatpak run org.blender.Blender --background --python "$PWD/tools/blender_assets.py" -- render
|
||||
python3 tools/blender_assets.py sheet # docs/blender_previews/sheet_*.jpg
|
||||
python3 tools/blender_assets.py audit # no Blender needed
|
||||
python3 tools/blender_assets.py fit # gear-vs-body fit report
|
||||
python3 tools/blender_assets.py texels # per-slot texel density (px/m) of the characters
|
||||
# `build --head mh` selects the retained MakeHuman fallback head; the default is still `face`.
|
||||
godot --headless --path . --import
|
||||
```
|
||||
|
||||
### Intake inspection (`inspect`)
|
||||
|
||||
`audit` reads accessor-local bounds and ignores node transforms, so it is the right
|
||||
release gate but a poor first look at a downloaded or newly authored GLB. `inspect`
|
||||
imports the file into Blender, clears parents and applies object transforms, then
|
||||
reports what the scene actually contains:
|
||||
|
||||
```bash
|
||||
# one asset, a batch, or an external download; repeat --file as needed
|
||||
flatpak run org.blender.Blender --background \
|
||||
--python "$PWD/tools/blender_assets.py" -- inspect \
|
||||
--file "$PWD/assets/models/blender/npc/npc_casual.glb" \
|
||||
--file "$PWD/tmp/downloads/SomeCharacter.glb"
|
||||
# machine-readable, for intake scripts (write to a file: Blender prints a banner first)
|
||||
... -- inspect --file "$PWD/tmp/x.glb" --json --out "$PWD/tmp/x.json"
|
||||
# force a contract family instead of inferring it from the path
|
||||
... -- inspect --file "$PWD/tmp/x.glb" --category npc
|
||||
```
|
||||
|
||||
It prints triangles (with the category budget), raw and seam-split vertex counts,
|
||||
Godot-space bounds with height/feet for characters, whether transforms had to be
|
||||
applied, mesh nodes and armatures, bone names, materials, UV layers, skinning and
|
||||
animation count. Hard contract failures (`issues`) fail the command; advisory
|
||||
findings (`warnings`) do not. The category is inferred from the path:
|
||||
`blender/npc` → `npc`, `blender/npc_gear` → `npc_gear`, `blender/props` and
|
||||
`generated` → `props`, anything else → `external` (measured but with no project
|
||||
budget or rig contract).
|
||||
|
||||
Caveats:
|
||||
|
||||
- The glTF importer adds a bone-display mesh (`Icosphere`, used as
|
||||
`pose_bone.custom_shape`). `inspect` excludes custom-shape helpers so they never
|
||||
pollute bounds or the unskinned list.
|
||||
- Bounds and bone rests are converted from Blender Z-up to Godot Y-up (`x, z, -y`),
|
||||
matching the contract bands and the audit's coordinate space.
|
||||
|
||||
### Intake adaptation (`adapt`)
|
||||
|
||||
`adapt` is the mechanical first half of an external-mesh intake (Quaternius,
|
||||
MakeHuman fallback, downloaded GLB): import, apply transforms, ground the feet,
|
||||
weld seam splits, decimate to the character budget, and export a GLB. It does **not** map
|
||||
the five tint slots or rebuild the 13-core-bone `npc_base` armature (optional extra bones are allowed) — those stay a
|
||||
manual `retopology`/`rigging` step with a recorded second-rig contract if the
|
||||
source rig cannot be retargeted:
|
||||
|
||||
```bash
|
||||
flatpak run org.blender.Blender --background \
|
||||
--python "$PWD/tools/blender_assets.py" -- adapt \
|
||||
--file "$PWD/tmp/npc-research/quaternius/Casual.glb" \
|
||||
--out "$PWD/tmp/npc-research/quaternius_adapted.glb" \
|
||||
--target-tris 24000
|
||||
# prints: adapted quaternius_adapted.glb: <source> -> 24000 tris (bones=N, meshes=1)
|
||||
```
|
||||
|
||||
Run `inspect` on the adapted GLB before and after, and keep the procedural
|
||||
generator as the fallback until a Quaternius variant passes `audit`/`fit` and the
|
||||
24-NPC budget.
|
||||
- Numbers are Blender-side measurements. Triangles match `audit` on project assets; the seam-split
|
||||
vertex count can differ by ±1 because the importer rounds normals. `audit` stays the single final
|
||||
gate: it is stdlib-only, runs in CI, and matches what the engine imports.
|
||||
- `inspect` never writes or adapts the file; scale/grounding/weld/retarget work is the
|
||||
separate (not yet built) `adapt` step.
|
||||
- `--json` also captures the importer's INFO log in `stats.import_log` instead of printing it.
|
||||
Blender still prints its own version banner before the script runs, so intake scripts should use
|
||||
`--json --out <file>` rather than parsing stdout.
|
||||
|
||||
### Reference-anchored concept generation
|
||||
|
||||
`tools/scene_concepts.py generate --reference <photo>` anchors a concept frame on a real
|
||||
photograph: the photo is sent to the OpenRouter multimodal chat endpoint as a base64 data URL
|
||||
(`generate_textures.generate_image(reference_path=...)`) with a prompt suffix that pins the
|
||||
photographed geometry (column rhythm, band heights, signage placement, camera angle) while the
|
||||
prompt restages lighting, weather and people.
|
||||
|
||||
Licence gate: the reference must be listed in its `docs/screens/refs/<venue>/SIDECAR.md` row
|
||||
(which also supplies the licence and Commons page for provenance). A photo from anywhere else
|
||||
requires `--reference-unchecked`, which records `unchecked` in the provenance. Each anchored
|
||||
generation writes a `.refgen.json` sidecar next to the frame (`model`, reference file/licence/page,
|
||||
geometry suffix) so a concept frame can always be traced back to its evidence photo. Where a
|
||||
generated frame and its reference photo disagree, the photo wins for shape.
|
||||
|
||||
### Photo-anchored variant pass
|
||||
|
||||
`tools/scene_concepts.py anchor --reference <photo> --context "..." --treatments day,night,weathered
|
||||
--out-dir docs/screens/concepts/<block>` generates several restaged conditions (day, night,
|
||||
weathered) of one reference photo's framing, each with its `.refgen.json` provenance sidecar, so a
|
||||
concept board stays comparable with its photo board. The reference passes the same SIDECAR licence
|
||||
gate as `--reference` on `generate`.
|
||||
|
||||
### Local-only reference collection (`refcollect`)
|
||||
|
||||
`tools/refcollect.py` downloads images from DuckDuckGo image search into `tmp/research/<slug>/` for
|
||||
development reference only. It applies **no licence gate** (rows are marked `local_only` /
|
||||
`unchecked-local`); everything it fetches must never be committed or promoted into
|
||||
[`screens/refs/`](../screens/refs/README.md) without the normal sidecar licence + privacy gate. This
|
||||
is the owner-authorised local-only override (TODO.md §3D model re-evaluation → A).
|
||||
|
||||
```bash
|
||||
python3 tools/refcollect.py search "Comedy Store Sunset Boulevard" --slug comedy-store --limit 20
|
||||
python3 tools/refcollect.py sheet --slug comedy-store # local contact sheet (never committed)
|
||||
```
|
||||
|
||||
Requires `pip install duckduckgo-search` (renamed `ddgs` upstream). DuckDuckGo rate-limits by IP
|
||||
(HTTP 202), so every request — the search itself and each image download — passes through a shared
|
||||
token bucket (`--rate`, default 0.5 req/s, and `--burst`, default 3) and a polite retry routine:
|
||||
transient statuses (408/425/429/5xx/52x) and network errors back off exponentially with full
|
||||
jitter, `Retry-After` is honoured, permanent failures fail fast, and the loop gives up after six
|
||||
attempts. Requests carry a full, coherent Firefox header set (`--user-agent` overrides the UA
|
||||
string); the UA alone is no longer enough to look like a browser.
|
||||
|
||||
### Reference-board research (`refboard`)
|
||||
|
||||
`tools/refboard.py` searches, fetches and documents Wikimedia Commons photos of a real location for
|
||||
the [`screens/refs/`](../screens/refs/README.md) boards. It is stdlib-only and never commits anything
|
||||
itself: downloads land in `tmp/`, and moving a file into a board folder is a deliberate manual step
|
||||
that goes through the sidecar licence gate.
|
||||
|
||||
```bash
|
||||
# search: keyword, geosearch (the reliable "photos of this exact place"), or category
|
||||
python3 tools/refboard.py search --source geo "34.10118|-118.34009" --limit 60 --months 12
|
||||
python3 tools/refboard.py search "El Capitan Entertainment Centre" --source search
|
||||
python3 tools/refboard.py search "Hollywood Masonic Temple" --source category --json
|
||||
# fetch the usable rows into tmp/ (review-only rows need --include-review)
|
||||
python3 tools/refboard.py search ... --json > /tmp/manifest.json
|
||||
python3 tools/refboard.py fetch --from /tmp/manifest.json --dir tmp/research/<block> --max 12
|
||||
# emit SIDECAR + README table rows from the fetched manifest
|
||||
python3 tools/refboard.py rows --from tmp/research/<block>/meta.json
|
||||
```
|
||||
|
||||
Licence gate: `Public domain`, `CC0` and `CC BY` are committable with attribution; `CC BY-SA` rows
|
||||
are flagged `review` and stay research-only until the share-alike decision is written into the
|
||||
sidecar (`--allow-ccbysa` marks them committable after that review); anything else is skipped.
|
||||
`--months 12` flags recent captures so a "current reality" photo can be distinguished from the
|
||||
older geometry evidence. Network gotchas baked in: a descriptive User-Agent, batched `titles=`
|
||||
metadata queries and ~3 s download spacing to avoid Commons HTTP 429s.
|
||||
|
||||
### Texel density (`texels`)
|
||||
|
||||
`texel_density()` and the `texels` command compute `resolution * sqrt(UV area / world area)` per
|
||||
material slot straight from the GLB, so the number does not depend on island packing. The NPC slot
|
||||
maps are 256², and cloth/pants are reported twice: authored density and effective density with the
|
||||
runtime 4× repeat from `Materials.NPC_FABRIC_UV_SCALE`. Current values and interpretation are in
|
||||
[`3d-model-review.md`](3d-model-review.md).
|
||||
|
||||
```bash
|
||||
python3 tools/blender_assets.py texels # all eight characters
|
||||
python3 tools/blender_assets.py texels --json # machine-readable
|
||||
python3 tools/blender_assets.py texels --only npc_coat
|
||||
```
|
||||
|
||||
|
||||
### Quaternius modular parts (outfit variety)
|
||||
|
||||
`tools/quaternius_parts.py` is the intake for the upstream modular pack, which ships as a Google
|
||||
Drive folder and is therefore downloaded once by hand into `tmp/quaternius-intake/` (gitignored):
|
||||
|
||||
| Step | Command | Runs in |
|
||||
| --- | --- | --- |
|
||||
| list what the download contains | `python3 tools/quaternius_parts.py inventory --dir tmp/quaternius-intake` | plain Python (parses the GLB container) |
|
||||
| cut one mesh out as a part | `... extract --file <glb> --mesh <name> --part <name>` | Blender (rewrites the skin) |
|
||||
| hash and validate the kept parts | `... verify --dir assets/models/blender_src/quaternius/parts` | plain Python |
|
||||
|
||||
A part is one skinned mesh on the 62-joint rig; the contract and the join behaviour are in
|
||||
`assets/models/blender_src/quaternius/parts/README.md`. `build` joins a role's
|
||||
`CHARACTERS[...]["parts"]` onto its base body before the slot map, keeping the part's weights and
|
||||
dropping its redundant armature. The pack is never committed — only the parts that are used, with
|
||||
their SHA-256 in `CREDITS.md`.
|
||||
|
||||
## Local map development (planned)
|
||||
|
||||
The local map workstreams (H–M) in [`TODO.md`](../../TODO.md#3d-model-and-local-map-re-evaluation) are
|
||||
planned, not implemented; there are no map-generation commands yet. The runtime
|
||||
contract, provenance rules, budget/navigation gates and fallback are in
|
||||
[`architecture.md#venue-implementation-contract`](../project/architecture.md#venue-implementation-contract).
|
||||
The detailed pipeline/task list lives in `TODO.md`.
|
||||
|
||||
## Confirmed game assets
|
||||
|
||||
Everything below is audited and shipped in the game; development-only outputs
|
||||
(AI concept sheets, Blender sources, raw AI fabrics) are gitignored.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
- **Surface textures:** the v2 picks above (brick, sidewalk pavers, dry and wet asphalt, awning canvas) plus their normal maps, loaded by `src/game/materials.gd` and `WorldBuilder`; the three skyline panoramas are switched per shift phase. The first-generation tiles and `assets/textures/skyline.png` are no longer loaded.
|
||||
- **UI art:** `assets/textures/ai/game_ready/icons/` (flashlight, door lock, wand, REC) and `assets/textures/ai/game_ready/cards/` (ID, wristband, ticket, credential, guest list), loaded by `src/game/ui/hud.gd`.
|
||||
- **Generated models:** `assets/models/generated/*.glb` — bollard, bench, trash bin, planter, newspaper box, hydrant, and the NPC base — plus their colour atlases.
|
||||
- **NPC material maps:** `assets/textures/npc/*_{albedo,normal,orm}.png` and `palette.json`.
|
||||
|
||||
Audit them all:
|
||||
|
||||
```bash
|
||||
python3 tools/asset_pipeline.py audit assets/textures/ai/game_ready
|
||||
python3 tools/model_pipeline.py audit
|
||||
python3 tools/npc_pipeline.py audit
|
||||
```
|
||||
|
||||
## Optional interactive Blender MCP
|
||||
|
||||
Status: **optional interactive tooling, not a build dependency.** Blender MCP is
|
||||
useful for exploring a local part scene, moving the viewport and iterating on a
|
||||
head/hand mesh without a manual UI walkthrough. The authoritative asset
|
||||
pipeline remains the headless CLI plus `audit`/`fit`/`inspect`; CI, tests and
|
||||
release builds must continue to work with no Blender process and no MCP server.
|
||||
|
||||
This project does not vend the third-party addon or pin a runtime MCP
|
||||
dependency. Install it outside the repository and keep generated scenes under
|
||||
`tmp/`.
|
||||
|
||||
### What it adds
|
||||
|
||||
With Blender MCP connected, an agent can:
|
||||
|
||||
- query the current scene and object data;
|
||||
- run small `bpy` snippets through the MCP bridge;
|
||||
- capture a viewport screenshot for visual checks;
|
||||
- inspect or modify a local part scene before exporting a GLB.
|
||||
|
||||
It does **not** replace:
|
||||
|
||||
- `python3 tools/blender_assets.py audit/fit/inspect`;
|
||||
- `python3 tools/npc_proportions.py`;
|
||||
- the Godot capture and smoke gates;
|
||||
- the project's licence/provenance review.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Blender 5.2 LTS, installed as the `org.blender.Blender` flatpak (the project
|
||||
standard).
|
||||
- An MCP-capable client.
|
||||
- The upstream [BlenderMCP addon](https://github.com/ahujasid/blender-mcp)
|
||||
(`addon.py`) and its MCP server.
|
||||
- `uv`/`uvx` if you use the server's `uvx blender-mcp` launcher; a local install
|
||||
of the server package is equally valid.
|
||||
|
||||
Check the upstream README for the current package/addon version and any command
|
||||
changes before installing. Do not copy the addon into this repository.
|
||||
|
||||
### Setup
|
||||
|
||||
#### 1. Install the Blender addon
|
||||
|
||||
1. Download or clone the upstream BlenderMCP project.
|
||||
2. In Blender: **Edit → Preferences → Add-ons → Install from Disk**, select
|
||||
`addon.py` from that checkout.
|
||||
3. Enable **Interface: Blender MCP**.
|
||||
4. Open the 3D View sidebar (N panel), find the **BlenderMCP** tab, and click
|
||||
**Connect to MCP server**. The default server listens on
|
||||
`localhost:9876`.
|
||||
|
||||
The addon must be running inside the Blender process that has the scene open.
|
||||
Starting a second Blender instance does not connect the first one.
|
||||
|
||||
#### 2. Configure the MCP client
|
||||
|
||||
For Pi, add the server at user scope so it is not committed into the project:
|
||||
|
||||
```bash
|
||||
pi mcp add blender -- uvx blender-mcp
|
||||
pi mcp list
|
||||
```
|
||||
|
||||
Then start/reload Pi and use `/mcp` to inspect the connection. Other clients
|
||||
use their normal MCP server configuration. A generic JSON example:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"blender": {
|
||||
"command": "uvx",
|
||||
"args": ["blender-mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For a client with a different config schema, use the equivalent server command
|
||||
and keep the server name `blender`. Restart/reload the client after adding it,
|
||||
then request the tool list and run a harmless `get_scene_info` before editing.
|
||||
|
||||
#### 3. Connect through the flatpak
|
||||
|
||||
Start Blender through the project's normal flatpak command, open the local part
|
||||
scene, and connect from the sidebar. If the client cannot reach the addon:
|
||||
|
||||
- confirm the addon panel says connected;
|
||||
- confirm the client is launching the MCP server, not just a shell;
|
||||
- check port `9876` is not occupied;
|
||||
- if the flatpak sandbox is blocking localhost networking, grant network
|
||||
permission only for this interactive use, for example:
|
||||
`flatpak override --user --share=network org.blender.Blender`
|
||||
(review the permission change afterwards; do not add it to CI or build
|
||||
scripts).
|
||||
- restart both the MCP client and Blender after changing the addon or config.
|
||||
|
||||
### Recommended part-workflow
|
||||
|
||||
The headless part pipeline is `tools/npc_parts.py` (see
|
||||
[`blender-assets.md#local-part-pipeline`](blender-assets.md#local-part-pipeline)). It builds,
|
||||
extracts, assembles, inspects and sheets the reusable head/face and hand parts and reuses the
|
||||
`blender_assets.py` builders, so there is no second face/hand implementation. The MCP steps below
|
||||
remain optional interactive exploration; the headless commands stay the build and audit path.
|
||||
|
||||
1. Create a local scene, for example
|
||||
`tmp/npc-parts/npc_casual/head.blend`, or open a copy of an exported part GLB.
|
||||
Never save over the tracked `assets/models/npc/npc_base.blend`.
|
||||
2. Ask the MCP client for `get_scene_info`, then inspect the object/bone/mesh
|
||||
names it reports.
|
||||
3. Use `execute_blender_code` for small, reversible edits. Prefer adding a new
|
||||
object or modifier over deleting existing geometry; keep a backup copy.
|
||||
4. Use `get_viewport_screenshot` after camera/material changes to check the
|
||||
head/face/hand before exporting.
|
||||
5. Export a **local** GLB under `tmp/npc-parts/`, not directly into
|
||||
`assets/models/`.
|
||||
6. Run the normal gates on the exported file:
|
||||
|
||||
```bash
|
||||
flatpak run org.blender.Blender --background \
|
||||
--python "$PWD/tools/blender_assets.py" -- \
|
||||
inspect --file "$PWD/tmp/npc-parts/head.glb" --category npc --json
|
||||
python3 tools/blender_assets.py audit
|
||||
python3 tools/blender_assets.py fit
|
||||
python3 tools/npc_proportions.py report
|
||||
```
|
||||
|
||||
7. Only after the part is reviewed and the contract passes should it be
|
||||
assembled into a character and rebuilt through the headless pipeline.
|
||||
|
||||
### Safety and project rules
|
||||
|
||||
- MCP is **not** part of `project.godot`, the build, CI, tests, release packages
|
||||
or runtime.
|
||||
- Do not commit `.blend` part scenes, MCP configuration, API keys or generated
|
||||
previews. The working set stays in `tmp/`; a part GLB is not a shipped asset.
|
||||
- Do not use MCP to bypass the contract, budgets, AO/UV rules or licence gate.
|
||||
- The 13 core bones must remain at their rest positions; extra optional bones
|
||||
are allowed only if `NpcAvatar` and the audit/tests still pass.
|
||||
- Prefer scripted headless changes for reproducible work; MCP is for the
|
||||
interactive exploration and review that is awkward in a one-shot script.
|
||||
- If MCP is unavailable, use the headless CLI and document the manual step; never
|
||||
make the project depend on MCP being present.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
| Symptom | Check |
|
||||
| --- | --- |
|
||||
| Client cannot find `blender` | MCP client config path/schema; restart client; confirm server command exists |
|
||||
| `Connection refused` | Addon **Connect to MCP server** pressed; Blender still running; port 9876 |
|
||||
| Tools return stale scene data | Refresh scene/object query after edits; restart the connection if needed |
|
||||
| Flatpak cannot reach localhost | Check flatpak network permission; grant only for interactive use |
|
||||
| Changes look wrong in export | Re-import the GLB with `inspect`; check transforms, scale, UVs and materials |
|
||||
| Contract audit fails | Fix the mesh/bones first; do not edit the audit expectations around a broken asset |
|
||||
|
||||
## Realism pass: scene concepts, skylines and textures
|
||||
|
||||
The skyline, surface tiles and phase lighting were rebuilt against AI concept frames. The
|
||||
concepts live in [`docs/screens/concepts/`](../screens/concepts/README.md); the validated
|
||||
picks the game loads are in `assets/textures/ai/game_ready/v2/picks/` (day/dusk/night
|
||||
panoramas, brick, sidewalk pavers, dry and wet asphalt, awning canvas, each with a normal
|
||||
map).
|
||||
|
||||
`tools/scene_concepts.py` generates and processes new candidates, promotes the validated ones
|
||||
to stable names, audits them, and scores captures against the concepts. It never overwrites an
|
||||
image: new files get a `-vN` suffix, and `process` skips existing candidates unless `--new`.
|
||||
|
||||
```bash
|
||||
python3 tools/scene_concepts.py generate --group scene # or backdrop / texture, --variants N
|
||||
python3 tools/scene_concepts.py process && python3 tools/scene_concepts.py audit
|
||||
python3 tools/scene_concepts.py promote # validated picks -> v2/picks/
|
||||
godot --headless --path . --import
|
||||
|
||||
# A/B a candidate without touching game code: the same cameras, textures swapped at runtime.
|
||||
godot --path . --script res://src/test/capture_realism.gd -- --out=res://docs/screens/realism/<run>/after
|
||||
godot --path . --script res://src/test/capture_realism.gd -- --out=res://docs/screens/realism/<run>/try \
|
||||
--swap=res://assets/textures/ai/game_ready/v2/picks/brick_facade.png=res://assets/textures/ai/game_ready/v2/brick_facade-v2.png
|
||||
|
||||
# Score before/after against the concepts (Pillow detail metrics + vision judge).
|
||||
python3 tools/scene_concepts.py realism --run docs/screens/realism/<run> --judge \
|
||||
--model google/gemini-2.5-flash --repeats 3
|
||||
```
|
||||
|
||||
Judges default to `--model auto`: `tools/openrouter_judge.py` reads the live
|
||||
OpenRouter catalogue, keeps image-input models that support `response_format`,
|
||||
and ranks them by advertised token/image price. The run falls through the list
|
||||
on provider/model errors and aborts on an invalid key. Use
|
||||
`GIG_LINE_JUDGE_MODEL` or `--model <id>` for a pinned comparison.
|
||||
|
||||
For an open-ended review of a single capture/reference (no structured verdict),
|
||||
`tools/describe_image.py` sends the image plus a question to a vision model and
|
||||
prints the answer. It is how the 2026-11-03 re-evaluation read the local-only
|
||||
reference frames and the prop/venue captures:
|
||||
|
||||
```bash
|
||||
python3 tools/describe_image.py docs/screenshots/12-joke-shop.png \
|
||||
--question "Do the red curtain bays read as tied-back fabric or flat slabs?"
|
||||
```
|
||||
|
||||
`capture_npc_review.gd` is the pre-build NPC gate: it stages all eight
|
||||
library character models at portrait, conversation and crowd distance on both
|
||||
promoted maps in day and night lighting, with the normal crowd hidden. It uses
|
||||
the review-only `NpcAvatar.character_override` to include the guard model, which
|
||||
has no spawned NPC role:
|
||||
|
||||
```bash
|
||||
godot --path . --script res://src/test/capture_npc_review.gd -- \
|
||||
--out=res://tmp/npc-review
|
||||
```
|
||||
|
||||
Contact sheets can be assembled from the files (the output is local QA and is
|
||||
not committed). The runtime applies small attach-space offsets to the lanyard
|
||||
and firefighter helmet so the badge clears the jacket and the brim does not sit
|
||||
over the eyes; these are `NpcAvatar.GEAR_OFFSETS`, not changes to the gear
|
||||
contract.
|
||||
|
||||
`tools/npc_proportions.py` checks the eight character GLBs against the shared
|
||||
height/head/shoulder/hip/torso/leg/arm/foot guide; the current results are in
|
||||
[`3d-model-review.md`](3d-model-review.md#character-measurements). It reads the exported GLBs
|
||||
and does not require Blender.
|
||||
|
||||
`capture_npc_pose.gd` is the pose/deformation harness for the 3D re-evaluation: idle,
|
||||
walk-contact, walk-pass and seated poses of all eight variants, full-body plus shoulder
|
||||
close-ups. **It is currently blocked**: the applied pose is visible in the rig and bone values
|
||||
but not in the captured frames, so do not use its sheets as pose evidence. The reproduction and
|
||||
what was ruled out are in [`3d-model-review.md`](3d-model-review.md) §"Pose evidence is blocked".
|
||||
|
||||
The baseline sheet tool accepts a custom view list, which is also how pose sheets are tiled:
|
||||
|
||||
```bash
|
||||
# baseline set (six views, eight variants)
|
||||
python3 tools/npc_baseline.py sheet --dir tmp/npc-baseline
|
||||
# pose set produced by capture_npc_pose.gd
|
||||
python3 tools/npc_baseline.py sheet --dir tmp/npc-pose \
|
||||
--views idle,walk_contact,walk_pass,sit --out tmp/npc-pose/sheet_pose.jpg
|
||||
```
|
||||
|
||||
`capture_npc_baseline.gd` is the 0.9.4 neutral baseline: eight characters ×
|
||||
front/three-quarter/profile/back/full-body/crowd = 48 frames with a fixed
|
||||
camera, studio lighting and no venue/HUD/animation. The raw PNGs stay in
|
||||
`tmp/`; `tools/npc_baseline.py sheet` builds the committed contact sheet, and
|
||||
`tools/npc_baseline.py compare --before DIR --after DIR` builds the review
|
||||
comparison after a model change. See
|
||||
[`3d-model-review.md`](3d-model-review.md).
|
||||
|
||||
```bash
|
||||
godot --path . --script res://src/test/capture_npc_baseline.gd -- \
|
||||
--out=res://tmp/npc-baseline
|
||||
python3 tools/npc_baseline.py sheet --dir tmp/npc-baseline
|
||||
```
|
||||
|
||||
`capture_realism.gd` runs nine stages in this order: day, Hollywood surface close-ups,
|
||||
portrait, evening, late, Joke Shop day, Joke Shop surface close-ups, Joke Shop night,
|
||||
studio; `--stages=day,late` limits it while tuning; `--flashlight` turns on the guard
|
||||
lamp for night stages (used by the Desktop HQ A/B). The `portrait` shot freezes one
|
||||
NPC on open sidewalk and frames the face, which is the only capture that can judge
|
||||
avatar detail (`--portrait-type=N` picks a role). For the 0.9.5 texture audit, run
|
||||
the surface stages directly; the HUD is hidden for them:
|
||||
|
||||
```bash
|
||||
godot --path . --script res://src/test/capture_realism.gd \
|
||||
-- --out=res://tmp/surfaces --stages=surfaces,surfaces_joke
|
||||
```
|
||||
|
||||
The generated realism runs under
|
||||
`docs/screens/realism/` are **local, gitignored QA artifacts**; commit only the
|
||||
resulting decisions/docs, not the run folders. The 0.9.5 texture/UI audit checked actual screen sizes: all screen art is
|
||||
1280×720, access cards are 256×128 and HUD icons are 64×64; no runtime texture
|
||||
was oversized or unused. The 24-NPC smoke now reports 168 meshes / 131,616
|
||||
triangles / 6.94 ms. Compressed payloads are 24.55 MB of surface picks, 22.67 MB
|
||||
of screen art and 0.48 MB of NPC maps.
|
||||
|
||||
The 0.9.5 realism judge ran once per shot with `z-ai/glm-5.3-flash`
|
||||
(`python3 tools/scene_concepts.py realism --run docs/screens/realism/2026-11-02-tex
|
||||
--judge`). Its mean `after` scores were overall 1.8, lighting 2.3, materials 1.8,
|
||||
texture detail 1.2, backdrop 2.0 and characters 1.2; the Pillow metric gap was
|
||||
0.306. The judge still ranks the painted concept targets above the stylized build,
|
||||
particularly lit night facades and dense backdrop detail. That is expected: the
|
||||
pass validated close-range surfaces and promoted the texture repairs, not a
|
||||
photoreal renderer. The 0.9.5 before/after close-up sheet is
|
||||
[`blender_previews/sheet_texture_refinement_0.9.5.jpg`](../blender_previews/sheet_texture_refinement_0.9.5.jpg);
|
||||
the texture-adoption run itself was the earlier 0.8-style candidate comparison,
|
||||
and its report remains local/gitignored.
|
||||
|
||||
For the 0.9.6 integration checkpoint the same judge was pinned to
|
||||
`z-ai/glm-5.3-flash` and run once per shot on the 10-shot set: mean overall 2.1,
|
||||
lighting 2.4, materials 2.1, texture 1.7, backdrop 2.3, characters 1.8, Pillow
|
||||
gap 0.291. No `review` verdicts were produced by the screen judge, so there was
|
||||
no manual layout verdict to convert into a fix; the remaining realism comments
|
||||
are the accepted stylized gaps.
|
||||
|
||||
Build release artifacts from a clean checkout or detached worktree. A normal
|
||||
working tree may contain ignored `tmp/` captures and old `build/` artifacts, and
|
||||
Godot can pack enough of those into exports to inflate a 0.9.6 package from
|
||||
~160 MB to 1.6–3.1 GB. The 0.9.6 clean worktree produced 161.5 MB Linux,
|
||||
197.1 MB Windows, 148.0 MB macOS, 97.8 MB Web and 241.5 MB Desktop HQ.
|
||||
|
||||
The Forward+ A/B compared the Compatibility entrance with the optional Desktop HQ
|
||||
preset. The gear-fit run checked the sunglasses, bag and helmet against measured
|
||||
head/skirt bounds with `python3 tools/blender_assets.py fit`; it also fixed the
|
||||
portrait yaw and hid the focus overlay so the close-up shows the avatar.
|
||||
|
||||
Regenerate a run locally with the capture and `realism --run ...` commands above;
|
||||
the reports and sheets are intentionally not part of the release repository.
|
||||
|
||||
## Screen design references
|
||||
|
||||
Screen mockups and screenshot comparison use `tools/screen_designs.py`. It turns the
|
||||
screen descriptions in `docs/` (README captions, ADRs 0007–0010, `PLAN.md`, and
|
||||
`architecture.md`) into runtime reference art under `assets/images/ui/` (with a
|
||||
reference-only snapshot in `docs/screens/designs/`), then uses those images as
|
||||
the expected state to check the game's captures.
|
||||
|
||||
Each screen is tagged `scene` (a realistic first-person 3D world view) or `ui`
|
||||
(an in-game panel overlaid on a softened scene), and the generator appends a
|
||||
shared art-direction suffix so the frames read as one coherent game. Requests use
|
||||
a 3:2 landscape source and crop to the 16:9 capture dimensions.
|
||||
|
||||
```bash
|
||||
# Export the built-in scenarios to editor-authorable .tres resources
|
||||
godot --headless --path . --script res://tools/export_venues.gd
|
||||
|
||||
# List the catalogue (name, kind, whether a reference design exists)
|
||||
python3 tools/screen_designs.py list
|
||||
|
||||
# Deterministic wireframes (offline, no API key); existing designs are kept
|
||||
# unless --force is passed, so an offline run never replaces an AI mockup
|
||||
python3 tools/screen_designs.py generate --draft
|
||||
python3 tools/screen_designs.py generate --draft --only wand --force
|
||||
|
||||
# Realistic 3D scenes and in-game screen designs via OpenRouter
|
||||
# (requires OPENROUTER_API_KEY)
|
||||
python3 tools/screen_designs.py generate
|
||||
|
||||
# UI-state gallery (menu, pause, settings, review, radio, wheel, crowd, responder)
|
||||
godot --path . --script res://src/test/capture_ui_states.gd -- --out=res://docs/screenshots
|
||||
|
||||
# Prop gallery (studio line-up) for close-range prop checks
|
||||
godot --path . --script res://src/test/capture_prop_gallery.gd
|
||||
|
||||
# Compare docs/screenshots/*.png against the designs
|
||||
python3 tools/screen_designs.py compare
|
||||
python3 tools/screen_designs.py compare --json
|
||||
|
||||
# Ask a vision model whether each capture realises its design's screen and layout
|
||||
# (requires OPENROUTER_API_KEY; the repo .env is read automatically)
|
||||
python3 tools/screen_designs.py judge
|
||||
python3 tools/screen_designs.py judge --model openai/gpt-4o-mini --json
|
||||
python3 tools/screen_designs.py judge --only 03- # one capture
|
||||
python3 tools/screen_designs.py judge --dry-run # build requests, no network
|
||||
```
|
||||
|
||||
The catalogue covers 21 screens: the thirteen scene captures plus the start menu,
|
||||
pause overlay, settings, shift review, radial menu, emergency radio, crowd panic,
|
||||
and responder arrival. All 21 have an AI reference in `assets/images/ui/`
|
||||
(snapshot in `docs/screens/designs/`) and a committed capture, so every entry is
|
||||
compared or judged against the running game.
|
||||
|
||||
`compare` flags gross drift: wrong aspect ratio, undersized frame, near-blank
|
||||
frame, and large layout/palette/detail distance. It is a regression gate, not a
|
||||
semantic judge — pixel and perceptual metrics cannot decide whether a frame's
|
||||
composition is *right*.
|
||||
|
||||
`judge` covers that gap: it sends each capture together with its concept mockup to
|
||||
a vision model and asks whether the capture realises the same screen and layout
|
||||
intent. Art style, fidelity, fonts, and placeholder text are explicitly ignored.
|
||||
Verdicts are classed as `match`, `review` (a mismatch below `--fail-confidence`,
|
||||
default 0.6 — expected when a concept mockup is more stylised than the game), or
|
||||
`diff` (a confident mismatch, which exits non-zero). Regenerate the designs after changing screen layout, then re-run
|
||||
`compare` after regenerating the game screenshots with
|
||||
`src/test/capture_screenshots.gd` and `src/test/capture_ui_states.gd`.
|
||||
|
||||
For a one-command drift check, run `tools/check_screenshots.sh`. It renders the
|
||||
scene captures, UI-state captures and prop gallery into `docs/screenshots/` (using
|
||||
`xvfb-run` when no display is available) and then runs `screen_designs.py compare`.
|
||||
@@ -0,0 +1,304 @@
|
||||
# Blender asset library
|
||||
|
||||
Architecture context: the asset contracts and fallback path are summarized in
|
||||
[`architecture.md`](../project/architecture.md#content-and-asset-pipeline-architecture).
|
||||
|
||||
Status: built, audited, and integrated (2026-11-01). The active character migration
|
||||
replaces the procedural body with a baked CC0 Quaternius base
|
||||
([ADR-0017](../adr/0017-quaternius-npc-base.md)); the current library remains the shipped
|
||||
fallback until the Quaternius 12-variant baseline passes review. `NpcAvatar` selects the
|
||||
character variants and attaches the gear; `WorldBuilder` loads the prop library
|
||||
with colliders and phase-driven emissives. The 0.9.4 neutral baseline and its
|
||||
acceptance criteria are in [`architecture.md`](../project/architecture.md#character-and-gear-asset-contract);
|
||||
the committed 8 × 6 contact sheet is
|
||||
[`blender_previews/sheet_npc_baseline.jpg`](../blender_previews/sheet_npc_baseline.jpg).
|
||||
|
||||
Generator: [`tools/blender_assets.py`](../../tools/blender_assets.py). Manifest (the
|
||||
source of truth for integration):
|
||||
[`assets/models/blender/manifest.json`](../../assets/models/blender/manifest.json).
|
||||
|
||||
## Art direction
|
||||
|
||||
The references were the AI mockups in `docs/screens/designs/` and the current
|
||||
captures in `docs/screenshots/`.
|
||||
|
||||
- **People** (`targeting_npc`, `joke_shop_queue`, `studio_arrival`,
|
||||
`crowd_panic`, `responder_arrival`): ordinary adults in sweaters, jackets,
|
||||
long coats and hoodies; a security guard in a dark uniform with a cap
|
||||
and belt; a firefighter in a dark turnout coat with yellow reflective bands,
|
||||
a helmet and an SCBA tank. The current skinned `npc_base` is a set of boxes
|
||||
and cylinders with a flat face, so silhouette was the main gap.
|
||||
- **Props** (`prop_gallery`, `venue_overview`, `late_night_avenue`,
|
||||
`joke_shop_queue`): the gallery mockup has an octagonal tapered bollard, a
|
||||
slatted bench with thin metal legs, an octagonal green litter bin with a slot
|
||||
and recessed panels, a stone planter with a lumpy shrub, a blue news box with
|
||||
a window and a slanted top, and a hydrant with caps and nozzles. The venue
|
||||
shots add ornate street lamps, palm trees, round stone pots with topiary, a
|
||||
chalkboard A-frame sign, a wall lantern, a bulb-lit marquee, glazed double
|
||||
doors, stair handrails and a dark sedan. Today's in-game props are primitive
|
||||
boxes, and the trees are cones.
|
||||
|
||||
## Frozen fallback boundary
|
||||
|
||||
`assets/models/npc/npc_base.blend` is the tracked, frozen plain-primitive
|
||||
fallback. Prototype head/face/hand detail from the local part pipeline must not
|
||||
be saved into it; a separate contract decision (ADR or owner sign-off) is
|
||||
required before any prototype detail is absorbed. Working scenes live under
|
||||
`tmp/npc-parts/` (gitignored) and are exported as local GLBs for review, never
|
||||
directly over the tracked `.blend`. The same rule already appears in
|
||||
[`asset-tooling.md#recommended-part-workflow`](asset-tooling.md#recommended-part-workflow).
|
||||
|
||||
## Local part pipeline
|
||||
|
||||
The overhaul uses `tools/npc_parts.py` to build, extract, assemble, inspect and sheet the
|
||||
reusable head/face and hand parts before they are rolled into the 12 characters. It reuses the
|
||||
`blender_assets.py` builders (no second face/hand implementation) and stays local-only/headless.
|
||||
Part sources and preview GLBs live under `tmp/npc-parts/<variant>/`; they are **not shipped
|
||||
assets** — only the assembled character GLBs, AO/maps, the manifest and the review sheets are
|
||||
committed.
|
||||
|
||||
```bash
|
||||
# Build one part fresh from the current builders (head|face|hand_l|hand_r)
|
||||
flatpak run org.blender.Blender --background \
|
||||
--python "$PWD/tools/npc_parts.py" -- build --part face --variant npc_casual \
|
||||
--out "$PWD/tmp/npc-parts/npc_casual/face.glb"
|
||||
# Pull the current shipped head/hands out of the built character GLB (comparison)
|
||||
flatpak run org.blender.Blender --background \
|
||||
--python "$PWD/tools/npc_parts.py" -- extract --part hand_l --variant npc_casual \
|
||||
--out "$PWD/tmp/npc-parts/npc_casual/hand_l.glb"
|
||||
# Assemble a variant (current fallback path; Quaternius migration in progress)
|
||||
flatpak run org.blender.Blender --background \
|
||||
--python "$PWD/tools/npc_parts.py" -- assemble --variant npc_casual \
|
||||
--out "$PWD/tmp/npc-parts/npc_casual/assembled.glb"
|
||||
# Inspect a part or assembled GLB (Blender import view)
|
||||
flatpak run org.blender.Blender --background \
|
||||
--python "$PWD/tools/npc_parts.py" -- inspect --file "$PWD/tmp/npc-parts/npc_casual/face.glb"
|
||||
# Contact sheet / before-after comparison (outside Blender; stdlib + Pillow)
|
||||
python3 tools/npc_parts.py sheet --dir tmp/npc-parts/npc_casual \
|
||||
--out docs/blender_previews/sheet_parts.jpg
|
||||
python3 tools/npc_parts.py compare --before DIR --after DIR --out sheet.jpg
|
||||
```
|
||||
|
||||
`build --part head` produces the bare head shell; `build --part face` is the shell plus
|
||||
nose/eyes/mouth/ears; `build --part hand_l|hand_r` are the left/right hand geometry. `extract`
|
||||
pulls the current shipped geometry out of the built character GLB by vertex group (`Head` for
|
||||
`head`/`face`, `HandL`/`HandR` for the hands), so it shows what is actually shipped rather than
|
||||
what the builders would emit fresh. The assembled GLB must still pass
|
||||
`blender_assets.py inspect`/`audit`/`fit` before any part is accepted.
|
||||
|
||||
## Optional MCP
|
||||
|
||||
Blender MCP can be used for interactive head/face/hand part exploration, but it
|
||||
is not required. The headless commands below remain the build and audit path;
|
||||
see [`asset-tooling.md#optional-interactive-blender-mcp`](asset-tooling.md#optional-interactive-blender-mcp) for installation, connection and safety.
|
||||
|
||||
## Running it
|
||||
|
||||
```bash
|
||||
# Build every asset (or --only a,b,c). Writes GLBs, .blend sources, manifest.
|
||||
flatpak run org.blender.Blender --background \
|
||||
--python "$PWD/tools/blender_assets.py" -- build
|
||||
# Cycles previews (CPU, ~3 s each) -> docs/blender_previews/<name>.png (gitignored)
|
||||
flatpak run org.blender.Blender --background \
|
||||
--python "$PWD/tools/blender_assets.py" -- render
|
||||
python3 tools/blender_assets.py sheet # -> docs/blender_previews/sheet_<category>.jpg
|
||||
python3 tools/blender_assets.py audit # stdlib only; exit 1 on any issue
|
||||
python3 tools/blender_assets.py fit # head/clothing fit + bag-body clearance, per character
|
||||
python3 tools/blender_assets.py texels # 256² slot-map density (authored + 4× fabric), per character
|
||||
python3 tools/blender_assets.py list
|
||||
# import a GLB, apply node transforms, report geometry/bones/UVs/skinning + contract failures
|
||||
flatpak run org.blender.Blender --background \
|
||||
--python "$PWD/tools/blender_assets.py" -- inspect --file "$PWD/tmp/downloads/x.glb"
|
||||
godot --headless --path . --import # after any rebuild
|
||||
```
|
||||
|
||||
The flatpak cannot read `/tmp` and its cwd is `/app/blender`, so the script
|
||||
derives every path from its own location. A full build takes about 5 s; a full
|
||||
render about 2.5 min.
|
||||
|
||||
## Layout
|
||||
|
||||
| Kind | Path | Count |
|
||||
| --- | --- | --- |
|
||||
| Characters (skinned) | `assets/models/blender/npc/<name>.glb` | 8 |
|
||||
| Gear (static) | `assets/models/blender/npc_gear/<name>.glb` | 18 |
|
||||
| Props (static) | `assets/models/blender/props/<name>.glb` | 20 |
|
||||
| Editable sources | `assets/models/blender_src/<name>.blend` (`.gdignore`d) | 46 |
|
||||
| Review sheets | `docs/blender_previews/sheet_{npc,npc_gear,props}.jpg` | 3 |
|
||||
|
||||
## Conventions
|
||||
|
||||
- Blender is Z-up. The glTF exporter converts to Godot Y-up.
|
||||
- **Front = Blender −Y = Godot +Z**, the face direction that
|
||||
`NpcAvatar.yaw_towards()` assumes.
|
||||
- **Foliage** (`street_tree`, `planter_box`, `round_planter`) is a cluster of many small
|
||||
flat-shaded clumps plus outer clumps that break the silhouette, not one smooth blob. That is
|
||||
how the realism pass made the street greenery read as leaves; the three assets carry fresh AO
|
||||
maps (`street_tree` is 2,592 triangles).
|
||||
- Grounded assets have their origin on the floor (`min y ≈ 0`). The two
|
||||
exceptions are `wall_lantern` (origin on the wall plate, at mount height) and
|
||||
`marquee` (origin at the wall where the canopy meets it, at canopy height).
|
||||
- Units are metres, at the same scale as the current world (1.82 m characters).
|
||||
|
||||
## Characters
|
||||
|
||||
All library variants share the
|
||||
[character and gear asset contract](../project/architecture.md#character-and-gear-asset-contract):
|
||||
`npc_base` mesh node, five tint slots, +Z visual front, 24,000/24,000 character budget and the
|
||||
same 256² slot-map/AO rules. The shipped roster is on the 62-joint Quaternius skeleton
|
||||
(ADR-0018); the procedural fallback built by `build --source procedural` keeps the 13 core bones.
|
||||
Both are accepted by `audit`, `inspect`, the runtime bone map and the tests.
|
||||
|
||||
The three Quaternius sources (`casual`, `hoodie`, `worker`) live in
|
||||
`assets/models/blender_src/quaternius/` as build inputs. `build` (default
|
||||
`--source quaternius`) does a two-pass bake per character: a cached T-pose round trip that joins
|
||||
without welding, maps the upstream materials onto the five slots and unwraps the degenerate
|
||||
source UVs, then an arm-down rest bake exported with `export_apply=False`. The per-character slot
|
||||
map, triangle counts and measurements are in
|
||||
[`npc-model-sourcing.md#shipped-roster-099-owner-chose-the-full-swap`](npc-model-sourcing.md#shipped-roster-099-owner-chose-the-full-swap).
|
||||
|
||||
Role selection and look notes (tint + gear now carry the role, not the mesh):
|
||||
|
||||
| Character | `layer_for()` / look | Suggested roles | Preview gear |
|
||||
| --- | --- | --- | --- |
|
||||
| `npc_casual` | `none`: sweater/tee, short sleeves | tourist, confused visitor, intoxicated, guest | backpack, camera |
|
||||
| `npc_jacket` | `jacket`: collar + placket | authorized staff, protester, guest | lanyard, clipboard |
|
||||
| `npc_coat` | `coat`: knee-length coat, long hair | influencer, trespasser, violent threat | sunglasses, shoulder bag, phone |
|
||||
| `npc_vest` | `vest`: hi-vis vest, reflective bands | delivery | cap, clipboard |
|
||||
| `npc_dress` | `dress`: slim build, bun, tights | performer | shoulder bag |
|
||||
| `npc_hoodie` | `hood` accessory: hood up, beard, pocket | trespasser, mental crisis, violent threat | cup |
|
||||
| `npc_firefighter` | responder: broad build, banded turnout coat, boots | responder | helmet, SCBA tank, flashlight |
|
||||
| `npc_guard` | uniform: belt, buckle, badge, shoulder patches, radio | player guard / venue security | cap, wand |
|
||||
|
||||
**Fixed-colour materials.** Detail materials outside the five slots (`hivis`,
|
||||
`reflective`, `leather`, `steel`, `patch`, `plastic_black`, `rubber`) carry a 4×4
|
||||
colour texture. The current `_apply_outfit_materials()` sets
|
||||
`albedo_color = WHITE` on any slot it does not recognise, and these keep their
|
||||
colour through that. When integrating, tint only the five contract slots.
|
||||
|
||||
**Hair part library.** Each base ships one hair shell and the build reshapes it in place
|
||||
(`QUATERNIUS_HAIR_PARTS`): `short` is identity, `buzz` squashes toward the hairline, `long` stretches
|
||||
from the crown. The role picks the style through `CHARACTERS[...]["hair"]`, and the T-pose cache is
|
||||
keyed on it. `bald` is only allowed on the `casual` and `hoodie` bases, where the skin closes the
|
||||
skull; the modular Worker's head stops at the hairline, so removing its hair would open the crown.
|
||||
|
||||
**Face detail.** None of the three Quaternius heads ships a mouth or eye whites, so the build adds
|
||||
both: `_add_quaternius_mouth()` places a lip block on the measured skin surface, and
|
||||
`_add_quaternius_eye_whites()` renames the eye quads to `sclera` and adds a shrunken `pupil` copy in
|
||||
front of each. Both are fixed-colour (not tint slots), so they survive the role palettes.
|
||||
|
||||
**Head gear fit.** Head gear is authored for the shipped Quaternius head:
|
||||
`HEAD_GEAR_SCALE = 0.64` (0.201 m base head against the 0.315 m procedural one) and
|
||||
`HEAD_GEAR_LIFT = 0.07` (its centre sits higher). The procedural fallback therefore wears slightly
|
||||
undersized caps — cosmetic and fallback-only.
|
||||
|
||||
**Runtime tint notes (implemented).** Only the five contract slots are tinted;
|
||||
fixed detail materials keep their authored colour. On `npc_vest` the vest is the
|
||||
fixed `hivis` material and `cloth` is the undershirt, so the yellow override is
|
||||
skipped. `npc_firefighter`/`npc_guard` use dark turnout/uniform tints instead of
|
||||
the role's `TYPE_COLORS`.
|
||||
|
||||
## Gear attach points
|
||||
|
||||
Gear is authored in attach-point space; the exact contract and runtime fit offsets are in
|
||||
[`architecture.md#character-and-gear-asset-contract`](../project/architecture.md#character-and-gear-asset-contract).
|
||||
|
||||
The shipped matrix is **fifteen pieces** since the hat removal (owner, 2026-11-06): `cap` and
|
||||
`hood` are retired, and `firefighter_helmet` is the only head item.
|
||||
|
||||
| Attach | Pieces |
|
||||
| --- | --- |
|
||||
| head | `firefighter_helmet`, `sunglasses` |
|
||||
| back | `backpack`, `shoulder_bag`, `scba_tank` |
|
||||
| chest | `lanyard` |
|
||||
| hand | `phone`, `cup`, `clipboard`, `umbrella`, `camera`, `protest_sign`, `flashlight`, `wand`, `radio` |
|
||||
|
||||
### Hand-item fit on the Quaternius rig
|
||||
|
||||
The hand attach point moved from Godot y 0.78 (procedural wrist) to **y 1.017** (Quaternius
|
||||
wrist), so `fit` measures hand gear against the new wrist instead of the authored attach offset.
|
||||
Run `blender_assets.py fit`; per item it reports the grip drop, the lowest point at rest, whether
|
||||
that clears the hip line (0.95) and `size_vs_head` — the proportion that makes "reads oversized"
|
||||
measurable on the 0.20 m head.
|
||||
|
||||
| Item | Drop (m) | Lowest at rest | Clears hip | Longest | × head |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `camera` | 0.053 | 0.964 | yes | 0.138 | 0.69 |
|
||||
| `phone` | 0.105 | 0.912 | no | 0.150 | 0.75 |
|
||||
| `cup` | 0.110 | 0.907 | no | 0.142 | 0.71 |
|
||||
| `radio` | 0.108 | 0.909 | no | 0.203 | 1.01 |
|
||||
| `flashlight` | 0.077 | 0.940 | no | 0.243 | 1.14 |
|
||||
| `wand` | 0.125 | 0.892 | no | 0.400 | 1.87 |
|
||||
| `umbrella` | 0.201 | 0.816 | no | 0.900 | 4.48 |
|
||||
| `clipboard` | 0.375 | 0.642 | no | 0.305 | 1.63 |
|
||||
| `protest_sign` | 0.250 | 0.767 | no | 1.230 | 6.12 |
|
||||
|
||||
Falling below the hip line is normal for a carried object; the number that matters is whether the
|
||||
item was *authored* for the new grip. Only the `clipboard` was: its grip sat in the middle of the
|
||||
board, so it swung through the thigh. It now grips the top edge (0.22 × 0.30 m) like a carried
|
||||
clipboard, and `HAND_ITEM_AUTHORED_SIZE` in the tool flags any future drift past 10 %.
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Replaces / role | Notes |
|
||||
| --- | --- | --- |
|
||||
| `bollard`, `bench`, `trash_bin`, `planter_box`, `newspaper_box`, `fire_hydrant` | `assets/models/generated/<name>.glb` | same footprint and height as the generated set, so colliders and `PROP_PLACEMENTS` still fit |
|
||||
| `street_lamp` | the box lamps in `_build_street_furniture()` | 5 m; the arm points to −X with the bulb at (−0.85, 4.8, 0). Rotate 180° for the other side of the street. Emissive `bulb` |
|
||||
| `street_tree` | the cone trees | 3.6 m, with a tree grate |
|
||||
| `traffic_cone` | the `cone` prop accessory | 0.69 m |
|
||||
| `palm_tree` | new (boulevard) | 7 m, 14 fronds |
|
||||
| `round_planter` | new (mockup pots) | stone pot with topiary |
|
||||
| `a_frame_sign` | new (comedy club queue) | chalkboard |
|
||||
| `wall_lantern` | new (club door) | wall-mounted; emissive `bulb` |
|
||||
| `crowd_barrier`, `stanchion` | new (queue / crowd control) | 2.2 m bike-rack barricade; chrome post with velvet rope |
|
||||
| `handrail` | new (steps) | 2 m run that climbs 0.8 m along +X |
|
||||
| `security_podium` | new (door check-in) | tablet screen is mildly emissive |
|
||||
| `double_door` | the procedural entrance door | 2.0 × 2.7 m; emissive `lock_led` on the card reader |
|
||||
| `marquee` | the procedural marquee | 6 m canopy and sign box; emissive `bulb` + `sign_face`. Overlay the text with the existing `Label3D` |
|
||||
| `sedan` | new (studio arrival) | 4.6 m; emissive `headlight`, red tail lamps |
|
||||
|
||||
Props use plain PBR factors (base colour, roughness, metallic, emission) plus
|
||||
the committed 256² baked AO maps (`<name>_ao.png`) wired by `WorldBuilder`;
|
||||
emissive materials are tied to `WorldBuilder.set_night_mode()`. Characters
|
||||
carry baked AO and the shared neutral material maps described above.
|
||||
|
||||
## Checks
|
||||
|
||||
- `python3 tools/blender_assets.py audit`: container, `POSITION`/`NORMAL`/
|
||||
`TEXCOORD_0`, UVs in [0, 1], no zero-area triangles (Godot warns about them
|
||||
during LOD generation), budgets (characters 24000/24000, gear 1500, props 4000
|
||||
triangles), grounding, and an editable `.blend`. For characters it also
|
||||
checks skinning, bones, slots, the `npc_base` mesh node and the height.
|
||||
- `tools/tests/test_blender_assets.py`: catalogue and contract consistency with
|
||||
`npc_pipeline.py`, plus a clean audit, a texel-density gate (all five slots,
|
||||
40–400 px/m authored, 250 px/m fabric floor) and full manifest coverage when
|
||||
the library is built.
|
||||
- `src/test/tests/test_blender_assets.gd`: every asset imports as a scene
|
||||
with geometry, characters keep their bones and slots, joints match
|
||||
`npc_base`, the bones accept `NpcAvatar`'s pose composition, and props are
|
||||
grounded.
|
||||
- `tools/test_model_review.py`: every `CHARACTERS`/`GEAR`/`PROPS` entry has a
|
||||
scorecard row and verdict in `docs/assets/3d-model-review.md` (coverage gate).
|
||||
- `src/test/tests/test_world.gd`: every library prop that blocks walking has a
|
||||
`StaticBody3D` + `NavigationObstacle3D`, checked against the catalogue
|
||||
(collider/proxy gate).
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Characters carry a baked `<name>_ao.png` (red channel, wired by
|
||||
`_apply_outfit_materials()`), but no ORM texture of their own. They reuse the
|
||||
tiling slot maps, so the runtime `SurfaceMaterials` recipes still supply
|
||||
roughness.
|
||||
- There is a single body topology per build (average, slim, broad) and no
|
||||
shape keys. Height variety still comes from `outfit_for()["height_scale"]`.
|
||||
- Skirts and coats follow the hip and knee bones, so a full stride can clip
|
||||
the legs through the hem.
|
||||
- Preview colours (`PREVIEW_TINTS`) only stand in for `outfit_for()`. The GLBs
|
||||
stay neutral.
|
||||
- The 0.9.4 baseline found three P1 portrait issues. The tourist's photo prop
|
||||
now only appears in photo/film poses and the backpack straps were refit as
|
||||
flattened chest bands. The face carries the 0.9.4 sculpt (socketed sclera,
|
||||
chin/cheekbone, nose, lips) but its `seg=18/rings=12` head still reads
|
||||
somewhat faceted at portrait range; a subdivision/deeper-sculpt prototype is
|
||||
gated on the owner scorecard review. The full ranked list and measurements are
|
||||
in [`3d-model-review.md`](3d-model-review.md).
|
||||
@@ -0,0 +1,140 @@
|
||||
# MakeHuman head prototype
|
||||
|
||||
Status: **retained fallback; superseded as the active NPC head by the Quaternius
|
||||
base (ADR-0017).** The owner accepted the basic human head on 2026-11-04. The automated vision gate
|
||||
still records REJECT on the eye/lid region as deferred polish. The asset is a
|
||||
standalone head/neck extracted from the CC0 MakeHuman base. The assembled path
|
||||
adds hair, a hidden scalp trim and the procedural body. The 12 shipped GLBs have
|
||||
not been rebuilt with it, and the Quaternius migration replaces it as the active
|
||||
base. The development source files were removed from `tmp/` on 2026-11-05.
|
||||
|
||||
## Asset
|
||||
|
||||
- GLB: [`assets/models/blender_src/makehuman_head.glb`](../../assets/models/blender_src/makehuman_head.glb)
|
||||
- Review sheet: [`blender_previews/sheet_makehuman_head.jpg`](../blender_previews/sheet_makehuman_head.jpg)
|
||||
- Source workflow (development-only, files removed after the Quaternius pivot;
|
||||
retained here as the historical method) — extracts the MakeHuman head, cuts it at the neck, keeps the head/eye/mouth
|
||||
components, cuts an almond aperture through a skin eyelid mask, adds recessed
|
||||
eyeballs with flat iris/pupil discs, anchors brows to the face surface, renders
|
||||
front/three-quarter/profile and exports the standalone GLB.
|
||||
- Measurements (final standalone build, `inspect`): **12,641 triangles / 7,690
|
||||
split vertices**; bounds approximately `x ±0.094 m`, `y -0.180…0.042 m`,
|
||||
`z 1.530…1.803 m` in project Z-up space.
|
||||
- Assembled `npc_casual --head mh`: **14,753 triangles / 12,823 seam-split
|
||||
vertices / 14 bones / 9 materials**, `inspect` clean. All 12 variants count
|
||||
14,693–15,987 triangles with the same head.
|
||||
- Materials used: `skin`, `eye_white`, `iris`, `pupil`, `hair`. No textures are
|
||||
baked into this prototype.
|
||||
|
||||

|
||||
|
||||
## What was done
|
||||
|
||||
1. Imported the CC0 MakeHuman base OBJ and cleared the importer axis rotation.
|
||||
2. Applied the project scale/axis transform: 1.82 m body, Z-up, metres,
|
||||
front −Y.
|
||||
3. Cut the head at the neck (`z ≈ 1.53`) rather than keeping the cut shoulders;
|
||||
capped the neck opening.
|
||||
4. Built an eyelid mask by subtracting an almond aperture from a skin sphere
|
||||
centred on each eye (`solver = "EXACT"`), then recessed the globes so the
|
||||
cornea sits behind the lid plane.
|
||||
5. Added flat iris/pupil discs on the globe axis; disabled backface culling so
|
||||
the discs stay visible at three-quarter angles.
|
||||
6. Anchored brow strips to the skin with object-space ray casts and added a
|
||||
shallow supraorbital ridge so they do not float, then reduced the crown
|
||||
height to improve the profile silhouette.
|
||||
7. Fixed a material-remap bug introduced by joining the extra eye objects: the
|
||||
joined head faces were pointing at the `eye_white` slot, making the whole
|
||||
head render as white clay. The largest connected component is now forced
|
||||
back onto `skin` after the join.
|
||||
8. Rendered front/three-quarter/profile views in a neutral review studio and
|
||||
exported the standalone GLB.
|
||||
|
||||
## Provenance
|
||||
|
||||
- Base mesh: MakeHuman / MPFB base, CC0, from the local research set
|
||||
(development source files removed after the 2026-11-05 Quaternius pivot); see
|
||||
[`CREDITS.md`](../../CREDITS.md).
|
||||
- The head is a **derived prototype**, not a shipped NPC. It does not enter
|
||||
`assets/models/blender/npc/`, `npc_base`, the runtime `NpcAvatar` path, CI, or
|
||||
a release package. It is retained as a fallback, not the active NPC base.
|
||||
- Source photographs/proportions are not used as textures here; this is a
|
||||
geometry-only head built from the researched model data.
|
||||
|
||||
## Review criteria
|
||||
|
||||
Accept only if:
|
||||
|
||||
- the head reads as human at front/three-quarter/profile and the neck is a
|
||||
clean head/bust silhouette, not a shoulder cut;
|
||||
- eyes, brow, nose, mouth and ears are readable without spikes, holes or
|
||||
floating shells;
|
||||
- the asset is inside the character budget and has no script/contract violation;
|
||||
- the prototype remains clearly local and un-integrated until the part pipeline
|
||||
is approved.
|
||||
|
||||
## Judge result
|
||||
|
||||
**Owner verdict: ACCEPT (basic human head, 2026-11-04).**
|
||||
**Automated vision gate: REJECT** (vision judge `z-ai/glm-5.3-flash`, final
|
||||
standalone build render set above). The eye/lid findings below remain deferred
|
||||
polish; they are no longer a rollout blocker because the owner accepted the head.
|
||||
|
||||
Blocking findings:
|
||||
|
||||
- **P0 — globe depth is inconsistent across views.** The front/three-quarter
|
||||
reads still show a bead-like iris with a pale sclera halo, while the profile
|
||||
reduces the eye to a brown sliver. The judge reads this as a faked recess
|
||||
rather than a socketed globe under a lid.
|
||||
- **P0 — no convincing lid overlap.** The iris remains nearly circular in front
|
||||
and the upper lid does not visibly clip the globe; the skin lid mask reads as
|
||||
a crater around a bead.
|
||||
- **P0 — iris orientation reads decal-locked** at three-quarter, so the gaze
|
||||
appears to drift off the head axis.
|
||||
- **P0 — the aperture/lid mask is too circular** and lacks medial/lateral
|
||||
canthi, so the eye still reads as a doll eye at portrait distance.
|
||||
|
||||
Should-fix findings:
|
||||
|
||||
- the brow tubes are better anchored but the medial ends still detach in
|
||||
three-quarter/profile;
|
||||
- nose lacks a defined tip/wing and nostrils;
|
||||
- mouth is a faint geometric seam with no lip volume;
|
||||
- crown/back-of-skull is still faceted and egg-like in profile;
|
||||
- the catchlight is baked geometry and is not consistent between eyes.
|
||||
|
||||
What passed: the skin/eye material remap is correct, the eye openings are
|
||||
closed, the standalone GLB reports **12,641 triangles / 7,690 split vertices**,
|
||||
and an assembled `npc_casual` with this head passes `inspect` at **14,753 /
|
||||
16,000 triangles, 12,823 seam-split vertices, 14 bones, no issues**.
|
||||
|
||||
Known limits: bald mannequin-like presentation, stylized geometry-only
|
||||
iris/pupil, no rig, no UV texture pass, and the neck cap is a review cut rather
|
||||
than a body-welded seam.
|
||||
|
||||
## Assembled prototype (2026-11-04)
|
||||
|
||||
- `_makehuman_head` imports the GLB and adds a **skull-conforming hair cap**
|
||||
derived from the closed head shell (scaled shell + boolean front-sphere
|
||||
cut + decimate). Hair skips the old `hair_normal.png` because the skull UVs
|
||||
produced large dark tangent patches.
|
||||
- Head faces fully covered by the cap are deleted to recover ~950 triangles;
|
||||
boundary faces stay as a safety band, so the accepted face geometry is
|
||||
untouched. The hidden neck plug uses 12 segments.
|
||||
- The procedural body was narrowed at the neck and shoulders (chest 0.158,
|
||||
neck base 0.060, neck top 0.054, shoulder root z 1.345) and its skin decimate
|
||||
dropped to 0.21 so all 12 variants fit the 16,000 cap.
|
||||
- Local review renders were removed with the 2026-11-05 `tmp/` cleanup; the
|
||||
committed review sheet above is the retained evidence.
|
||||
|
||||
## Deferred polish
|
||||
|
||||
- Replace the radial lid mask with real upper/lower lid masses that fold over
|
||||
the globe, with pointed canthi, and pass the profile view.
|
||||
- Move the iris onto a curved cornea cap so its orientation follows the globe at
|
||||
three-quarter angles, and use a lighting-driven catchlight instead of baked
|
||||
white geometry.
|
||||
- Refine the hair cap silhouette (current short cap reads as a helmet) and
|
||||
improve the full-body shoulder/arm shape before the 12-variant rollout.
|
||||
- Add a clean UV/material pass and a contract armature/weights when the head
|
||||
becomes part of the shipped library characters.
|
||||
@@ -0,0 +1,380 @@
|
||||
# NPC Model Sourcing
|
||||
|
||||
This page records the source evaluation and the active character plan. The
|
||||
decision is [ADR-0017](../adr/0017-quaternius-npc-base.md), which supersedes the
|
||||
generator-first sourcing decision in [ADR-0015](../adr/0015-npc-model-variety-sourcing.md).
|
||||
|
||||
## Active direction
|
||||
|
||||
**Quaternius Ultimate Modular Characters (CC0) is the primary NPC mesh and
|
||||
animation source.** The project-owned procedural library remains the shipped
|
||||
fallback until the Quaternius migration passes the silhouette and gear gates.
|
||||
|
||||
The migration keeps the runtime contract that Godot, gear and tests already
|
||||
consume:
|
||||
|
||||
- one `npc_base` skinned mesh under the 13 required core bones (optional extras
|
||||
allowed);
|
||||
- five tint slots: `skin`, `cloth`, `pants`, `accessory`, `hair`;
|
||||
- front on Blender −Y / Godot +Z, feet grounded, height about 1.81 m;
|
||||
- 24,000 triangles / 24,000 glTF vertices per character;
|
||||
- gear stays at 1,500 triangles and props at 4,000;
|
||||
- committed GLBs and maps only; no Quaternius, Blender or network dependency in
|
||||
CI, build or runtime.
|
||||
|
||||
### Stage 1 (default)
|
||||
|
||||
1. Import the researched Quaternius GLBs and choose a canonical base.
|
||||
2. Map the 62 source joints to the 13-core contract and bake a proportion warp.
|
||||
3. Skin the mesh, retarget Idle and Walk, and verify seated/arm-down poses.
|
||||
4. Map the five slots, unwrap and bake 256² maps.
|
||||
5. Fit the existing gear and render the silhouette gate.
|
||||
6. Build the 12 variants, audit, and capture the 12 × 6 baseline.
|
||||
|
||||
The Quaternius 62-joint rig and its 24 animation clips are an **authoring
|
||||
source**. They are not loaded at runtime.
|
||||
|
||||
### Stage 2 gate
|
||||
|
||||
The 62-joint T-pose source does not share the project's arm-down rest pose or
|
||||
proportions. If the Stage 1 proportion warp cannot preserve the Quaternius
|
||||
silhouette and deformation, stop the rollout and write a new ADR for adopting
|
||||
the 62-joint rig at runtime. Do not ship a hybrid rig, a second gear offset
|
||||
system or a silent `NpcAvatar` change.
|
||||
|
||||
## Research findings
|
||||
|
||||
The researched files are the three converted Quaternius outfits used by the
|
||||
Ducz Character Creator: `Casual`, `Casual_Hoodie` and `Worker`. They are kept
|
||||
locally in `tmp/npc-research/quaternius/` with the licence and mirror notes.
|
||||
|
||||
| File | SHA-256 | Structure |
|
||||
| --- | --- | --- |
|
||||
| `Casual.glb` | `bc2d84fba249ebaa0629cb9d133ad37234196e4b8004aa205a6397abed04e05c` | 4 meshes, 62 joints, 24 clips, 7 materials |
|
||||
| `Casual_Hoodie.glb` | `f5b722199f7628b24550989af4230f65d5230727df9c326fd2b3e360effe5249` | 4 meshes, 62 joints, 24 clips, 7 materials |
|
||||
| `Worker.glb` | `8d0f454336c2effc5c08f63a177af3a82d1a7af025ca9588b40e4c90c4703534` | 4 meshes, 62 joints, 24 clips, 11 materials |
|
||||
|
||||
Source: [Quaternius Ultimate Modular Characters](https://quaternius.com/packs/ultimatemodularcharacters.html)
|
||||
and [Ultimate Modular Women](https://quaternius.com/packs/ultimatemodularwomen.html),
|
||||
CC0 1.0. Download date: 2026-11-03 (via the Ducz Character Creator browser export).
|
||||
The Ducz mirror is a browser builder over the same packs; it adds 21
|
||||
outfits and a shared skeleton. The three full GLBs are the initial scope; the
|
||||
21-part builder is a fallback for role coverage, not the default.
|
||||
|
||||
**Initial outfit scope (2026-11-05):** the three full GLBs (`Casual`,
|
||||
`Casual_Hoodie`, `Worker`) are the canonical-base and silhouette-gate scope.
|
||||
They cover only ~3 of the 12 roles (casual, hood, vest); the jacket, coat,
|
||||
dress, responder (firefighter) and guard looks are not in the three GLBs, so
|
||||
§4 roster coverage must pull those from the 21-part modular builder or author
|
||||
new clothing. Do not start the modular builder until the canonical base passes
|
||||
§3.
|
||||
|
||||
The first prototype measured 12,518 imported vertices, 3,199 after welding and
|
||||
2,423 vertices / 4,654 triangles after a 0.75 decimate. Idle and Walk read well.
|
||||
The source is a T-pose with a 62-joint realistic rig, roughly 0.96 m hip,
|
||||
1.68 m arm span and 0.153 m shoulder offset, so a contract bake is required.
|
||||
|
||||
The 24,000 cap leaves room to stay much closer to the source LOD than the
|
||||
prototype's 4,654 triangles. Do not decimate to the old 7,000-triangle target
|
||||
before the silhouette gate.
|
||||
|
||||
## Quaternius GLB inventory (2026-11-05)
|
||||
|
||||
All three GLBs share one 62-joint rig and the same 24 animation clips; only the meshes and
|
||||
materials differ. Each import also adds a 42-vert/80-tri `Icosphere` bone-display helper — ignore it.
|
||||
|
||||
### Meshes (verts / tris)
|
||||
|
||||
| GLB | Body | Feet | Head | Legs | Total |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `Casual` | 5,338 / 2,654 | 1,082 / 538 | 5,122 / 2,526 | 1,426 / 706 | 12,968 / 6,424 |
|
||||
| `Casual_Hoodie` | 6,132 / 3,048 | 1,024 / 512 | 3,836 / 1,888 | 1,526 / 758 | 12,518 / 6,206 |
|
||||
| `Worker` | 6,074 / 3,018 | 928 / 464 | 1,862 / 936 | 1,670 / 822 | 10,534 / 5,240 |
|
||||
|
||||
### Joints (62)
|
||||
|
||||
- Spine: `Root, Hips, Torso, Body, Abdomen, Chest, Neck, Head` (8).
|
||||
- Arms: `Shoulder.L/R, UpperArm.L/R, LowerArm.L/R, Wrist.L/R` (8) plus `PT.L/R` (2 pole targets).
|
||||
- Fingers: `Thumb×3, Index×4, Middle×4, Ring×4, Pinky×4` × L/R (30).
|
||||
- Legs: `UpperLeg.L/R, LowerLeg.L/R, Foot.L/R` (6).
|
||||
|
||||
### Animation clips (24)
|
||||
|
||||
`Idle, Idle_Neutral, Walk, Run, Run_Back, Run_Left, Run_Right, Wave, Interact, Death,
|
||||
Gun_Shoot, Idle_Gun, Idle_Gun_Pointing, Idle_Gun_Shoot, Idle_Sword, Run_Shoot, Sword_Slash,
|
||||
HitRecieve, HitRecieve_2, Kick_Left, Kick_Right, Punch_Left, Punch_Right, Roll`.
|
||||
|
||||
The retarget scope is Idle and Walk; combat/gun/sword clips are discarded (see §2).
|
||||
|
||||
### Material → slot map
|
||||
|
||||
The five project slots (`skin`, `cloth`, `pants`, `accessory`, `hair`) are assigned by mesh
|
||||
region first, then material — the same material name can mean `cloth` on the body and
|
||||
`accessory` on the feet.
|
||||
|
||||
| Material | Where | Project slot |
|
||||
| --- | --- | --- |
|
||||
| `Skin` | all meshes | `skin` |
|
||||
| `Hair_Blond`, `Hair_Brown`, `Hair`, `Brown` (head), `Eyebrows`, `Moustache` | Head | `hair` |
|
||||
| `Eye` | Head | `accessory` |
|
||||
| `Worker_Yellow` | Worker body (vest stripe) + head (helmet) | `cloth` / `accessory` |
|
||||
| `White`, `Grey`, `Purple`, `Black` | Body / Feet | `cloth` (body) / `accessory` (feet) |
|
||||
| `Orange`, `LightBlue`, `LightBrown`, `Brown`, `Brown2` | Legs | `pants` |
|
||||
| `Worker_Vest` | Worker body | `cloth` |
|
||||
|
||||
### Canonical base (built 2026-11-05)
|
||||
|
||||
`tmp/npc-research/quaternius_base.glb` is the welded, triangulated, grounded, animation-free
|
||||
canonical base built from `Casual_Hoodie.glb` (the most detailed body) by
|
||||
`tmp/npc-research/build_quaternius_base.py`:
|
||||
|
||||
- one `npc_base` mesh — 3,199 welded verts / 6,206 tris (Body + Feet + Head + Legs joined);
|
||||
- 62 joints, skinned (no unskinned faces);
|
||||
- height 1.81 m, feet at y 0, front on Godot +Z / Blender −Y (T-pose rest);
|
||||
- no animation clips; `inspect` clean (no issues).
|
||||
|
||||
This is the §2 contract-bake input: the 62-joint rig is authoring-only and is mapped to the
|
||||
13-core contract next, not shipped as-is.
|
||||
|
||||
## 62-joint → 13-core joint map (2026-11-05)
|
||||
|
||||
The Quaternius hierarchy (measured from `quaternius_base.glb`):
|
||||
|
||||
- `Root → Body → Hips → Abdomen → Torso → Chest → Neck → Head` (spine);
|
||||
- `Chest → Shoulder.L/R → UpperArm.L/R → LowerArm.L/R → Wrist.L/R → fingers` (arms);
|
||||
- `Body → UpperLeg.L/R → LowerLeg.L/R` and `Root → Foot.L/R` (legs — the foot is a separate
|
||||
child of Root, not of LowerLeg);
|
||||
- `Root → PT.L/R` (IK pole targets, no deforming weights).
|
||||
|
||||
The contract is 14 bones (13 core + `Hips` root): `Hips, Spine, Neck, Head, Shoulder.L/R,
|
||||
Elbow.L/R, Hand.L/R, Hip.L/R, Knee.L/R`. The map:
|
||||
|
||||
| Contract bone | Quaternius source | Notes |
|
||||
| --- | --- | --- |
|
||||
| `Hips` | `Hips` | `Root` + `Body` collapse into `Hips` |
|
||||
| `Spine` | `Abdomen`, `Torso`, `Chest` | three collapse into one |
|
||||
| `Neck` | `Neck` | |
|
||||
| `Head` | `Head` | |
|
||||
| `Shoulder.L/R` | `UpperArm.L/R` | the collar `Shoulder.L/R` collapses into `Spine` |
|
||||
| `Elbow.L/R` | `LowerArm.L/R` | |
|
||||
| `Hand.L/R` | `Wrist.L/R` | |
|
||||
| `Hip.L/R` | `UpperLeg.L/R` | |
|
||||
| `Knee.L/R` | `LowerLeg.L/R` | |
|
||||
|
||||
Optional extras (allowed by the contract; kept if they survive within 24,000 vertices):
|
||||
|
||||
- the 30 finger bones (`Thumb×3, Index×4, Middle×4, Ring×4, Pinky×4` × L/R) → optional finger
|
||||
bones;
|
||||
- `Foot.L/R` → optional toe bones.
|
||||
|
||||
Dropped (never shipped): `PT.L/R` (pole targets carry no deforming weights), and the
|
||||
`Root`/`Body`/collar-`Shoulder` collapse bones above.
|
||||
|
||||
## Rest-pose and proportion-warp measurement (2026-11-05)
|
||||
|
||||
The rest-pose bake (T-pose → arm-down) works: rotate the `UpperArm.L/R` pose bones −90° around
|
||||
their local X, apply the Armature modifier to bake the deformation, then `armature_apply` to bake
|
||||
it as the new rest pose (see `tmp/npc-research/warp_full.py`). The mesh deforms cleanly to the
|
||||
arm-down pose (deformed bounds x ±0.221 vs the T-pose ±0.811).
|
||||
|
||||
The proportion warp to the contract positions is measured and it **distorts the silhouette**:
|
||||
|
||||
| Joint | Quaternius | Contract | Delta |
|
||||
| --- | --- | --- | --- |
|
||||
| Hips height | 0.869 | 0.62 | −0.249 (lower hip) |
|
||||
| Head top | 1.614 | 1.82 | +0.206 (raise head) |
|
||||
| Shoulder x | 0.148 | 0.27 | +0.122 (widen) |
|
||||
| Arm (shoulder→wrist) | 0.402 | 0.60 | ×1.49 |
|
||||
| Arm (shoulder→hand tip) | 0.429 | 0.70 | ×1.63 |
|
||||
| Leg (hip→ankle) | 0.835 | 0.62 | ×0.74 |
|
||||
| Torso (hip→head) | 0.745 | 1.20 | ×1.61 |
|
||||
|
||||
The vision judge reads the warped mesh as: paddle/claw hands (the 1.63× arm stretch blows the
|
||||
hand out), forearms lost into the torso, stretched shins, and "long legs + short torso". The arm
|
||||
must either stay short (a "shortened arm" vs the contract hand) or stretch 1.63× (a distorted
|
||||
deltoid + hand) — both are the gate's rejection conditions.
|
||||
|
||||
**Finding:** the Stage 1 proportion warp cannot preserve the Quaternius silhouette at the
|
||||
contract proportions; the gate fails and Stage 2 (a 62-joint runtime ADR) is indicated. Do not
|
||||
start the 12-variant rebuild on the warped base.
|
||||
|
||||
[ADR-0018](../adr/0018-quaternius-stage2-runtime-rig.md) records the Stage 2 decision (accepted
|
||||
2026-11-05, owner chose option 2): adopt the **Quaternius 62-joint rig at runtime**, keep the
|
||||
mesh at source LOD, re-derive gear attach points from the Quaternius skeleton, and use the
|
||||
Quaternius Idle/Walk clips directly (no retarget).
|
||||
|
||||
## Pilot bake and shipped state (2026-11-05)
|
||||
|
||||
`npc_casual` is the first Quaternius-derived character. The reproducible pipeline lives in
|
||||
`tmp/npc-research/` (local tooling, not shipped) and has two steps:
|
||||
|
||||
1. `build_base_minimal.py` — import `quaternius/Casual_Hoodie.glb`, join the four skinned meshes
|
||||
**without welding**, rename the upstream materials to the project tint slots, Smart-UV-unwrap
|
||||
and repack, export `quaternius_tpose.glb`.
|
||||
2. `bake_armdown_v5.py` — pose `UpperArm.L/R` −90° local X, read the posed matrices, apply the
|
||||
Armature modifier, **reset the pose to identity**, set the rest matrices, re-add the modifier,
|
||||
export `quaternius_base.glb` and save `assets/models/blender_src/npc_casual.blend` for the AO
|
||||
bake.
|
||||
|
||||
Then `tools/bake_ao.py --only npc_casual` and a copy of the GLB to
|
||||
`assets/models/blender/npc/npc_casual.glb`.
|
||||
|
||||
| Measurement | npc_casual (Quaternius) | procedural library |
|
||||
| --- | --- | --- |
|
||||
| Triangles | 6,206 | 4,812–6,046 |
|
||||
| glTF vertices | 12,988 | 4,908–5,785 |
|
||||
| Bones | 62 | 13 |
|
||||
| Head height / body height | 0.134 | 0.16–0.22 (stylized band) |
|
||||
| Shoulder width | 0.201 m head width, `fit` OK | `fit` OK |
|
||||
| Texel density (256² maps) | 73–74 px/m | 75–96 px/m |
|
||||
| Materials | `skin`, `cloth`, `pants`, `hair`, `accessory` + fixed `Eye`, `Eyebrows` | five slots + fixed detail |
|
||||
|
||||
Two source quirks shaped the bake:
|
||||
|
||||
- the upstream UV map is degenerate (every loop at 0,0) and its materials are flat colours, so the
|
||||
AO/texture bake must follow a real unwrap or it silently writes a black map;
|
||||
- the upstream materials are named `Skin`/`Purple`/`LightBlue`/`White`/`Hair`, so the five-slot
|
||||
contract is applied as a rename, not a re-author.
|
||||
|
||||
The face/silhouette gate that chose the full swap is recorded in the follow-up decision of
|
||||
[ADR-0018](../adr/0018-quaternius-stage2-runtime-rig.md); its comparison sheets were retired with the
|
||||
review pages.
|
||||
|
||||
## Shipped roster (0.9.9, owner chose the full swap)
|
||||
|
||||
Every public character name is baked from one of the three CC0 sources. The build is
|
||||
`tools/blender_assets.py build` (default `--source quaternius`); the sources live in
|
||||
`assets/models/blender_src/quaternius/` and the geometry pipeline is two passes:
|
||||
|
||||
1. `_quaternius_tpose_glb()` — import, drop the Icosphere helper, join **without welding**,
|
||||
repoint the upstream materials onto the five tint slots, Smart-UV-unwrap + repack, export an
|
||||
intermediate T-pose GLB. The round trip also normalises the mirrored upstream bone rolls,
|
||||
which is what makes the next pass symmetric.
|
||||
2. `_bake_quaternius_rest()` — pose `UpperArm.L/R` -90° local X, read the posed matrices, apply
|
||||
the Armature modifier, reset the pose, write the rest matrices, re-add the modifier, export
|
||||
with `export_apply=False`.
|
||||
|
||||
Then `tools/bake_ao.py --only <name>`.
|
||||
|
||||
| Character | Source | Tris | Material slots |
|
||||
| --- | --- | --- | --- |
|
||||
| `npc_casual` | hoodie | 6,206 | skin, cloth, pants, hair, accessory + Eye, Eyebrows |
|
||||
| `npc_jacket` | casual | 6,424 | five slots + eyes/brows |
|
||||
| `npc_coat` | hoodie | 6,206 | five slots + eyes/brows |
|
||||
| `npc_vest` | worker | 5,240 | five slots + hivis vest/hat, eyes, moustache |
|
||||
| `npc_dress` | casual | 6,424 | five slots + eyes/brows |
|
||||
| `npc_hoodie` | hoodie | 6,206 | five slots + eyes/brows |
|
||||
| `npc_firefighter` | worker (vest + hat dropped) | 4,458 | five slots + eyes, moustache |
|
||||
| `npc_guard` | worker (vest + hat dropped) | 4,458 | five slots + eyes, moustache |
|
||||
| `npc_crowd_a` | casual | 6,424 | five slots + eyes/brows |
|
||||
| `npc_crowd_b` | casual | 6,424 | five slots + eyes/brows |
|
||||
| `npc_crowd_c` | hoodie | 6,206 | five slots + eyes/brows |
|
||||
| `npc_crowd_d` | worker (vest + hat dropped) | 4,458 | five slots + eyes, moustache |
|
||||
|
||||
Source slot maps (`QUATERNIUS_SOURCES`): the `casual` tee is `Skin`/`White`/`Orange`/`Grey`/
|
||||
`Hair_Blond`; the hoodie is `Skin`/`Purple`/`LightBlue`/`Hair`/`White`; the worker is `Skin`/
|
||||
`LightBrown`/`Brown`+`Brown2`/`Grey`+`Black`/`Eyebrows`. Everything not in the map keeps its
|
||||
authored colour (eyes, brows, moustache, hivis vest and hard hat), so the contract still holds if
|
||||
upstream adds a material — it shows up as an extra fixed-colour slot instead of vanishing.
|
||||
|
||||
Head gear is authored for this head: `HEAD_GEAR_SCALE = 0.64` and `HEAD_GEAR_LIFT = 0.07` map the
|
||||
0.315 m procedural head onto the 0.201–0.214 m Quaternius head. The procedural fallback therefore
|
||||
wears slightly small hats.
|
||||
|
||||
## Modular outfit intake (2026-11-06)
|
||||
|
||||
The owner downloaded the *Ultimate Modular Men* pack once (CC0 1.0, 54.5 MB zip, 15 outfit families
|
||||
plus weapons and a horse) into the gitignored `tmp/quaternius-intake/`. The pack ships FBX, not GLB,
|
||||
so the intake composes each family into a single skinned base:
|
||||
|
||||
python3 tools/quaternius_parts.py make-base \
|
||||
--family "tmp/quaternius-intake/Ultimate Modular Men- Feb 2022/Separate Skeletal Meshes and Animations/Suit" \
|
||||
--out assets/models/blender_src/quaternius/Suit.glb
|
||||
|
||||
What `make-base` has to undo, and why it is not a straight import:
|
||||
|
||||
1. each of the four FBX files carries its own copy of the armature — one is kept and the meshes are
|
||||
repointed at it, otherwise `join` leaves three meshes bound to deleted armatures;
|
||||
2. the FBX rig adds zero-length `_end` leaf bones and a `neutral_bone` root; Godot's importer fails
|
||||
the exported GLB with them (`resource_format_binary` error), so they are stripped back to the
|
||||
accepted 62 joints;
|
||||
3. the object transforms are baked before export, or the glTF node transforms change the rest pose
|
||||
on import and the character renders with a giant head;
|
||||
4. the pack reuses one fabric material for jacket and trousers, so the leg meshes get their own
|
||||
`*_legs` copies that the slot map sends to `pants`;
|
||||
5. imported parts share seams, which leaves zero-area triangles the audit rejects.
|
||||
|
||||
The pack itself is never committed. Only the composed bases are, with their SHA-256 here and in
|
||||
`CREDITS.md`.
|
||||
|
||||
| Base | Source family | SHA-256 | Used by |
|
||||
| --- | --- | --- | --- |
|
||||
| `Suit.glb` | Suit | `674f2a63…` | `npc_coat` |
|
||||
| `Adventurer.glb` | Adventurer | `062caf69…` | `npc_crowd_c` |
|
||||
| `Beach.glb` | Beach | `81c69837…` | `npc_crowd_a` |
|
||||
| `Casual_2.glb` | Casual_2 | `fe4edb73…` | `npc_jacket` |
|
||||
| `Farmer.glb` | Farmer | `b9b67058…` | `npc_crowd_b` (hair slot patched) |
|
||||
| `King.glb` | King | `85aae2a0…` | `npc_dress` |
|
||||
| `Punk.glb` | Punk | `56993274…` | `npc_hoodie` (the mohawk is the `Red` material) |
|
||||
| `Swat.glb` | Swat | `43659684…` | **not used** — authored facing the other way; see below |
|
||||
|
||||
The roster therefore runs **eleven distinct outfits across twelve roles** (the worker base serves
|
||||
`npc_vest`, `npc_firefighter`, `npc_guard` and `npc_crowd_d`).
|
||||
|
||||
### Open: the Swat base faces backwards
|
||||
|
||||
`Swat.glb` is authored facing Godot −Z. Rotating the exported base about world Z rotates the skin
|
||||
with the armature node and the character renders headless, and rotating the mesh as well collapses
|
||||
it into a spike; the reliable fix is to yaw the four FBX parts *before* the join and re-bake. Until
|
||||
then `npc_guard` stays on the worker base.
|
||||
|
||||
### Slots a family cannot paint
|
||||
|
||||
`Farmer` has no hair shell and `Swat`/`SpaceSuit` have neither hair nor skin. The audit and the
|
||||
runtime both need the five slots as *used* materials, so `patch_slots` buries a 4 cm triangle
|
||||
inside the skull or the pelvis for each missing slot (`_patch_missing_slots`). It is invisible, it
|
||||
keeps the material in the export, and it means a helmeted head still satisfies the contract.
|
||||
|
||||
## Contract mismatch
|
||||
|
||||
The expensive part is the rig and proportions, not the mesh. The source has a
|
||||
T-pose and a realistic skeleton; the runtime expects an arm-down 13-core rig,
|
||||
fixed gear attach points and the existing pose composition. The Stage 1 bake
|
||||
must therefore:
|
||||
|
||||
- rotate the arms down and match the contract wrist positions;
|
||||
- warp the hips, knees, shoulders and head without collapsing the deltoid,
|
||||
shortening the arms or changing the silhouette;
|
||||
- keep one skinned `npc_base` mesh and the five slots;
|
||||
- verify gear against the new shoulder, hip and head positions.
|
||||
|
||||
If those constraints distort the model, Stage 2 is the correct next decision,
|
||||
not a local workaround.
|
||||
|
||||
## Licence and provenance
|
||||
|
||||
- CC0 preferred. CC-BY is allowed only with attribution in
|
||||
[`CREDITS.md`](../../CREDITS.md).
|
||||
- No service asset whose terms restrict redistribution may be shipped.
|
||||
- Keep the source URL, author, licence, download date and SHA-256 in this page
|
||||
and in `CREDITS.md`.
|
||||
- Research files and generated previews stay in `tmp/` (gitignored). Only final
|
||||
audited assets and committed review sheets enter the repository.
|
||||
- Quaternius models and animations are CC0; the existing credits entry already
|
||||
names Quaternius. Keep it accurate if the shipped source set changes.
|
||||
|
||||
## Closed tracks
|
||||
|
||||
The MakeHuman/MPFB body pipeline is closed for the NPC base. The accepted
|
||||
MakeHuman head and the procedural `npc_base` remain tested fallbacks until the
|
||||
Quaternius rollout is complete. Their research scripts and renders were removed
|
||||
from `tmp/` after the pivot; the measurements and decisions remain in
|
||||
[`DONE.md`](../../DONE.md), [`MEMORY.md`](../../MEMORY.md) and the review docs.
|
||||
|
||||
## Related documents
|
||||
|
||||
- [ADR-0017: Quaternius NPC base](../adr/0017-quaternius-npc-base.md)
|
||||
- [ADR-0016: 24,000-triangle character budget](../adr/0016-npc-character-budget-24k.md)
|
||||
- [Blender asset contract](../project/architecture.md#character-and-gear-asset-contract)
|
||||
- [`3d-model-review.md`](3d-model-review.md)
|
||||
- [`asset-tooling.md`](asset-tooling.md)
|
||||
Reference in new issue
Block a user