Compare commits

...
48 Commits
Author SHA1 Message Date
zwitschi b3acf370fc Mark the roster build done and record the release-size follow-up
Full build / Full desktop build + optional release (push) Successful in 4m58s
Full build / Import + headless tests (push) Successful in 42s
2026-11-06 13:59:28 +01:00
zwitschi 04d211b90b Release 0.9.9: full Quaternius roster, docs, changelog, version, slimmer export filters and checksums
Full build / Import + headless tests (push) Successful in 40s
Full build / Full desktop build + optional release (push) Skipped
2026-11-06 13:59:23 +01:00
zwitschi a6ccd86f85 Swap the full 12-character roster onto the CC0 Quaternius bases
Full build / Import + headless tests (push) Successful in 40s
Full build / Full desktop build + optional release (push) Skipped
2026-11-06 13:56:30 +01:00
zwitschi 1ce173e9d1 Record the Quaternius pilot bake, measurements and face-gate decision point in the docs
Full build / Import + headless tests (push) Successful in 45s
Full build / Full desktop build + optional release (push) Skipped
2026-11-06 13:45:05 +01:00
zwitschi 90f189a269 Add the Quaternius face/silhouette gate sheet and record the geometry measurements
Full build / Import + headless tests (push) Successful in 41s
Full build / Full desktop build + optional release (push) Skipped
2026-11-06 13:44:46 +01:00
zwitschi fb3e75aba3 Refit the backpack straps for both torsos (ADR-0018)
Full build / Import + headless tests (push) Successful in 44s
Full build / Full desktop build + optional release (push) Skipped
2026-11-06 13:43:52 +01:00
zwitschi cd285b3a39 Ship the Quaternius npc_casual pilot base: rig-agnostic pose sync, five tint slots, unwrapped UVs and wrist-strap camera
Full build / Import + headless tests (push) Successful in 41s
Full build / Full desktop build + optional release (push) Skipped
2026-11-06 13:42:37 +01:00
zwitschi 68f49253fe Record the Quaternius pilot fixes and remaining 0.9.9 work in MEMORY.md
Full build / Import + headless tests (push) Successful in 42s
Full build / Full desktop build + optional release (push) Skipped
2026-11-06 13:19:42 +01:00
zwitschi b012c6a675 Record the Quaternius bake/pose-sync progress
Full build / Import + headless tests (push) Successful in 50s
Full build / Full desktop build + optional release (push) Skipped
2026-11-06 12:17:30 +01:00
zwitschi 2a6f29f5d0 Fix the Quaternius side swap, arm swing axis, and hand-proxy position (backward-compatible)
Full build / Import + headless tests (push) Successful in 45s
Full build / Full desktop build + optional release (push) Skipped
2026-11-06 12:17:18 +01:00
zwitschi bb54ef3eec Record the canonical-base and arm-down-bake findings precisely
Full build / Import + headless tests (push) Successful in 46s
Full build / Full desktop build + optional release (push) Skipped
2026-11-06 10:33:52 +01:00
zwitschi 11f53f66a3 Record the two skinning root causes: weld corrupts weights, bake needs head/tail plus roll
Full build / Import + headless tests (push) Successful in 48s
Full build / Full desktop build + optional release (push) Skipped
2026-11-06 10:26:56 +01:00
zwitschi 9b65a93587 Narrow the Godot skinning blocker to the importer's rotated bind_pose
Full build / Import + headless tests (push) Successful in 47s
Full build / Full desktop build + optional release (push) Skipped
2026-11-06 10:13:32 +01:00
zwitschi 56a14747d4 Record the pose-composition fix and the remaining Godot skinning-import blocker
Full build / Import + headless tests (push) Successful in 47s
Full build / Full desktop build + optional release (push) Skipped
2026-11-06 10:00:07 +01:00
zwitschi 8b1bf736af Correct the pose composition for a non-180° rest (rest-relative swing axis), backward-compatible with the 13-core fallback
Full build / Import + headless tests (push) Successful in 42s
Full build / Full desktop build + optional release (push) Skipped
2026-11-06 09:57:27 +01:00
zwitschi f121167e52 Record the arm-down bake fix and the remaining non-180° rest pose-composition task
Full build / Import + headless tests (push) Successful in 47s
Full build / Full desktop build + optional release (push) Skipped
2026-11-06 09:52:13 +01:00
zwitschi 140a7ada0f Plan the 0.9.9-1.0.0 roadmap and re-evaluate the NPC base
Full build / Import + headless tests (push) Successful in 48s
Full build / Full desktop build + optional release (push) Skipped
Move the gap analysis and risks into PLAN.md, version the character track
(0.9.9 rig correction, 0.9.10 roster) and split 0.9.11-0.9.12 polish and
verification out of the old 0.10.0 milestone. Add roadmaps to 0.10.0, 0.11.0
and 1.0.0, record the Quaternius face-fidelity gate and the rest-relative
pose finding in MEMORY.md, and move the completed 0.9.7 and pose-harness
sections to DONE.md.
2026-11-06 09:40:28 +01:00
zwitschi 5508f5510f Record the Quaternius pose-sync blockers (GLB skinning + non-180° rest pose formula)
Full build / Import + headless tests (push) Successful in 48s
Full build / Full desktop build + optional release (push) Skipped
2026-11-06 09:25:45 +01:00
zwitschi 7baa2c4f54 Record the Quaternius pose-sync finding (swing axis needs per-bone calibration)
Full build / Import + headless tests (push) Successful in 50s
Full build / Full desktop build + optional release (push) Skipped
2026-11-06 08:35:29 +01:00
zwitschi 0cd12af994 Fix proxy pivot derivation to use global rest (local rest was misplacing the Quaternius joints)
Full build / Import + headless tests (push) Successful in 55s
Full build / Full desktop build + optional release (push) Skipped
2026-11-06 08:35:14 +01:00
zwitschi 271f10ac4d Map NpcAvatar bone sync and proxy pivots to the Quaternius 62-joint names with old-rig fallback
Full build / Import + headless tests (push) Successful in 2m36s
Full build / Full desktop build + optional release (push) Skipped
2026-11-06 07:38:39 +01:00
zwitschi e2f52fbfd1 Accept ADR-0018 option 2: adopt the Quaternius 62-joint rig at runtime
Full build / Import + headless tests (push) Successful in 48s
Full build / Full desktop build + optional release (push) Skipped
2026-11-05 21:39:38 +01:00
zwitschi f5fa321a8f Draft ADR-0018 (Stage 2 runtime rig) and mark the remaining contract-bake items blocked
Full build / Import + headless tests (push) Successful in 45s
Full build / Full desktop build + optional release (push) Skipped
2026-11-05 21:38:26 +01:00
zwitschi a2ae9e03c3 Bake the rest pose, warp to the contract, and record the failing silhouette gate
Full build / Import + headless tests (push) Successful in 47s
Full build / Full desktop build + optional release (push) Skipped
2026-11-05 21:01:26 +01:00
zwitschi cff3937c16 Mark the 62-to-13 joint map complete
Full build / Import + headless tests (push) Successful in 46s
Full build / Full desktop build + optional release (push) Skipped
2026-11-05 20:49:05 +01:00
zwitschi 1ed9e05d9b Author the 62-to-13 joint map and align the AGENTS.md contract with the Quaternius base
Full build / Import + headless tests (push) Successful in 50s
Full build / Full desktop build + optional release (push) Skipped
2026-11-05 20:48:55 +01:00
zwitschi 8c69f367b0 Record the owner directive: keep Quaternius at source detail during the contract bake
Full build / Import + headless tests (push) Successful in 48s
Full build / Full desktop build + optional release (push) Skipped
2026-11-05 20:47:08 +01:00
zwitschi 2a7921bae6 Build and document the canonical quaternius_base.glb (welded, skinned, 62-joint, 1.81m)
Full build / Import + headless tests (push) Successful in 44s
Full build / Full desktop build + optional release (push) Skipped
2026-11-05 20:43:50 +01:00
zwitschi b419923637 Decide the Quaternius outfit scope: three full GLBs first, modular builder as the role fallback
Full build / Import + headless tests (push) Successful in 48s
Full build / Full desktop build + optional release (push) Skipped
2026-11-05 20:41:23 +01:00
zwitschi c7fd4b59cc Inventory the Quaternius GLBs: meshes, 62 joints, 24 clips and the material-slot map
Full build / Import + headless tests (push) Successful in 55s
Full build / Full desktop build + optional release (push) Skipped
2026-11-05 20:40:59 +01:00
zwitschi 3fa3482608 Complete Quaternius intake record: verify SHA-256 and add the download date
Full build / Import + headless tests (push) Successful in 47s
Full build / Full desktop build + optional release (push) Skipped
2026-11-05 20:39:39 +01:00
zwitschi 1049d8631e Compact MEMORY.md: drop the redundant session handoff, trim the AI-review entry 2026-11-05 20:38:26 +01:00
zwitschi deb74c53af Record the Quaternius pivot handoff in session memory 2026-11-05 19:44:26 +01:00
zwitschi 9968bc0a4c Rewrite NPC plans around the Quaternius base 2026-11-05 19:43:35 +01:00
zwitschi 2e1bb19e2c Correct the MPFB prototype glTF vertex count 2026-11-05 19:37:37 +01:00
zwitschi 5450dbf88a Correct the MPFB prototype glTF vertex count 2026-11-05 19:37:33 +01:00
zwitschi 79224c5acf Raise the NPC character budget to 24k and refine the prototype neck 2026-11-05 19:37:19 +01:00
zwitschi f0d4b81239 Record the AI-assisted asset review loop and MakeHuman OBJ transform gotchas in MEMORY.md
Full build / Import + headless tests (push) Successful in 45s
Full build / Full desktop build + optional release (push) Skipped
2026-11-05 19:22:43 +01:00
zwitschi 48a9795248 Record the rejected prototype shirt-shell experiment 2026-11-04 21:31:45 +01:00
zwitschi a5e62534ce Fix the prototype crotch material and loosen the shirt 2026-11-04 21:26:00 +01:00
zwitschi cd7697cba4 Polish the MPFB prototype shoes, cuffs and shirt fit 2026-11-04 19:36:52 +01:00
zwitschi d2f773c7ad Polish the prototype shoes and thigh silhouette 2026-11-04 19:31:58 +01:00
zwitschi a3c3a37270 Smooth the prototype neck and shrink the shoes 2026-11-04 19:27:51 +01:00
zwitschi 8dd4f77901 Fix the MPFB prototype neck and ankle junctions 2026-11-04 19:22:22 +01:00
zwitschi ba444152c9 Record current MPFB body prototype measurement and neck review 2026-11-04 19:16:20 +01:00
zwitschi d56f14c6dd Document MPFB full-body NPC prototype status and seams 2026-11-04 18:53:19 +01:00
zwitschi b297c4543f Add MakeHuman head hair/body prototype and 12-variant rollout plan 2026-11-04 18:08:03 +01:00
zwitschi 958d3ae0ff Add reviewed MakeHuman head prototype (vision gate rejected) 2026-11-04 17:04:20 +01:00
83 changed files with 2786 additions and 944 deletions

No files matched your search

@@ -1,6 +1,6 @@
--- ---
name: gig-line-blender-asset-contract name: gig-line-blender-asset-contract
description: Use when adding, rebuilding, auditing or fixing any Gig Line Blender-authored character, NPC base, gear, prop or venue geometry. Covers the required 13 core bones (optional extra bones allowed), five tint slots, 16,000/16,000 character budgets, gear attach space, 256² texture/AO rules, headless flatpak Blender commands, optional MCP guidance, and the inspect/audit/fit/proportion/capture gates. Do not use for pure GDScript/UI work or release publishing. description: Use when adding, rebuilding, auditing or fixing any Gig Line Blender-authored character, NPC base, gear, prop or venue geometry. Covers the required 13 core bones (optional extra bones allowed), five tint slots, 24,000/24,000 character budgets, gear attach space, 256² texture/AO rules, headless flatpak Blender commands, optional MCP guidance, and the inspect/audit/fit/proportion/capture gates. Do not use for pure GDScript/UI work or release publishing.
compatibility: Gig Line repository checkout. Godot 4.7.2 on PATH; Blender 5.2 LTS via the org.blender.Blender flatpak for build/bake/render. Audit, fit, inspect and proportion checks are stdlib-only. compatibility: Gig Line repository checkout. Godot 4.7.2 on PATH; Blender 5.2 LTS via the org.blender.Blender flatpak for build/bake/render. Audit, fit, inspect and proportion checks are stdlib-only.
metadata: metadata:
project: Gig Line project: Gig Line
@@ -51,8 +51,8 @@ and resolve the source of truth before editing assets.
| Bones | The 13 required core bones: `Head`, `HipL/R`, `KneeL/R`, `ShoulderL/R`, `ElbowL/R`, `HandL/R`, `Spine`, `Neck`. Optional extra bones (fingers, face, toes) are allowed; they must not move the core rest positions or break animation proxies. | | Bones | The 13 required core bones: `Head`, `HipL/R`, `KneeL/R`, `ShoulderL/R`, `ElbowL/R`, `HandL/R`, `Spine`, `Neck`. Optional extra bones (fingers, face, toes) are allowed; they must not move the core rest positions or break animation proxies. |
| Tint slots | `skin`, `cloth`, `pants`, `accessory`, `hair`. | | Tint slots | `skin`, `cloth`, `pants`, `accessory`, `hair`. |
| Orientation | Front is Blender −Y / Godot +Z; feet grounded near y = 0; metres. | | Orientation | Front is Blender −Y / Godot +Z; feet grounded near y = 0; metres. |
| Character budget | **16,000 triangles / 16,000 glTF vertices** per character (`CHAR_MAX_TRIS`/`CHAR_MAX_VERTS`). | | Character budget | **24,000 triangles / 24,000 glTF vertices** per character (`CHAR_MAX_TRIS`/`CHAR_MAX_VERTS`). |
| Frozen fallback | `npc_base` remains the low-poly fallback; it shares the 16,000/16,000 cap but is not required to use it. Do not silently lower the cap. | | Frozen fallback | `npc_base` remains the low-poly fallback; it shares the 24,000/24,000 cap but is not required to use it. Do not silently lower the cap. |
| Gear budget | **1,500 triangles** per gear piece; authored in attach-point space. | | Gear budget | **1,500 triangles** per gear piece; authored in attach-point space. |
| Prop budget | **4,000 triangles** per prop. | | Prop budget | **4,000 triangles** per prop. |
| Gear attach | Use the catalogued attach points; verify with `fit`. Runtime `GEAR_OFFSETS` (lanyard forward, firefighter helmet up) and `HAND_ITEM_FORWARD` are intentional, not asset-contract changes. | | Gear attach | Use the catalogued attach points; verify with `fit`. Runtime `GEAR_OFFSETS` (lanyard forward, firefighter helmet up) and `HAND_ITEM_FORWARD` are intentional, not asset-contract changes. |
@@ -167,7 +167,7 @@ At minimum:
- `npc_proportions.py` passes the stylized guide. - `npc_proportions.py` passes the stylized guide.
- `npc_pipeline.py audit` passes if the base/textures changed. - `npc_pipeline.py audit` passes if the base/textures changed.
- texel density is inside the authored/fabric bands. - texel density is inside the authored/fabric bands.
- No character exceeds 16,000/16,000; gear/prop caps hold. - No character exceeds 24,000/24,000; gear/prop caps hold.
- 24-NPC budget and Web/Compatibility path remain green. - 24-NPC budget and Web/Compatibility path remain green.
- Contract tests, headless suite, capture gates and docs check pass. - Contract tests, headless suite, capture gates and docs check pass.
- The diff contains only the intended assets, tools, tests and docs. - The diff contains only the intended assets, tools, tests and docs.
@@ -59,7 +59,7 @@ skin, cloth, pants, accessory, hair
| Asset | Limit | | Asset | Limit |
| --- | --- | | --- | --- |
| Character (library and base fallback) | 16,000 triangles / 16,000 glTF vertices | | Character (library and base fallback) | 24,000 triangles / 24,000 glTF vertices |
| Gear piece | 1,500 triangles | | Gear piece | 1,500 triangles |
| Prop | 4,000 triangles | | Prop | 4,000 triangles |
| Library prop/venue total | must stay inside the venue tests and library budget | | Library prop/venue total | must stay inside the venue tests and library budget |
+13 -8
View File
@@ -90,12 +90,16 @@ specialists the task needs:
### Project rules that override a skill ### Project rules that override a skill
1. **The project contract wins.** `npc_base` mesh node, the 13 required core bones at their rest 1. **The project contract wins.** `npc_base` mesh node, five tint slots, front on Blender −Y /
positions, five tint slots, front on Blender −Y / Godot +Z, grounded feet, 16,000 Godot +Z, grounded feet, 24,000 triangles / 24,000 glTF vertices per character, 1,500 per
triangles / 16,000 glTF vertices per character, 1,500 per gear, 4,000 per prop. Optional gear, 4,000 per prop. The shipped procedural library uses the 13 required core bones at their
extra bones (fingers, face, toes) are allowed if they do not move the core rest positions. A skill rest positions; optional extra bones (fingers, face, toes) are allowed if they do not move the
may recommend another topology, naming or budget scheme; do not adopt it unless the core rest positions. The one documented exception is the accepted Quaternius runtime path
change is explicitly planned and the contract, tests and docs are updated together. ([ADR-0018](docs/adr/0018-quaternius-stage2-runtime-rig.md)): it keeps the 62-joint source rig
at runtime while the 0.9.9 face/silhouette gate is open, and it must still supply the five tint
slots, valid gear attach points and the same budgets. Both paths stay tested until that gate
closes. A skill may recommend another topology, naming or budget scheme; do not adopt it unless
the change is explicitly planned and the contract, tests and docs are updated together.
2. **Blender MCP is optional interactive tooling.** The headless flatpak scripts 2. **Blender MCP is optional interactive tooling.** The headless flatpak scripts
(`tools/blender_assets.py`, `tools/npc_pipeline.py`) remain authoritative. MCP may be used (`tools/blender_assets.py`, `tools/npc_pipeline.py`) remain authoritative. MCP may be used
for local part exploration; setup and safety are in [`docs/asset-tooling.md#optional-interactive-blender-mcp`](docs/asset-tooling.md#optional-interactive-blender-mcp). for local part exploration; setup and safety are in [`docs/asset-tooling.md#optional-interactive-blender-mcp`](docs/asset-tooling.md#optional-interactive-blender-mcp).
@@ -104,8 +108,9 @@ specialists the task needs:
3. **CI stays offline and skill-free.** Skills are local guidance only; every gate must still 3. **CI stays offline and skill-free.** Skills are local guidance only; every gate must still
work from a fresh checkout with committed `.glb`/`.png`/`.tres` files and Godot. work from a fresh checkout with committed `.glb`/`.png`/`.tres` files and Godot.
4. **No new asset source without the licence gate.** Skills may suggest reference packs or 4. **No new asset source without the licence gate.** Skills may suggest reference packs or
generated meshes; ADR-0015 (CC0, or CC-BY with attribution in `CREDITS.md`) and the generated meshes; ADR-0017 (the CC0 Quaternius NPC base) supersedes the generator-first
provenance rules in `docs/npc-model-sourcing.md` still apply. decision in ADR-0015, and the CC0/CC-BY rule in ADR-0015 (attribution in `CREDITS.md`) plus
the provenance rules in `docs/npc-model-sourcing.md` still apply to any new source.
5. **Evidence, not vibes.** After a skill-informed change, run `blender_assets.py inspect`, 5. **Evidence, not vibes.** After a skill-informed change, run `blender_assets.py inspect`,
`audit` and `fit` plus the relevant capture/smoke gates, and say in the handoff which `audit` and `fit` plus the relevant capture/smoke gates, and say in the handoff which
skills informed the change and what the measurements were. skills informed the change and what the measurements were.
+30
View File
@@ -4,6 +4,36 @@ All notable user-facing changes to Gig Line. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions use [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions use
Semantic Versioning. Semantic Versioning.
## [Unreleased]
## [0.9.9] — 2026-11-06
### Changed
- Raised the character mesh budget from 16,000 to 24,000 triangles and glTF
vertices so the Quaternius base and the role-dressed variants fit without
heavy decimation. Gear and prop budgets are unchanged.
- Replaced the whole NPC roster with the CC0 Quaternius characters. All twelve
characters (tourist, jacket, coat, vest, dress, hoodie, firefighter, guard and
the four crowd variants) now use one of three source outfits — casual tee,
hoodie or work clothes — dressed by role with the five tint slots and the gear
library. The procedural library and the MakeHuman head remain in the build as
the fallback.
- Characters stand with their arms down instead of a T/A pose, and the seated,
idle and walking poses swing from the shoulder and hip the way the animation
code intends on the new skeleton.
- Head gear (cap, hood, sunglasses, firefighter helmet) and the camera were
resized for the new head and arm. The camera now hangs from a wrist strap
instead of a neck strap that floated next to the head.
- The backpack straps were pulled in so they sit on the chest instead of
hovering in front of it.
### Fixed
- Characters no longer render with black legs or black patches: the new base
carried a collapsed UV map that made the ambient-occlusion bake come out
black, and the roster is now unwrapped and baked correctly.
## [0.9.8] — 2026-11-04 ## [0.9.8] — 2026-11-04
### Fixed ### Fixed
+20 -6
View File
@@ -15,6 +15,10 @@ been cleared for public distribution.
## 3D characters, gear and props ## 3D characters, gear and props
The local MakeHuman head prototype uses the CC0 MakeHuman/MPFB base mesh
(see [`docs/makehuman-head-review.md`](docs/makehuman-head-review.md)); it is a
development prototype, not a shipped character.
The character, gear and prop library is generated by project-owned scripts The character, gear and prop library is generated by project-owned scripts
(`tools/blender_assets.py`, `tools/npc_pipeline.py`, `tools/model_pipeline.py`) (`tools/blender_assets.py`, `tools/npc_pipeline.py`, `tools/model_pipeline.py`)
using Blender. The `.blend` working sources are developer artifacts and are not using Blender. The `.blend` working sources are developer artifacts and are not
@@ -42,12 +46,22 @@ shipped derivative of CesiumMan or RiggedFigure must keep the attribution above.
### MakeHuman head source ### MakeHuman head source
The decimated head used by the character head prototype is derived from the The standalone MakeHuman head prototype is derived from the MakeHuman / MPFB base
MakeHuman / MPFB base mesh, **CC0** (`assets/models/blender_src/makehuman_head.glb`; mesh, **CC0** (`assets/models/blender_src/makehuman_head.glb`; source
source `tmp/npc-research/makehuman/base.obj` with its bundled <https://static.makehumancommunity.org/makehuman.html>, MakeHuman base mesh,
`LICENSE.md`/`LICENSE.ASSETS.md`). It is retargeted, decimated and re-weighted by CC0 1.0). It is cut, retopologised and re-weighted by project scripts; the exported
project scripts; the exported character GLBs remain project assets. See ADR-0015 character GLBs remain project assets. The development source files were removed
and `docs/npc-model-sourcing.md`. from `tmp/` after the 2026-11-05 pivot to Quaternius. The head is retained as a
tested fallback, not the active NPC base. See ADR-0017 and
`docs/npc-model-sourcing.md`.
### Quaternius NPC base source
The active NPC base migration uses **Quaternius Ultimate Modular Characters** and
**Ultimate Modular Women** (CC0 1.0, <https://quaternius.com/>). The three
researched GLBs are development inputs and are not shipped directly; the baked,
retargeted character GLBs are project assets. See ADR-0017 and
`docs/npc-model-sourcing.md` for the decision, source hashes and rollout status.
The venue geometry is authored procedurally in `src/game/world_builder.gd` as a The venue geometry is authored procedurally in `src/game/world_builder.gd` as a
stylized reconstruction. The Joke Shop is a fictional stand-in for the real stylized reconstruction. The Joke Shop is a fictional stand-in for the real
+49
View File
@@ -2143,3 +2143,52 @@ must inherit the reviewed face/hand/gear baseline instead of multiplying whateve
project-owned clothing; keep out of CI/build.) project-owned clothing; keep out of CI/build.)
- [x] Body-shape diversity decision (2026-11-04, owner): **yes** — use MakeHuman for the next - [x] Body-shape diversity decision (2026-11-04, owner): **yes** — use MakeHuman for the next
character pass. Promoted to the **MakeHuman character pipeline** track. character pass. Promoted to the **MakeHuman character pipeline** track.
## NPC pose harness (v0.9.8)
Completed 2026-11-04. The pose harness bug that made every staged capture save a rest frame
was found and fixed; it unblocked the weights/pose evidence and the seated/walking deformation
fixes. User-facing changes are recorded under 0.9.7 and 0.9.8 in `CHANGELOG.md`.
Goal: `src/test/capture_npc_pose.gd` reports a pose but saves rest frames; the root cause is
narrowed to the skinned mesh ignoring its `Rig` parent transform. Fixing it unblocks the
weights/pose column of the 3D review; the evidence now feeds the Quaternius base bake.
*Skill route: `rigging`; evidence in `docs/3d-model-review.md` §"Pose evidence is blocked".*
- [x] Root cause found (2026-11-04): the harness never disabled the avatar's `_physics_process`,
so `_advance_fsm` + `_move_toward_target` ran every tick and reset `arrived`/velocity, which
made `theater_pose()` return "idle" and reverted the pose to rest. (The earlier "moving the
`Rig` child does not render" probe was a red herring: it set `rig.position.y` inside `_shoot()`
in the same frame as `root.get_texture().get_image()`, so it never got a rendered frame.)
- [x] Harness rebuilt: `capture_npc_pose.gd` now calls `set_physics_process(false)`/
`set_process(false)` after staging the avatar; the pose survives because only the direct
`_animate()` ticks drive it. Verified: sit vs idle diff 5.63, walk vs idle diff 2.29, and the
vision judge confirms the seated frame reads as crouched.
- [x] Captured all poses for all 12 variants (idle / walk_contact / walk_pass / sit × full +
shoulder views = 96 frames) and built `sheet_pose.jpg`/`sheet_shoulder.jpg` under `tmp/`.
(No separate "wave" theater pose exists; the escalation raise-hand gesture is not a distinct
capture in this harness.)
## Milestone 0.9.7 — Character development (patch)
Completed 2026-11-04 and published as v0.9.7. The detailed character-development record already
lives under "Character development" above; this milestone checklist is kept verbatim for the
release record.
Goal: release the finished procedural character development — the realistic face on all eight
variants, the four crowd-variety characters and the per-role pools — as a **patch** on top of
0.9.6. This keeps the 3D model and map improvements out of the patch; they land together in the
0.10.0 minor.
- [x] Rig pose harness fixed and the weights/pose evidence captured (see the top track).
- [x] Character work done: 12-character library, realistic face, crowd variants, per-role pools,
24-NPC budget 6.94 ms (152,464 tris / 168 meshes).
- [x] QA + local build for 0.9.7: Godot 277/277, Python 161/161, audit 49 OK, fit/proportions
12/12, release smoke matrix green, gallery 21/0, clean worktree build (Linux 172 MB,
Windows 206 MB, macOS 159 MB, Web 111 MB, HQ 266 MB), checksums verified, Linux binary
launches headless. Tagged `v0.9.7` (CI build/publish follows).
**Exit:** met — 0.9.7 ships the 12-character library (face realism + crowd variety) with the pose
harness fixed; the runtime contract is unchanged. The Quaternius NPC base and the map rebuild are
not in this patch (they target 0.10.0).
+113 -17
View File
@@ -10,6 +10,11 @@ canonical implemented-features list in `docs/development-status.md`.
the licence gate. Downloads live in `tmp/research/` (gitignored): DDG image search, street-view the licence gate. Downloads live in `tmp/research/` (gitignored): DDG image search, street-view
frames, neighbouring buildings — never committed, never shipped. Promotion into frames, neighbouring buildings — never committed, never shipped. Promotion into
`docs/screens/refs/` always passes the sidecar licence + privacy gate and `CREDITS.md`. `docs/screens/refs/` always passes the sidecar licence + privacy gate and `CREDITS.md`.
- **AI-assisted asset review loop (2026-11-04):** head/face shape decisions use a concept +
judge loop — generate a target concept (`generate_textures.generate_image`, Seedream) and use
`tools/describe_image.py` as the vision judge, iterating until the judge's specific fixes
converge. Review pages: `docs/screens/head-review/README.md`, `docs/3d-model-review.md`
§AI-driven head rework.
- **Project agent skills (2026-11-03):** two tracked skills live in `.agents/skills/`: - **Project agent skills (2026-11-03):** two tracked skills live in `.agents/skills/`:
`gig-line-blender-asset-contract` and `gig-line-release-qa`. They are guidance only, `gig-line-blender-asset-contract` and `gig-line-release-qa`. They are guidance only,
add no runtime dependency and are referenced from `tools/README.md`. The third-party add no runtime dependency and are referenced from `tools/README.md`. The third-party
@@ -45,11 +50,6 @@ canonical implemented-features list in `docs/development-status.md`.
(94 skills + shared `references/`), gitignored, kept out of Godot's scanner by (94 skills + shared `references/`), gitignored, kept out of Godot's scanner by
`.agents/.gdignore`. Keep the pack flat when updating (`cp -a <pack>/.claude/skills/. ...`). `.agents/.gdignore`. Keep the pack flat when updating (`cp -a <pack>/.claude/skills/. ...`).
Pi gates project `.agents/skills` behind project trust. Pi gates project `.agents/skills` behind project trust.
- **NPC model sourcing (ADR-0015):** CC0 acceptable for shipped assets; CC-BY only with
attribution in `CREDITS.md`; no service assets with redistribution restrictions. The procedural
generator is the primary path. Quaternius and MakeHuman were evaluated as references only, not
shipped bases (Quaternius: 62-bone T-pose rig mismatch; MakeHuman: needs retopology + custom
rig + clothing).
- **Gaussian splatting (unversioned):** offline/development-only until an ADR proves licence, - **Gaussian splatting (unversioned):** offline/development-only until an ADR proves licence,
privacy, Compatibility/Web cost and mesh collision. Accepted photo channels: team captures, privacy, Compatibility/Web cost and mesh collision. Accepted photo channels: team captures,
permissioned user/volunteer photos, clearly licensed downloads; every set needs a permissioned user/volunteer photos, clearly licensed downloads; every set needs a
@@ -62,11 +62,33 @@ canonical implemented-features list in `docs/development-status.md`.
`docs/asset-tooling.md#optional-interactive-blender-mcp`. The headless flatpak scripts remain authoritative; MCP is `docs/asset-tooling.md#optional-interactive-blender-mcp`. The headless flatpak scripts remain authoritative; MCP is
never a build/CI/test/runtime dependency, and anything MCP touched must pass the never a build/CI/test/runtime dependency, and anything MCP touched must pass the
CLI audit/fit gates. CLI audit/fit gates.
- **Character mesh cap (2026-11-04):** all characters use 16,000 tris / 16,000 glTF vertices - **Character mesh cap (2026-11-05):** all characters use 24,000 tris / 24,000 glTF vertices
(`CHAR_MAX_TRIS`/`CHAR_MAX_VERTS` in `tools/blender_assets.py`; `npc_pipeline.MAX_TRIANGLES`/ (`CHAR_MAX_TRIS`/`CHAR_MAX_VERTS` in `tools/blender_assets.py`; `npc_pipeline.MAX_TRIANGLES`/
`MAX_VERTICES`). The required 13 core bones stay, optional extra bones are allowed, and the `MAX_VERTICES`). The required 13 core bones stay, optional extra bones are allowed, and the
low-poly `npc_base` fallback remains the shipped fallback. Measure the 24-NPC/Web budget after low-poly `npc_base` fallback remains the shipped fallback. The raise funds the Quaternius base bake and
the 12-variant clothing detail. Measure the 24-NPC/Web budget after
any geometry change; never silently lower the cap without a new decision. any geometry change; never silently lower the cap without a new decision.
- **Quaternius is the NPC base (2026-11-05, owner):** ADR-0017 supersedes the generator-first
sourcing decision. The CC0 Quaternius Ultimate Modular character pack is the primary NPC mesh
and animation source. Stage 1 bakes a proportion-warped, 13-core-contract `npc_base` so
`NpcAvatar`, gear and animations keep working; the 62-joint/24-clip source rig is authoring
input, not a runtime dependency. If the warp fails the silhouette gate, Stage 2 needs a new ADR
for a 62-joint runtime, not a hybrid. The MakeHuman head and procedural `npc_base` remain
tested fallbacks until the Quaternius rollout ships. Quaternius source: CC0, attribution in
`CREDITS.md`; the three researched GLBs and licence notes are kept in
`tmp/npc-research/quaternius/`. MPFB/MakeHuman body work is closed; do not add new work there.
- **Quaternius bake keeps source detail (2026-11-05, owner):** during the contract bake (§2),
keep the Quaternius mesh as close to source as possible — no unnecessary decimation or LOD
reduction. The canonical base is only 3,199 verts / 6,206 tris, far under the 24,000 cap, so
there is no budget reason to decimate before the silhouette gate. Do not reuse the old
7,000-triangle or MPFB decimation targets.
- **Quaternius runtime rig (2026-11-05, owner, ADR-0018 option 2):** the Stage 1 proportion warp
failed the silhouette gate (arm ×1.63 / leg ×0.74 / torso ×1.61; vision judge: paddle hands,
lost forearms, stretched shins). The owner chose to **adopt the Quaternius 62-joint rig at
runtime** instead of warping: `NpcAvatar` maps the Quaternius joint names, gear attach points
are re-derived from the Quaternius skeleton (hip ≈0.87, shoulder ≈0.148/1.39, wrist ≈0.99,
knee ≈0.52), the mesh keeps source LOD (arm-down rest pose baked), and Idle/Walk play directly
on the 62-joint rig (no retarget).
- **Engine baseline:** Godot 4.7.2 stable (official editor `~/.local/bin/godot`). The system's - **Engine baseline:** Godot 4.7.2 stable (official editor `~/.local/bin/godot`). The system's
`godot-runner` cannot load arbitrary project paths (compiled without path overrides); use the `godot-runner` cannot load arbitrary project paths (compiled without path overrides); use the
official editor binary. official editor binary.
@@ -85,6 +107,26 @@ canonical implemented-features list in `docs/development-status.md`.
cleared, storefront/rating approval pending, external playtests and full hardware matrix pending. cleared, storefront/rating approval pending, external playtests and full hardware matrix pending.
Recorded in `CREDITS.md`, `PRIVACY.md`, `docs/accessibility.md`, `docs/known-limitations.md`. Recorded in `CREDITS.md`, `PRIVACY.md`, `docs/accessibility.md`, `docs/known-limitations.md`.
- **Release roadmap (2026-11-05, owner):** `0.9.9` Quaternius pilot base (rig/export fix,
rest-relative pose math, owner face/silhouette gate), `0.9.10` 12-role roster, `0.9.11`
scheduled venue/prop/lighting polish, `0.9.12` performance/Web/accessibility verification,
`0.10.0` public-readiness minor, then `0.10.1`–`0.10.3` fixes, `0.11.0` replay + fourth venue +
localization foundation, `0.12.0` signed/storefront builds + save v2, `0.13.x` content complete
and release candidates, `1.0.0` stable. The full gap analysis and roadmap live in `PLAN.md`; the
character track is versioned now, so a blocked rig cannot block the release. Planning only —
none of these versions is built yet.
- **Quaternius face/readability gate (2026-11-05):** the base is 6.2k tris total and replaces a
library with a sculpted realistic face, so the 0.9.9 gate is a product decision, not just an
art check. Owner picks full swap / crowd-only / extra head detail. Do not lower character
fidelity silently.
- **Pose composition is rest-relative (resolved 2026-11-05):** Godot's `set_bone_pose_rotation`
offsets from the bone rest, so the correct swing around a world axis is
`rest * Quaternion(rest.inverse() * axis, angle)`. For the old 180° rest this reduces to the
legacy `rest * Quaternion(axis, angle)`, so the 13-core fallback is unchanged (commit `8b1bf73`).
- **One character source, one fallback:** no new NPC source without an ADR. MPFB body work stays
closed, the Stage-1 proportion warp is not reopened, and the procedural `npc_base` + MakeHuman
head stay tested until the Quaternius base passes its gate.
## Gotchas / Known Issues ## Gotchas / Known Issues
- **Two character builders — do not confuse them.** `tools/blender_assets.py` is the real builder - **Two character builders — do not confuse them.** `tools/blender_assets.py` is the real builder
@@ -105,6 +147,44 @@ canonical implemented-features list in `docs/development-status.md`.
out of a portrait frame (park the rest of the crowd away instead). out of a portrait frame (park the rest of the crowd away instead).
- Blender: `bisect_plane` and thin bevels leave zero-area triangles (Godot "non-finite normal in - Blender: `bisect_plane` and thin bevels leave zero-area triangles (Godot "non-finite normal in
LOD generation"); `_clean()` + a weld after bisect fixes them and the audit rejects them. LOD generation"); `_clean()` + a weld after bisect fixes them and the audit rejects them.
- Quaternius arm-down bake (working recipe, `tmp/npc-research/bake_armdown_v5.py`): pose-rotate
`UpperArm.L/R` −90° local X → read the pose matrices → apply the Armature modifier → **reset the
pose to identity** → set each arm bone's rest matrix from its pose matrix → re-add the modifier →
export `export_apply=False`. Missing the pose reset double-applies the arm-down (mesh ±0.53
instead of ±0.23). `armature_apply` in Blender 5.2 is broken (deforms the mesh but does not bake
the rest). The canonical base must be **join-only** (`build_base_minimal.py`: no `remove_doubles`
weld, no scale/ground transform, keep the Armature modifiers) — the weld and the scale/ground
transform corrupt the skin weights (arm poses to ±0.51 instead of −0.23).
- Quaternius `NpcAvatar` wiring (commits `2a6f29f`, `cd285b3`): `RIG_BONES` +
`NpcAvatar.bone_name(joint, side)` hold the **only** side mapping — the Quaternius labels `L`/`R`
opposite the project, so proxy side 0 (`-x`) drives `UpperLeg.R`/`UpperArm.R` while side 1 drives
the `.L` bones. The arm/elbow swing axis is `Vector3.LEFT` (legs stay `RIGHT`). The hand proxy is
`global_wrist − global_elbow` (subtracting the parent-relative `elbow.position` misplaces
handheld gear). Old 13-core names resolve through the same table.
- **Bone posing must write the global pose** (`NpcAvatar._swing_bone`): `reset_bone_pose()` then
`set_bone_global_pose(Transform3D(Basis(swing * global_rot), current_origin))`, conjugated with
`get_bone_global_rest()`. Both rejected alternatives fail: the rest-relative
`set_bone_pose_rotation(rest * Quaternion(rest⁻¹·axis, angle))` swings about the parent-relative
rest (the glTF importer already folded the axis conversion into it) and splayed the Quaternius
arms sideways; the plain `rest⁻¹ * swing * rest` conjugation flips the limbs 180°. Verified by
measuring `get_bone_global_pose()` after the call — posed bone axis matches the proxy pivot at
dot 1.000 on the Quaternius and the procedural rigs render unchanged.
Two caveats: children need the parent's swing passed in (the write is absolute), and
`set_bone_global_pose()` is a **no-op outside the scene tree**, so the headless runner (tests run
in `_initialize()`) cannot assert on it.
- **The Quaternius source UV map is degenerate** — every loop at (0,0), flat-colour materials, no
atlas. The AO/texture bake silently produces a 100 % black map, which tints every AO'd slot
(skin, hair, accessory) black in Godot: a black leg is the symptom. `build_base_minimal.py` now
Smart-UV-unwraps and repacks the layout (texel density 0 → 73–74 px/m on 256²), and the base
pipeline is two explicit steps with distinct files: `build_base_minimal.py`
(`quaternius/Casual_Hoodie.glb` → `quaternius_tpose.glb`, joins without welding, renames the
materials to the five tint slots, unwraps) then `bake_armdown_v5.py` (`quaternius_tpose.glb` →
`quaternius_base.glb` + `assets/models/blender_src/npc_casual.blend`). Re-running v5 on its own
output double-bakes the rest.
- Hand gear must be self-contained: the camera carried a 0.42 m neck-strap loop that floats when the
item is parented to the wrist. Hand items get a short wrist strap, not a neck strap. The backpack
straps were authored for the wider procedural chest and floated ~5 cm proud of the Quaternius
torso; the chest points now sit at x 0.12 / Godot z 0.15, which fits both.
- Image models have per-model size floors/caps (Seedream needs 1920+; `GEN_SIZE=2048`, do not - Image models have per-model size floors/caps (Seedream needs 1920+; `GEN_SIZE=2048`, do not
lower; gpt-5-image-mini caps lower). `generate_image` applies model-specific sizing; concept lower; gpt-5-image-mini caps lower). `generate_image` applies model-specific sizing; concept
generation defaults to `--request-size 1536`. generation defaults to `--request-size 1536`.
@@ -169,6 +249,8 @@ canonical implemented-features list in `docs/development-status.md`.
- Character checks: `python3 tools/blender_assets.py inspect|audit|fit`, - Character checks: `python3 tools/blender_assets.py inspect|audit|fit`,
`python3 tools/npc_proportions.py` `python3 tools/npc_proportions.py`
- Docs: `python3 tools/check_docs.py` (every tracked image must be linked from its folder README) - Docs: `python3 tools/check_docs.py` (every tracked image must be linked from its folder README)
- Head/part pipeline: `python3 tools/npc_parts.py build|extract|assemble|inspect|sheet|compare`
(Blender subcommands run under flatpak). Head sources: `build_character(head="face"|"proto"|"mh")`.
- Reference research: `python3 tools/refcollect.py search <q> --slug <block> --limit N` - Reference research: `python3 tools/refcollect.py search <q> --slug <block> --limit N`
(local-only), `tools/refboard.py` (Commons), `tools/three_up.py compose/rows`, (local-only), `tools/refboard.py` (Commons), `tools/three_up.py compose/rows`,
`tools/scene_concepts.py generate/anchor/realism` `tools/scene_concepts.py generate/anchor/realism`
@@ -176,18 +258,32 @@ canonical implemented-features list in `docs/development-status.md`.
## Open Questions ## Open Questions
- Whether the responder avatar should be playable/controllable or purely scripted. - Whether the responder avatar should be playable/controllable or purely scripted.
- Localization target languages beyond English. - Quaternius scope: full 12-role swap, crowd-only, or extra head detail (owner decision at 0.9.9).
- macOS notarization approach for broad distribution. - Localization target languages beyond English (0.11.0/0.12.0).
- macOS notarization approach for broad distribution (0.12.0).
- Fourth-venue choice for 0.11.0 (candidate: TCL Chinese Theatre forecourt).
## Current State ## Current State
The playable build covers the three venues, tutorial/campaign/checkpoints, the 0.9.3–0.9.6 v0.9.8 is published. The build covers the three venues, the shift loop, tutorial/campaign/
polish/texture/integration passes and the Blender + realism asset pipeline (see `DONE.md` and checkpoints, the 0.9.3–0.9.8 polish/texture/integration/character passes and the Blender +
`CHANGELOG.md`). The 12-character library (realistic face + four crowd variants) and the realism asset pipeline (see `DONE.md` and `CHANGELOG.md`). The 12-character procedural library
Comedy Store map rebuild are complete. **Priority (2026-11-04, owner):** 1) the **Blender NPC (realistic face + four crowd variants) and the Comedy Store map rebuild are complete.
model overhaul** (head/face/hands, local part pipeline), 2) finish the MakeHuman character + the
3D model and map improvements, 3) ship it all together in the **0.10.0 minor**. The 0.9.7/0.9.8 **Planning pivot (2026-11-05, owner):** the character and map work was re-sequenced into
character patches are published; `TODO.md` is the priority source. Test counts as of 2026-11-04: patches (`0.9.9`–`0.9.12`) with `0.10.0` as the public-readiness minor. The gap analysis and the
Godot 277/277, Python 162/162, `check_docs` OK. three release roadmaps now live in `PLAN.md`; `TODO.md` is the executable task list. ADR-0017
Stage 1 (proportion warp) is dead; ADR-0018 option 2 (62-joint runtime) is accepted.
**Quaternius pilot (2026-11-05, commits `cd285b3`, `fb3e75a`, `90f189a`):** `npc_casual` ships as
the Quaternius base. It renders arm-down, carries the five tint slots, plays the Idle/Walk/Sit pose
code on the 62-joint rig, and the procedural rigs are visually unchanged. Verified: `inspect`
(no issues), `audit` OK (6,206 tris / 12,988 verts, 62 bones), `fit` head 0.201 m, texel density
73–74 px/m, `npc_proportions` measured, 24-NPC budget smoke OK (168 meshes, 162,304 tris,
0.82 ms avg frame). The face/silhouette gate sheet and the three options are in
`docs/screens/npc-face-gate/`; the **owner decision is the only thing left before the 0.9.9
rollout**.
Test counts as of 2026-11-05: Python 167/167, Godot 278/278, `check_docs` OK.
<!-- b64: RWFzdGVyIGVnZzogYSByYXJlIHJhZGlvIHRyYW5zbWlzc2lvbiAiwr9Ew7NuZGUgZXN0w6EgbGEgbW90YT8iIGZpcmVzIHZpYSBHYW1lTWFuYWdlci5fbWF5YmVfcmFkaW9fZWFzdGVyX2VnZyAoMi41JSBjaGFuY2UgYWZ0ZXIgYSBzdWNjZXNzZnVsIHJhZGlvIGNhbGwpLiBTdHJpbmcga2V5IHJhZGlvLmVhc3Rlcl9lZ2cgaW4gY29udGVudC9zdHJpbmdzLmVuLmpzb24uIERvIG5vdCByZW1vdmUu --> <!-- b64: RWFzdGVyIGVnZzogYSByYXJlIHJhZGlvIHRyYW5zbWlzc2lvbiAiwr9Ew7NuZGUgZXN0w6EgbGEgbW90YT8iIGZpcmVzIHZpYSBHYW1lTWFuYWdlci5fbWF5YmVfcmFkaW9fZWFzdGVyX2VnZyAoMi41JSBjaGFuY2UgYWZ0ZXIgYSBzdWNjZXNzZnVsIHJhZGlvIGNhbGwpLiBTdHJpbmcga2V5IHJhZGlvLmVhc3Rlcl9lZ2cgaW4gY29udGVudC9zdHJpbmdzLmVuLmpzb24uIERvIG5vdCByZW1vdmUu -->
+188 -16
View File
@@ -16,20 +16,91 @@ The game is about **judgment, communication, access verification, and proportion
## Development Priorities ## Development Priorities
The active planning order is set in [`TODO.md`](TODO.md) and in `AGENTS.md`: The active task order is in [`TODO.md`](TODO.md); the gap analysis and the release roadmap are
below. The current order is:
1. **Character development (unversioned).** Expand the owned procedural character library with 1. **0.9.9–0.9.12 patches.** Finish the character base (the accepted Quaternius 62-joint runtime),
crowd, age, build, hair and outfit variety. Keep the frozen bone/tint/gear contract; see roll it out to the 12 roles, close the scheduled venue/prop/lighting polish, and verify
[ADR-0015](docs/adr/0015-npc-model-variety-sourcing.md) and performance, Web and accessibility.
[`docs/npc-model-sourcing.md`](docs/npc-model-sourcing.md). Quaternius is a reference; 2. **0.10.0 minor.** Public readiness: external playtests, balance acceptance, legal/storefront
MakeHuman is deferred to a separate local pipeline. approval, the platform matrix and the documentation/picture lock.
2. **Local map development (unversioned).** Move beyond stylized venue reconstruction with 3. **0.10.x patches → 0.11.0.** Content and systems depth: live body-camera replay, a fourth
licensed OpenStreetMap and 3D building data, downloaded reference imagery for photoreal venue and the localization foundation.
facades, AI-assisted composition, and a Gaussian-splatting evaluation that stays in the 4. **0.12.0 → 1.0.0.** Platform/distribution, content complete, release candidates and the stable
development pipeline unless it proves runtime-safe. release.
3. **Versioned release hardening.** The 0.10.0 release candidate and later semantic versions come
after the unversioned development tracks in planning order. Versions are assigned only when a Versions are assigned only when a track's scope is accepted into a release (see
track's scope is accepted into a release. [Release Roadmap](#release-roadmap)). Unversioned tracks — the 3D review evidence base,
maintenance and the Gaussian-splatting study — do not receive a placeholder version.
## Plan Evaluation (2026-11-05)
The game plan itself is sound and largely built: three venues, the escalation ladder, access
artifacts, the radio/report system, the shift review and the campaign loop all exist and are
tested. What needed fixing was the **delivery plan**, not the design.
- **Character sourcing churned.** The project moved procedural → MakeHuman → Quaternius, and the
Quaternius Stage-1 warp failed its own gate. That leaves a correct decision (ADR-0018) with a
half-finished implementation. The plan now makes the rig correction one small version, gates the
roster behind a face/readability decision, and keeps exactly one fallback.
- **One milestone carried too much.** The old 0.10.0 mixed character work, map work and every
public-release gate, so one blocked export blocked the release. Character and venue polish are
now patches; 0.10.0 is only the release gate.
- **Unversioned work had no deadline.** The 3D review backlog could grow without bound. It is now
capped: the ranked venue/prop/lighting items are scheduled into 0.9.11, the budget checks into
0.9.12, and everything else is an explicit accept or deferred. Gaussian splatting stays
unscheduled because no product need justifies its risk yet.
- **Legal, signing and storefront work is release work, not polish.** It now has named checkboxes
in 0.10.0 and 1.0.0 instead of living in prose.
- **What is deliberately not planned:** new genres or mechanics, a live-service model, or a
runtime splat renderer. The de-escalation-first, rare-emergency design stays frozen.
Feasibility: the remaining character work is small in geometry terms (6.2k triangles against a
24k cap) and larger in rig/export terms; the venue work is polish; most release gates are
evidence gathering rather than engineering. The main schedule risk is external review and
approval, not code.
## Character Creation Re-Evaluation (2026-11-05)
**Where it stands.** The shipped library is 12 procedural Blender variants (4.7k–5.8k triangles
each) with a sculpted face, five tint slots and 18 gear pieces. The owner then chose the CC0
**Quaternius Ultimate Modular Characters** as the primary source. Stage 1 (warp the Quaternius
mesh onto the 13-core contract) failed its silhouette gate — arm ×1.63, leg ×0.74, torso ×1.61,
judged distorted. [ADR-0018](docs/adr/0018-quaternius-stage2-runtime-rig.md) therefore adopts the
source **62-joint rig at runtime**: no warp, source LOD (3,199 verts / 6,206 tris), source
Idle/Walk clips, and gear pivots re-derived from the Quaternius skeleton.
**What is already built.** The canonical `quaternius_base.glb` (welded, grounded, arm-down rest),
the 62→13 joint map, the material→five-slot map, the clip inventory (24 clips; Idle and Walk in
scope) and the `NpcAvatar` joint-name mapping with rest-derived gear pivots.
**What actually blocks it.** Two technical items about the export and the pose math, not the art:
1. the arm-down GLB renders in A-pose in Godot because the bake writes wrong inverse bind
matrices; and
2. `_pose_bone()` composes rotations as `rest * Quaternion(axis, angle)`, which only cancels for
the old 180° rest, not the Quaternius ~76° rest.
**Re-evaluation findings.**
- Triangle budget is not the problem (6.2k against a 24k cap). Do not decimate the source.
- **Face fidelity is the real risk.** The 0.9.7 library has a sculpted realistic face; the
Quaternius head is low-poly. A blanket swap would regress every close-up. The plan therefore
makes the owner choose at 0.9.9 between (a) a full swap, (b) Quaternius for crowd roles with the
procedural library for named roles, or (c) extra head subdivision/hair cards on the Quaternius
head.
- **Animation** becomes clip-driven, which removes the hand-built pose composition as the main
path. Poses the clips do not cover (seated, raise-hand, head turn) must be decided explicitly
rather than patched per scene.
- **One fallback, no third source.** The procedural `npc_base` and the MakeHuman head stay tested
until the new base passes the gate. MPFB body work stays closed and the Stage-1 warp is not
reopened. A new character source needs a new ADR.
- **Sequence matters.** Fix the rig first (0.9.9), then the roster (0.9.10). Rolling a broken rig
across 12 variants would waste the whole pass.
**Reversal condition.** If the skin cannot be exported correctly without a new exporter path, or
if the owner rejects the low-poly face in every option, the plan falls back to the procedural
library and retires Quaternius with a recorded ADR. That decision is made at 0.9.9, not later.
## Core Gameplay Loop ## Core Gameplay Loop
@@ -169,7 +240,7 @@ The action wheel prioritizes de-escalation: Stop, Look, Point, No, Ask, Inspect,
The visual direction is readable stylized realism: The visual direction is readable stylized realism:
- **Character direction:** 8 skinned NPC variants, 18 gear pieces and 20 street/venue props, with four crowd-variety variants prototyped. The generator-first expansion is the active character track ([ADR-0015](docs/adr/0015-npc-model-variety-sourcing.md)); Quaternius is a reference and MakeHuman is deferred to a local pipeline. - **Character direction:** 12 skinned NPC variants, 18 gear pieces and 20 street/venue props. The active character track is the Quaternius base at runtime ([ADR-0018](docs/adr/0018-quaternius-stage2-runtime-rig.md)): the CC0 source mesh keeps its proportions on the 62-joint rig, Idle/Walk run as clips, and the procedural library stays as the tested fallback until the face/silhouette gate passes. See [Character Creation Re-Evaluation](#character-creation-re-evaluation-2026-11-05).
- **Realism pass:** AI-assisted per-phase skyline panoramas, v2 surface tiles with normal maps, wet asphalt at night, a warm day haze, phase-specific sun/exposure/fog, a tight flashlight spot (no beam geometry), and denser foliage. - **Realism pass:** AI-assisted per-phase skyline panoramas, v2 surface tiles with normal maps, wet asphalt at night, a warm day haze, phase-specific sun/exposure/fog, a tight flashlight spot (no beam geometry), and denser foliage.
- **Local map direction:** the venues are currently stylized reconstructions. The local-map track evaluates licensed OpenStreetMap/3D building data, licensed reference photography, AI-assisted composition and Gaussian splatting as a development tool; online data and splat rendering are not runtime dependencies. - **Local map direction:** the venues are currently stylized reconstructions. The local-map track evaluates licensed OpenStreetMap/3D building data, licensed reference photography, AI-assisted composition and Gaussian splatting as a development tool; online data and splat rendering are not runtime dependencies.
- **References and QA:** runtime UI art lives in `assets/images/ui/`; reference/art-direction sets live in `docs/screens/designs/` and `docs/screens/concepts/`; captures live in `docs/screenshots/`; realism runs live in `docs/screens/realism/`. `screen_designs.py compare/judge` and `tools/check_screenshots.sh` guard drift. - **References and QA:** runtime UI art lives in `assets/images/ui/`; reference/art-direction sets live in `docs/screens/designs/` and `docs/screens/concepts/`; captures live in `docs/screenshots/`; realism runs live in `docs/screens/realism/`. `screen_designs.py compare/judge` and `tools/check_screenshots.sh` guard drift.
@@ -177,7 +248,7 @@ The visual direction is readable stylized realism:
The active planning order — unversioned character and local-map development first, then the versioned 0.10.0 release-candidate hardening and later releases — is tracked in [`TODO.md`](TODO.md); this plan stays at the product/design level. The active planning order — unversioned character and local-map development first, then the versioned 0.10.0 release-candidate hardening and later releases — is tracked in [`TODO.md`](TODO.md); this plan stays at the product/design level.
Primary decisions: [ADR-0007](docs/adr/0007-locomotion-and-input-layers.md), [ADR-0008](docs/adr/0008-cone-focus-and-head-ring.md), [ADR-0009](docs/adr/0009-radial-action-menu.md), [ADR-0010](docs/adr/0010-procedural-npc-presentation.md), [ADR-0011](docs/adr/0011-aim-free-selection-and-precision-walk.md), [ADR-0012](docs/adr/0012-surface-materials-and-generated-props.md), [ADR-0013](docs/adr/0013-blender-authored-npc-characters.md), [ADR-0015](docs/adr/0015-npc-model-variety-sourcing.md). Primary decisions: [ADR-0007](docs/adr/0007-locomotion-and-input-layers.md), [ADR-0008](docs/adr/0008-cone-focus-and-head-ring.md), [ADR-0009](docs/adr/0009-radial-action-menu.md), [ADR-0010](docs/adr/0010-procedural-npc-presentation.md), [ADR-0011](docs/adr/0011-aim-free-selection-and-precision-walk.md), [ADR-0012](docs/adr/0012-surface-materials-and-generated-props.md), [ADR-0013](docs/adr/0013-blender-authored-npc-characters.md), [ADR-0015](docs/adr/0015-npc-model-variety-sourcing.md), [ADR-0017](docs/adr/0017-quaternius-npc-base.md).
## Tech Stack ## Tech Stack
@@ -196,6 +267,106 @@ Primary decisions: [ADR-0007](docs/adr/0007-locomotion-and-input-layers.md), [AD
`config/version` in `project.godot` is the single version source (currently `0.9.8`). Releases are tagged `v<version>`; the CI pipeline builds and publishes the standard artifacts plus the optional Desktop HQ zip. Version bumps follow [Semantic Versioning](https://semver.org/): every major, minor and patch increment needs a changelog entry, a local `tools/build_releases.sh` build, and artifact/checksum verification before tagging. Unversioned development tracks do not receive a version until their scope enters a release. See [`docs/RELEASING.md`](docs/RELEASING.md) and [`README.md`](README.md) for commands. `config/version` in `project.godot` is the single version source (currently `0.9.8`). Releases are tagged `v<version>`; the CI pipeline builds and publishes the standard artifacts plus the optional Desktop HQ zip. Version bumps follow [Semantic Versioning](https://semver.org/): every major, minor and patch increment needs a changelog entry, a local `tools/build_releases.sh` build, and artifact/checksum verification before tagging. Unversioned development tracks do not receive a version until their scope enters a release. See [`docs/RELEASING.md`](docs/RELEASING.md) and [`README.md`](README.md) for commands.
## Gap Analysis
**Published baseline:** v0.9.8 (2026-11-04) — three playable venues, tutorial/campaign/checkpoint
resume, the access/radio/escalation loop, shift review, the 12-character Blender library, 277
Godot + 162 Python tests, a 21/21 screen-design judge and Compatibility/Web/Desktop HQ builds.
**Current source:** 0.9.8. The 0.10.0-rc.1 package built on 2026-11-03 is stale and is superseded
by the patch sequence below; it is not a published release.
| Area | Remaining gap | Milestone |
| --- | --- | --- |
| Character base | Quaternius arm-down skin export, rest-relative pose composition, face/silhouette acceptance | 0.9.9 |
| Character roster | 12-role mapping, modular outfits, gear fit, AO/texels, budget evidence | 0.9.10 |
| Venue and asset polish | Ranked prop/foliage/lighting/set-dressing fixes, collider verification | 0.9.11 |
| Performance and Web | Frame/memory/load evidence after the content changes, LOD/impostor verification | 0.9.12 |
| Onboarding and balance | External first-time-player evidence, balance sweeps/tuning, content lock | 0.10.0 |
| Accessibility and localization | Acceptance pass, caption coverage, pseudo-locale/length checks | 0.9.12 / 0.10.0 |
| QA and release engineering | Full smoke matrix, clean build/checksum verification, target-matrix launches | 0.10.0 |
| Legal, signing, storefront | macOS signing/notarization, AI-provider rights, storefront/rating approval, privacy sign-off | 0.10.0 / 0.12.0 |
| Systems and content depth | Live incident replay, fourth venue, localization foundation | 0.11.0 |
| Distribution | Signed storefront builds, frozen save contract, launch languages, hardware matrix | 0.12.0 |
| Stable release | Content complete, accessibility audit, release candidates, 1.0.0 decision | 0.13.0 / 1.0.0 |
| Future | More venues/seasonal content, image-to-mesh props, other deferred ideas | Unscheduled |
The 0.9.3 UI, 0.9.4 animation, 0.9.5 texture, 0.9.6 integration, 0.9.7 character development and
0.9.8 deformation fixes are complete in [`DONE.md`](DONE.md); they are not remaining gaps. The
2026-11-01 documentation audit is also closed.
## Release Roadmap
Every version below follows the release procedure in `AGENTS.md`. Patch versions contain fixes and
polish; minor versions add user-facing features; the major version is the stable release with a
compatibility promise. Each version's executable checklist is in [`TODO.md`](TODO.md).
### Roadmap to 0.10.0
The goal of this line is to finish the character and venue work and clear every public-release
gate. The character track is versioned rather than unversioned so a blocked rig cannot hold the
whole release.
| Version | Type | Scope | Exit criterion |
| --- | --- | --- | --- |
| 0.9.9 | patch | Quaternius pilot: correct skin export, rest-relative pose math, Idle/Walk verification, owner face/silhouette gate, one-role (or crowd-only) rollout | A Quaternius NPC renders and animates correctly and ships behind a tested procedural fallback |
| 0.9.10 | patch | 12-role roster, modular outfits, five slots, AO/texels, gear fit, 12×6 baseline | The full roster passes contract, gear, budget and owner-baseline gates |
| 0.9.11 | patch | Scheduled 3D review fixes: props/foliage, gear silhouette, venue shape language, lighting, set dressing, colliders | Every scheduled P1/P2 item is fixed or accepted with a measurement |
| 0.9.12 | patch | Performance/Web/LOD/memory verification plus accessibility and pseudo-locale checks | No regression against the pre-character baselines |
| 0.10.0 | minor | Public readiness: external playtests, balance acceptance, legal/storefront/signing decisions, platform matrix, docs and picture lock | 0.10.0 is content-locked, externally played, legally reviewed, documented and reproducibly built |
### Roadmap to 0.11.0
This line turns a stable 0.10.x into a deeper game: the incident timeline becomes a live replay, a
fourth venue enters the campaign, and the text catalogue becomes a real localization pipeline.
| Version | Type | Scope | Exit criterion |
| --- | --- | --- | --- |
| 0.10.1 | patch | Onboarding and accessibility issues deferred from the 0.10.0 playtests | First-hour and accessibility defects closed |
| 0.10.2 | patch | Balance/fairness sweeps, review-text and difficulty-progression tuning | Every balance-acceptance cell is in target or explicitly accepted |
| 0.10.3 | patch | Save/checkpoint edge cases, gamepad/remap, pause/resume, small quality-of-life fixes | No known P1 stability defect remains |
| 0.11.0 | minor | Live body-camera replay, fourth venue (candidate: TCL Chinese Theatre forecourt), localization foundation with one secondary language | The new systems ship without regressing performance, accessibility or balance |
### Roadmap to 1.0.0
This line is about distribution, contracts and completeness: signed builds on the launch stores, a
frozen save format, finished content and release candidates.
| Version | Type | Scope | Exit criterion |
| --- | --- | --- | --- |
| 0.11.1 | patch | Replay/venue/localization fixes from wider play | No P1 defect in the 0.11.0 feature set |
| 0.11.2 | patch | New-venue balance, campaign pacing and content readability | The 0.11.x line is stable enough to freeze its contracts |
| 0.12.0 | minor | macOS signing/notarization, storefront packages/metadata/ratings, save-format v2 freeze, hardware matrix, launch languages | Signed, storefront-ready builds exist on every launch platform |
| 0.12.1 | patch | Signing, installer, metadata and save-migration fixes from storefront dry runs | Every storefront build installs, launches and migrates cleanly |
| 0.13.0 | minor | Content complete: all planned venues/events/dialogue, audio decision, final tutorial, accessibility audit, balance freeze | All planned content is implemented and frozen |
| 0.13.1 | patch | Release candidate 1: full smoke/performance/save/accessibility matrix | A candidate passes every gate except final approval |
| 0.13.2 | patch | Release candidate 2: verify fixes, freeze release notes/credits/privacy/known limitations | The final candidate is reproducible and documented |
| 1.0.0 | major | Stable release: legal/storefront/rating approval, hardware/accessibility/localization sign-off, no P0/P1, publish on every platform | 1.0.0 is published with verified checksums and no open blocker |
## Roadmap Dependencies and Risks
- Content volume is the largest schedule risk; tutorial and campaign work must not outrun the
event/dialogue matrix.
- The vision judge is a dev-time aid with an external key and budget; CI must never make it a hard
gate.
- Godot engine changes can alter lighting and imports; do not combine an engine baseline move with
a content or feature milestone.
- Signed macOS builds, AI provenance and storefront approval are release blockers, not polish.
- The character pipeline is the current critical path. The rig/export fix must be proven on one
variant before the roster rollout; otherwise 0.9.10 multiplies a defect.
- Photo-derived map assets add a provenance risk: downloaded or user-supplied photos can carry
unclear licences, identifiable people, plates and property restrictions. No photo- or
splat-derived asset ships without a completed sidecar, consent/licence review and redaction.
- Gaussian-splat training is GPU/disk-heavy and the toolchain churns; keep it offline,
version-pinned and outside CI, and never make a runtime splat renderer a release dependency.
- The Blender skill pack is local, unversioned guidance; it can drift from the project contract or
assume Blender MCP. Re-verify every adopted technique with `inspect`/`audit`/`fit` and never make
a skill, asset pack or tool version a build or CI dependency.
- The 3D re-evaluation can expand without bound. Cap it with the scorecard ranking: P0/P1 only, one
asset family per commit, and an explicit accept for everything else.
- Keep the de-escalation-first rules and rare-emergency design unchanged while adding progression,
difficulty and feedback.
## Design Direction ## Design Direction
The game should feel like a tense workplace simulation with occasional crisis moments. The most satisfying outcome is not overpowering an NPC; it is recognizing the situation early and resolving it calmly. The game should feel like a tense workplace simulation with occasional crisis moments. The most satisfying outcome is not overpowering an NPC; it is recognizing the situation early and resolving it calmly.
@@ -206,4 +377,5 @@ The game should feel like a tense workplace simulation with occasional crisis mo
- A rare Bob Dobbs cameo (Easter-egg appearance; placement note in `MEMORY.md`). - A rare Bob Dobbs cameo (Easter-egg appearance; placement note in `MEMORY.md`).
- Specific unusual NPC behaviors, such as slapping their own face, using hands as scissors, or interacting with props unexpectedly. - Specific unusual NPC behaviors, such as slapping their own face, using hands as scissors, or interacting with props unexpectedly.
- Live 3D body-camera replay; additional venues or seasonal events; real voice/dialogue audio; an image-to-mesh prop pipeline. - A fifth venue or seasonal events after the 0.11.0 fourth venue proves the venue pipeline.
- Real voice/dialogue audio if the 0.13.0 audio decision does not fund it; an image-to-mesh prop pipeline remains unscheduled.
+19 -13
View File
@@ -8,11 +8,12 @@ The central design goal is simple:
## Development Priorities ## Development Priorities
The active planning order lives in [`TODO.md`](TODO.md) and [`AGENTS.md`](AGENTS.md): The active planning order lives in [`PLAN.md`](PLAN.md) (gap analysis and release roadmap), [`TODO.md`](TODO.md) (executable checklist) and [`AGENTS.md`](AGENTS.md):
1. **3D model and local map re-evaluation** — score every shipped asset, then evaluate licensed OpenStreetMap/3D data, reference-derived photoreal facades, AI-assisted composition and a Gaussian-splatting development evaluation. 1. **`0.9.9`–`0.9.12` patches** — finish the Quaternius NPC base on the accepted 62-joint runtime, roll it out to the 12 roles, close the scheduled venue/prop/lighting polish, then verify performance, Web and accessibility.
2. **Character development** — crowd, age, build, hair and outfit variety on the owned procedural library, keeping the frozen NPC contract. Source evaluation and the licence decision are in [`docs/npc-model-sourcing.md`](docs/npc-model-sourcing.md). 2. **`0.10.0` minor** — public readiness: external playtests, balance acceptance, legal/storefront/signing decisions, the platform matrix and the documentation/picture lock.
3. **Versioned release hardening** — the 0.10.0 release candidate and later semantic versions follow the unversioned development tracks. 3. **`0.10.x` → `0.11.0`** — live incident replay, a fourth venue and the localization foundation.
4. **`0.12.0` → `1.0.0`** — signed storefront builds, a frozen save contract, content complete, release candidates and the stable release.
Versions are assigned only when a track's scope is accepted into a release. Every major, minor and patch version requires a changelog entry, a local release build and artifact verification before tagging. Versions are assigned only when a track's scope is accepted into a release. Every major, minor and patch version requires a changelog entry, a local release build and artifact verification before tagging.
@@ -33,14 +34,16 @@ Versions are assigned only when a track's scope is accepted into a release. Ever
> Downloads are public (no sign-in required) since the host's instance-wide > Downloads are public (no sign-in required) since the host's instance-wide
> `REQUIRE_SIGNIN_VIEW` was disabled on 2026-10-31. > `REQUIRE_SIGNIN_VIEW` was disabled on 2026-10-31.
> >
> **Latest published: v0.9.8** — the character-development patch: the > **Latest published: v0.9.9** — the character-roster patch: all twelve NPCs now
> use the CC0 Quaternius base with the five tint slots, role gear and baked maps.
> Superseded: v0.9.8, the character-development patch: the
> 12-character library, realistic face pass, four crowd-variety variants and the > 12-character library, realistic face pass, four crowd-variety variants and the
> wrist/hip/knee deformation fixes. Previous 0.9.x patches added the > wrist/hip/knee deformation fixes. Previous 0.9.x patches added the
> Hollywood/Joke Shop maps, UI/accessibility polish, NPC visual optimization, > Hollywood/Joke Shop maps, UI/accessibility polish, NPC visual optimization,
> texture/material refinement and integration checkpoint. > texture/material refinement and integration checkpoint.
> >
> **Current source: v0.9.8** — tagged from the same commit. The 0.10.0 > **Current source: v0.9.8** — tagged from the same commit. The 0.10.0
> development track remains gated on the NPC overhaul, MakeHuman/local-map work > development track remains gated on the Quaternius NPC rollout, local-map work
> and release approvals. > and release approvals.
> Releases are built automatically when a `vX.Y.Z` tag is pushed > Releases are built automatically when a `vX.Y.Z` tag is pushed
> (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use > (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use
@@ -95,12 +98,14 @@ Every capture is rendered from the current build by [`tools/check_screenshots.sh
### 3D asset progress ### 3D asset progress
The character, gear and prop libraries are authored procedurally in Blender and audited before use; The character roster is built from the CC0 Quaternius bases (ADR-0017/ADR-0018) and audited
the sheets below track their visual progress. before use; the procedural library and the MakeHuman head stay as the tested fallback. The
sheets below show the
current library and will be replaced after the 12 × 6 baseline review.
| | | | | |
| --- | --- | | --- | --- |
| ![Eight NPC characters](docs/blender_previews/sheet_npc.jpg) | **Character library.** Eight skinned variants (4.5–5.5k triangles each) with five tint slots, attach-point gear and baked maps. | | ![Eight NPC characters](docs/blender_previews/sheet_npc.jpg) | **Character library.** Twelve role-dressed variants (4.5–6.4k triangles each) on three CC0 Quaternius bases, with five tint slots, attach-point gear and baked maps. |
| ![Prop library](docs/blender_previews/sheet_props.jpg) | **Street and venue props.** Twenty library props with colliders, navigation avoidance and colour atlases. | | ![Prop library](docs/blender_previews/sheet_props.jpg) | **Street and venue props.** Twenty library props with colliders, navigation avoidance and colour atlases. |
| ![Neutral NPC baseline](docs/blender_previews/sheet_npc_baseline.jpg) | **Neutral baseline.** 8 characters × 6 views (front, three-quarter, profile, back, full body, crowd) used for the 0.9.4 pass and the current re-evaluation. | | ![Neutral NPC baseline](docs/blender_previews/sheet_npc_baseline.jpg) | **Neutral baseline.** 8 characters × 6 views (front, three-quarter, profile, back, full body, crowd) used for the 0.9.4 pass and the current re-evaluation. |
| ![Texture refinement comparison](docs/blender_previews/sheet_texture_refinement_0.9.5.jpg) | **Texture refinement.** 128 → 256 px NPC maps and a 4× fabric weave, shown before/after on the same camera. | | ![Texture refinement comparison](docs/blender_previews/sheet_texture_refinement_0.9.5.jpg) | **Texture refinement.** 128 → 256 px NPC maps and a 4× fabric weave, shown before/after on the same camera. |
@@ -131,7 +136,7 @@ decisions are recorded as ADRs in [`docs/adr/`](docs/adr/).
## Tech Stack ## Tech Stack
- **Engine:** Godot 4.7.2 · **Language:** GDScript - **Engine:** Godot 4.7.2 · **Language:** GDScript
- **NPCs:** Blender-authored skinned library (`assets/models/blender/`) with procedural fallbacks; lightweight FSMs on `NpcProfile` - **NPCs:** CC0 Quaternius skinned library (`assets/models/blender/`, ADR-0018) with a tested procedural fallback; lightweight FSMs on `NpcProfile`
- **World:** procedural `WorldBuilder` + generated prop models - **World:** procedural `WorldBuilder` + generated prop models
- **Art:** v2 skyline/surface textures in `assets/textures/ai/game_ready/v2/picks/`; UI/scene art in `assets/images/` - **Art:** v2 skyline/surface textures in `assets/textures/ai/game_ready/v2/picks/`; UI/scene art in `assets/images/`
- **Asset tooling:** `asset_pipeline.py`, `model_pipeline.py`, `npc_pipeline.py`, `blender_assets.py`, `scene_concepts.py`, `screen_designs.py`, `check_screenshots.sh` - **Asset tooling:** `asset_pipeline.py`, `model_pipeline.py`, `npc_pipeline.py`, `blender_assets.py`, `scene_concepts.py`, `screen_designs.py`, `check_screenshots.sh`
@@ -160,6 +165,7 @@ Full setup, every run/test/smoke command, and the controls table are in
- [`TODO.md`](TODO.md) — Priority-ordered tasks: character and local-map development, release milestones, and future work. - [`TODO.md`](TODO.md) — Priority-ordered tasks: character and local-map development, release milestones, and future work.
- [`docs/README.md`](docs/README.md) — Documentation index, current priorities and visual previews. - [`docs/README.md`](docs/README.md) — Documentation index, current priorities and visual previews.
- [`docs/npc-model-sourcing.md`](docs/npc-model-sourcing.md) — Character-source evaluation, licence rules and the character-development plan. - [`docs/npc-model-sourcing.md`](docs/npc-model-sourcing.md) — Character-source evaluation, licence rules and the character-development plan.
- [`docs/makehuman-head-review.md`](docs/makehuman-head-review.md) — Local MakeHuman head prototype, provenance and review verdict.
- [`AGENTS.md`](AGENTS.md) — Development, commit, build and collaboration workflow. - [`AGENTS.md`](AGENTS.md) — Development, commit, build and collaboration workflow.
- [`DONE.md`](DONE.md) — Completed task sections. - [`DONE.md`](DONE.md) — Completed task sections.
- [`MEMORY.md`](MEMORY.md) — Persistent session notes. - [`MEMORY.md`](MEMORY.md) — Persistent session notes.
@@ -189,9 +195,9 @@ v0.9.8 is the current source version: the character-development patch with the
and save-migration/release hardening. It sits on the 0.9.6 integration and save-migration/release hardening. It sits on the 0.9.6 integration
checkpoint, 0.9.5 texture refinement, 0.9.4 NPC visual optimization, 0.9.3 checkpoint, 0.9.5 texture refinement, 0.9.4 NPC visual optimization, 0.9.3
UI/accessibility polish, 0.9.2 Hollywood/Joke Shop maps and the 0.9.x engine UI/accessibility polish, 0.9.2 Hollywood/Joke Shop maps and the 0.9.x engine
baseline. The active priorities are the Blender NPC head/face/hand overhaul, baseline. The active work is the `0.9.9`–`0.9.12` patch line (Quaternius NPC
MakeHuman body intake and the 3D model/map re-evaluation; the 0.10.0 release base, roster, venue/asset polish, performance) followed by the `0.10.0`
candidate follows those tracks. See public-readiness minor. See
[`docs/development-status.md`](docs/development-status.md) for the implemented list [`docs/development-status.md`](docs/development-status.md) for the implemented list
and [`TODO.md`](TODO.md) for the priority-ordered roadmap. Manual verification uses and [`TODO.md`](TODO.md) for the priority-ordered roadmap. Manual verification uses
[`docs/release-qa.md`](docs/release-qa.md). [`docs/release-qa.md`](docs/release-qa.md).
+501 -479
View File
File diff suppressed because it is too large. Load diff
+180 -174
View File
@@ -28,12 +28,12 @@
"attach": "back", "attach": "back",
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
0.161, 0.15,
0.296, 0.296,
0.212 0.172
], ],
"min": [ "min": [
-0.161, -0.15,
-0.12, -0.12,
-0.319 -0.319
] ]
@@ -46,7 +46,7 @@
], ],
"path": "assets/models/blender/npc_gear/backpack.glb", "path": "assets/models/blender/npc_gear/backpack.glb",
"triangles": 380, "triangles": 380,
"vertices": 408 "vertices": 410
}, },
"bench": { "bench": {
"bounds_godot": { "bounds_godot": {
@@ -98,14 +98,14 @@
"attach": "hand", "attach": "hand",
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
0.106, 0.065,
0.376, 0.06,
0.143 0.143
], ],
"min": [ "min": [
-0.106, -0.065,
-0.053, -0.053,
-0.055 0.005
] ]
}, },
"category": "npc_gear", "category": "npc_gear",
@@ -114,21 +114,21 @@
"lens" "lens"
], ],
"path": "assets/models/blender/npc_gear/camera.glb", "path": "assets/models/blender/npc_gear/camera.glb",
"triangles": 318, "triangles": 312,
"vertices": 406 "vertices": 422
}, },
"cap": { "cap": {
"attach": "head", "attach": "head",
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
0.168, 0.108,
0.209, 0.204,
0.258 0.165
], ],
"min": [ "min": [
-0.168, -0.108,
0.037, 0.094,
-0.186 -0.119
] ]
}, },
"category": "npc_gear", "category": "npc_gear",
@@ -264,14 +264,14 @@
"attach": "head", "attach": "head",
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
0.26, 0.166,
0.245, 0.227,
0.26 0.166
], ],
"min": [ "min": [
-0.26, -0.166,
0.035, 0.092,
-0.351 -0.225
] ]
}, },
"category": "npc_gear", "category": "npc_gear",
@@ -333,14 +333,14 @@
"attach": "head", "attach": "head",
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
0.184, 0.118,
0.236, 0.221,
0.123 0.079
], ],
"min": [ "min": [
-0.184, -0.118,
-0.146, -0.024,
-0.225 -0.144
] ]
}, },
"category": "npc_gear", "category": "npc_gear",
@@ -428,16 +428,17 @@
"vertices": 648 "vertices": 648
}, },
"npc_casual": { "npc_casual": {
"base": "hoodie",
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
0.321, 0.228,
1.824, 1.866,
0.189 0.287
], ],
"min": [ "min": [
-0.321, -0.228,
0, -0.004,
-0.178 -0.091
] ]
}, },
"category": "npc", "category": "npc",
@@ -445,11 +446,11 @@
"materials": [ "materials": [
"skin", "skin",
"cloth", "cloth",
"pants",
"accessory", "accessory",
"eye_white", "Eyebrows",
"Eye",
"hair", "hair",
"rubber" "pants"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_casual.glb", "path": "assets/models/blender/npc/npc_casual.glb",
@@ -466,20 +467,21 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 5034, "triangles": 6206,
"vertices": 4607 "vertices": 12988
}, },
"npc_coat": { "npc_coat": {
"base": "hoodie",
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
0.332, 0.228,
1.824, 1.866,
0.189 0.287
], ],
"min": [ "min": [
-0.332, -0.228,
0, -0.004,
-0.18 -0.091
] ]
}, },
"category": "npc", "category": "npc",
@@ -487,11 +489,11 @@
"materials": [ "materials": [
"skin", "skin",
"cloth", "cloth",
"pants",
"accessory", "accessory",
"eye_white", "Eyebrows",
"Eye",
"hair", "hair",
"rubber" "pants"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_coat.glb", "path": "assets/models/blender/npc/npc_coat.glb",
@@ -507,20 +509,21 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 6046, "triangles": 6206,
"vertices": 5645 "vertices": 12988
}, },
"npc_crowd_a": { "npc_crowd_a": {
"base": "casual",
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
0.288, 0.18,
1.825, 1.843,
0.189 0.271
], ],
"min": [ "min": [
-0.288, -0.18,
0, -0.009,
-0.195 -0.099
] ]
}, },
"category": "npc", "category": "npc",
@@ -528,11 +531,11 @@
"materials": [ "materials": [
"skin", "skin",
"cloth", "cloth",
"pants",
"accessory", "accessory",
"eye_white", "Hair_Brown",
"Brown",
"hair", "hair",
"rubber" "pants"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_crowd_a.glb", "path": "assets/models/blender/npc/npc_crowd_a.glb",
@@ -547,20 +550,21 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 5284, "triangles": 6424,
"vertices": 4908 "vertices": 13440
}, },
"npc_crowd_b": { "npc_crowd_b": {
"base": "casual",
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
0.358, 0.18,
1.809, 1.843,
0.189 0.271
], ],
"min": [ "min": [
-0.358, -0.18,
0, -0.009,
-0.155 -0.099
] ]
}, },
"category": "npc", "category": "npc",
@@ -568,11 +572,11 @@
"materials": [ "materials": [
"skin", "skin",
"cloth", "cloth",
"pants",
"accessory", "accessory",
"eye_white", "Hair_Brown",
"Brown",
"hair", "hair",
"rubber" "pants"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_crowd_b.glb", "path": "assets/models/blender/npc/npc_crowd_b.glb",
@@ -587,20 +591,21 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 4812, "triangles": 6424,
"vertices": 4550 "vertices": 13440
}, },
"npc_crowd_c": { "npc_crowd_c": {
"base": "hoodie",
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
0.332, 0.228,
1.824, 1.866,
0.189 0.287
], ],
"min": [ "min": [
-0.332, -0.228,
0, -0.004,
-0.18 -0.091
] ]
}, },
"category": "npc", "category": "npc",
@@ -608,11 +613,11 @@
"materials": [ "materials": [
"skin", "skin",
"cloth", "cloth",
"pants",
"accessory", "accessory",
"eye_white", "Eyebrows",
"Eye",
"hair", "hair",
"rubber" "pants"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_crowd_c.glb", "path": "assets/models/blender/npc/npc_crowd_c.glb",
@@ -627,20 +632,21 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 5690, "triangles": 6206,
"vertices": 5510 "vertices": 12988
}, },
"npc_crowd_d": { "npc_crowd_d": {
"base": "worker",
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
0.293, 0.217,
1.856, 1.826,
0.189 0.287
], ],
"min": [ "min": [
-0.293, -0.217,
0, -0.004,
-0.225 -0.065
] ]
}, },
"category": "npc", "category": "npc",
@@ -648,11 +654,11 @@
"materials": [ "materials": [
"skin", "skin",
"cloth", "cloth",
"pants",
"accessory", "accessory",
"eye_white",
"hair", "hair",
"rubber" "Moustache",
"Eye",
"pants"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_crowd_d.glb", "path": "assets/models/blender/npc/npc_crowd_d.glb",
@@ -667,20 +673,21 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 5810, "triangles": 4458,
"vertices": 5444 "vertices": 9370
}, },
"npc_dress": { "npc_dress": {
"base": "casual",
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
0.288, 0.18,
1.825, 1.843,
0.208 0.271
], ],
"min": [ "min": [
-0.288, -0.18,
0, -0.009,
-0.208 -0.099
] ]
}, },
"category": "npc", "category": "npc",
@@ -688,11 +695,11 @@
"materials": [ "materials": [
"skin", "skin",
"cloth", "cloth",
"pants",
"accessory", "accessory",
"eye_white", "Hair_Brown",
"Brown",
"hair", "hair",
"rubber" "pants"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_dress.glb", "path": "assets/models/blender/npc/npc_dress.glb",
@@ -706,20 +713,21 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 5540, "triangles": 6424,
"vertices": 5073 "vertices": 13440
}, },
"npc_firefighter": { "npc_firefighter": {
"base": "worker",
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
0.37, 0.217,
1.819, 1.826,
0.213 0.287
], ],
"min": [ "min": [
-0.37, -0.217,
0, -0.004,
-0.213 -0.065
] ]
}, },
"category": "npc", "category": "npc",
@@ -727,13 +735,11 @@
"materials": [ "materials": [
"skin", "skin",
"cloth", "cloth",
"pants",
"accessory", "accessory",
"eye_white",
"hair", "hair",
"hivis", "Moustache",
"reflective", "Eye",
"rubber" "pants"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_firefighter.glb", "path": "assets/models/blender/npc/npc_firefighter.glb",
@@ -747,20 +753,21 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 5888, "triangles": 4458,
"vertices": 5785 "vertices": 9370
}, },
"npc_guard": { "npc_guard": {
"base": "worker",
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
0.361, 0.217,
1.819, 1.826,
0.189 0.287
], ],
"min": [ "min": [
-0.361, -0.217,
0, -0.004,
-0.172 -0.065
] ]
}, },
"category": "npc", "category": "npc",
@@ -768,15 +775,11 @@
"materials": [ "materials": [
"skin", "skin",
"cloth", "cloth",
"pants",
"accessory", "accessory",
"eye_white",
"hair", "hair",
"rubber", "Moustache",
"leather", "Eye",
"steel", "pants"
"patch",
"plastic_black"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_guard.glb", "path": "assets/models/blender/npc/npc_guard.glb",
@@ -791,20 +794,21 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 5670, "triangles": 4458,
"vertices": 5671 "vertices": 9370
}, },
"npc_hoodie": { "npc_hoodie": {
"base": "hoodie",
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
0.326, 0.228,
1.856, 1.866,
0.189 0.287
], ],
"min": [ "min": [
-0.326, -0.228,
0, -0.004,
-0.225 -0.091
] ]
}, },
"category": "npc", "category": "npc",
@@ -812,11 +816,11 @@
"materials": [ "materials": [
"skin", "skin",
"cloth", "cloth",
"pants",
"accessory", "accessory",
"eye_white", "Eyebrows",
"Eye",
"hair", "hair",
"rubber" "pants"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_hoodie.glb", "path": "assets/models/blender/npc/npc_hoodie.glb",
@@ -832,20 +836,21 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 5864, "triangles": 6206,
"vertices": 5325 "vertices": 12988
}, },
"npc_jacket": { "npc_jacket": {
"base": "casual",
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
0.332, 0.18,
1.824, 1.843,
0.189 0.271
], ],
"min": [ "min": [
-0.332, -0.18,
0, -0.009,
-0.178 -0.099
] ]
}, },
"category": "npc", "category": "npc",
@@ -853,11 +858,11 @@
"materials": [ "materials": [
"skin", "skin",
"cloth", "cloth",
"pants",
"accessory", "accessory",
"eye_white", "Hair_Brown",
"Brown",
"hair", "hair",
"rubber" "pants"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_jacket.glb", "path": "assets/models/blender/npc/npc_jacket.glb",
@@ -873,34 +878,35 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 5432, "triangles": 6424,
"vertices": 5225 "vertices": 13440
}, },
"npc_vest": { "npc_vest": {
"base": "worker",
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
0.321, 0.217,
1.819, 1.863,
0.189 0.287
], ],
"min": [ "min": [
-0.321, -0.217,
0, -0.004,
-0.172 -0.103
] ]
}, },
"category": "npc", "category": "npc",
"layer": "vest", "layer": "vest",
"materials": [ "materials": [
"skin", "skin",
"Worker_Yellow",
"Worker_Vest",
"cloth", "cloth",
"pants",
"accessory", "accessory",
"eye_white",
"hair", "hair",
"rubber", "Moustache",
"hivis", "Eye",
"reflective" "pants"
], ],
"mesh_node": "npc_base", "mesh_node": "npc_base",
"path": "assets/models/blender/npc/npc_vest.glb", "path": "assets/models/blender/npc/npc_vest.glb",
@@ -914,8 +920,8 @@
"accessory", "accessory",
"hair" "hair"
], ],
"triangles": 5532, "triangles": 5240,
"vertices": 5195 "vertices": 10936
}, },
"palm_tree": { "palm_tree": {
"bounds_godot": { "bounds_godot": {
@@ -1239,14 +1245,14 @@
"attach": "head", "attach": "head",
"bounds_godot": { "bounds_godot": {
"max": [ "max": [
0.101, 0.065,
0.059, 0.108,
0.158 0.101
], ],
"min": [ "min": [
-0.101, -0.065,
0.011, 0.077,
0.003 0.002
] ]
}, },
"category": "npc_gear", "category": "npc_gear",
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.
Binary file not shown.
@@ -0,0 +1,33 @@
# Quaternius build sources (CC0 1.0)
These are the **build inputs** for the shipped NPC roster, not runtime assets.
`blender_src/.gdignore` keeps Godot from importing this directory.
Source: Quaternius *Ultimate Modular Characters* and *Ultimate Modular Women*
(<https://quaternius.com/packs/ultimatemodularcharacters.html>), licence CC0 1.0,
attribution recorded in the repository `CREDITS.md`.
| File | SHA-256 | Bones | Meshes |
| --- | --- | --- | --- |
| `Casual.glb` | `bc2d84fba249ebaa0629cb9d133ad37234196e4b8004aa205a6397abed04e05c` | 62 | 4 skinned + Icosphere helper |
| `Casual_Hoodie.glb` | `f5b722199f7628b24550989af4230f65d5230727df9c326fd2b3e360effe5249` | 62 | 4 skinned + Icosphere helper |
| `Worker.glb` | `8d0f454336c2effc5c08f63a177af3a82d1a7af025ca9588b40e4c90c4703534` | 62 | 4 skinned + Icosphere helper |
Each file also carries 24 animation clips; the build exports **no** animation data, so
the shipped GLBs contain the mesh, the 62-joint skin and the material slots only.
Reproducing the shipped characters:
flatpak run org.blender.Blender --background \
--python "$PWD/tools/blender_assets.py" -- build --only npc
Upstream quirks the build compensates for (see `docs/npc-model-sourcing.md`):
1. every mesh ships a **degenerate UV map** (all loops at `0,0`) with flat-colour
materials, so the build Smart-UV-unwraps and repacks before export;
2. the meshes must be joined **without welding** — `remove_doubles` corrupts the
skin weights;
3. the rest pose is a T-pose, so the arm-down rest is baked by
`_bake_quaternius_rest()`;
4. the upstream material names differ per file, so each source carries a slot map
down to the project's five tint slots.
Binary file not shown.
+22 -7
View File
@@ -85,10 +85,12 @@ Readings:
## Baseline inventory (2026-11-04) ## Baseline inventory (2026-11-04)
Recorded for the Blender NPC model overhaul track: the exact starting point before the local Recorded for the character baseline before the Quaternius migration. `npc_base.blend` is the
part pipeline is built. `npc_base.blend` is the frozen fallback; the 12 characters are assembled frozen fallback; the 12 characters are currently assembled procedurally by
procedurally by `tools/blender_assets.py`; the MakeHuman prototypes live under `tmp/npc-research/` `tools/blender_assets.py`. The active plan is now the CC0 Quaternius base bake
and are not the shipped path. ([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) ### `assets/models/npc/npc_base.blend` (frozen fallback)
@@ -143,7 +145,7 @@ width scale.
287–440 px/m via the 4× repeat; runtime tint carries the look. This is a recorded note until a 287–440 px/m via the 4× repeat; runtime tint carries the look. This is a recorded note until a
portrait shows texture starvation. portrait shows texture starvation.
### MakeHuman prototypes (not shipped; `tmp/npc-research/makehuman/`) ### MakeHuman prototypes (historical, not shipped; research files removed from `tmp/`)
| File | Verts / tris | Bones | Slots | Note | | File | Verts / tris | Bones | Slots | Note |
| --- | ---: | ---: | --- | --- | | --- | ---: | ---: | --- | --- |
@@ -193,7 +195,7 @@ capture). Pass conditions:
### Contract (both, every prototype) ### Contract (both, every prototype)
- 16,000 triangles / 16,000 glTF vertices per character; gear stays at 1,500. - 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 - 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. move; the `npc_base` mesh node name and the five tint slots stay unchanged.
@@ -242,7 +244,7 @@ nose/lip/chin relief cleanly.
The judge then compared the displacement head against the **decimated MakeHuman head** 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 (extracted from `makehuman_retargeted.glb`, 2,009 verts / 1,573 tris, saved as
`tmp/npc-parts/makehuman_head.glb`) and returned a decisive result: **ship the MakeHuman head** — 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 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". "clean ellipse outline instantly screams procedural sphere".
@@ -257,6 +259,16 @@ at the MakeHuman landmarks. `build_character(name, head="mh")` assembles it; the
`inspect` (14 bones, skinned, no issues). The judge reads the face ~3.5/5; remaining polish is `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. 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) ### 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 Three `head_proto` variants at UV-sphere segments 24/48/64 were rendered at 1.85 m and 1.5 m in
@@ -485,6 +497,9 @@ and the full carded-cluster foliage pass is the one remaining owner-gated P1. No
## Next actions ## 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 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, 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. collider-coverage and scorecard-coverage gates this document found missing.
+3 -2
View File
@@ -4,8 +4,8 @@ This folder holds the project documentation, visual references and generated QA
## Current priorities ## Current priorities
1. [Blender NPC model overhaul](../TODO.md#blender-npc-model-overhaul-top-priority) — part-based head/face/hands pipeline and 16,000-budget contract. 1. [Quaternius NPC base migration](../TODO.md#quaternius-npc-base-migration-top-priority) — CC0 Quaternius mesh/animation source baked to the 24,000-budget runtime contract.
2. [MakeHuman and 3D model/map improvements](../TODO.md#makehuman-character-pipeline) — body intake, asset re-evaluation and local map reconstruction. 2. [3D model and local map improvements](../TODO.md#3d-model-and-local-map-re-evaluation) — asset re-evaluation and local map reconstruction.
3. [0.10.0 release candidate](../TODO.md#milestone-0100--3d-model-and-map-improvements-minor) — versioned release hardening after the unversioned development tracks. 3. [0.10.0 release candidate](../TODO.md#milestone-0100--3d-model-and-map-improvements-minor) — versioned release hardening after the unversioned development tracks.
## Start here ## Start here
@@ -13,6 +13,7 @@ This folder holds the project documentation, visual references and generated QA
- [`../PLAN.md`](../PLAN.md) — product, design intent and development priorities - [`../PLAN.md`](../PLAN.md) — product, design intent and development priorities
- [`../TODO.md`](../TODO.md) — priority-ordered active tasks and roadmap - [`../TODO.md`](../TODO.md) — priority-ordered active tasks and roadmap
- [`npc-model-sourcing.md`](npc-model-sourcing.md) — character-source evaluation, licence rules and the character-development plan - [`npc-model-sourcing.md`](npc-model-sourcing.md) — character-source evaluation, licence rules and the character-development plan
- [`makehuman-head-review.md`](makehuman-head-review.md) — retained MakeHuman fallback head, provenance and review verdict
- [`architecture.md`](architecture.md) — arc42 system architecture - [`architecture.md`](architecture.md) — arc42 system architecture
- [`getting-started.md`](getting-started.md) — setup, run/test commands and controls - [`getting-started.md`](getting-started.md) — setup, run/test commands and controls
- [`asset-tooling.md`](asset-tooling.md) — image/model pipelines and screenshot gates - [`asset-tooling.md`](asset-tooling.md) — image/model pipelines and screenshot gates
+7 -1
View File
@@ -1,8 +1,14 @@
# ADR-0015: NPC model variety sourcing # ADR-0015: NPC model variety sourcing
- **Status:** Accepted (planning) - **Status:** Superseded by [ADR-0017](0017-quaternius-npc-base.md) (2026-11-05)
- **Date:** 2026-11-03 - **Date:** 2026-11-03
## Superseded
ADR-0017 replaces the generator-first decision below: Quaternius is now the
primary NPC base. The evaluation, licence rules and consequences remain as the
record of the 2026-11-03 decision.
## Context ## Context
The library has eight skinned characters, and role/seed selection reuses them The library has eight skinned characters, and role/seed selection reuses them
+42
View File
@@ -0,0 +1,42 @@
# ADR-0016: 24,000-triangle character budget
- **Status:** Accepted
- **Date:** 2026-11-05
## Context
ADR-0015 raised the character contract to 16,000 triangles / 16,000 glTF
vertices for the MakeHuman head, hands and crowd variety. The local MPFB
full-body prototype now uses that head, project hands and shoes, and a fused
neck: it measures 19,747 triangles before any clothing variation. The 16,000 cap
leaves no room for the firefighter and hoodie gear, and it forces the body and
neck into a low decimation that produces visible facets.
## Decision
Raise the character contract to **24,000 triangles / 24,000 glTF vertices** per
character (`CHAR_MAX_TRIS`/`CHAR_MAX_VERTS`, `npc_pipeline.MAX_TRIANGLES`/
`MAX_VERTICES`, and the `adapt` defaults). The 13 core bones, five tint slots,
front orientation, grounding and single `npc_base` skinned mesh are unchanged.
Gear stays at 1,500 triangles and props at 4,000.
## Consequences
- The MPFB body, accepted head and neck fit with headroom for the 12 clothing
variants and future jacket/hoodie detail.
- The 24-NPC and Web frame-time budgets must be re-measured before shipping any
variant built at the new cap; the extra geometry is not free.
- The procedural generator and the low-poly `npc_base` fallback remain valid;
they are not required to use the new headroom.
- MPFB stays a local development tool. Production must bake a committed source
GLB; CI and the build must not depend on MPFB.
- Historical reviews and measurements that state 16,000 remain accurate for the
date they were recorded; current contract documents state 24,000.
## Alternatives
- **Keep 16,000 and decimate the MPFB body further:** rejected because the neck
and shoulder already show facets and the clothing variants would exceed the
cap.
- **Separate caps for the MPFB prototype and the shipped characters:** rejected
because it creates a second contract and makes the rollout ambiguous.
+69
View File
@@ -0,0 +1,69 @@
# ADR-0017: Quaternius NPC base
- **Status:** Accepted
- **Date:** 2026-11-05
- **Supersedes:** ADR-0015 (generator-first character sourcing)
## Context
The current NPC library is built by the project-owned procedural generator. It
keeps the contract and ships, but the body, neck, hands and shoes need a
bespoke part pipeline to read as human. The MakeHuman head and MPFB body
prototypes improved detail but added a second authoring path, a 19,747-triangle
prototype and unresolved neck/shoulder seams.
The CC0 **Quaternius Ultimate Modular Characters** pack already provides a
coherent low-poly human silhouette, 21 modular outfits and 24 animation clips.
The researched GLBs (`Casual`, `Casual_Hoodie`, `Worker`) measure 4 meshes, 62
joints and 24 clips each. The owner directed that Quaternius become the NPC
base. The full evaluation is in [`docs/npc-model-sourcing.md`](../npc-model-sourcing.md).
## Decision
1. **Quaternius is the primary NPC mesh and animation source.** Keep the
licence and SHA-256 provenance for the three researched GLBs.
2. **Stage 1 keeps the runtime contract.** Bake a canonical Quaternius base onto
the existing 13-core contract, five tint slots, 24,000/24,000 budget,
grounded −Y front and one `npc_base` skinned mesh. Retarget Idle and Walk;
discard the combat/gun/sword clips. `NpcAvatar`, gear attach points and the
pose composition remain the runtime interface.
3. **The 62-joint rig is an authoring source, not a runtime dependency.** It is
not shipped and Quaternius is not loaded by CI, build or runtime.
4. **Stage 2 needs its own ADR.** If the proportion warp fails the silhouette,
deformation or gear gate, stop and decide whether to adopt the 62-joint rig
at runtime. Do not ship a hybrid rig or a second gear offset system.
5. **Close the MakeHuman/MPFB body work.** The accepted MakeHuman head and the
procedural `npc_base` stay as tested fallbacks until the Quaternius rollout
ships. No new MPFB development.
6. **Commit baked assets only.** The release consumes committed GLBs, maps and
the manifest; Quaternius and Blender remain development tools.
## Consequences
### Positive
- A human base, modular outfits and a coherent animation set are already
authored and CC0.
- The bespoke neck, hand and shoe pipelines are no longer on the critical path.
- The runtime contract and gear stay stable during the mesh migration.
### Negative
- A 62-joint T-pose does not share the arm-down 13-core rest pose or
proportions; the proportion warp is the main risk.
- Quaternius' animation quality is only available after retargeting, unless
Stage 2 is accepted.
- The migration touches all 12 variants, their AO maps, manifest entries and
baseline captures at once.
## Alternatives
- **Keep the procedural generator as the primary path.** Rejected by the owner:
the human silhouette remains the weak point and the bespoke part pipeline is
larger than the Quaternius bake.
- **Adopt the 62-joint rig immediately.** Deferred to Stage 2 because it changes
`NpcAvatar`, pose composition and gear offsets at the same time as the mesh.
- **Continue the MPFB body.** Rejected: it needs a separate head/neck/hand
pipeline and still has unresolved surface seams.
- **Use a service character generator (Mixamo, Ready Player Me).** Rejected:
redistribution and runtime terms are not a CC0 basis.
@@ -0,0 +1,96 @@
# ADR-0018: Quaternius Stage 2 — adopt source proportions at runtime
- **Status:** Accepted (2026-11-05, owner chose option 2)
- **Date:** 2026-11-05
- **Supersedes:** ADR-0017 decision 2 (Stage 1 keeps the 13-core contract)
## Context
ADR-0017 made Quaternius the NPC base and staged the migration: Stage 1 bakes a
proportion-warped mesh onto the existing 13-core contract; Stage 2 adopts the
62-joint rig at runtime only if the warp fails.
The Stage 1 proportion warp was executed and measured (`docs/npc-model-sourcing.md`
§Rest-pose and proportion-warp measurement). The arm-down rest pose bakes cleanly, but the
warp to the contract proportions does not:
| Joint | Quaternius | Contract | Delta |
| --- | --- | --- | --- |
| Hips height | 0.869 | 0.62 | −0.249 |
| Shoulder x | 0.148 | 0.27 | +0.122 |
| 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 distorted: paddle/claw hands, forearms lost into the
torso, stretched shins, "long legs + short torso". The arm either stays short (a shortened arm
against the contract hand) or stretches 1.63× (a distorted deltoid + hand). Both are the gate's
rejection conditions.
## Decision
Stop the Stage 1 rollout and **adopt the Quaternius 62-joint rig at runtime** (owner chose
option 2). The mesh is not proportion-warped: it keeps its source LOD (3,199 welded verts /
6,206 tris) and only the arm-down rest pose is baked.
- `NpcAvatar` maps the Quaternius joint names instead of the 13-core names.
- Gear attach points are re-derived from the Quaternius skeleton (hip ≈0.87, shoulder ≈0.148/1.39,
wrist ≈0.99, knee ≈0.52).
- The Quaternius Idle and Walk clips are used directly — they are authored for the 62-joint rig,
so no retargeting pass is needed. Combat/gun/sword clips stay unused.
Option 1 (keep the 13-core names with Quaternius rest positions) is rejected in favour of the
full source rig.
## Consequences
### Positive
- The Quaternius silhouette, deltoid, hand and leg proportions are preserved — no paddle hands,
no shortened arm, no torso/leg distortion.
- The mesh stays at source LOD (far under the 24,000 cap), so no decimation is needed.
### Negative
- The runtime contract numbers (hip 0.62, shoulder 0.27, hand 0.78) change; every gear attach
point, `npc_proportions` guide, fit measurement and pose-composition constant must be
re-derived and re-tested.
- `NpcAvatar`'s bone mapping and pose composition are rewritten for the 62-joint rig, a larger
change than the mesh swap.
- The 12-variant rollout and the 12×6 baseline are still blocked until the new rest positions
and gear fit are owner-approved.
## Alternatives
- **Ship the warped mesh.** Rejected by the gate: the 1.63× arm / 0.74× leg warp visibly
distorts and the owner directed keeping the Quaternius as close to source as possible.
- **Keep the old contract and accept a short arm.** Rejected for the same reason: the hand ends
at ≈0.99 instead of 0.78, so handheld gear floats unless attach points move — which is option 1.
- **Abandon Quaternius and keep the procedural generator.** Rejected in ADR-0017.
## Open question
Resolved: option 2 (62-joint runtime) is the owner's choice. The first implementation step is
the `NpcAvatar` bone-map + gear-attach re-derivation, then the slot/AO bake and the 12-variant
rollout.
## Follow-up decision: full roster swap
- **Date:** 2026-11-05 (owner, at the `0.9.9` face/silhouette gate)
The owner chose **option (a) full swap** from
[`docs/screens/npc-face-gate/`](../../screens/npc-face-gate/README.md): every roster character
moves onto the Quaternius base, and the procedural generator plus the MakeHuman head retire to
the tested fallback rather than staying in the shipped roster.
Scope consequences:
- The shipped roster keeps its twelve public character names and `CHARACTER_POOLS` mapping; the
geometry behind them comes from the three CC0 full-body sources already researched
(`Casual`, `Casual_Hoodie`, `Worker`), aliased per role.
- Variety now comes from the five tint slots, the gear library and the per-role outfit rules
instead of twelve distinct meshes. Roster silhouettes are therefore *less* varied than the
procedural library until the modular part library (hair, outfits) is added — tracked as a
`0.9.10` task, not hidden.
- The procedural library, `_build_body()` and the MakeHuman head stay in the tree and tested as
the instant fallback; no MPFB or Stage-1 proportion-warp work reopens.
+3
View File
@@ -19,3 +19,6 @@ Each ADR records the context, decision, consequences and alternatives for a dura
- [0013-blender-authored-npc-characters](0013-blender-authored-npc-characters.md) - [0013-blender-authored-npc-characters](0013-blender-authored-npc-characters.md)
- [0014-runtime-image-root](0014-runtime-image-root.md) - [0014-runtime-image-root](0014-runtime-image-root.md)
- [0015-npc-model-variety-sourcing](0015-npc-model-variety-sourcing.md) - [0015-npc-model-variety-sourcing](0015-npc-model-variety-sourcing.md)
- [0016-npc-character-budget-24k](0016-npc-character-budget-24k.md)
- [0017-quaternius-npc-base](0017-quaternius-npc-base.md)
- [0018-quaternius-stage2-runtime-rig](0018-quaternius-stage2-runtime-rig.md)
+13 -12
View File
@@ -81,7 +81,7 @@ The player interacts with NPCs, venue entrances, access artifacts, equipment, st
- `WorldBuilder` assembles the procedural venue from a per-venue `VENUE_SPECS` table (facade, windows, stairs, street, nav bounds, guard spawn, feature flags and prop placements), prefers the Blender prop library in `assets/models/blender/props/`, and falls back to `assets/models/generated/` or primitives. `GameManager` rebuilds only the `World` container when the venue changes, so the guard/UI and their state survive. It also wires the phase-driven library emissives and street dressing. - `WorldBuilder` assembles the procedural venue from a per-venue `VENUE_SPECS` table (facade, windows, stairs, street, nav bounds, guard spawn, feature flags and prop placements), prefers the Blender prop library in `assets/models/blender/props/`, and falls back to `assets/models/generated/` or primitives. `GameManager` rebuilds only the `World` container when the venue changes, so the guard/UI and their state survive. It also wires the phase-driven library emissives and street dressing.
- `src/game/ui/art.gd` loads runtime UI/scene art from `assets/images/ui/` and `assets/images/scenes/`, with null fallbacks so panels keep their procedural layouts. - `src/game/ui/art.gd` loads runtime UI/scene art from `assets/images/ui/` and `assets/images/scenes/`, with null fallbacks so panels keep their procedural layouts.
- `tools/model_pipeline.py` builds and audits the street-prop `.glb` models and packs their colour atlases. - `tools/model_pipeline.py` builds and audits the street-prop `.glb` models and packs their colour atlases.
- `tools/npc_pipeline.py` builds and audits the skinned NPC base model (`assets/models/generated/npc_base.glb`) and its per-slot material maps, with an optional AI fabric override (ADR-0013). - `tools/npc_pipeline.py` builds and audits the skinned NPC base model (`assets/models/generated/npc_base.glb`) and its per-slot material maps, with an optional AI fabric override (ADR-0013). The active character migration bakes the CC0 Quaternius base onto the same contract (ADR-0017); the procedural base remains the fallback.
- `tools/blender_assets.py` builds, renders and audits the Blender asset library (`assets/models/blender/{npc,npc_gear,props}/` + `manifest.json`): NPC character variants that are drop-in for `npc_base`, attach-point gear, and street/venue props. `NpcAvatar` selects variants/gear by role/seed and `WorldBuilder` loads the prop replacements and new street dressing, with procedural fallbacks (see `docs/blender-assets.md`). - `tools/blender_assets.py` builds, renders and audits the Blender asset library (`assets/models/blender/{npc,npc_gear,props}/` + `manifest.json`): NPC character variants that are drop-in for `npc_base`, attach-point gear, and street/venue props. `NpcAvatar` selects variants/gear by role/seed and `WorldBuilder` loads the prop replacements and new street dressing, with procedural fallbacks (see `docs/blender-assets.md`).
- `tools/scene_concepts.py` generates realism-pass references (`docs/screens/concepts/`), skyline backdrops and surface tiles (raw `assets/textures/ai/v2/raw/` → candidates `assets/textures/ai/game_ready/v2/`), promotes the validated ones to the tracked `v2/picks/` that `SurfaceMaterials`/`WorldBuilder` preload, audits them, and scores `src/test/capture_realism.gd` A/B captures (six stages, runtime texture swaps) against the concepts (`docs/screens/realism/<run>/`). - `tools/scene_concepts.py` generates realism-pass references (`docs/screens/concepts/`), skyline backdrops and surface tiles (raw `assets/textures/ai/v2/raw/` → candidates `assets/textures/ai/game_ready/v2/`), promotes the validated ones to the tracked `v2/picks/` that `SurfaceMaterials`/`WorldBuilder` preload, audits them, and scores `src/test/capture_realism.gd` A/B captures (six stages, runtime texture swaps) against the concepts (`docs/screens/realism/<run>/`).
- `tools/asset_pipeline.py` generates and audits role-aware textures; `tools/screen_designs.py` generates screen references and judges captures; `tools/check_screenshots.sh` renders the staged gallery and runs the compare gate. - `tools/asset_pipeline.py` generates and audits role-aware textures; `tools/screen_designs.py` generates screen references and judges captures; `tools/check_screenshots.sh` renders the staged gallery and runs the compare gate.
@@ -139,7 +139,7 @@ The runtime NPC contract is intentionally narrow. It is enforced by
| Core bones | `Head`, `HipL/R`, `KneeL/R`, `ShoulderL/R`, `ElbowL/R`, `HandL/R`, `Spine`, `Neck` at the frozen rest positions. Optional extra bones (fingers, face, toes) are allowed if they do not move the core rest positions or break `NpcAvatar` proxies. | | Core bones | `Head`, `HipL/R`, `KneeL/R`, `ShoulderL/R`, `ElbowL/R`, `HandL/R`, `Spine`, `Neck` at the frozen rest positions. Optional extra bones (fingers, face, toes) are allowed if they do not move the core rest positions or break `NpcAvatar` proxies. |
| Tint slots | `skin`, `cloth`, `pants`, `accessory`, `hair`; fixed detail materials keep their authored colours. | | Tint slots | `skin`, `cloth`, `pants`, `accessory`, `hair`; fixed detail materials keep their authored colours. |
| Orientation | Blender −Y / Godot +Z front, feet grounded near `y = 0`, metre scale. | | Orientation | Blender −Y / Godot +Z front, feet grounded near `y = 0`, metre scale. |
| Budget | 16,000 triangles / 16,000 glTF vertices per character; 1,500 per gear piece; 4,000 per prop. The low-poly `npc_base` remains a fallback, not a second contract. | | Budget | 24,000 triangles / 24,000 glTF vertices per character; 1,500 per gear piece; 4,000 per prop. The low-poly `npc_base` remains a fallback, not a second contract. |
| Textures | 256² albedo + normal + packed ORM per slot; cloth/pants use the 4× `NPC_FABRIC_UV_SCALE` and skip per-character baked AO, while skin/hair/gear keep AO. | | Textures | 256² albedo + normal + packed ORM per slot; cloth/pants use the 4× `NPC_FABRIC_UV_SCALE` and skip per-character baked AO, while skin/hair/gear keep AO. |
| Gear attach | Authored in attach-point space: head `Rig/Head` (0, 1.62, 0), back (0, 1.10, 0), chest (0, 1.30, 0), hand (±0.27, 0.78, 0.02); grip 5 cm below the origin. `GEAR_OFFSETS` and `HAND_ITEM_FORWARD` are runtime fit corrections, not authored-geometry changes. | | Gear attach | Authored in attach-point space: head `Rig/Head` (0, 1.62, 0), back (0, 1.10, 0), chest (0, 1.30, 0), hand (±0.27, 0.78, 0.02); grip 5 cm below the origin. `GEAR_OFFSETS` and `HAND_ITEM_FORWARD` are runtime fit corrections, not authored-geometry changes. |
| Fallback chain | Library variant → `npc_base.glb` → procedural `NpcAvatar` mesh. Committed GLBs and maps must keep CI offline and Blender-free. | | Fallback chain | Library variant → `npc_base.glb` → procedural `NpcAvatar` mesh. Committed GLBs and maps must keep CI offline and Blender-free. |
@@ -206,19 +206,19 @@ transient input state.
### Character development tooling ### Character development tooling
Characters have two authored sources and one procedural fallback. The eight-variant Characters have one active migration, two committed fallbacks and one procedural last
Blender library (`assets/models/blender/npc/`) is the normal presentation path; the resort. The Quaternius base migration (ADR-0017) bakes the CC0 Quaternius mesh and
older skinned `npc_base` (`assets/models/generated/npc_base.glb`) remains a frozen Idle/Walk clips onto the existing 13-core contract and produces the next 12-variant
contract and fallback; `NpcAvatar`'s procedural rig is the offline last resort. library. Until that ships, the 12-variant Blender library
Four crowd-variety prototypes have been built and audited locally but are not yet in (`assets/models/blender/npc/`) is the normal presentation path, the older skinned
the shipped catalogue; generator-first expansion is the active character track `npc_base` (`assets/models/generated/npc_base.glb`) is the frozen contract fallback,
(ADR-0015). Blender is a developer tool only — runtime, tests and CI consume committed and `NpcAvatar`'s procedural rig is the offline last resort. Blender and Quaternius are
GLBs and maps. developer tools only — runtime, tests and CI consume committed GLBs and maps.
| Tool | Role | Committed artifact | Verification gate | | Tool | Role | Committed artifact | Verification gate |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `tools/npc_pipeline.py` | Builds the skinned base avatar, five-slot material maps, palette and AI-fabric preparation (`prepare`/`palette`) | `npc_base.glb`, `assets/textures/npc/*_{albedo,normal,orm}.png`, `palette.json` | `audit_glb` against the 16,000-triangle / 16,000-vertex base contract (13 core bones required; extras allowed) | | `tools/npc_pipeline.py` | Builds the skinned base avatar, five-slot material maps, palette and AI-fabric preparation (`prepare`/`palette`) | `npc_base.glb`, `assets/textures/npc/*_{albedo,normal,orm}.png`, `palette.json` | `audit_glb` against the 24,000-triangle / 24,000-vertex base contract (13 core bones required; extras allowed) |
| `tools/blender_assets.py` | Builds/renders the library: 8 characters, 18 gear pieces, 20 props, plus `manifest.json`; stdlib `audit`/`fit`/`sheet`/`list` | `assets/models/blender/{npc,npc_gear,props}/*.glb` | Character cap 16,000 tris / 16,000 verts, 1,500 per gear, 4,000 per prop; grounding, UV, degenerate-triangle, attach-fit and manifest coverage checks | | `tools/blender_assets.py` | Builds/renders the library: 8 characters, 18 gear pieces, 20 props, plus `manifest.json`; stdlib `audit`/`fit`/`sheet`/`list` | `assets/models/blender/{npc,npc_gear,props}/*.glb` | Character cap 24,000 tris / 24,000 verts, 1,500 per gear, 4,000 per prop; grounding, UV, degenerate-triangle, attach-fit and manifest coverage checks |
| `tools/bake_ao.py` | Bakes per-asset AO maps | `<asset>_ao.png` (256²) | Wired through `Materials.apply_ao()`/`SurfaceMaterials`; cloth/pants opt out (see below) | | `tools/bake_ao.py` | Bakes per-asset AO maps | `<asset>_ao.png` (256²) | Wired through `Materials.apply_ao()`/`SurfaceMaterials`; cloth/pants opt out (see below) |
| `tools/npc_proportions.py` | Measures exported GLB rest pose (height, head, shoulders, hips, torso, limbs, feet) against the stylized guide | report / JSON in `docs/npc-visual-review.md#proportion-audit` | All eight variants must stay inside the guide after a body change | | `tools/npc_proportions.py` | Measures exported GLB rest pose (height, head, shoulders, hips, torso, limbs, feet) against the stylized guide | report / JSON in `docs/npc-visual-review.md#proportion-audit` | All eight variants must stay inside the guide after a body change |
| `tools/npc_baseline.py` | Assembles neutral-baseline sheets and before/after comparisons | `docs/blender_previews/sheet_npc_baseline*.jpg` | Human review against `docs/npc-visual-review.md` | | `tools/npc_baseline.py` | Assembles neutral-baseline sheets and before/after comparisons | `docs/blender_previews/sheet_npc_baseline*.jpg` | Human review against `docs/npc-visual-review.md` |
@@ -388,6 +388,7 @@ Architecture decisions are recorded as ADRs:
- [ADR-0008: Cone focus and focus circle](adr/0008-cone-focus-and-head-ring.md) - [ADR-0008: Cone focus and focus circle](adr/0008-cone-focus-and-head-ring.md)
- [ADR-0009: Radial action menu](adr/0009-radial-action-menu.md) - [ADR-0009: Radial action menu](adr/0009-radial-action-menu.md)
- [ADR-0010: Procedural NPC presentation](adr/0010-procedural-npc-presentation.md) - [ADR-0010: Procedural NPC presentation](adr/0010-procedural-npc-presentation.md)
- [ADR-0017: Quaternius NPC base](adr/0017-quaternius-npc-base.md)
- [ADR-0011: Aim-free selection and precision walk](adr/0011-aim-free-selection-and-precision-walk.md) - [ADR-0011: Aim-free selection and precision walk](adr/0011-aim-free-selection-and-precision-walk.md)
- [ADR-0012: Surface materials and generated props](adr/0012-surface-materials-and-generated-props.md) - [ADR-0012: Surface materials and generated props](adr/0012-surface-materials-and-generated-props.md)
- [ADR-0013: Blender-authored NPC characters and baked materials](adr/0013-blender-authored-npc-characters.md) - [ADR-0013: Blender-authored NPC characters and baked materials](adr/0013-blender-authored-npc-characters.md)
+16 -12
View File
@@ -82,6 +82,8 @@ tonemapping, glow, fog, colour grading) that the phase lighting applies.
NPCs are moving from the procedural capsule rig to a skinned, UV-mapped base 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)). 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 `tools/npc_pipeline.py` builds `assets/models/generated/npc_base.glb` plus the
per-slot material maps. `NpcAvatar` instantiates that skinned model, tints its 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 five material slots from the seed-driven outfit, and drives the bones from the
@@ -129,10 +131,10 @@ with colliders/avoidance and drives their emissive parts from the day/night
cycle. The single `npc_base.glb` and primitive props remain offline fallbacks. cycle. The single `npc_base.glb` and primitive props remain offline fallbacks.
Contract and integration notes: [`blender-assets.md`](blender-assets.md). Contract and integration notes: [`blender-assets.md`](blender-assets.md).
Sourcing and expansion research (no external model is shipped yet): Sourcing and migration plan: [`npc-model-sourcing.md`](npc-model-sourcing.md) records the
[`npc-model-sourcing.md`](npc-model-sourcing.md) compares Quaternius, Kenney, Khronos and 2026-11-05 direction (ADR-0017). Quaternius Ultimate Modular (CC0) is the primary NPC mesh
MakeHuman sources against the character contract and records the 2026-11-03 direction and animation source, baked to the existing 13-core contract; the procedural generator is
(procedural generator first, Quaternius reference, MakeHuman deferred). the fallback. Kenney/Khronos/MakeHuman remain evaluated references, not active bases.
| Characters | Props | | Characters | Props |
| --- | --- | | --- | --- |
@@ -140,11 +142,13 @@ MakeHuman sources against the character contract and records the 2026-11-03 dire
```bash ```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
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 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 sheet # docs/blender_previews/sheet_*.jpg
python3 tools/blender_assets.py audit # no Blender needed 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 fit # gear-vs-body fit report
python3 tools/blender_assets.py texels # per-slot texel density (px/m) of the characters 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 godot --headless --path . --import
``` ```
@@ -186,9 +190,9 @@ Caveats:
### Intake adaptation (`adapt`) ### Intake adaptation (`adapt`)
`adapt` is the mechanical first half of an external-mesh intake (MakeHuman, `adapt` is the mechanical first half of an external-mesh intake (Quaternius,
MPFB, downloaded GLB): import, apply transforms, ground the feet, weld seam MakeHuman fallback, downloaded GLB): import, apply transforms, ground the feet,
splits, decimate to the character budget, and export a GLB. It does **not** map 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 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 manual `retopology`/`rigging` step with a recorded second-rig contract if the
source rig cannot be retargeted: source rig cannot be retargeted:
@@ -196,14 +200,14 @@ source rig cannot be retargeted:
```bash ```bash
flatpak run org.blender.Blender --background \ flatpak run org.blender.Blender --background \
--python "$PWD/tools/blender_assets.py" -- adapt \ --python "$PWD/tools/blender_assets.py" -- adapt \
--file "$PWD/tmp/npc-research/makehuman_base.glb" \ --file "$PWD/tmp/npc-research/quaternius/Casual.glb" \
--out "$PWD/tmp/npc-research/makehuman_adapted.glb" \ --out "$PWD/tmp/npc-research/quaternius_adapted.glb" \
--target-tris 16000 --target-tris 24000
# prints: adapted makehuman_adapted.glb: 36972 -> 16000 tris (bones=N, meshes=1) # 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 Run `inspect` on the adapted GLB before and after, and keep the procedural
generator as the fallback until a MakeHuman variant passes `audit`/`fit` and the generator as the fallback until a Quaternius variant passes `audit`/`fit` and the
24-NPC budget. 24-NPC budget.
- Numbers are Blender-side measurements. Triangles match `audit` on project assets; the seam-split - 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 vertex count can differ by ±1 because the importer rounds normals. `audit` stays the single final
+27 -7
View File
@@ -3,7 +3,10 @@
Architecture context: the asset contracts and fallback path are summarized in Architecture context: the asset contracts and fallback path are summarized in
[`architecture.md`](architecture.md#content-and-asset-pipeline-architecture). [`architecture.md`](architecture.md#content-and-asset-pipeline-architecture).
Status: built, audited, and integrated (2026-11-01). `NpcAvatar` selects the 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 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 with colliders and phase-driven emissives. The 0.9.4 neutral baseline and its
acceptance criteria are in [`npc-visual-review.md`](npc-visual-review.md); acceptance criteria are in [`npc-visual-review.md`](npc-visual-review.md);
@@ -63,7 +66,7 @@ flatpak run org.blender.Blender --background \
flatpak run org.blender.Blender --background \ flatpak run org.blender.Blender --background \
--python "$PWD/tools/npc_parts.py" -- extract --part hand_l --variant npc_casual \ --python "$PWD/tools/npc_parts.py" -- extract --part hand_l --variant npc_casual \
--out "$PWD/tmp/npc-parts/npc_casual/hand_l.glb" --out "$PWD/tmp/npc-parts/npc_casual/hand_l.glb"
# Assemble a variant (currently rebuilds the procedural character) # Assemble a variant (current fallback path; Quaternius migration in progress)
flatpak run org.blender.Blender --background \ flatpak run org.blender.Blender --background \
--python "$PWD/tools/npc_parts.py" -- assemble --variant npc_casual \ --python "$PWD/tools/npc_parts.py" -- assemble --variant npc_casual \
--out "$PWD/tmp/npc-parts/npc_casual/assembled.glb" --out "$PWD/tmp/npc-parts/npc_casual/assembled.glb"
@@ -139,10 +142,22 @@ render about 2.5 min.
## Characters ## Characters
All library variants share the [character and gear asset contract](architecture.md#character-and-gear-asset-contract): All library variants share the
`npc_base` mesh node, required 13 core bones (optional extras allowed), five [character and gear asset contract](architecture.md#character-and-gear-asset-contract):
tint slots, +Z visual front, 16,000/16,000 character budget and the same 256² `npc_base` mesh node, five tint slots, +Z visual front, 24,000/24,000 character budget and the
slot-map/AO rules. Role selection and look notes: 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 | | Character | `layer_for()` / look | Suggested roles | Preview gear |
| --- | --- | --- | --- | | --- | --- | --- | --- |
@@ -161,6 +176,11 @@ colour texture. The current `_apply_outfit_materials()` sets
`albedo_color = WHITE` on any slot it does not recognise, and these keep their `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. colour through that. When integrating, tint only the five contract slots.
**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; **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 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 fixed `hivis` material and `cloth` is the undershirt, so the yellow override is
@@ -200,7 +220,7 @@ carry baked AO and the shared neutral material maps described above.
- `python3 tools/blender_assets.py audit`: container, `POSITION`/`NORMAL`/ - `python3 tools/blender_assets.py audit`: container, `POSITION`/`NORMAL`/
`TEXCOORD_0`, UVs in [0, 1], no zero-area triangles (Godot warns about them `TEXCOORD_0`, UVs in [0, 1], no zero-area triangles (Godot warns about them
during LOD generation), budgets (characters 16000/16000, gear 1500, props 4000 during LOD generation), budgets (characters 24000/24000, gear 1500, props 4000
triangles), grounding, and an editable `.blend`. For characters it also triangles), grounding, and an editable `.blend`. For characters it also
checks skinning, bones, slots, the `npc_base` mesh node and the height. checks skinning, bones, slots, the `npc_base` mesh node and the height.
- `tools/tests/test_blender_assets.py`: catalogue and contract consistency with - `tools/tests/test_blender_assets.py`: catalogue and contract consistency with
+1
View File
@@ -9,6 +9,7 @@ capture harnesses.
| --- | --- | | --- | --- |
| [![sheet_npc.jpg](sheet_npc.jpg)](sheet_npc.jpg) <br> `sheet_npc.jpg` | Eight skinned character variants (casual, jacket, coat, hi-vis vest, dress, hoodie, firefighter, guard) with their gear attach points. | | [![sheet_npc.jpg](sheet_npc.jpg)](sheet_npc.jpg) <br> `sheet_npc.jpg` | Eight skinned character variants (casual, jacket, coat, hi-vis vest, dress, hoodie, firefighter, guard) with their gear attach points. |
| [![sheet_npc_baseline.jpg](sheet_npc_baseline.jpg)](sheet_npc_baseline.jpg) <br> `sheet_npc_baseline.jpg` | Neutral studio baseline: 8 characters x front, three-quarter, profile, back, full body and crowd views. | | [![sheet_npc_baseline.jpg](sheet_npc_baseline.jpg)](sheet_npc_baseline.jpg) <br> `sheet_npc_baseline.jpg` | Neutral studio baseline: 8 characters x front, three-quarter, profile, back, full body and crowd views. |
| [![sheet_makehuman_head.jpg](sheet_makehuman_head.jpg)](sheet_makehuman_head.jpg) <br> `sheet_makehuman_head.jpg` | MakeHuman head: front, three-quarter and profile review views. The owner accepted the basic head; the automated vision gate still rejects the eye/lid region as deferred polish. See [`../makehuman-head-review.md`](../makehuman-head-review.md). |
| [![sheet_npc_baseline_compare-body.jpg](sheet_npc_baseline_compare-body.jpg)](sheet_npc_baseline_compare-body.jpg) <br> `sheet_npc_baseline_compare-body.jpg` | Neck/shoulder transition: fuller neck base and a lower shoulder root. | | [![sheet_npc_baseline_compare-body.jpg](sheet_npc_baseline_compare-body.jpg)](sheet_npc_baseline_compare-body.jpg) <br> `sheet_npc_baseline_compare-body.jpg` | Neck/shoulder transition: fuller neck base and a lower shoulder root. |
| [![sheet_npc_baseline_compare-face-detail.jpg](sheet_npc_baseline_compare-face-detail.jpg)](sheet_npc_baseline_compare-face-detail.jpg) <br> `sheet_npc_baseline_compare-face-detail.jpg` | Face detail: nose ala wings, a single rounded mouth and a darker ear concha. | | [![sheet_npc_baseline_compare-face-detail.jpg](sheet_npc_baseline_compare-face-detail.jpg)](sheet_npc_baseline_compare-face-detail.jpg) <br> `sheet_npc_baseline_compare-face-detail.jpg` | Face detail: nose ala wings, a single rounded mouth and a darker ear concha. |
| [![sheet_npc_baseline_compare-face.jpg](sheet_npc_baseline_compare-face.jpg)](sheet_npc_baseline_compare-face.jpg) <br> `sheet_npc_baseline_compare-face.jpg` | Face-depth pass: recessed sclera, upper lid and sculpted chin/cheek planes. | | [![sheet_npc_baseline_compare-face.jpg](sheet_npc_baseline_compare-face.jpg)](sheet_npc_baseline_compare-face.jpg) <br> `sheet_npc_baseline_compare-face.jpg` | Face-depth pass: recessed sclera, upper lid and sculpted chin/cheek planes. |
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

+8 -2
View File
@@ -34,8 +34,14 @@ and the active order/gaps in [`TODO.md`](../TODO.md).
## Known gaps ## Known gaps
- External first-time-player sessions and balance sweeps are not completed. - External first-time-player sessions and balance sweeps are not completed.
- The NPC head/face/hand overhaul and MakeHuman body pipeline are planned, not - The Quaternius roster is shipped (`0.9.9`): all twelve characters are baked from the three CC0
shipped. sources (casual tee, hoodie, work clothes), 4,458–6,424 triangles each, 62-joint rig at source
proportions, arm-down rest, five tint slots, unwrapped UVs and baked 256² AO. The procedural
library and the MakeHuman head remain the tested fallback.
- Roster silhouette variety is narrower than the procedural library (three meshes for twelve
roles, differentiated by tint and gear) until the modular part library is added.
- Head gear is authored for the shipped Quaternius head; the procedural fallback therefore wears
slightly undersized caps. Cosmetic, fallback-only.
- Accessibility/localization acceptance, target-hardware profiling, macOS - Accessibility/localization acceptance, target-hardware profiling, macOS
signing/notarization and AI/storefront legal approval remain open. signing/notarization and AI/storefront legal approval remain open.
- Live 3D replay, recorded VO/music, more venues and seasonal content are future - Live 3D replay, recorded VO/music, more venues and seasonal content are future
+140
View File
@@ -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.
![MakeHuman head review sheet](blender_previews/sheet_makehuman_head.jpg)
## 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.
+310 -51
View File
@@ -1,65 +1,324 @@
# NPC Model Sourcing # NPC Model Sourcing
This page records the source evaluation behind [ADR-0015](adr/0015-npc-model-variety-sourcing.md). This page records the source evaluation and the active character plan. The
The shipped path is the project-owned Blender/procedural library; external sources decision is [ADR-0017](adr/0017-quaternius-npc-base.md), which supersedes the
remain references or local prototypes unless a later ADR accepts them. generator-first sourcing decision in [ADR-0015](adr/0015-npc-model-variety-sourcing.md).
## Evaluation ## Active direction
The library needs variety beyond the original eight silhouettes while preserving **Quaternius Ultimate Modular Characters (CC0) is the primary NPC mesh and
the `npc_base` contract: mesh node, 13 required core bones (optional extras animation source.** The project-owned procedural library remains the shipped
allowed), five tint slots, −Y/+Z front, grounded feet and the 16,000/16,000 fallback until the Quaternius migration passes the silhouette and gear gates.
character budget.
| Source | Licence | Result | 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 |
| --- | --- | --- | | --- | --- | --- |
| Quaternius Ultimate Modular | CC0 | Good silhouette/decimation material, but a 62-bone T-pose realistic rig and different proportions; not a drop-in. Kept as a reference/prototype. | | `Casual.glb` | `bc2d84fba249ebaa0629cb9d133ad37234196e4b8004aa205a6397abed04e05c` | 4 meshes, 62 joints, 24 clips, 7 materials |
| Kenney Blocky Characters | CC0 | No matching rig; style is much blockier than the project look. | | `Casual_Hoodie.glb` | `f5b722199f7628b24550989af4230f65d5230727df9c326fd2b3e360effe5249` | 4 meshes, 62 joints, 24 clips, 7 materials |
| Khronos CesiumMan / RiggedFigure | CC BY 4.0 | Different bone names/rest pose and no five-slot materials; not a drop-in. | | `Worker.glb` | `8d0f454336c2effc5c08f63a177af3a82d1a7af025ca9588b40e4c90c4703534` | 4 meshes, 62 joints, 24 clips, 11 materials |
| MakeHuman / MPFB base | CC0 | Useful body-shape base, but requires retopology, a custom rig, clothing and part integration. Active local pipeline, not the shipped path. |
The contract mismatch is the expensive part, not decimation: retargeting a Source: [Quaternius Ultimate Modular Characters](https://quaternius.com/packs/ultimatemodularcharacters.html)
different rig or rebuilding slots/weights can distort the body and invalidate and [Ultimate Modular Women](https://quaternius.com/packs/ultimatemodularwomen.html),
gear placement. Any future second-rig path needs its own ADR and animation/gear CC0 1.0. Download date: 2026-11-03 (via the Ducz Character Creator browser export).
offsets. 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.
## Decision and outcome **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.
- Extend the project-owned procedural generator as the primary shipped path The first prototype measured 12,518 imported vertices, 3,199 after welding and
(12-character library, crowd variants, role pools). 2,423 vertices / 4,654 triangles after a 0.75 decimate. Idle and Walk read well.
- Keep Quaternius as a studied reference, not a shipped base. The source is a T-pose with a 62-joint realistic rig, roughly 0.96 m hip,
- MakeHuman is a local development pipeline for body-shape diversity; it must 1.68 m arm span and 0.153 m shoulder offset, so a contract bake is required.
consume the same head/hand parts as the procedural library and never become a
CI/build dependency. The 24,000 cap leaves room to stay much closer to the source LOD than the
- Record CC0/CC-BY provenance; CC-BY previews require attribution in 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 and silhouette gate sheet is in [`screens/npc-face-gate/`](screens/npc-face-gate/README.md).
## 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.
## 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). [`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.
## MakeHuman prototype notes ## Closed tracks
The local prototype (`tmp/npc-research/`, never committed) scaled the base to The MakeHuman/MPFB body pipeline is closed for the NPC base. The accepted
1.82 m, grounded it, triangulated/decimated it and retargeted it to the 13-core MakeHuman head and the procedural `npc_base` remain tested fallbacks until the
armature. The first retarget measured **6,444 / 16,000 tris and** Quaternius rollout is complete. Their research scripts and renders were removed
**7,726 / 16,000 verts**, 14 bones, skinned, arm-down, five slots. It exposed from `tmp/` after the pivot; the measurements and decisions remain in
the expected issues: a 1.63× arm-scale warp at the deltoid/shoulder, blocky [`DONE.md`](../DONE.md), [`MEMORY.md`](../MEMORY.md) and the review docs.
decimated hands, rough slot boundaries and a head/neck shadow gap. The later
project head/hands swap reduced this to **6,407 / 16,000 tris and**
**6,651 / 16,000 verts**, but manual shoulder/neck retopology and part cleanup
remain before it can be considered shippable.
The head/face/hand overhaul in [`TODO.md`](../TODO.md) is the prerequisite: the ## Related documents
MakeHuman body should reuse the approved local head/hand parts rather than
forking a second face implementation.
## Provenance rules - [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)
- CC0 preferred; CC-BY only with attribution in `CREDITS.md`; no service asset - [Blender asset contract](architecture.md#character-and-gear-asset-contract)
whose terms restrict redistribution. - [`3d-model-review.md`](3d-model-review.md), [`npc-visual-review.md`](npc-visual-review.md)
- Downloaded research and generated previews stay in `tmp/`/gitignored folders; - [`asset-tooling.md`](asset-tooling.md)
only final audited assets and committed review sheets enter the repository.
- Record source URL, licence, author, date and what decision the reference
informed. Never treat a generated concept as licensed map/model data.
Measurements, visual decisions and accepted limits are in
[`3d-model-review.md`](3d-model-review.md) and
[`npc-visual-review.md`](npc-visual-review.md). Commands are in
[`asset-tooling.md`](asset-tooling.md).
+2 -2
View File
@@ -71,8 +71,8 @@ The criteria below are for the stylized target, not photorealism. A change must
pass every applicable item before a library rebuild is accepted. pass every applicable item before a library rebuild is accepted.
1. **Asset contract:** `npc_base` mesh node, the required bones, five tint slots, 1. **Asset contract:** `npc_base` mesh node, the required bones, five tint slots,
front +Z, feet within 3 cm of y=0, 1.6–2.1 m height, at most 16,000 triangles front +Z, feet within 3 cm of y=0, 1.6–2.1 m height, at most 24,000 triangles
and 16,000 glTF vertices per character. The required 13 core bones stay fixed; and 24,000 glTF vertices per character. The required 13 core bones stay fixed;
optional extra bones are allowed. Gear remains at most 1,500 triangles. optional extra bones are allowed. Gear remains at most 1,500 triangles.
2. **Proportion and silhouette:** head, neck, shoulders, torso, hips, limbs and 2. **Proportion and silhouette:** head, neck, shoulders, torso, hips, limbs and
feet read as one body; front/three-quarter/profile silhouettes agree with the feet read as one body; front/three-quarter/profile silhouettes agree with the
+13 -4
View File
@@ -1,5 +1,13 @@
# Head rework — review page (2026-11-04) # Head rework — review page (2026-11-04)
> **Historical page / changed source (2026-11-04).** The numbers below describe the
> earlier decimated MakeHuman head (1,548-tri skin). The shared source
> `assets/models/blender_src/makehuman_head.glb` now holds the higher-resolution
> standalone prototype. The owner accepted the basic head; the automated vision gate
> still records REJECT on the eye/lid region as deferred polish. See
> [`../../makehuman-head-review.md`](../../makehuman-head-review.md). Re-run the
> library head build before relying on the comparison counts below.
This page is the review checkpoint for the NPC head rework. The goal was to replace the This page is the review checkpoint for the NPC head rework. The goal was to replace the
sphere-and-floating-primitive face with a head that has real nose / chin / jaw landmarks at sphere-and-floating-primitive face with a head that has real nose / chin / jaw landmarks at
portrait distance. portrait distance.
@@ -52,8 +60,9 @@ Left to right: front, three-quarter, profile.
4. The head skin is 1.5k tris (smooth but low-poly); a retopology pass could add resolution if a 4. The head skin is 1.5k tris (smooth but low-poly); a retopology pass could add resolution if a
portrait shows faceting. portrait shows faceting.
## Decision needed ## Decision (2026-11-04, owner)
Approve or reject the MakeHuman-head direction. If approved, the next step is rolling it into the The MakeHuman-head direction is approved for the basic human head. The remaining review is the
other 11 variants (one asset family at a time) with a before/after sheet per batch, and re-running assembled prototype's hair cap and full-body silhouette. After that, roll the head into the other
the 24-NPC budget + AO bake. 11 variants (one asset family at a time) with a before/after sheet per batch, then re-run the
24-NPC budget + AO bake.
+66
View File
@@ -0,0 +1,66 @@
# NPC face and silhouette gate — Quaternius pilot (2026-11-05)
This page is the owner decision point for
[ADR-0017](../../adr/0017-quaternius-npc-base.md) + [ADR-0018](../../adr/0018-quaternius-stage2-runtime-rig.md)
stage 2. It answers one question: **which head ships?**
`npc_casual` is now the CC0 Quaternius `Casual_Hoodie` base baked to the project contract
(arm-down rest, five tint slots, 62-joint rig at source proportions). The procedural library with
the MakeHuman head is unchanged and still ships for the other eleven characters.
## The sheet
![face gate sheet](sheet_face_gate.jpg)
Rows: the Quaternius base, the procedural library with the MakeHuman head, and a procedural crowd
variant. Columns: front, three-quarter, profile, and the crowd-distance silhouette the player
actually sees from 8 m.
Source frames: `godot --path . --script res://src/test/capture_npc_baseline.gd -- --out=res://tmp/npc-gate`.
## What the sheet shows
| | Head height / body | Face read at portrait | Crowd-distance read |
| --- | --- | --- | --- |
| Quaternius base | 0.134 | clean low-poly brows, nose, jaw, layered hair | slim, upright, clearly a person |
| procedural + MakeHuman head | ~0.20 | oversized head, soft nose, beard slab reads as a bar | bobblehead proportions |
| procedural crowd variant | ~0.20 | oversized head, flat cheeks | bobblehead proportions |
Measured (`python3 -m pytest tools/tests/test_npc_proportions.py -q`,
`python3 tools/npc_proportions.py`):
- `npc_casual` (Quaternius): head 13.4 % of height, arm-down rest, 6,206 tris, 12,988 glTF
vertices, texel density 73–74 px/m on the 256² maps.
- The procedural library: head 16–22 % of height (the stylized band in
`tools/npc_proportions.GUIDE`).
The Quaternius head is *smaller and more realistic* than the stylized library head, which is
exactly the trade the owner has to accept or reject. The vision judge that reviewed the earlier
head rework reads the Quaternius face as the cleaner silhouette of the three at both distances.
## The three options
1. **Full swap.** Every character rebaked onto the Quaternius base in `0.9.10`; the procedural
library and the MakeHuman head retire to the tested fallback. Consistent look, real anatomy,
no further head work.
2. **Crowd only.** Quaternius for the anonymous crowd roles, procedural + MakeHuman for the named
roles the player talks to. Keeps two art styles in one frame.
3. **Add head detail.** Keep the Quaternius body and author a higher-detail head (subdivision and
hair cards) inside the 24,000-triangle / 24,000-vertex budget. Most work; the current head
already reads well at gameplay distance.
## Recommendation
Option 1. The Quaternius head beats the MakeHuman head at portrait distance in this sheet, and it
costs no further authoring; the budget headroom (6.2k of 24k triangles) stays available for the
hands and gear that the review still flags as weak.
## Recorded decision
Status: **option 1 (full swap) accepted by the owner, 2026-11-05.** Every roster character moves
onto the Quaternius base; the procedural library and the MakeHuman head become the tested
fallback. See the follow-up decision section in
[ADR-0018](../../adr/0018-quaternius-stage2-runtime-rig.md#follow-up-decision-full-roster-swap).
Roster silhouettes are less varied than the procedural library until the modular part library
(hair, outfits) is added in the `0.9.10` roster workstream — recorded rather than hidden.
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

+4 -4
View File
@@ -8,7 +8,7 @@ dedicated_server=false
custom_features="" custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="" include_filter=""
exclude_filter="" exclude_filter="tmp/*, tmp/**, build/*, build/**, docs/*, docs/**, tools/*, tools/**, src/test/*, src/test/**, .agents/*, .agents/**, packaging/*, packaging/**"
export_path="build/gig-line-linux.x86_64" export_path="build/gig-line-linux.x86_64"
patches=PackedStringArray() patches=PackedStringArray()
encryption_include_filters="" encryption_include_filters=""
@@ -39,7 +39,7 @@ dedicated_server=false
custom_features="" custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="" include_filter=""
exclude_filter="" exclude_filter="tmp/*, tmp/**, build/*, build/**, docs/*, docs/**, tools/*, tools/**, src/test/*, src/test/**, .agents/*, .agents/**, packaging/*, packaging/**"
export_path="build/gig-line-windows.exe" export_path="build/gig-line-windows.exe"
patches=PackedStringArray() patches=PackedStringArray()
encryption_include_filters="" encryption_include_filters=""
@@ -75,7 +75,7 @@ dedicated_server=false
custom_features="" custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="" include_filter=""
exclude_filter="" exclude_filter="tmp/*, tmp/**, build/*, build/**, docs/*, docs/**, tools/*, tools/**, src/test/*, src/test/**, .agents/*, .agents/**, packaging/*, packaging/**"
export_path="build/gig-line-macos.zip" export_path="build/gig-line-macos.zip"
patches=PackedStringArray() patches=PackedStringArray()
encryption_include_filters="" encryption_include_filters=""
@@ -114,7 +114,7 @@ dedicated_server=false
custom_features="" custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="" include_filter=""
exclude_filter="" exclude_filter="tmp/*, tmp/**, build/*, build/**, docs/*, docs/**, tools/*, tools/**, src/test/*, src/test/**, .agents/*, .agents/**, packaging/*, packaging/**"
export_path="build/web/index.html" export_path="build/web/index.html"
patches=PackedStringArray() patches=PackedStringArray()
encryption_include_filters="" encryption_include_filters=""
+1 -1
View File
@@ -12,7 +12,7 @@ config_version=5
config/name="Gig Line" config/name="Gig Line"
config/description="Workplace security simulation about observation, access verification, and proportionate escalation." config/description="Workplace security simulation about observation, access verification, and proportionate escalation."
config/version="0.9.8" config/version="0.9.9"
run/main_scene="res://scenes/main.tscn" run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.7", "GL Compatibility") config/features=PackedStringArray("4.7", "GL Compatibility")
+83 -27
View File
@@ -413,38 +413,45 @@ func _build_skinned_body(npc_type: int) -> bool:
# Animation proxies: the existing _animate()/idle code drives these Node3D # Animation proxies: the existing _animate()/idle code drives these Node3D
# pivots; _sync_skeleton() copies their rotations onto the armature bones. # pivots; _sync_skeleton() copies their rotations onto the armature bones.
_bind_bones()
_head = Node3D.new() _head = Node3D.new()
_head.name = "Head" _head.name = "Head"
_head.position = Vector3(0, 1.62, 0) _head.position = _rest_origin(["Head"])
_rig.add_child(_head) _rig.add_child(_head)
for side in [-1.0, 1.0]: for i in 2:
var suffix := "L" if side < 0.0 else "R" var suffix := "L" if i == 0 else "R"
# The proxy pivots sit on the resolved rig bones (see RIG_BONES), so the
# proxy chirality and the exported joint naming never have to agree.
var hip_bone := bone_name("hip", i)
var knee_bone := bone_name("knee", i)
var shoulder_bone := bone_name("shoulder", i)
var elbow_bone := bone_name("elbow", i)
var hand_bone := bone_name("hand", i)
var hip := Node3D.new() var hip := Node3D.new()
hip.name = "Hip" + suffix hip.name = "Hip" + suffix
hip.position = Vector3(0.13 * side, 0.64, 0) hip.position = _rest_origin([hip_bone])
_rig.add_child(hip) _rig.add_child(hip)
var knee := Node3D.new() var knee := Node3D.new()
knee.name = "Knee" knee.name = "Knee"
knee.position = Vector3(0, -0.34, 0) knee.position = _rest_origin([knee_bone]) - hip.position
hip.add_child(knee) hip.add_child(knee)
_legs.append(hip) _legs.append(hip)
_knees.append(knee) _knees.append(knee)
var shoulder := Node3D.new() var shoulder := Node3D.new()
shoulder.name = "Shoulder" + suffix shoulder.name = "Shoulder" + suffix
shoulder.position = Vector3(0.27 * side, 1.38, 0) shoulder.position = _rest_origin([shoulder_bone])
_rig.add_child(shoulder) _rig.add_child(shoulder)
var elbow := Node3D.new() var elbow := Node3D.new()
elbow.name = "Elbow" elbow.name = "Elbow"
elbow.position = Vector3(0, -0.30, 0) elbow.position = _rest_origin([elbow_bone]) - shoulder.position
shoulder.add_child(elbow) shoulder.add_child(elbow)
var hand := Node3D.new() var hand := Node3D.new()
hand.name = "Hand" hand.name = "Hand"
hand.position = Vector3(0, -0.30, 0.02) hand.position = _rest_origin([hand_bone]) - _rest_origin([elbow_bone])
elbow.add_child(hand) elbow.add_child(hand)
_arms.append(shoulder) _arms.append(shoulder)
_elbows.append(elbow) _elbows.append(elbow)
_hands.append(hand) _hands.append(hand)
_bind_bones()
_add_accessory(str(outfit["accessory"]), cloth) _add_accessory(str(outfit["accessory"]), cloth)
_add_character_gear() _add_character_gear()
@@ -495,35 +502,84 @@ func _bind_bones() -> void:
for index in _skeleton.get_bone_count(): for index in _skeleton.get_bone_count():
_bone_indices[_skeleton.get_bone_name(index)] = index _bone_indices[_skeleton.get_bone_name(index)] = index
## First-found rest origin for a list of candidate bone names, in the skeleton's
## space (global rest), so proxy pivots sit on the actual joints.
func _rest_origin(names: Array) -> Vector3:
if _skeleton == null:
return Vector3.ZERO
for bone_name in names:
if _bone_indices.has(bone_name):
return _skeleton.get_bone_global_rest(_bone_indices[bone_name]).origin
return Vector3.ZERO
## Bone-name candidates per animation joint, in proxy order: index 0 is the
## avatar's left (-x) and index 1 its right (+x). The Quaternius rig labels its
## dot-suffixed joints with the opposite side (its `L` sits at +x), so the pairs
## carry the rig-specific mapping and nothing else has to know about it.
const RIG_BONES := {
"head": [["Head"], ["Head"]],
"hip": [["UpperLeg.R", "HipL"], ["UpperLeg.L", "HipR"]],
"knee": [["LowerLeg.R", "KneeL"], ["LowerLeg.L", "KneeR"]],
"shoulder": [["UpperArm.R", "ShoulderL"], ["UpperArm.L", "ShoulderR"]],
"elbow": [["LowerArm.R", "ElbowL"], ["LowerArm.L", "ElbowR"]],
"hand": [["Wrist.R", "HandL"], ["Wrist.L", "HandR"]],
}
## Resolved skeleton bone name for an animation joint on one side. Returns an
## empty string when the rig has neither candidate, so both the Quaternius
## 62-joint rig and the 13-core fallback resolve through the same call.
func bone_name(joint: String, side: int = 0) -> String:
if not RIG_BONES.has(joint):
return ""
var pair: Array = RIG_BONES[joint]
return _bone_of(pair[0], pair[1], side)
## Copy the proxy pivot rotations onto armature bones. Limb bones point down, so ## Copy the proxy pivot rotations onto armature bones. Limb bones point down, so
## an X rotation swings them forward/back; the Head bone points up, so a Y ## an X rotation swings them forward/back; the Head bone points up, so a Y
## rotation is the look yaw. ## rotation is the look yaw.
func _sync_skeleton() -> void: func _sync_skeleton() -> void:
if _skeleton == null or _bone_indices.is_empty(): if _skeleton == null or _bone_indices.is_empty():
return return
var names := ["HipL", "HipR"]
for i in 2: for i in 2:
_pose_bone(names[i], _legs[i].rotation.x, Vector3.RIGHT) var hip_swing := Quaternion(Vector3.RIGHT, _legs[i].rotation.x)
names = ["KneeL", "KneeR"] _swing_bone(bone_name("hip", i), hip_swing)
for i in 2: # `_swing_bone` writes the absolute global rotation, so a child bone has to
_pose_bone(names[i], _knees[i].rotation.x, Vector3.RIGHT) # receive the swing it inherits from its parent as well as its own.
names = ["ShoulderL", "ShoulderR"] _swing_bone(bone_name("knee", i), hip_swing * Quaternion(Vector3.RIGHT, _knees[i].rotation.x))
for i in 2: var shoulder_swing := Quaternion(Vector3.LEFT, _arms[i].rotation.x)
_pose_bone(names[i], _arms[i].rotation.x, Vector3.RIGHT) _swing_bone(bone_name("shoulder", i), shoulder_swing)
names = ["ElbowL", "ElbowR"] _swing_bone(bone_name("elbow", i), shoulder_swing * Quaternion(Vector3.LEFT, _elbows[i].rotation.x))
for i in 2:
_pose_bone(names[i], _elbows[i].rotation.x, Vector3.RIGHT)
if _head != null: if _head != null:
_pose_bone("Head", _head.rotation.y, Vector3.UP) var head := bone_name("head")
if head != "":
_swing_bone(head, Quaternion(Vector3.UP, _head.rotation.y))
func _pose_bone(bone_name: String, angle: float, axis: Vector3) -> void: ## First bone present from the left list (index 0) or the right list (index 1).
if not _bone_indices.has(bone_name): func _bone_of(left: Array, right: Array, index: int) -> String:
for bone_name in (left if index == 0 else right):
if _bone_indices.has(bone_name):
return str(bone_name)
return ""
## Swing a bone about a world axis through its head, keeping its rest orientation
## as the neutral. Writing the *global* pose is rig-agnostic: it does not depend
## on how the armature pack exported its rest rolls or on the pose/rest split the
## glTF importer chose, both of which differ between the 62-joint Quaternius rig
## and the 13-core fallback. Measuring `get_bone_global_pose()` after the call is
## the only reliable check (see the pose-capture gate).
##
## `rest` must be the **global** rest. `get_bone_rest()` is parent-relative and
## already carries the importer's axis conversion, so conjugating with it swung
## the limbs about the wrong axis and splayed the arms.
func _swing_bone(bone_name: String, swing: Quaternion) -> void:
if bone_name == "":
return return
var index: int = _bone_indices[bone_name] var index: int = _bone_indices[bone_name]
# Bone pose rotation is the absolute local orientation, not an offset from _skeleton.reset_bone_pose(index)
# rest; compose with the rest rotation so identity preserves the bind pose. var rest := _skeleton.get_bone_global_rest(index).basis.get_rotation_quaternion()
var rest := _skeleton.get_bone_rest(index).basis.get_rotation_quaternion() var current := _skeleton.get_bone_global_pose(index)
_skeleton.set_bone_pose_rotation(index, rest * Quaternion(axis, angle)) var target := Basis((swing * rest).normalized()).scaled(current.basis.get_scale())
_skeleton.set_bone_global_pose(index, Transform3D(target, current.origin))
func _build_body(npc_type: int) -> void: func _build_body(npc_type: int) -> void:
_rig = Node3D.new() _rig = Node3D.new()
+29 -4
View File
@@ -9,6 +9,17 @@ const NPC_BASE := "res://assets/models/generated/npc_base.glb"
const SLOTS := ["skin", "cloth", "pants", "accessory", "hair"] const SLOTS := ["skin", "cloth", "pants", "accessory", "hair"]
const BONES := ["Head", "HipL", "HipR", "KneeL", "KneeR", "ShoulderL", "ShoulderR", const BONES := ["Head", "HipL", "HipR", "KneeL", "KneeR", "ShoulderL", "ShoulderR",
"ElbowL", "ElbowR", "HandL", "HandR"] "ElbowL", "ElbowR", "HandL", "HandR"]
## The Quaternius rig (ADR-0018) is a 62-joint armature. `NpcAvatar` maps these
## joints onto the animation proxies, so they are the drop-in requirement for a
## Quaternius-derived character instead of the 13-core names.
const QUATERNIUS_BONES := ["Head", "UpperLeg.L", "UpperLeg.R", "LowerLeg.L",
"LowerLeg.R", "UpperArm.L", "UpperArm.R", "LowerArm.L", "LowerArm.R",
"Wrist.L", "Wrist.R"]
## Quaternius-derived characters are detected by a dot-suffixed UpperArm joint;
## the procedural library uses ShoulderL/ShoulderR instead.
func _is_quaternius(skeleton: Skeleton3D) -> bool:
return skeleton != null and skeleton.find_bone("UpperArm.L") >= 0
func _manifest() -> Dictionary: func _manifest() -> Dictionary:
if not FileAccess.file_exists(MANIFEST): if not FileAccess.file_exists(MANIFEST):
@@ -76,7 +87,8 @@ func test_characters_match_the_npc_base_contract() -> bool:
problems.append("%s lacks Skeleton3D/npc_base" % name) problems.append("%s lacks Skeleton3D/npc_base" % name)
node.free() node.free()
continue continue
for bone in BONES: var required: Array = QUATERNIUS_BONES if _is_quaternius(skeleton) else BONES
for bone in required:
if skeleton.find_bone(bone) < 0: if skeleton.find_bone(bone) < 0:
problems.append("%s missing bone %s" % [name, bone]) problems.append("%s missing bone %s" % [name, bone])
var slots := {} var slots := {}
@@ -105,6 +117,11 @@ func test_character_joints_line_up_with_npc_base() -> bool:
var skeleton := node.find_child("Skeleton3D", true, false) as Skeleton3D if node else null var skeleton := node.find_child("Skeleton3D", true, false) as Skeleton3D if node else null
if skeleton == null: if skeleton == null:
problems.append("%s has no skeleton" % name) problems.append("%s has no skeleton" % name)
elif _is_quaternius(skeleton):
# ADR-0018: a Quaternius character keeps its own 62-joint skeleton and
# NpcAvatar re-derives the proxy pivots from it, so the joints are not
# expected to sit on npc_base's 13-core positions.
pass
else: else:
for bone in BONES: for bone in BONES:
var a := _global_rest(base_skel, base_skel.find_bone(bone)).origin var a := _global_rest(base_skel, base_skel.find_bone(bone)).origin
@@ -127,10 +144,18 @@ func test_character_bones_accept_the_avatar_pose_math() -> bool:
if skeleton == null: if skeleton == null:
problems.append(name) problems.append(name)
else: else:
for bone in BONES: var bones: Array = QUATERNIUS_BONES if _is_quaternius(skeleton) else BONES
for bone in bones:
var index := skeleton.find_bone(bone) var index := skeleton.find_bone(bone)
var rest := skeleton.get_bone_rest(index).basis.get_rotation_quaternion() if index < 0:
skeleton.set_bone_pose_rotation(index, rest * Quaternion(Vector3.RIGHT, 0.4)) problems.append("%s missing %s" % [name, bone])
continue
# NpcAvatar._swing_bone(): pose = rest^-1 * swing * rest, applied
# through the global pose. The composition must stay a valid
# rotation on every limb bone of either rig.
var rest := skeleton.get_bone_global_rest(index).basis.get_rotation_quaternion()
var swing := Quaternion(Vector3.RIGHT, 0.4)
skeleton.set_bone_pose_rotation(index, rest.inverse() * swing * rest)
if not skeleton.get_bone_pose_rotation(index).is_normalized(): if not skeleton.get_bone_pose_rotation(index).is_normalized():
problems.append("%s.%s" % [name, bone]) problems.append("%s.%s" % [name, bone])
if node: if node:
+49 -6
View File
@@ -141,8 +141,8 @@ func test_skinned_model_loads_and_keeps_the_head_contract() -> bool:
"a skinned library character is used") "a skinned library character is used")
var b := TestUtils.check(avatar.get_node_or_null("Rig/Head") != null, var b := TestUtils.check(avatar.get_node_or_null("Rig/Head") != null,
"Rig/Head survives the skinned path") "Rig/Head survives the skinned path")
var c := TestUtils.check(avatar._skeleton != null and avatar._bone_indices.has("Head") var c := TestUtils.check(avatar._skeleton != null and avatar.bone_name("head") != ""
and avatar._bone_indices.has("HipL") and avatar._bone_indices.has("HandR"), and avatar.bone_name("hip", 0) != "" and avatar.bone_name("hand", 1) != "",
"armature bones are bound") "armature bones are bound")
avatar.free() avatar.free()
return a and b and c return a and b and c
@@ -152,10 +152,10 @@ func test_skinned_model_loads_and_keeps_the_head_contract() -> bool:
func test_pose_reaches_the_skeleton_bones() -> bool: func test_pose_reaches_the_skeleton_bones() -> bool:
var avatar := _avatar(Rules.NpcType.TOURIST, 55) var avatar := _avatar(Rules.NpcType.TOURIST, 55)
var sk := avatar._skeleton var sk := avatar._skeleton
var hip: int = int(avatar._bone_indices["HipL"]) var hip: int = int(avatar._bone_indices[avatar.bone_name("hip", 0)])
var knee: int = int(avatar._bone_indices["KneeL"]) var knee: int = int(avatar._bone_indices[avatar.bone_name("knee", 0)])
var rest_hip := sk.get_bone_rest(hip).basis.get_rotation_quaternion() var rest_hip := sk.get_bone_pose_rotation(hip)
var rest_knee := sk.get_bone_rest(knee).basis.get_rotation_quaternion() var rest_knee := sk.get_bone_pose_rotation(knee)
avatar.arrived = true avatar.arrived = true
avatar.npc.intent = "sit" avatar.npc.intent = "sit"
for _i in 45: for _i in 45:
@@ -179,6 +179,49 @@ func test_pose_reaches_the_skeleton_bones() -> bool:
avatar.free() avatar.free()
return a and b and c and d return a and b and c and d
## Regression (ADR-0018): the shipped base must resolve every animation joint on
## the rig it actually uses, keep the baked arm-down rest, and put each proxy on
## the matching body side. The Quaternius rig labels its dot-suffixed joints with
## the opposite side (its `L` sits at +x), so `RIG_BONES` is the single mapping and
## a silent side swap would splay the arms across the torso.
##
## The swing itself is proven by the pose-capture gate; `_swing_bone()` writes the
## global pose, which Godot only computes for a skeleton inside the scene tree and
## the headless runner executes tests before the tree is ready.
func test_shipped_rig_resolves_the_animation_joints() -> bool:
var avatar := _avatar(Rules.NpcType.TOURIST, 91)
var sk := avatar._skeleton
var problems: Array[String] = []
for joint in NpcAvatar.RIG_BONES:
for side in 2:
var bone := avatar.bone_name(joint, side)
if bone == "" or not avatar._bone_indices.has(bone):
problems.append("%s/%d unresolved" % [joint, side])
var a := TestUtils.check(problems.is_empty(),
"the shipped rig resolves every animation joint (%s)" % str(problems))
var wrist := avatar.bone_name("hand", 0)
var shoulder := avatar.bone_name("shoulder", 0)
var b := TestUtils.check(wrist != "" and shoulder != "", "shoulder/hand joints resolve")
if not b:
avatar.free()
return false
var shoulder_rest := sk.get_bone_global_rest(int(avatar._bone_indices[shoulder]))
var wrist_rest := sk.get_bone_global_rest(int(avatar._bone_indices[wrist]))
var c := TestUtils.check(shoulder_rest.origin.y - wrist_rest.origin.y > 0.3,
"the shipped base is baked arm-down (%.3f below the shoulder)"
% (shoulder_rest.origin.y - wrist_rest.origin.y))
# A straight-down arm axis is what makes an X swing read as forward/back.
var arm_down := shoulder_rest.basis.y.normalized().dot(Vector3.DOWN)
var d := TestUtils.check(arm_down > 0.95,
"the arm rest axis points down (dot %.3f)" % arm_down)
# Proxy side 0 must drive the -x arm and side 1 the +x arm on either rig.
var left := sk.get_bone_global_rest(int(avatar._bone_indices[avatar.bone_name("shoulder", 0)]))
var right := sk.get_bone_global_rest(int(avatar._bone_indices[avatar.bone_name("shoulder", 1)]))
var e := TestUtils.check(left.origin.x < right.origin.x,
"proxy side 0 drives the -x arm (%.3f vs %.3f)" % [left.origin.x, right.origin.x])
avatar.free()
return a and b and c and d and e
func test_character_selection_is_deterministic_and_complete() -> bool: func test_character_selection_is_deterministic_and_complete() -> bool:
# Seed 5 resolves each role to a fixed pool member; the routine crowd roles # Seed 5 resolves each role to a fixed pool member; the routine crowd roles
# now pick from the crowd-variety pools instead of a single npc_casual. # now pick from the crowd-variety pools instead of a single npc_casual.
+553 -65
View File
@@ -53,14 +53,20 @@ MANIFEST = OUT_DIR / "manifest.json"
SLOTS = ["skin", "cloth", "pants", "accessory", "hair"] SLOTS = ["skin", "cloth", "pants", "accessory", "hair"]
BONES = ["Head", "HipL", "HipR", "KneeL", "KneeR", "ShoulderL", "ShoulderR", BONES = ["Head", "HipL", "HipR", "KneeL", "KneeR", "ShoulderL", "ShoulderR",
"ElbowL", "ElbowR", "HandL", "HandR"] "ElbowL", "ElbowR", "HandL", "HandR"]
# Character cap raised to 16000/16000 on 2026-11-04 for the head/face/hand # ADR-0018: a Quaternius-derived character keeps the upstream 62-joint armature
# overhaul and possible MakeHuman parts. The required 13 core bones stay, but # instead of the 13-core skeleton, so these are the joints `NpcAvatar` maps onto
# optional extra bones (fingers, face, toes) are allowed; they must not move the # the animation proxies and the ones the audit requires.
# core rest positions. The glTF vertex count is after UV-seam splits; each QUATERNIUS_BONES = ["Head", "UpperLeg.L", "UpperLeg.R", "LowerLeg.L", "LowerLeg.R",
# avatar is still one skinned mesh, and `smoke_npc_budget.gd` must confirm the "UpperArm.L", "UpperArm.R", "LowerArm.L", "LowerArm.R",
# 24-NPC frame time before accepting the increase. "Wrist.L", "Wrist.R"]
CHAR_MAX_TRIS = 16000 # Character cap raised to 24000/24000 on 2026-11-05 for the MPFB body, the
CHAR_MAX_VERTS = 16000 # accepted MakeHuman head and a seamless neck/shoulder junction. The required
# 13 core bones stay, but optional extra bones (fingers, face, toes) are
# allowed; they must not move the core rest positions. The glTF vertex count is
# after UV-seam splits; each avatar is still one skinned mesh, and the 24-NPC
# smoke test must confirm frame time before shipping.
CHAR_MAX_TRIS = 24000
CHAR_MAX_VERTS = 24000
GEAR_MAX_TRIS = 1500 GEAR_MAX_TRIS = 1500
PROP_MAX_TRIS = 4000 PROP_MAX_TRIS = 4000
@@ -79,37 +85,47 @@ except ImportError: # pragma: no cover - audit/sheet/list run outside Blender
# Characters map onto NpcAvatar.layer_for() / ACCESSORIES so the runtime can # Characters map onto NpcAvatar.layer_for() / ACCESSORIES so the runtime can
# pick one per avatar later. `tint` lists the slots the runtime should tint; # pick one per avatar later. `tint` lists the slots the runtime should tint;
# every other material keeps its authored colour. # every other material keeps its authored colour.
# `base` selects the CC0 Quaternius source the character is baked from
# (ADR-0017/ADR-0018, owner chose the full roster swap); `drop` removes upstream
# materials that do not belong to the role (the Worker's hivis vest and hard hat).
# Rebuild one procedurally with `build --source procedural --head mh`.
CHARACTERS = { CHARACTERS = {
"npc_casual": {"layer": "none", "hair": "short", "gear": ["backpack", "camera"], "npc_casual": {"layer": "none", "hair": "short", "gear": ["backpack", "camera"],
"base": "hoodie",
"roles": ["tourist", "confused_visitor", "intoxicated", "guest"]}, "roles": ["tourist", "confused_visitor", "intoxicated", "guest"]},
"npc_jacket": {"layer": "jacket", "hair": "short", "gear": ["lanyard", "clipboard"], "npc_jacket": {"layer": "jacket", "hair": "short", "gear": ["lanyard", "clipboard"],
"base": "casual",
"roles": ["authorized_staff", "protester", "guest"]}, "roles": ["authorized_staff", "protester", "guest"]},
"npc_coat": {"layer": "coat", "hair": "long", "npc_coat": {"layer": "coat", "hair": "long", "base": "hoodie",
"gear": ["sunglasses", "shoulder_bag", "phone"], "gear": ["sunglasses", "shoulder_bag", "phone"],
"roles": ["influencer", "trespasser", "violent_threat"]}, "roles": ["influencer", "trespasser", "violent_threat"]},
"npc_vest": {"layer": "vest", "hair": "buzz", "gear": ["cap", "clipboard"], "npc_vest": {"layer": "vest", "hair": "buzz", "gear": ["cap", "clipboard"],
"base": "worker",
"roles": ["delivery"]}, "roles": ["delivery"]},
"npc_dress": {"layer": "dress", "hair": "bun", "build": "slim", "npc_dress": {"layer": "dress", "hair": "bun", "build": "slim", "base": "casual",
"gear": ["shoulder_bag"], "gear": ["shoulder_bag"],
"roles": ["performer"]}, "roles": ["performer"]},
"npc_hoodie": {"layer": "hood", "hair": "short", "gear": ["cup"], "npc_hoodie": {"layer": "hood", "hair": "short", "gear": ["cup"], "base": "hoodie",
"roles": ["trespasser", "mental_crisis", "violent_threat"]}, "roles": ["trespasser", "mental_crisis", "violent_threat"]},
"npc_firefighter": {"layer": "responder", "hair": "buzz", "build": "broad", "npc_firefighter": {"layer": "responder", "hair": "buzz", "build": "broad",
"base": "worker", "drop": ["Worker_Vest", "Worker_Yellow"],
"gear": ["firefighter_helmet", "scba_tank", "flashlight"], "gear": ["firefighter_helmet", "scba_tank", "flashlight"],
"roles": ["responder"]}, "roles": ["responder"]},
"npc_guard": {"layer": "guard", "hair": "buzz", "build": "broad", "npc_guard": {"layer": "guard", "hair": "buzz", "build": "broad",
"base": "worker", "drop": ["Worker_Vest", "Worker_Yellow"],
"gear": ["cap", "wand"], "gear": ["cap", "wand"],
"roles": ["player_guard", "venue_security"]}, "roles": ["player_guard", "venue_security"]},
# Crowd-variety prototypes (character development): vary build, head # Crowd-variety prototypes (character development): the three Quaternius
# silhouette, hair/beard and outer layer without labelling behaviour. # sources are spread across the crowd pools so a crowd never shows one mesh.
"npc_crowd_a": {"layer": "none", "hair": "bun", "build": "slim", "npc_crowd_a": {"layer": "none", "hair": "bun", "build": "slim", "base": "casual",
"gear": ["sunglasses"], "roles": ["tourist", "guest"]}, "gear": ["sunglasses"], "roles": ["tourist", "guest"]},
"npc_crowd_b": {"layer": "none", "hair": "bald", "build": "broad", "npc_crowd_b": {"layer": "none", "hair": "bald", "build": "broad", "base": "casual",
"beard": True, "gear": ["shoulder_bag"], "beard": True, "gear": ["shoulder_bag"],
"roles": ["confused_visitor", "guest"]}, "roles": ["confused_visitor", "guest"]},
"npc_crowd_c": {"layer": "coat", "hair": "short", "npc_crowd_c": {"layer": "coat", "hair": "short", "base": "hoodie",
"gear": ["umbrella"], "roles": ["intoxicated", "guest"]}, "gear": ["umbrella"], "roles": ["intoxicated", "guest"]},
"npc_crowd_d": {"layer": "hood", "hair": "short", "build": "slim", "npc_crowd_d": {"layer": "hood", "hair": "short", "build": "slim",
"base": "worker", "drop": ["Worker_Vest", "Worker_Yellow"],
"gear": ["cup"], "roles": ["tourist", "guest"]}, "gear": ["cup"], "roles": ["tourist", "guest"]},
} }
@@ -127,6 +143,11 @@ ATTACH_BLENDER = {"head": (0.0, 0.0, 1.62), "hand": (0.27, -0.02, 0.78),
ATTACH_GODOT = {"head": (0.0, 1.62, 0.0), "hand": (0.27, 0.78, 0.02), ATTACH_GODOT = {"head": (0.0, 1.62, 0.0), "hand": (0.27, 0.78, 0.02),
"back": (0.0, 1.10, 0.0), "chest": (0.0, 1.30, 0.0)} "back": (0.0, 1.10, 0.0), "chest": (0.0, 1.30, 0.0)}
HEAD_BAND = (1.55, 1.81) # face/headgear height band in Godot metres HEAD_BAND = (1.55, 1.81) # face/headgear height band in Godot metres
# Head gear is authored at the procedural library's head size; the shipped
# Quaternius roster has a 0.20 m head whose centre sits higher (see
# `build_gear`). 0.64 = 0.201 / 0.315.
HEAD_GEAR_SCALE = 0.64
HEAD_GEAR_LIFT = 0.07
FIT_HEAD_MATERIALS = ("skin",) FIT_HEAD_MATERIALS = ("skin",)
FIT_CLOTHING_MATERIAL = "cloth" FIT_CLOTHING_MATERIAL = "cloth"
FIT_CLOTHING_BAND = (0.30, 0.52) # hip/skirt band; captures coat/dress flare FIT_CLOTHING_BAND = (0.30, 0.52) # hip/skirt band; captures coat/dress flare
@@ -276,13 +297,17 @@ def material(name, textured_solid=False):
check_existing=True) check_existing=True)
albedo.image.colorspace_settings.name = "sRGB" albedo.image.colorspace_settings.name = "sRGB"
links.new(albedo.outputs["Color"], bsdf.inputs["Base Color"]) links.new(albedo.outputs["Color"], bsdf.inputs["Base Color"])
normal_tex = nodes.new("ShaderNodeTexImage") # The hair normal map was authored against the old cap UVs. Reusing it
normal_tex.image = bpy.data.images.load( # on the skull-conforming MakeHuman cap produces large dark tangent
str(NPC_TEX_DIR / f"{name}_normal.png"), check_existing=True) # patches, so hair keeps its albedo but takes flat shading normals.
normal_tex.image.colorspace_settings.name = "Non-Color" if name != "hair":
nmap = nodes.new("ShaderNodeNormalMap") normal_tex = nodes.new("ShaderNodeTexImage")
links.new(normal_tex.outputs["Color"], nmap.inputs["Color"]) normal_tex.image = bpy.data.images.load(
links.new(nmap.outputs["Normal"], bsdf.inputs["Normal"]) str(NPC_TEX_DIR / f"{name}_normal.png"), check_existing=True)
normal_tex.image.colorspace_settings.name = "Non-Color"
nmap = nodes.new("ShaderNodeNormalMap")
links.new(normal_tex.outputs["Color"], nmap.inputs["Color"])
links.new(nmap.outputs["Normal"], bsdf.inputs["Normal"])
rough = {"skin": 0.6, "cloth": 0.86, "pants": 0.92, rough = {"skin": 0.6, "cloth": 0.86, "pants": 0.92,
"accessory": 0.5, "hair": 0.7}[name] "accessory": 0.5, "hair": 0.7}[name]
bsdf.inputs["Roughness"].default_value = rough bsdf.inputs["Roughness"].default_value = rough
@@ -550,22 +575,21 @@ def _skin_body(spec):
"guard": 0.05}.get(layer, 0.0) "guard": 0.05}.get(layer, 0.0)
leg = 1.0 + {"responder": 0.1, "dress": -0.1}.get(layer, 0.0) leg = 1.0 + {"responder": 0.1, "dress": -0.1}.get(layer, 0.0)
nodes = [ # (pos, radius_x, radius_y) nodes = [ # (pos, radius_x, radius_y)
((0, 0, 0.80), 0.168 * w, 0.116 * d), # 0 pelvis ((0, 0, 0.80), 0.158 * w, 0.112 * d), # 0 pelvis
((0, 0, 0.96), 0.146 * w * bulk, 0.103 * d * bulk), # 1 waist ((0, 0, 0.96), 0.142 * w * bulk, 0.101 * d * bulk), # 1 waist
((0, 0.0, 1.18), 0.168 * w * bulk, 0.114 * d * bulk), # 2 chest ((0, 0.0, 1.18), 0.155 * w * bulk, 0.110 * d * bulk), # 2 chest
((0, 0.0, 1.355), 0.170 * w * bulk, 0.108 * d * bulk), # 3 upper chest ((0, 0.0, 1.335), 0.158 * w * bulk, 0.105 * d * bulk), # 3 upper chest
# A slightly fuller neck base and a lower shoulder root give the # Narrow neck and a lower, sloped shoulder root. The old wide neck base
# head/neck/shoulder line a trapezius slope instead of a thin post on # and horizontal shoulder shelf made the accepted head read as small.
# a hanger. The head and collar still sit above this band. ((0, 0, 1.42), 0.060, 0.060), # 4 neck base
((0, 0, 1.43), 0.074, 0.072), # 4 neck base ((0, -0.005, 1.53), 0.054, 0.054), # 5 neck top
((0, -0.005, 1.53), 0.060, 0.060), # 5 neck top
] ]
edges = [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5)] edges = [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5)]
for s in (-1.0, 1.0): for s in (-1.0, 1.0):
base = len(nodes) base = len(nodes)
nodes += [ nodes += [
((0.20 * s * w, 0, 1.37), 0.080 * t * sleeve, 0.074 * t * sleeve), # shoulder ((0.19 * s * w, 0, 1.345), 0.070 * t * sleeve, 0.068 * t * sleeve), # shoulder
((0.27 * s * w, 0, 1.23), 0.058 * t * sleeve, 0.058 * t * sleeve), # upper arm ((0.27 * s * w, 0, 1.22), 0.054 * t * sleeve, 0.055 * t * sleeve), # upper arm
((0.275 * s * w, 0.005, 1.06), 0.046 * t * sleeve, 0.048 * t * sleeve), # elbow ((0.275 * s * w, 0.005, 1.06), 0.046 * t * sleeve, 0.048 * t * sleeve), # elbow
((0.275 * s * w, -0.01, 0.78), 0.034 * t, 0.030 * t), # wrist ((0.275 * s * w, -0.01, 0.78), 0.034 * t, 0.030 * t), # wrist
] ]
@@ -591,7 +615,7 @@ def _skin_body(spec):
sub = obj.modifiers.new("Sub", "SUBSURF") sub = obj.modifiers.new("Sub", "SUBSURF")
sub.levels = 2 sub.levels = 2
dec = obj.modifiers.new("Decimate", "DECIMATE") dec = obj.modifiers.new("Decimate", "DECIMATE")
dec.ratio = 0.26 dec.ratio = 0.21
dec.use_symmetry = True dec.use_symmetry = True
dec.symmetry_axis = "X" dec.symmetry_axis = "X"
_apply_all(obj) _apply_all(obj)
@@ -716,6 +740,102 @@ def _hair_cap(name, mat, front_z, back_z, radius=0.163, center=(0, 0.008, 1.645)
return _finish(_link(name, bm), mat, smooth=True, sharp_angle=70) return _finish(_link(name, bm), mat, smooth=True, sharp_angle=70)
def _hair_curve(style):
"""Hairline endpoints and skull offset for a hair style."""
if style == "buzz":
return 1.734, 1.608, 1.035
return 1.714, 1.588, 1.055
def _trim_hidden_scalp(head, style):
"""Delete the scalp that sits under the hair cap to free triangle budget.
The cap is a scaled copy of the skull, so any head face whose scaled
vertices all fall outside the hairline sphere is fully covered. Keeping
boundary-touching faces leaves a safety band under the hair rim; the open
hole is never visible and the accepted face geometry is untouched.
"""
front_z, back_z, scale = _hair_curve(style)
center = Vector((0.0, 0.0, 1.665))
cutter_z = (front_z + back_z) * 0.5 - 0.396
bm = bmesh.new()
bm.from_mesh(head.data)
def covered(p):
q = center + (p - center) * scale
return (q.x * q.x + (q.y + 0.41) ** 2
+ (q.z - cutter_z) ** 2) > 0.56 ** 2
doomed = [f for f in bm.faces if all(covered(v.co) for v in f.verts)]
if doomed:
bmesh.ops.delete(bm, geom=doomed, context="FACES")
loose = [v for v in bm.verts if not v.link_faces]
if loose:
bmesh.ops.delete(bm, geom=loose, context="VERTS")
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
bm.to_mesh(head.data)
bm.free()
def _hair_from_head(source, style, mat_hair):
"""Build the hair cap as a solid shell derived from the skull.
A separate hair ellipsoid cannot follow the broad MakeHuman crown, and an
open, face-deleted copy produces a sawtooth hairline. Scaling the closed
head shell outwards and boolean-cutting the face with a large front sphere
gives a watertight cap whose curved hairline is smooth from every angle.
"""
front_z, back_z, scale = _hair_curve(style)
cap = source.copy()
cap.data = source.data.copy()
cap.name = "HairCap"
bpy.context.scene.collection.objects.link(cap)
center = Vector((0.0, 0.0, 1.665))
for v in cap.data.vertices:
v.co = center + (v.co - center) * scale
# The cutter is a big sphere in front of the face: its upper surface rises
# toward the brow and falls toward the nape, which is the hairline curve.
mid_z = (front_z + back_z) * 0.5
cutter_z = mid_z - 0.396
bpy.ops.mesh.primitive_uv_sphere_add(segments=32, ring_count=20, radius=0.56,
location=(0.0, -0.41, cutter_z))
cutter = bpy.context.object
bpy.context.view_layer.objects.active = cap
mod = cap.modifiers.new("Hairline", "BOOLEAN")
mod.operation = "DIFFERENCE"
mod.solver = "EXACT"
mod.object = cutter
bpy.ops.object.modifier_apply(modifier=mod.name)
bpy.data.objects.remove(cutter, do_unlink=True)
# The boolean result is still head-density. Hair needs silhouette, not
# pores, and the character budget has to stay under 16k triangles.
dec = cap.modifiers.new("HairReduce", "DECIMATE")
dec.ratio = 0.34
dec.use_collapse_triangulate = True
bpy.ops.object.modifier_apply(modifier=dec.name)
bm = bmesh.new()
bm.from_mesh(cap.data)
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
bm.to_mesh(cap.data)
bm.free()
cap.data.materials.clear()
cap.data.materials.append(mat_hair)
cap.data.shade_smooth()
cap.data.set_sharp_from_angle(angle=math.radians(55))
parts = [cap]
if style == "long":
parts.append(sphere("HairBack", 0.095, (0, 0.060, 1.560), mat_hair,
seg=14, rings=9, scale=(0.98, 0.55, 1.55)))
for s in (-1.0, 1.0):
parts.append(sphere("Lock", 0.048, (0.089 * s, -0.012, 1.585),
mat_hair, seg=8, rings=6,
scale=(0.55, 0.85, 2.05)))
if style == "bun":
parts.append(sphere("Bun", 0.060, (0, 0.100, 1.775), mat_hair,
seg=12, rings=8, scale=(1.0, 0.9, 0.95)))
return parts
def _hair(style, mat_hair, broad=False): def _hair(style, mat_hair, broad=False):
parts = [] parts = []
if style == "buzz": if style == "buzz":
@@ -968,11 +1088,12 @@ def _head_proto(mats, spec, subdiv=48):
def _makehuman_head(mats, spec): def _makehuman_head(mats, spec):
"""MakeHuman head shape plus the project's eye/mouth/brow pieces. """MakeHuman head shape plus the project's eye/mouth/brow pieces.
The AI-judged head rework (2026-11-04) picked the decimated MakeHuman head The AI-judged head rework (2026-11-04) picked the MakeHuman head over the
over the displacement shell: it keeps the nose/chin/jaw landmarks at 1,573 displacement shell: it keeps the nose/chin/jaw landmarks. Newer part builds
tris. The MakeHuman mesh has no eyeballs, so the project's sclera/iris/lid/ carry their own embedded eye/iris/pupil/mouth geometry, so those materials
brow pieces are added at the MakeHuman landmarks (eyes ≈ ±0.052, −0.160, are preserved; the older shell-only asset still gets the project's
1.664) for readability. Blender Z-up, face front −Y. sclera/iris/lid/brow pieces added at the MakeHuman landmarks. Blender Z-up,
face front −Y.
""" """
skin, acc, hair = mats["skin"], mats["accessory"], mats["hair"] skin, acc, hair = mats["skin"], mats["accessory"], mats["hair"]
white = material("eye_white", textured_solid=True) white = material("eye_white", textured_solid=True)
@@ -991,9 +1112,32 @@ def _makehuman_head(mats, spec):
if head is None: if head is None:
raise SystemExit(f"no head mesh in {src}") raise SystemExit(f"no head mesh in {src}")
head.name = "HeadShell" head.name = "HeadShell"
# The decimated MakeHuman "hair" scalp slot reads as shaggy shards; collapse # Newer MakeHuman part builds carry their own eye/mouth/brow materials and
# the head to one skin slot so the join does not collide with the body's # geometry. Use those instead of adding the project's primitive face pieces
# cloth/pants slots (the project `_hair` cap provides hair later). # a second time; only the old shell-only asset falls through to that path.
embedded = {m.name.split(".")[0] for m in head.data.materials if m}
if "eye_white" in embedded:
# Preserve the embedded iris/pupil/mouth materials; only map the
# project contract slots onto the imported head.
remap = {"skin": skin, "eye_white": white, "hair": hair}
for index, mat in enumerate(list(head.data.materials)):
if mat:
target = remap.get(mat.name.split(".")[0])
if target is not None:
head.data.materials[index] = target
# Bridge the 1.48-1.53 m neck gap between the MakeHuman cut and the
# procedurally built torso. The plug sits inside both shells, so only
# the visible band reads as neck and no seam ring reaches the surface.
plug = cyl("NeckPlug", 0.062, 0.052, 0.095, (0, 0.002, 1.500), skin,
seg=12, scale=(1.10, 1.0, 1.0))
hair_style = "buzz" if spec["layer"] == "hood" else spec["hair"]
hair_parts = _hair_from_head(head, hair_style, hair)
_trim_hidden_scalp(head, hair_style)
return [head, plug] + hair_parts
# The old decimated MakeHuman "hair" scalp slot reads as shaggy shards;
# collapse the head to one skin slot so the join does not collide with the
# body's cloth/pants slots (the project `_hair` cap provides hair later).
head.data.materials.clear() head.data.materials.clear()
head.data.materials.append(skin) head.data.materials.append(skin)
# Clean the ragged neck cut left by the Head-group extraction: bisect below # Clean the ragged neck cut left by the Head-group extraction: bisect below
@@ -1229,7 +1373,8 @@ def build_character(name, head="face"):
else: else:
head_parts = _face(mats, spec) head_parts = _face(mats, spec)
hair_style = spec["hair"] if spec["layer"] != "hood" else "buzz" hair_style = spec["hair"] if spec["layer"] != "hood" else "buzz"
head_parts += _hair(hair_style, mats["hair"]) if head != "mh":
head_parts += _hair(hair_style, mats["hair"])
head = join(head_parts, "head") head = join(head_parts, "head")
_auto_weights(head, layout, rigid=lambda p: "Head") _auto_weights(head, layout, rigid=lambda p: "Head")
@@ -1309,6 +1454,312 @@ def build_character(name, head="face"):
return [arm, mesh] return [arm, mesh]
# ---------------------------------------------------------------------------
# Quaternius roster (ADR-0017 / ADR-0018)
# ---------------------------------------------------------------------------
#
# The CC0 sources live under `blender_src/quaternius/` (gitignored for Godot via
# the parent `.gdignore`) and are build inputs, not runtime assets. Every source
# is 4 skinned meshes on the same 62-joint armature; the build joins them
# **without welding** (a weld corrupts the skin weights), renames the upstream
# materials onto the five tint slots, unwraps the degenerate source UVs and bakes
# the arm-down rest. See docs/npc-model-sourcing.md for the measurements.
QUATERNIUS_SRC = SRC_DIR / "quaternius"
# Material -> contract slot per source. `"keep"` leaves the authored colour
# (face, hair detail and hivis fabrics); anything not listed is kept too, so a
# new upstream material shows up as an extra fixed-colour slot instead of
# silently disappearing. The maps were derived from the per-material height bands
# measured in docs/npc-model-sourcing.md.
QUATERNIUS_SOURCES = {
"casual": {
"file": "Casual.glb",
"slots": {"Skin": "skin", "White": "cloth", "Orange": "pants",
"Grey": "accessory", "Hair_Blond": "hair",
"Brown": "keep", "Hair_Brown": "keep"},
"notes": "white tee + orange trousers + grey shoes; blond hair",
},
"hoodie": {
"file": "Casual_Hoodie.glb",
"slots": {"Skin": "skin", "Purple": "cloth", "LightBlue": "pants",
"Hair": "hair", "White": "accessory",
"Eye": "keep", "Eyebrows": "keep"},
"notes": "hoodie + shorts + white shoes (the hoodie covers the shoe uppers)",
},
"worker": {
"file": "Worker.glb",
"slots": {"Skin": "skin", "LightBrown": "cloth", "Brown": "pants",
"Brown2": "pants", "Grey": "accessory", "Black": "accessory",
# The Worker's head is bare apart from a hard hat, so the
# brows carry the hair tint and the slot keeps a surface.
"Eyebrows": "hair",
"Eye": "keep", "Moustache": "keep",
"Worker_Vest": "keep", "Worker_Yellow": "keep"},
"notes": "work shirt + trousers + hivis vest + hard hat",
},
}
def _quaternius_slot_map(obj, slot_map, drop):
"""Rename the upstream materials onto the contract slots.
Several upstream materials can share a slot (trousers + patches), so the
polygons are repointed at one Blender material instead of relying on a
rename that Blender would de-duplicate to `pants.001`.
"""
materials = [m for m in obj.data.materials if m is not None]
dropped = {i for i, m in enumerate(materials) if m.name in drop}
kept = []
target = {}
remap = {}
for index, m in enumerate(materials):
slot = slot_map.get(m.name, "keep")
if slot == "keep":
if m.name not in target:
target[m.name] = len(kept)
kept.append(m)
remap[index] = target[m.name]
continue
if slot in target:
remap[index] = target[slot]
continue
m.name = slot
target[slot] = len(kept)
kept.append(m)
remap[index] = target[slot]
# Faces that only exist for a dropped material go away with it.
if dropped:
doomed = [p.index for p in obj.data.polygons
if p.material_index in dropped]
if doomed:
bm = bmesh.new()
bm.from_mesh(obj.data)
bm.faces.ensure_lookup_table()
bmesh.ops.delete(bm, geom=[bm.faces[i] for i in doomed],
context="FACES")
bm.to_mesh(obj.data)
bm.free()
# Loose vertices left behind by the helmet/vest would export as an
# unreferenced blob, so drop them before the UV pass.
bm = bmesh.new()
bm.from_mesh(obj.data)
loose = [v for v in bm.verts if not v.link_faces]
if loose:
bmesh.ops.delete(bm, geom=loose, context="VERTS")
bm.to_mesh(obj.data)
bm.free()
# `materials.clear()` resets every polygon to slot 0, so record the source
# indices first or the whole mesh collapses onto the first slot.
source_index = [poly.material_index for poly in obj.data.polygons]
obj.data.materials.clear()
for m in kept:
obj.data.materials.append(m)
for poly, index in zip(obj.data.polygons, source_index):
poly.material_index = remap.get(index, 0)
def _bake_quaternius_rest(arm, mesh):
"""Bake the T-pose into an arm-down rest pose.
This runs on the re-imported T-pose export, not on the raw upstream file.
The order matters: pose, read the posed matrices, apply the Armature
modifier, **reset the pose to identity**, write the rest matrices, re-add the
modifier. Skipping the reset double-applies the arm-down (mesh width ±0.53 m
instead of ±0.23 m); `bpy.ops.object.armature_apply()` in Blender 5.2
deforms the mesh without baking the rest and is not usable here.
"""
bpy.context.view_layer.objects.active = arm
bpy.ops.object.mode_set(mode="POSE")
for side in ("L", "R"):
pose_bone = arm.pose.bones.get("UpperArm." + side)
if pose_bone is None:
continue
pose_bone.rotation_mode = "XYZ"
# Local X swings the arm in the frontal plane. This is only symmetric on
# *our* exported rest: the raw upstream file mirrors the bone rolls, so
# the build always round-trips through `_quaternius_tpose_glb()` first.
pose_bone.rotation_euler = (-math.pi / 2, 0, 0)
bpy.ops.object.mode_set(mode="OBJECT")
bpy.context.view_layer.update()
for side in ("L", "R"):
wrist = arm.pose.bones.get("Wrist." + side)
if wrist is not None:
head = arm.matrix_world @ wrist.head
print(f" wrist.{side} at x={head.x:+.3f} y={head.y:+.3f} z={head.z:+.3f}")
fingers = ("Thumb", "Index", "Middle", "Ring", "Pinky")
posed = {}
for side in ("L", "R"):
for bone in arm.data.bones:
name = bone.name
if not name.endswith(side):
continue
if name.startswith(("UpperArm", "LowerArm", "Wrist")) or name.startswith(fingers):
posed[name] = arm.pose.bones[name].matrix.copy()
arm_modifier = next(m for m in mesh.modifiers if m.type == "ARMATURE")
bpy.context.view_layer.objects.active = mesh
bpy.ops.object.modifier_apply(modifier=arm_modifier.name)
bpy.context.view_layer.objects.active = arm
bpy.ops.object.mode_set(mode="POSE")
for pose_bone in arm.pose.bones:
pose_bone.matrix_basis.identity()
bpy.ops.object.mode_set(mode="OBJECT")
bpy.context.view_layer.update()
bpy.context.view_layer.objects.active = arm
bpy.ops.object.mode_set(mode="EDIT")
for name, matrix in posed.items():
arm.data.edit_bones[name].matrix = matrix
bpy.ops.object.mode_set(mode="OBJECT")
bpy.context.view_layer.update()
bpy.context.view_layer.objects.active = mesh
modifier = mesh.modifiers.new("Armature", "ARMATURE")
modifier.object = arm
bpy.context.view_layer.update()
evaluated = mesh.evaluated_get(bpy.context.evaluated_depsgraph_get())
xs = [v.co.x for v in evaluated.data.vertices]
print(f" arm-down rest baked, deformed x {min(xs):.3f}..{max(xs):.3f}")
def _export_preserving_skin(objs, category, name):
"""Export a skinned GLB without touching the rest pose.
`export_apply=True` (what `export()` uses for the procedural library) bakes
the evaluated deformation a second time and writes wrong inverse bind
matrices, so Godot then renders the A-pose.
"""
path = asset_path(category, name)
path.parent.mkdir(parents=True, exist_ok=True)
for o in bpy.context.selected_objects:
o.select_set(False)
for o in objs:
o.select_set(True)
bpy.context.view_layer.objects.active = objs[0]
bpy.ops.export_scene.gltf(
filepath=str(path), export_format="GLB", use_selection=True,
export_apply=False, export_animations=False, export_skins=True,
export_yup=True, export_materials="EXPORT", export_image_format="AUTO")
SRC_DIR.mkdir(parents=True, exist_ok=True)
bpy.ops.wm.save_as_mainfile(filepath=str(SRC_DIR / f"{name}.blend"),
compress=True)
return path
def _quaternius_work_dir():
path = ROOT / "tmp" / "npc-build"
path.mkdir(parents=True, exist_ok=True)
return path
def _quaternius_tpose_glb(base, drop):
"""Export (and cache) the joined, slot-mapped, unwrapped T-pose base.
The arm-down bake only behaves symmetrically on our own export, because the
upstream file mirrors the bone rolls; this round trip normalises them. The
result depends only on the source and the dropped materials, so it is cached
for the run.
"""
key = base + ("-" + "-".join(sorted(drop)) if drop else "")
path = _quaternius_work_dir() / f"tpose_{key}.glb"
if path.is_file():
return path
source = QUATERNIUS_SOURCES[base]
source_path = QUATERNIUS_SRC / source["file"]
if not source_path.is_file():
raise FileNotFoundError(
f"{source_path} is missing; see {QUATERNIUS_SRC / 'README.md'}")
reset_scene()
bpy.ops.import_scene.gltf(filepath=str(source_path))
# The importer brings an Icosphere helper along; it is not part of the body
# and has no UVs or weights.
for o in list(bpy.context.scene.objects):
if o.name.startswith("Icosphere"):
bpy.data.objects.remove(o, do_unlink=True)
meshes = [o for o in bpy.context.scene.objects if o.type == "MESH"]
if not meshes:
raise RuntimeError(f"{source_path} imported no skinned meshes")
arm = next(o for o in bpy.context.scene.objects if o.type == "ARMATURE")
# Join without welding: remove_doubles merges vertices across weight groups
# and the resulting skinning is wrong (see MEMORY.md).
for o in bpy.context.scene.objects:
o.select_set(False)
for o in meshes:
o.select_set(True)
bpy.context.view_layer.objects.active = meshes[0]
bpy.ops.object.join()
obj = bpy.context.object
obj.name = "npc_base"
obj.data.name = "npc_base"
_quaternius_slot_map(obj, source["slots"], drop)
# Upstream UVs are degenerate (every loop at 0,0), so an AO bake would write
# an all-black map. Repack a real layout at a tight island margin (the
# default 0.01 wastes most of the square on a dense body), then scale it to
# fill the square so the 256-square maps keep a usable texel density.
for o in bpy.context.selected_objects:
o.select_set(False)
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action="SELECT")
bpy.ops.uv.smart_project(angle_limit=math.radians(66.0), island_margin=0.0005)
bpy.ops.uv.average_islands_scale()
bpy.ops.uv.pack_islands(margin=0.0015, rotate=True)
bpy.ops.object.mode_set(mode="OBJECT")
uv_layer = obj.data.uv_layers.active
loops = uv_layer.data
us = [d.uv[0] for d in loops]
vs = [d.uv[1] for d in loops]
span = max(max(us) - min(us), max(vs) - min(vs))
if span > 1e-6:
scale = 0.98 / span
low_u, low_v = min(us), min(vs)
for d in loops:
d.uv = ((d.uv[0] - low_u) * scale + 0.01,
(d.uv[1] - low_v) * scale + 0.01)
# Only the five contract slots are tinted at runtime; make sure a source that
# does not paint with one of them still declares it.
present = {m.name for m in obj.data.materials if m}
for slot in SLOTS:
if slot not in present:
obj.data.materials.append(bpy.data.materials.get(slot)
or material(slot))
for o in bpy.context.scene.objects:
o.select_set(False)
arm.select_set(True)
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
path.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.export_scene.gltf(
filepath=str(path), export_format="GLB", use_selection=True,
export_apply=False, export_animations=False, export_skins=True,
export_yup=True, export_materials="EXPORT", export_image_format="AUTO")
print(f" T-pose base {path.relative_to(ROOT)}")
return path
def build_quaternius_character(name):
"""Bake one roster character from its CC0 Quaternius source."""
spec = CHARACTERS[name]
tpose = _quaternius_tpose_glb(spec["base"], spec.get("drop", []))
reset_scene()
bpy.ops.import_scene.gltf(filepath=str(tpose))
arm = next(o for o in bpy.context.scene.objects if o.type == "ARMATURE")
mesh = next(o for o in bpy.context.scene.objects if o.type == "MESH")
mesh.name = "npc_base"
mesh.data.name = "npc_base"
_bake_quaternius_rest(arm, mesh)
return [arm, mesh]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Gear (static, attach-point space; see GEAR_ATTACH) # Gear (static, attach-point space; see GEAR_ATTACH)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1362,13 +1813,14 @@ def build_gear(name):
bevel=0.02, segs=2)) bevel=0.02, segs=2))
p.append(box("Zip", (0.24, 0.005, 0.01), (0, 0.317, 0.05), M("steel"))) p.append(box("Zip", (0.24, 0.005, 0.01), (0, 0.317, 0.05), M("steel")))
for s in (-1.0, 1.0): for s in (-1.0, 1.0):
# Strap path: over the shoulder, then down the chest outside the # Strap path: over the shoulder, then down the chest. The chest points
# torso front (Godot z≈0.21). The old chest/waist points sat at # are pulled in to x 0.12 / Godot z 0.15 so the straps hug the slimmer
# z≈0.13 inside the body, so only thin tube fragments read as two # Quaternius torso (ADR-0018) as well as the wider procedural one;
# rigid black bars from the front. # the earlier x 0.145 / z 0.19 path floated ~5 cm proud of the
p.append(tube("Strap", [(0.09 * s, 0.13, 0.27), (0.11 * s, -0.02, 0.29), # Quaternius chest and read as two detached blue bars.
(0.12 * s, -0.06, 0.25), p.append(tube("Strap", [(0.075 * s, 0.13, 0.27), (0.095 * s, -0.02, 0.29),
(0.14 * s, -0.19, 0.18), (0.145 * s, -0.19, -0.08)], (0.105 * s, -0.06, 0.25),
(0.12 * s, -0.15, 0.18), (0.125 * s, -0.15, -0.08)],
0.016, M("denim_bag"), seg=8, width_scale=1.35, 0.016, M("denim_bag"), seg=8, width_scale=1.35,
thickness_scale=0.42)) thickness_scale=0.42))
elif name == "shoulder_bag": elif name == "shoulder_bag":
@@ -1431,9 +1883,13 @@ def build_gear(name):
rot=(math.radians(90), 0, 0))) rot=(math.radians(90), 0, 0)))
p.append(box("Prism", (0.05, 0.05, 0.03), (0, -0.04, 0.095), M("plastic_black"), p.append(box("Prism", (0.05, 0.05, 0.03), (0, -0.04, 0.095), M("plastic_black"),
bevel=0.006)) bevel=0.006))
p.append(tube("Strap", [(-0.065, -0.04, 0.06), (-0.10, 0.0, 0.25), (0.0, 0.05, 0.42), # The hand attach point hangs off the wrist, so a full neck strap would
(0.10, 0.0, 0.25), (0.065, -0.04, 0.06)], 0.006, # float beside the head instead of resting on the neck. A short wrist
M("plastic_black"), seg=5)) # loop keeps every hand item self-contained and follows the hand.
p.append(tube("WristStrap", [(-0.045, -0.02, 0.02), (-0.05, -0.035, 0.075),
(0.0, -0.045, 0.10), (0.05, -0.035, 0.075),
(0.045, -0.02, 0.02)], 0.005, M("plastic_black"),
seg=5, caps=False))
elif name == "protest_sign": elif name == "protest_sign":
p.append(box("Pole", (0.03, 0.03, 1.1), (0, 0, 0.35), M("wood"))) p.append(box("Pole", (0.03, 0.03, 1.1), (0, 0, 0.35), M("wood")))
p.append(box("Board", (0.62, 0.012, 0.42), (0, -0.025, 0.82), M("cardboard"), p.append(box("Board", (0.62, 0.012, 0.42), (0, -0.025, 0.82), M("cardboard"),
@@ -1467,7 +1923,19 @@ def build_gear(name):
else: else:
raise KeyError(name) raise KeyError(name)
obj = join(p, name) obj = join(p, name)
if GEAR[name] == "hand": # origin = Hand proxy (wrist); grip 5 cm lower if GEAR[name] == "head":
# Head gear is authored around the head *centre*. The shipped roster is
# the Quaternius base (ADR-0018): its head is 0.20 m wide against the
# procedural library's 0.315 m, and its centre sits 0.07 m higher, so the
# head gear is scaled and lifted to match. The procedural fallback then
# wears slightly small hats — cosmetic, and recorded in
# docs/blender-assets.md.
centre = Vector((0.0, 0.0, HEAD_GEAR_LIFT))
obj.data.transform(
Matrix.Translation(centre)
@ Matrix.Diagonal((HEAD_GEAR_SCALE, HEAD_GEAR_SCALE,
HEAD_GEAR_SCALE, 1.0)))
elif GEAR[name] == "hand": # origin = Hand proxy (wrist); grip 5 cm lower
obj.data.transform(Matrix.Translation((0, 0, -0.05))) obj.data.transform(Matrix.Translation((0, 0, -0.05)))
uv_unwrap(obj) uv_unwrap(obj)
return [obj] return [obj]
@@ -1795,15 +2263,18 @@ def build_prop(name):
# Build + render entry points (inside Blender) # Build + render entry points (inside Blender)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _build_one(category, name): def _build_one(category, name, head="face", source="quaternius"):
reset_scene() reset_scene()
quaternius = (category == "npc" and source == "quaternius"
and "base" in CHARACTERS[name])
if category == "npc": if category == "npc":
objs = build_character(name) objs = (build_quaternius_character(name) if quaternius
else build_character(name, head=head))
elif category == "npc_gear": elif category == "npc_gear":
objs = build_gear(name) objs = build_gear(name)
else: else:
objs = build_prop(name) objs = build_prop(name)
path = export(objs, category, name) path = (_export_preserving_skin if quaternius else export)(objs, category, name)
print(f"wrote {path.relative_to(ROOT)}") print(f"wrote {path.relative_to(ROOT)}")
return path return path
@@ -2040,7 +2511,8 @@ def audit_asset(category, name):
nodes = gltf.get("nodes", []) nodes = gltf.get("nodes", [])
bones = [nodes[j].get("name", "") for s in gltf.get("skins", []) bones = [nodes[j].get("name", "") for s in gltf.get("skins", [])
for j in s.get("joints", [])] for j in s.get("joints", [])]
for b in BONES: required = QUATERNIUS_BONES if "UpperArm.L" in bones else BONES
for b in required:
if b not in bones: if b not in bones:
res["issues"].append(f"missing bone {b}") res["issues"].append(f"missing bone {b}")
if not any(n.get("name") == "npc_base" and "mesh" in n for n in nodes): if not any(n.get("name") == "npc_base" and "mesh" in n for n in nodes):
@@ -2202,7 +2674,13 @@ def analyse_inspection(stats: dict) -> dict:
bones = list(stats.get("bones", [])) bones = list(stats.get("bones", []))
materials = list(stats.get("materials", [])) materials = list(stats.get("materials", []))
bones_ok = {b for b in BONES if b in bones} bones_ok = {b for b in BONES if b in bones}
needed = set(BONES) | {"Hips", "Spine", "Neck"} # ADR-0018: a Quaternius-derived character keeps the 62-joint upstream
# armature, so the required set is the joints `NpcAvatar` maps instead of the
# 13-core skeleton.
if "UpperArm.L" in bones:
needed = set(QUATERNIUS_BONES)
else:
needed = set(BONES) | {"Hips", "Spine", "Neck"}
if not meshes: if not meshes:
issues.append("no mesh objects") issues.append("no mesh objects")
@@ -2362,7 +2840,7 @@ def inspect_glb(path, category=None) -> dict:
return result return result
def adapt_glb(path, out_path, target_tris=16000): def adapt_glb(path, out_path, target_tris=24000):
"""Import an external GLB and adapt it toward the library contract. """Import an external GLB and adapt it toward the library contract.
Applies node transforms, grounds the feet (lowest vertex at y = 0), welds Applies node transforms, grounds the feet (lowest vertex at y = 0), welds
@@ -2698,7 +3176,8 @@ def write_manifest(results):
if r["category"] == "npc": if r["category"] == "npc":
spec = CHARACTERS[r["name"]] spec = CHARACTERS[r["name"]]
entry.update({"layer": spec["layer"], "roles": spec["roles"], entry.update({"layer": spec["layer"], "roles": spec["roles"],
"tint_slots": SLOTS, "mesh_node": "npc_base"}) "tint_slots": SLOTS, "mesh_node": "npc_base",
"base": spec.get("base", "procedural")})
elif r["category"] == "npc_gear": elif r["category"] == "npc_gear":
entry["attach"] = GEAR[r["name"]] entry["attach"] = GEAR[r["name"]]
else: else:
@@ -2748,6 +3227,12 @@ def main(argv=None):
for cmd in ("build", "render"): for cmd in ("build", "render"):
s = sub.add_parser(cmd) s = sub.add_parser(cmd)
s.add_argument("--only", default="") s.add_argument("--only", default="")
if cmd == "build":
s.add_argument("--head", default="face", choices=("face", "proto", "mh"),
help="procedural character head source (default: face)")
s.add_argument("--source", default="quaternius",
choices=("quaternius", "procedural"),
help="character mesh source (default: the Quaternius roster)")
a = sub.add_parser("audit") a = sub.add_parser("audit")
a.add_argument("--json", action="store_true") a.add_argument("--json", action="store_true")
fit = sub.add_parser("fit") fit = sub.add_parser("fit")
@@ -2765,8 +3250,8 @@ def main(argv=None):
ada.add_argument("--file", required=True, ada.add_argument("--file", required=True,
help="external GLB to adapt (absolute path; runs in Blender)") help="external GLB to adapt (absolute path; runs in Blender)")
ada.add_argument("--out", required=True, help="where to write the adapted GLB") ada.add_argument("--out", required=True, help="where to write the adapted GLB")
ada.add_argument("--target-tris", type=int, default=16000, ada.add_argument("--target-tris", type=int, default=24000,
help="decimate target (default 16000)") help="decimate target (default 24000)")
tex = sub.add_parser("texels", help="per-slot texel density of the character GLBs") tex = sub.add_parser("texels", help="per-slot texel density of the character GLBs")
tex.add_argument("--only", default="") tex.add_argument("--only", default="")
tex.add_argument("--json", action="store_true") tex.add_argument("--json", action="store_true")
@@ -2838,7 +3323,10 @@ def main(argv=None):
file=sys.stderr) file=sys.stderr)
return 2 return 2
for cat, name in _selected(args.only): for cat, name in _selected(args.only):
(_build_one if args.cmd == "build" else _render_one)(cat, name) if args.cmd == "build":
_build_one(cat, name, head=args.head, source=args.source)
else:
_render_one(cat, name)
if args.cmd == "build": if args.cmd == "build":
results = [audit_asset(c, n) for c, n in catalogue()] results = [audit_asset(c, n) for c, n in catalogue()]
write_manifest([r for r in results if r["vertices"]]) write_manifest([r for r in results if r["vertices"]])
+4
View File
@@ -90,5 +90,9 @@ echo ""
echo "Artifacts:" echo "Artifacts:"
ls -la build/*"${VERSION}"* ls -la build/*"${VERSION}"*
echo "" echo ""
echo "Checksums:"
shasum -a 256 build/*"${VERSION}"* > "build/SHA256SUMS-${VERSION}.txt"
cat "build/SHA256SUMS-${VERSION}.txt"
echo ""
echo "Upload to: https://git.allucanget.biz/zwitschi/security-game/releases" echo "Upload to: https://git.allucanget.biz/zwitschi/security-game/releases"
echo "See docs/RELEASING.md for the full release checklist." echo "See docs/RELEASING.md for the full release checklist."
+3 -3
View File
@@ -23,7 +23,7 @@ Contract (frozen in ADR-0013 and docs/blender-assets.md):
(plus Hips/Spine/Neck as parents) (plus Hips/Spine/Neck as parents)
* material slots: skin, cloth, pants, accessory, hair * material slots: skin, cloth, pants, accessory, hair
* neutral albedo + normal + packed ORM maps, albedo tinted at runtime * neutral albedo + normal + packed ORM maps, albedo tinted at runtime
* <= 16000 triangles / 16000 vertices, UVs in [0,1], feet at y ~= 0 * <= 24000 triangles / 24000 vertices, UVs in [0,1], feet at y ~= 0
* the 13 core bones are required; extra optional bones are allowed * the 13 core bones are required; extra optional bones are allowed
""" """
@@ -45,8 +45,8 @@ BONES = [
"Head", "HipL", "HipR", "KneeL", "KneeR", "Head", "HipL", "HipR", "KneeL", "KneeR",
"ShoulderL", "ShoulderR", "ElbowL", "ElbowR", "HandL", "HandR", "ShoulderL", "ShoulderR", "ElbowL", "ElbowR", "HandL", "HandR",
] ]
MAX_TRIANGLES = 16000 MAX_TRIANGLES = 24000
MAX_VERTICES = 16000 MAX_VERTICES = 24000
TEX_SIZE = 256 TEX_SIZE = 256
AI_DIR = TEX_DIR / "ai" AI_DIR = TEX_DIR / "ai"
# Base material slots that get an AI fabric albedo (`prepare` layers them over # Base material slots that get an AI fabric albedo (`prepare` layers them over
+42 -4
View File
@@ -78,8 +78,18 @@ def joint_positions(gltf):
for child in node.get("children", []): for child in node.get("children", []):
walk(child, world, child_q) walk(child, world, child_q)
# The Blender library exports the armature root as "Armature"; the Quaternius
# sources call it "CharacterArmature". Resolve it from the skin instead of the
# name so a rebuilt rig cannot silently return an empty joint map.
skin_joints = set(gltf["skins"][0]["joints"]) if gltf.get("skins") else set()
def holds_joints(index):
if index in skin_joints:
return True
return any(holds_joints(child) for child in nodes[index].get("children", []))
for root in roots: for root in roots:
if nodes[root].get("name") == "Armature": if root in skin_joints or holds_joints(root):
walk(root) walk(root)
return out return out
@@ -108,13 +118,39 @@ def mesh_vertices(gltf, binary):
return out return out
## ADR-0018: a Quaternius-derived character keeps its 62-joint armature, so the
## 13-core joint names are aliased onto the upstream ones before measuring.
QUATERNIUS_JOINTS = {
"ShoulderL": "UpperArm.L", "ShoulderR": "UpperArm.R",
"ElbowL": "LowerArm.L", "ElbowR": "LowerArm.R",
"HandL": "Wrist.L", "HandR": "Wrist.R",
"HipL": "UpperLeg.L", "HipR": "UpperLeg.R",
"KneeL": "LowerLeg.L", "KneeR": "LowerLeg.R",
"Spine": "Hips",
}
def _alias_joints(joints):
"""Return a joint map that answers both rig namings."""
out = dict(joints)
for core, upstream in QUATERNIUS_JOINTS.items():
if core not in out and upstream in joints:
out[core] = joints[upstream]
return out
def _span(values): def _span(values):
return max(values) - min(values) if values else 0.0 return max(values) - min(values) if values else 0.0
def measure(name): def measure(name):
_magic, _version, _length, _size, gltf, binary = _read_glb(asset_path("npc", name)) _magic, _version, _length, _size, gltf, binary = _read_glb(asset_path("npc", name))
joints = joint_positions(gltf) raw_joints = joint_positions(gltf)
# ADR-0018: the Quaternius base ships its upstream (semi-realistic)
# proportions, so the stylized guide band does not apply to it. The row still
# reports every measurement and ratio.
rig = "quaternius" if "UpperArm.L" in raw_joints else "core"
joints = _alias_joints(raw_joints)
verts = mesh_vertices(gltf, binary) verts = mesh_vertices(gltf, binary)
ys = [p[1] for p, _bone, _material in verts] ys = [p[1] for p, _bone, _material in verts]
height = _span(ys) height = _span(ys)
@@ -123,9 +159,10 @@ def measure(name):
shoulder = [p for p, _bone, _material in verts shoulder = [p for p, _bone, _material in verts
if shoulder_y - 0.08 <= p[1] <= shoulder_y + 0.06] if shoulder_y - 0.08 <= p[1] <= shoulder_y + 0.06]
hip = [p for p, bone, material in verts hip = [p for p, bone, material in verts
if bone in ("HipL", "HipR") and material == "pants"] if bone in ("HipL", "HipR", "UpperLeg.L", "UpperLeg.R")
and material == "pants"]
feet = [p for p, bone, _material in verts feet = [p for p, bone, _material in verts
if bone in ("KneeL", "KneeR") and p[1] < 0.2] if bone in ("KneeL", "KneeR", "LowerLeg.L", "LowerLeg.R") and p[1] < 0.2]
head_h = _span([p[1] for p in head]) head_h = _span([p[1] for p in head])
head_w = _span([p[0] for p in head]) head_w = _span([p[0] for p in head])
shoulder_w = _span([p[0] for p in shoulder]) shoulder_w = _span([p[0] for p in shoulder])
@@ -165,6 +202,7 @@ def measure(name):
} }
return { return {
"character": name, "character": name,
"rig": rig,
"measurements": {key: round(value, 3) for key, value in values.items()}, "measurements": {key: round(value, 3) for key, value in values.items()},
"ratios": {key: round(value, 3) for key, value in ratios.items()}, "ratios": {key: round(value, 3) for key, value in ratios.items()},
"checks": checks, "checks": checks,
+17 -10
View File
@@ -9,10 +9,10 @@ class CatalogueTests(unittest.TestCase):
def test_contract_matches_npc_pipeline(self): def test_contract_matches_npc_pipeline(self):
self.assertEqual(ba.SLOTS, npc_pipeline.SLOTS) self.assertEqual(ba.SLOTS, npc_pipeline.SLOTS)
self.assertEqual(sorted(ba.BONES), sorted(npc_pipeline.BONES)) self.assertEqual(sorted(ba.BONES), sorted(npc_pipeline.BONES))
# The character cap was raised to 16000/16000 for the head/face/hand # The character cap was raised to 24000/24000 for the MPFB body and the head
# overhaul; the 13 core bones stay required and extra bones are optional. # overhaul; the 13 core bones stay required and extra bones are optional.
self.assertLessEqual(ba.CHAR_MAX_TRIS, 16000) self.assertLessEqual(ba.CHAR_MAX_TRIS, 24000)
self.assertLessEqual(ba.CHAR_MAX_VERTS, 16000) self.assertLessEqual(ba.CHAR_MAX_VERTS, 24000)
def test_gear_uses_known_attach_points(self): def test_gear_uses_known_attach_points(self):
for name, attach in ba.GEAR.items(): for name, attach in ba.GEAR.items():
@@ -56,12 +56,17 @@ class BuiltAssetTests(unittest.TestCase):
report = {entry["character"]: entry for entry in ba.fit_report()} report = {entry["character"]: entry for entry in ba.fit_report()}
self.assertIn("npc_coat", report) self.assertIn("npc_coat", report)
coat = report["npc_coat"] coat = report["npc_coat"]
self.assertAlmostEqual(coat["reference"]["head_width"], 0.315, places=2) # Reference dimensions come from the shipped mesh, so the roster swap
self.assertAlmostEqual(coat["reference"]["clothing_radius"], 0.225, places=3) # (ADR-0018: the Quaternius base, 0.20 m head) moves them. The gear is
# authored for the shipped roster, so the ratio checks below are what
# must stay true; the absolute numbers are a drift guard.
self.assertAlmostEqual(coat["reference"]["head_width"], 0.201, places=2)
self.assertAlmostEqual(coat["reference"]["clothing_radius"], 0.19, places=3)
sunglasses = next(row for row in coat["gear"] if row["name"] == "sunglasses") sunglasses = next(row for row in coat["gear"] if row["name"] == "sunglasses")
self.assertEqual(sunglasses["attach"], "head") self.assertEqual(sunglasses["attach"], "head")
self.assertLessEqual(sunglasses["width"], self.assertLessEqual(sunglasses["width"],
0.70 * coat["reference"]["head_width"]) 0.70 * coat["reference"]["head_width"])
self.assertAlmostEqual(sunglasses["width_ratio"], 0.65, places=2)
bag = next(row for row in coat["gear"] if row["name"] == "shoulder_bag") bag = next(row for row in coat["gear"] if row["name"] == "shoulder_bag")
self.assertEqual(bag["attach"], "back") self.assertEqual(bag["attach"], "back")
self.assertGreaterEqual(bag["x_extent"], 0.2) self.assertGreaterEqual(bag["x_extent"], 0.2)
@@ -73,7 +78,7 @@ class BuiltAssetTests(unittest.TestCase):
self.assertEqual(len(row["max"]), 3) self.assertEqual(len(row["max"]), 3)
self.assertIn("npc_dress", report) self.assertIn("npc_dress", report)
dress = report["npc_dress"] dress = report["npc_dress"]
self.assertAlmostEqual(dress["reference"]["clothing_radius"], 0.26, places=3) self.assertAlmostEqual(dress["reference"]["clothing_radius"], 0.18, places=3)
dress_bag = next(row for row in dress["gear"] if row["name"] == "shoulder_bag") dress_bag = next(row for row in dress["gear"] if row["name"] == "shoulder_bag")
self.assertGreater(dress_bag["x_clearance"], 0) self.assertGreater(dress_bag["x_clearance"], 0)
@@ -106,7 +111,9 @@ class TexelDensityTests(unittest.TestCase):
def test_character_slots_report_density_and_area(self): def test_character_slots_report_density_and_area(self):
report = ba.texel_density("npc_casual") report = ba.texel_density("npc_casual")
self.assertEqual(report["resolution"], 256) self.assertEqual(report["resolution"], 256)
self.assertEqual(set(report["slots"]), set(ba.SLOTS) | {"eye_white", "rubber"}) # The five tint slots are the contract; rebuilds may add fixed-colour
# detail materials (the Quaternius base keeps `Eye` and `Eyebrows`).
self.assertLessEqual(set(ba.SLOTS), set(report["slots"]))
for slot in ba.SLOTS: for slot in ba.SLOTS:
row = report["slots"][slot] row = report["slots"][slot]
self.assertGreater(row["triangles"], 0, slot) self.assertGreater(row["triangles"], 0, slot)
@@ -197,7 +204,7 @@ class InspectionTests(unittest.TestCase):
def test_character_contract_failures_are_listed(self): def test_character_contract_failures_are_listed(self):
stats = self.stats(mesh_nodes=["Armature"], skinned=False, unskinned=["Armature"], stats = self.stats(mesh_nodes=["Armature"], skinned=False, unskinned=["Armature"],
materials=["cloth"], bones=["Head"], meshes_without_uv=1, materials=["cloth"], bones=["Head"], meshes_without_uv=1,
triangles=17000, split_vertices=17000, triangles=25000, split_vertices=25000,
world_bounds={"min": [-0.3, -0.4, -0.2], "max": [0.3, 1.2, 0.2]}) world_bounds={"min": [-0.3, -0.4, -0.2], "max": [0.3, 1.2, 0.2]})
issues = "\n".join(ba.analyse_inspection(stats)["issues"]) issues = "\n".join(ba.analyse_inspection(stats)["issues"])
self.assertIn("no mesh node named npc_base", issues) self.assertIn("no mesh node named npc_base", issues)
@@ -205,8 +212,8 @@ class InspectionTests(unittest.TestCase):
self.assertIn("missing material slot skin", issues) self.assertIn("missing material slot skin", issues)
self.assertIn("mesh not skinned", issues) self.assertIn("mesh not skinned", issues)
self.assertIn("mesh(es) without UVs", issues) self.assertIn("mesh(es) without UVs", issues)
self.assertIn(f"triangles 17000 > {ba.CHAR_MAX_TRIS}", issues) self.assertIn(f"triangles 25000 > {ba.CHAR_MAX_TRIS}", issues)
self.assertIn(f"glTF vertices 17000 > {ba.CHAR_MAX_VERTS}", issues) self.assertIn(f"glTF vertices 25000 > {ba.CHAR_MAX_VERTS}", issues)
self.assertIn("feet not on the ground", issues) self.assertIn("feet not on the ground", issues)
self.assertIn("unexpected height", issues) self.assertIn("unexpected height", issues)
+2 -2
View File
@@ -13,8 +13,8 @@ class NpcContractTests(unittest.TestCase):
"ShoulderL", "ShoulderR", "ElbowL", "ElbowR", "ShoulderL", "ShoulderR", "ElbowL", "ElbowR",
"HandL", "HandR"]: "HandL", "HandR"]:
self.assertIn(bone, npc_pipeline.BONES) self.assertIn(bone, npc_pipeline.BONES)
self.assertLessEqual(npc_pipeline.MAX_TRIANGLES, 16000) self.assertLessEqual(npc_pipeline.MAX_TRIANGLES, 24000)
self.assertLessEqual(npc_pipeline.MAX_VERTICES, 16000) self.assertLessEqual(npc_pipeline.MAX_VERTICES, 24000)
def test_textures_exist_for_every_slot(self): def test_textures_exist_for_every_slot(self):
for slot in npc_pipeline.SLOTS: for slot in npc_pipeline.SLOTS:
+10
View File
@@ -10,9 +10,19 @@ class NpcProportionTests(unittest.TestCase):
self.assertEqual(len({row["character"] for row in rows}), 12) self.assertEqual(len({row["character"] for row in rows}), 12)
for row in rows: for row in rows:
self.assertGreater(row["measurements"]["height"], 1.6) self.assertGreater(row["measurements"]["height"], 1.6)
self.assertIn(row["rig"], ("core", "quaternius"))
# Both rigs must resolve every measurement; a missing joint would
# collapse a ratio to zero.
for key, value in row["measurements"].items():
self.assertGreater(value, 0.0, f"{row['character']} {key}")
def test_current_library_stays_inside_the_stylized_guide(self): def test_current_library_stays_inside_the_stylized_guide(self):
for row in npc_proportions.report(): for row in npc_proportions.report():
# ADR-0018: the Quaternius base is accepted at its upstream
# (semi-realistic) proportions, so the stylized band does not apply.
# The measurement stays in the report for the face/silhouette gate.
if row["rig"] != "core":
continue
for key, check in row["checks"].items(): for key, check in row["checks"].items():
self.assertEqual(check["status"], "ok", self.assertEqual(check["status"], "ok",
f"{row['character']} {key}={check['value']}") f"{row['character']} {key}={check['value']}")