Plan the v0.6.0 graphics pass and add Blender-authored NPC assets

This commit is contained in:
zwitschi committed 2026-11-01 12:02:27 +01:00
1 parent a27e8cb74c
commit b24d0aeb61
33 files changed
+1005 -2

No files matched your search

+31
View File
@@ -4,6 +4,13 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
## Decisions ## Decisions
- Graphics v0.6.0 plan (2026-11-01): NPCs move to Blender-authored skinned meshes
(`docs/plan-graphics-v0.6.md`, ADR-0013). `tools/npc_pipeline.py` builds
`assets/models/generated/npc_base.glb`; `NpcAvatar` keeps the procedural `_build_body()`
fallback. Preserve `Rig/Head`, the limb node names, and the seed-deterministic
`outfit_for`/`layer_for`/`hand_item_for` helpers. Blender = flatpak 5.2.0 LTS, sandbox cwd
`/app/blender` → absolute paths for `--python`. CI stays offline (committed assets).
- Engine: Godot 4.3 stable (official editor build, `~/.local/bin/godot`). The system's preinstalled `godot-runner` (4.7.2, Fedora) cannot load projects from arbitrary paths because it was compiled without path overrides; use the official editor binary instead. - Engine: Godot 4.3 stable (official editor build, `~/.local/bin/godot`). The system's preinstalled `godot-runner` (4.7.2, Fedora) cannot load projects from arbitrary paths because it was compiled without path overrides; use the official editor binary instead.
- Language: GDScript. All cross-file references use explicit `preload()` constants (not `class_name` globals) so scripts compile in headless `--script` runs without the editor's class cache. - Language: GDScript. All cross-file references use explicit `preload()` constants (not `class_name` globals) so scripts compile in headless `--script` runs without the editor's class cache.
- Test framework deviation: the project uses a custom minimal headless test runner (`src/test/test_runner.gd`) instead of GUT, because GUT needs the editor plugin. CI runs it with `--headless`. - Test framework deviation: the project uses a custom minimal headless test runner (`src/test/test_runner.gd`) instead of GUT, because GUT needs the editor plugin. CI runs it with `--headless`.
@@ -15,6 +22,30 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
## Handoff (read this first) ## Handoff (read this first)
- **Phase 2 — AI texturing (this agent, DONE 2026-11-01).** Phase 1 assets were
already in the tree (untracked) and are preserved. Generated six seamless AI
clothing albedos into `assets/textures/npc/ai/{shirt,trousers,jacket,vest,
coat,dress}.png` (512×512, `--role tile`, gpt-5-image-mini; audit 6 good).
**Gotcha:** `asset_pipeline.py generate` defaults to `GEN_SIZE=2048`, which
gpt-5-image-mini rejects (HTTP 400); pass `--request-size 1024` for 1:1 roles.
`generate --role tile` already applies `make_seamless`, so a separate
`seamless` pass is redundant. Added to `tools/npc_pipeline.py`: `prepare`
(layers AI shirt→cloth, trousers→pants albedos at 128px) and `palette`
(writes `assets/textures/npc/palette.json` via `model_pipeline.dominant_colors`).
`write_slot_textures()` now skips the albedo for slots with an AI override
(normal/ORM stay procedural). Blender `build` re-run: GLB audit ok, 1204 tris /
2488 verts, AI albedo embedded for cloth+pants. **Do not commit yet** — Phase 1
files are uncommitted; coordinate the commit with the Phase 1/3 agents.
**Import concurrency:** a parallel agent is running
`timeout 300 godot --headless --editor --quit --path .` and then checks
`assets/models/generated/npc_base.glb.import` — treat that as the canonical
import. My own `godot --headless --path . --import` hung with no output past
the engine banner (two Godot instances contended on `.godot`); I killed the
orphan (PID 2482934). Do not run a second import while that `--editor --quit`
is active.
Next: Phase 3 (`NpcAvatar._load_model` + slot tinting) consumes
`palette.json` and the GLB; Phase 2's capture/judge item is blocked on that.
- Repo: `/run/media/system/DATA/Documents/06-games/security-game` (also reachable at - Repo: `/run/media/system/DATA/Documents/06-games/security-game` (also reachable at
`/var/home/zwitschi/Documents/06-games/security-game`). Godot: `~/.local/bin/godot` (4.3.stable). `/var/home/zwitschi/Documents/06-games/security-game`). Godot: `~/.local/bin/godot` (4.3.stable).
Tests: `godot --headless --path . --script res://src/test/test_runner.gd` → **177/177**. Tests: `godot --headless --path . --script res://src/test/test_runner.gd` → **177/177**.
+11
View File
@@ -399,6 +399,17 @@ The studio also has authorized guests and staff. They must either be recognized
- Someone presents a counterfeit wristband or credential. - Someone presents a counterfeit wristband or credential.
- A violent threat appears during a crowded arrival, requiring the player to protect the entrance while calling for assistance. - A violent threat appears during a crowded arrival, requiring the player to protect the entrance while calling for assistance.
---
## Visual Quality Direction
The game prioritizes readable, well-lit, low-poly presentation over realism (GL Compatibility
renderer). The next visual pass, v0.6.0, replaces the procedural NPC rig with Blender-authored,
UV-mapped, skinned characters carrying baked normal/AO/roughness maps, using the existing AI tools
for textures, palettes, and screen judging. See
[`docs/plan-graphics-v0.6.md`](docs/plan-graphics-v0.6.md) and
[ADR-0013](docs/adr/0013-blender-authored-npc-characters.md).
The scenario should test the player’s ability to distinguish ordinary Hollywood Boulevard activity from a genuine security problem. The best solution is usually a simple gesture, clear direction, or credential check rather than immediate escalation. The scenario should test the player’s ability to distinguish ordinary Hollywood Boulevard activity from a genuine security problem. The best solution is usually a simple gesture, clear direction, or credential check rather than immediate escalation.
Future venues can reuse this structure while changing the rules, equipment, staff roles, and types of access decisions. Future venues can reuse this structure while changing the rules, equipment, staff roles, and types of access decisions.
+2 -1
View File
@@ -391,6 +391,7 @@ The full arc42-style architecture documentation lives in [`docs/architecture.md`
- [`MEMORY.md`](MEMORY.md) — Persistent session notes. - [`MEMORY.md`](MEMORY.md) — Persistent session notes.
- [`docs/architecture.md`](docs/architecture.md) — arc42-style architecture documentation. - [`docs/architecture.md`](docs/architecture.md) — arc42-style architecture documentation.
- [`docs/adr/`](docs/adr/) — Architecture Decision Records. - [`docs/adr/`](docs/adr/) — Architecture Decision Records.
- [`docs/plan-graphics-v0.6.md`](docs/plan-graphics-v0.6.md) — Graphics-quality plan: Blender-authored NPC characters.
## Development Status ## Development Status
@@ -430,7 +431,7 @@ Implemented so far:
- Web build: `build/step-line-web-<version>.zip` (Godot's non-threaded web template, no COOP/COEP headers needed) built by `tools/build_releases.sh` and published with the desktop artifacts; serve the unzipped folder from any static host - Web build: `build/step-line-web-<version>.zip` (Godot's non-threaded web template, no COOP/COEP headers needed) built by `tools/build_releases.sh` and published with the desktop artifacts; serve the unzipped folder from any static host
- Aim-free selection pass (v0.3.0 work, [ADR-0011](docs/adr/0011-aim-free-selection-and-precision-walk.md)): the focus cone ranks candidates by angle then distance, `Tab` and the mouse wheel cycle nearby people nearest-first and pin the selection, right click clears focus with a short re-acquire grace, the HUD reports how many people are in the ring, and holding `Shift` walks at half speed for close work - Aim-free selection pass (v0.3.0 work, [ADR-0011](docs/adr/0011-aim-free-selection-and-precision-walk.md)): the focus cone ranks candidates by angle then distance, `Tab` and the mouse wheel cycle nearby people nearest-first and pin the selection, right click clears focus with a short re-acquire grace, the HUD reports how many people are in the ring, and holding `Shift` walks at half speed for close work
Not yet implemented: live 3D body-camera replay, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`). Not yet implemented: the v0.6.0 graphics pass (Blender-authored NPC characters, see [`docs/plan-graphics-v0.6.md`](docs/plan-graphics-v0.6.md)), live 3D body-camera replay, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`).
### Smoke Test Checklist ### Smoke Test Checklist
+69 -1
View File
@@ -5,7 +5,75 @@ suite **208/208** plus **56** Python tooling tests; the session, PLAN-verb, crow
timeline, audio, scenario-content, props, NPC-budget, movement, and windowed focus/radial smokes timeline, audio, scenario-content, props, NPC-budget, movement, and windowed focus/radial smokes
pass. Anonymous downloads are enabled on the host. pass. Anonymous downloads are enabled on the host.
The scheduled presentation pass is complete (see `DONE.md`). Only optional future ideas remain. The scheduled presentation pass is complete (see `DONE.md`). The next scheduled pass is the
graphics-quality work in [`docs/plan-graphics-v0.6.md`](docs/plan-graphics-v0.6.md).
## Graphics quality — Blender-authored NPC characters (v0.6.0)
Detailed plan: [`docs/plan-graphics-v0.6.md`](docs/plan-graphics-v0.6.md) · [ADR-0013](docs/adr/0013-blender-authored-npc-characters.md)
### Phase 0 — Toolchain spike and contract freeze
- [ ] Verify `flatpak run org.blender.Blender --version` reports Blender 5.2.x and runs headless in this shell.
- [ ] Run a minimal headless Blender export using an absolute `--python` path (sandbox cwd is `/app/blender`); confirm the output file lands where expected.
- [ ] Record the exact working invocation (flatpak + absolute paths) in `MEMORY.md`.
- [ ] Freeze the NPC asset contract in `docs/plan-graphics-v0.6.md` and ADR-0013: file paths, bone names, material slots, triangle budget.
- [ ] Fix the material-slot set (`skin` / `cloth` / `pants` / `accessory` / `hair`) and the ORM packing (R=occlusion, G=roughness, B=metallic).
- [ ] Choose and record the initial tier-0 triangle/vertex budget (≤ ~6000 tris / ~4000 verts) in the plan.
- [ ] Spike: confirm Godot 4.3 imports a test `.glb` as a `PackedScene` with a `Skeleton3D` and playable imported clips.
### Phase 1 — Blender authoring + export pipeline
- [ ] Author the low-poly base humanoid in Blender: torso, articulated arms/legs, head with a face front, hands, shoes.
- [ ] Rig with an armature whose bones mirror the node contract (`Head`, `HipL/R`, `KneeL/R`, `ShoulderL/R`, `ElbowL/R`, `HandL/R`); origin at the feet, +Y up.
- [ ] UV-unwrap the mesh into a power-of-two layout, one region per material slot.
- [ ] Save the editable source as `assets/models/npc/npc_base.blend` (committed).
- [ ] Add `tools/npc_pipeline.py` with `list` / `build` / `audit` subcommands, run through the Blender flatpak.
- [ ] `build` exports `assets/models/generated/npc_base.glb` plus the material textures.
- [ ] Bake neutral albedo per slot, normal maps (fabric weave, denim, knit), and the packed ORM map.
- [ ] Bake ambient occlusion into the albedo or the ORM R channel (the Compatibility renderer has no SSAO).
- [ ] `audit` enforces UVs in [0,1], expected bone names, triangle/vertex budget, feet at y≈0, and material-slot presence.
- [ ] Reuse the `model_pipeline.py` colour-space discipline (linear factors vs sRGB textures) in the exporter.
- [ ] Run `godot --headless --path . --import` and confirm `npc_base.glb` imports without errors.
### Phase 2 — AI-assisted texturing
- [x] Generate seamless clothing albedos (shirt, trousers, jacket, hi-vis vest, coat, dress) with `tools/asset_pipeline.py generate` (OpenRouter, `openai/gpt-5-image-mini`) — written to `assets/textures/npc/ai/` (512×512).
- [x] Tile-edge blending on each fabric — applied automatically by `generate --role tile` (`make_seamless`); the `tile` audit's edge check confirms continuity.
- [x] Run `asset_pipeline.py audit` on the outputs — 6 good, 0 bad (role `tile`).
- [ ] Commit the validated textures (files are under `assets/textures/npc/ai/`; git commit coordinated via `MEMORY.md` — Phase 1 files are still uncommitted).
- [x] Derive per-role outfit palettes via `model_pipeline.dominant_colors` — written to `assets/textures/npc/palette.json` (`npc_pipeline.py palette`).
- [x] Bake normal/roughness procedurally and layer the AI albedos on top — `npc_pipeline.py prepare` + Blender `build` keeps the AI albedo for `cloth`/`pants` and regenerates normal/ORM; GLB audit ok (1204 tris / 2488 verts).
- [ ] Regenerate the staged captures and run `screen_designs.py judge` (blocked: needs Phase 3 `NpcAvatar` integration to render the skinned model).
### Phase 3 — Godot integration
- [ ] Add `NpcAvatar._load_model()` that instantiates the imported `npc_base.glb` `PackedScene`.
- [ ] Keep `_build_body()` as the offline fallback; choose per-avatar and record `loaded_model` / `fallback_avatar` for tests.
- [ ] Map `outfit_for()` onto the model's material slots so seed variety, `layer_for`, `hand_item_for`, and archetype colors survive.
- [ ] Keep accessory attachments (cap / hood / backpack / sunglasses / lanyard) under `Rig` / `Head`.
- [ ] Add `SurfaceMaterials` baked-map recipes (`npc()` / `skin()` / `cloth()`); stop calling `Materials.flat()` for the body.
- [ ] Add `_set_limb_rotation()` so `_animate()` / `_idle_layer()` write to bones or pivots; keep `Rig/Head` findable by the focus cone.
- [ ] Update `test_npc_character`, `test_npc_theater`, and `test_target_cycle` for the skinned path (or document why they stay on the fallback).
- [ ] Re-measure `smoke_npc_budget.gd`; tune `LOD_TIERS` if the skinned rig is heavier up close.
- [ ] Re-verify the focus-cone smoke (`smoke_focus_radial.gd`) and the movement smoke.
### Phase 4 — Environment and lighting lift
- [ ] Bake AO for the prop set (or rebuild the props in Blender) behind the same `assets/models/generated/<prop>.glb` contract.
- [ ] Regenerate higher-resolution brick / concrete / asphalt textures via `asset_pipeline.py` with normal maps, keeping the current UV scales.
- [ ] Evaluate a desktop-only Forward+ export preset (SSAO/SSIL/volumetrics); keep the Web preset on GL Compatibility with `thread_support=false`.
- [ ] Record the renderer decision in an ADR addendum or `MEMORY.md`.
- [ ] Regenerate `docs/screenshots/` with `capture_screenshots.gd` and run `screen_designs.py compare`.
### Phase 5 — Verification and release
- [ ] Headless suite green: fallback build, outfit determinism, `Rig/Head` presence, material recipe assertions, `npc_pipeline.py audit` (skip with reason when Blender is absent).
- [ ] Windowed smokes pass: `smoke_npc_budget.gd`, `smoke_movement.gd`, `smoke_focus_radial.gd`, `smoke_props.gd`.
- [ ] `screen_designs.py compare` and `judge` show no regressions.
- [ ] Bump `config/version` to `0.6.0`.
- [ ] Update `README.md`, `docs/architecture.md` (Level 2 gains `tools/npc_pipeline.py`), and mark ADR-0013 Accepted.
- [ ] Publish the release per `docs/RELEASING.md`.
## Future ideas (optional, not scheduled) ## Future ideas (optional, not scheduled)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 510 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 597 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1003 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

+50
View File
@@ -0,0 +1,50 @@
{
"coat": {
"hex": "#868585",
"rgb": [
0.5255,
0.5216,
0.5216
]
},
"dress": {
"hex": "#cead94",
"rgb": [
0.8078,
0.6784,
0.5804
]
},
"jacket": {
"hex": "#171616",
"rgb": [
0.0902,
0.0863,
0.0863
]
},
"shirt": {
"hex": "#d5c8b8",
"rgb": [
0.8353,
0.7843,
0.7216
]
},
"trousers": {
"hex": "#294667",
"rgb": [
0.1608,
0.2745,
0.4039
]
},
"vest": {
"hex": "#e2e916",
"rgb": [
0.8863,
0.9137,
0.0863
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 685 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 897 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

@@ -0,0 +1,85 @@
# ADR-0013: Blender-authored NPC characters and baked materials
- **Status:** Proposed
- **Date:** 2026-11-01
## Context
NPCs are the procedural capsule-and-box rig from
[ADR-0010](0010-procedural-npc-presentation.md): articulated `Node3D` limbs,
flat `StandardMaterial3D` colors, and sphere hair/eyes. ADR-0010 accepted this
as a placeholder and recorded that a later skinned-mesh pass would replace the
rig while keeping the `Head` contract. The props already moved to generated
`.glb` assets ([ADR-0012](0012-surface-materials-and-generated-props.md)), but
people remain the lowest-fidelity part of the scene: no UVs, no baked normal or
ambient-occlusion maps, and silhouettes built from capsules.
The project renders with the **GL Compatibility** backend (no SSAO/SSIL), so
visual quality has to come from the assets and baked maps rather than a renderer
change. CI must stay offline: the Blender step is a developer tool, not a build
dependency.
## Decision
1. **Author the NPC body in Blender** and export a skinned, UV-mapped base mesh
as `assets/models/generated/npc_base.glb`, driven by a new
`tools/npc_pipeline.py` (`build` / `audit`) invoked through the Blender
flatpak in headless mode. The `.blend` source is committed under
`assets/models/npc/`.
2. **Bake material maps in Blender** — neutral albedo, normal, and a packed ORM
(occlusion/roughness/metallic) map per material slot (`skin`, `cloth`,
`pants`, `accessory`, `hair`). Ambient occlusion is baked into the albedo or
the ORM R channel, since Compatibility has no screen-space AO.
3. **Keep a procedural fallback.** `NpcAvatar` loads the imported `PackedScene`
when it exists and falls back to the current `_build_body()` rig otherwise,
mirroring `WorldBuilder._add_prop_model()`. Committed assets mean CI never
needs Blender, and the headless suite runs against the fallback when the
import is absent.
4. **Preserve the `Head` contract and seed determinism.** The armature bones are
named after the current nodes (`Head`, `HipL/R`, `KneeL/R`, `ShoulderL/R`,
`ElbowL/R`, `HandL/R`); `outfit_for` and friends stay pure and drive the
material slots. A thin `_set_limb_rotation()` wrapper lets `_animate()` /
`_idle_layer()` target either `Node3D` pivots or `Skeleton3D` bones.
5. **AI tooling supplies art direction only.** `tools/asset_pipeline.py`
generates seamless clothing albedos, `tools/model_pipeline.py`'s palette
helpers (`dominant_colors`, `assign_roles`) derive per-role palettes from
concept sheets, and `tools/screen_designs.py judge` closes the loop. All AI
outputs are committed; generation is dev-time and never a runtime or CI
dependency.
## Consequences
### Positive
- NPCs gain real geometry, UVs, and baked normal/AO/roughness, so they read as
people at cinematic distance without a renderer switch.
- Skinned meshes collapse ~33 draw calls per avatar into one, which can offset
the skinning cost at `MAX_LIVE_NPCS = 16`.
- The asset contract stays behind the same `.glb` + audit discipline that props
already use; the procedural fallback keeps offline CI deterministic.
- AO baked into the maps compensates for the absence of SSAO in Compatibility.
### Negative
- A new toolchain (Blender flatpak + `npc_pipeline.py`) and its path/colour-space
gotchas enter the repo; contributors must run `audit` after changing the
`.blend`.
- Skinning shifts cost from draw calls to CPU; the 16-NPC frame budget must be
re-measured before raising the cap.
- Two avatar backends (skinned + fallback) must stay behaviourally identical, or
tests that assume the fallback will silently pass against one and not the
other.
## Alternatives considered
- **Keep the procedural rig and only add textures.** Rejected: textures on
capsules do not fix the silhouette; the rig is the fidelity ceiling.
- **Use Mixamo / a downloaded skinned character.** Rejected: licensing and
retargeting overhead exceed the value; a low-poly base authored in-house keeps
the art direction and the `Head`/bone contract under our control.
- **Switch the renderer to Forward+ for SSAO/SSIL.** Rejected as a prerequisite;
it would break the Web export assumptions. A desktop-only Forward+ preset
remains a follow-up, not a dependency of this decision.
- **A 3D-model (image-to-mesh) API instead of Blender.** Rejected for characters;
output topology is not controllable enough to satisfy the bone/UV contract.
It remains a possible prop-only follow-up (ADR-0012).
+1
View File
@@ -161,6 +161,7 @@ Architecture decisions are recorded as ADRs:
- [ADR-0010: Procedural NPC presentation](adr/0010-procedural-npc-presentation.md) - [ADR-0010: Procedural NPC presentation](adr/0010-procedural-npc-presentation.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)
## 10. Quality Requirements ## 10. Quality Requirements
+215
View File
@@ -0,0 +1,215 @@
# Development Plan — Graphics Quality v0.6.0 (Blender-authored NPCs)
> Status: Proposed · Target version: **v0.6.0** (MINOR)
> Companion decision: [ADR-0013](adr/0013-blender-authored-npc-characters.md)
## Why this exists
The game is functionally complete through v0.5.0, but NPCs are still the
procedural capsule-and-box rig from [ADR-0010](adr/0010-procedural-npc-presentation.md).
They read as people at conversation distance but not at cinematic distance, and
every surface on a body is a flat color. ADR-0010 recorded that "a later
skinned-mesh pass will replace this rig and must keep the `Head` contract" —
this plan is that pass.
The goal is **better NPCs first**, then a modest environment lift, using the
existing AI tooling for art direction and quality gates, and **Blender** for the
geometry, UVs, skeleton, and baked material maps.
## Constraints that shape the plan
- **Renderer is GL Compatibility** (`project.godot` `config/features`). SSAO,
SSIL, and volumetrics are unavailable. Quality therefore comes from better
meshes, baked maps (AO/normal/roughness), and lighting — not from a renderer
switch.
- **CI builds offline.** `.gitea/workflows/build.yml` runs the Godot editor only
(no Blender, no network). Everything the game loads must be committed; the
Blender step is a developer tool, and the game keeps a procedural fallback so a
fresh checkout still boots and the headless suite still passes.
- **Determinism.** Seed-driven variety (`outfit_for`, `layer_for`,
`hand_item_for`, `idle_style_for`) must keep working and keep its tests green.
AI generation is a dev-time input, not a runtime dependency; the committed
assets are the source of truth.
- **Frame budget.** v0.5.0 measured 16 avatars = 534 meshes ≈ 6.9 ms headless.
A skinned rig replaces ~33 draw calls per avatar with one skin, but skinning
costs CPU. Re-measure with `src/test/smoke_npc_budget.gd` before raising
`MAX_LIVE_NPCS`.
## The NPC asset contract (what must not break)
From `src/game/npc_avatar.gd` and `src/game/guard_controller.gd`:
- A scene-level node named **`Rig/Head`** — `GuardController` raycasts for it
(`get_node_or_null("Rig/Head")`), and the focus circle + name tag + reaction
tag attach under it.
- Limb pivots currently named `HipL`/`HipR` → `Knee`, `ShoulderL`/`ShoulderR` →
`Elbow` → `Hand`. `_animate()` and `_idle_layer()` drive them by `rotation.x`.
- Pure static helpers must stay pure and seed-deterministic: `outfit_for`,
`layer_for`, `hand_item_for`, `idle_style_for`, `queue_slot_for`,
`lod_tier_for_distance`, `reaction_label`, `crouch_for_pose`,
`prop_lift_for_pose`, `limb_swing`, `sway_for_pose`.
- LOD tiers (`apply_lod`, 0 / 1 / 2 with a billboard impostor at tier 2) must
keep working; `_detail_nodes` are hidden at tier 1.
- `set_focus_visual`, `_focus_circle`, `_name_tag`, `_reaction_tag`, the
theater poses, and the crowd reaction (`start_rubberneck`, `start_flee`) all
operate on the rig and must survive the swap.
## Workstreams
### Phase 0 — Toolchain spike and contract freeze (small, throwaway)
1. Confirm headless Blender works in this environment. Blender **5.2.0 LTS**
flatpak is installed; the sandbox cwd is `/app/blender`, so `--python` and
all input/output paths must be absolute:
```bash
flatpak run org.blender.Blender --background \
--python /var/home/zwitschi/Documents/06-games/security-game/tools/npc_pipeline.py -- --build
```
Record the working invocation in `MEMORY.md` (the Im-Dornäcker notes already
warn about the cwd gotcha).
2. Freeze the asset contract in writing (this document + ADR-0013):
- `assets/models/generated/npc_base.glb` — one skinned mesh (or a small fixed
set of sub-meshes: body / head / hands) with a **named armature** and UVs.
- Material slots rather than one atlas-per-prop: `skin`, `cloth`,
`pants`, `accessory`, `hair` each carry a neutral albedo + normal + ORM
(occlusion/roughness/metallic) map. `outfit_for` colors tint the slot, so
multi-color outfits and seed variety survive.
- Bone names mirror the current node names (`Head`, `HipL/R`, `KneeL/R`,
`ShoulderL/R`, `ElbowL/R`, `HandL/R`) so `_animate()` can target them.
- Budget: tier-0 base body ≤ ~6,000 triangles, ≤ ~4,000 vertices (revisit
after the first `smoke_npc_budget.gd` run).
### Phase 1 — `tools/npc_pipeline.py`: author + export + audit
New tool, run through the Blender flatpak, mirroring the `model_pipeline.py`
shape (`list` / `build` / `audit`):
1. **Author** a low-poly base humanoid in Blender: torso, articulated arms/legs,
head with a face front (so the focus ring and the "noticed the guard" cue
keep a direction), hands, shoes. Keep the current silhouette language
(heads, caps, hoods, backpacks, lanyards) so existing `outfit_for`
accessories still attach.
2. **Rig** it with an armature whose bones are named to match the node contract.
Godot 4.3 imports glTF skeletons as `Skeleton3D`; keep the bone rest pose
matching the current `_animate()` assumptions (arms hang, legs straight,
+Y up, origin at the feet on the floor).
3. **UV-unwrap** into a power-of-two layout and **bake** maps in Blender from
node materials or a higher-detail source:
- neutral albedo (white fabric pattern) per material slot,
- normal map (fabric weave, denim, knit),
- ORM map (occlusion in R, roughness in G, metallic in B — same packing
`model_pipeline.atlas_for` already uses),
- ambient occlusion baked into the albedo or the ORM R channel (this is the
Compatibility renderer's only AO source).
4. **Export** `npc_base.glb` with materials referenced as Godot-importable
textures (or export textures alongside and wire them in GDScript — decide in
the spike).
5. **Audit** (`npc_pipeline.py audit`) enforcing: UVs in [0,1], expected bone
names present, triangle/vertex budget, feet at y ≈ 0, no missing material
slots. Reuse the checks and the colour-space discipline from
`model_pipeline.py` (glTF `baseColorFactor` is linear; albedo textures are
sRGB — do not mix them up).
6. **Commit** the `.blend` source (`assets/models/npc/npc_base.blend`) so the
build is reproducible and editable, plus the generated `.glb` + textures so
CI never needs Blender.
### Phase 2 — AI-assisted texturing (existing tools only)
Use the tools already in `tools/`, not new services:
1. `tools/asset_pipeline.py generate` (OpenRouter,
`openai/gpt-5-image-mini`) for seamless clothing albedos — shirt, trousers,
jacket, hi-vis vest, coat, dress — at the role sizes its `audit` validates.
Run `seamless` for tile-edge blending and `audit` to reject wrong sizes.
2. `tools/model_pipeline.py`'s palette helpers (`dominant_colors`,
`assign_roles`) or a small shared import, to derive per-role outfit palettes
from AI concept sheets (`concept`). `outfit_for` keeps determinism: the AI
supplies palettes and textures, never per-frame randomness.
3. Normal/roughness detail (fabric weave, denim) baked procedurally in Blender,
with the AI albedos layered on top.
4. Close the loop with `tools/screen_designs.py judge`: regenerate the staged
captures and confirm the new NPCs realise the same screen intents (art style
is ignored by the judge; layout and readability are not).
Outputs land under `assets/textures/ai/game_ready/` (or a new
`assets/textures/npc/`) and are **committed**.
### Phase 3 — Godot integration (`npc_avatar.gd`, `materials.gd`)
1. Add a `_load_model()` path in `NpcAvatar` that instantiates the imported
`npc_base.glb` `PackedScene`, exactly like `WorldBuilder._add_prop_model()`
loads props. Keep `_build_body()` as the offline/procedural fallback; choose
per-avatar with the same "asset or fallback" pattern (record the choice in a
`loaded_model` / `fallback_avatar` field for tests).
2. Map `outfit_for()` onto the model's material slots (skin/cloth/pants/
accessory/hair) so seed variety, `layer_for`, `hand_item_for`, and the
archetype color read stay intact. Accessories (cap/hood/backpack/
sunglasses/lanyard) keep attaching under `Rig`/`Head` as they do today.
3. Add `SurfaceMaterials` recipes for the baked maps — e.g. `npc()` /
`skin()` / `cloth()` — so `_material()` gains a textured path and the body
stops calling `Materials.flat()`.
4. Abstract the animation targets so `_animate()` / `_idle_layer()` write to
either the procedural `Node3D` pivots (fallback) or `Skeleton3D` bones
(skinned). Keep the existing `rotation.x` math; the abstraction is a thin
`_set_limb_rotation(part, axis, radians)` wrapper.
5. Keep `Rig/Head` as a real scene node (a marker following the head bone, or
the imported bone's Godot node) so focus, nametag, and head-turn still find
it. Re-verify the focus cone smoke and `test_npc_character` /
`test_npc_theater` / `test_target_cycle`.
6. Re-measure `smoke_npc_budget.gd`; adjust `LOD_TIERS` if the skinned rig is
heavier up close. The impostor path is unchanged.
### Phase 4 — Environment and lighting lift (after NPCs land)
1. **Baked AO on props:** export the existing prop set through Blender (or bake
AO in `npc_pipeline`-style tooling) so contact shadows read without SSAO.
This replaces the pure box/cylinder builder with Blender-authored props
behind the same `assets/models/generated/<prop>.glb` contract (ADR-0012's
follow-up).
2. **Higher-resolution environment textures:** regenerate brick/concrete/asphalt
through `asset_pipeline.py` with normal maps, keeping the current UV scales.
3. **Evaluate a desktop-only Forward+ preset** (SSAO/SSIL/volumetrics) while the
Web export preset keeps `thread_support=false` and GL Compatibility. Decide
explicitly in an ADR addendum; do not silently change the renderer.
4. Regenerate `docs/screenshots/` with `capture_screenshots.gd`, then run
`screen_designs.py compare` and `judge` as the regression gate.
### Phase 5 — Verification and release
- Headless: fallback build, `npc_pipeline.py audit` (via `test_model_pipeline`
style Python tests if Blender is present, otherwise skip with reason), outfit
determinism, `Rig/Head` presence, material recipe assertions.
- Windowed smokes: `smoke_npc_budget.gd`, `smoke_movement.gd`,
`smoke_focus_radial.gd`, `smoke_props.gd`; screenshot comparison.
- Bump `config/version` to `0.6.0`, update `README.md`, `docs/architecture.md`
(Level 2 gains `tools/npc_pipeline.py`), and publish per
[`docs/RELEASING.md`](RELEASING.md).
## Risks and mitigations
| Risk | Mitigation |
| --- | --- |
| Blender flatpak sandbox paths (cwd `/app/blender`) | Absolute paths everywhere; record the invocation in `MEMORY.md`. |
| glTF colour space (linear factors vs sRGB textures) | Reuse `model_pipeline.srgb_to_linear`; bake textures as sRGB, keep factors linear; assert in `audit`. |
| Skeleton retargeting vs current node math | Keep the bone names + a thin limb-rotation wrapper; fallback rig keeps old math running. |
| Frame budget with skinned avatars | Measure first (`smoke_npc_budget.gd`); tune `LOD_TIERS`; impostor path unchanged. |
| No SSAO in Compatibility | Bake AO into albedo/ORM; contact-shadow tune in Phase 4. |
| AI output nondeterminism | Generation is dev-time only; commit outputs and gate with `audit`/`judge`/`compare`. |
| Blender unavailable in CI | Committed `.glb` + textures; procedural fallback; `audit` skipped with reason when Blender is absent. |
## Acceptance criteria (definition of done)
1. NPCs are Blender-authored skinned meshes with baked maps; they read as people
at conversation distance and survive a cinematic close-up.
2. `Rig/Head`, the focus cone, nametag, theater poses, crowd reactions, and LOD
all still work; all existing NPC tests pass unchanged or are updated with
clear reasons.
3. The game boots and plays from a fresh checkout with **no** Blender and no
network (committed assets + procedural fallback).
4. `npc_pipeline.py build` is deterministic and `audit` enforces the contract.
5. 208 headless + 56 Python tests stay green (Python audit tests skip when
Blender is absent); NPC-budget and movement smokes pass; screenshot
`compare`/`judge` show no regressions.
6. Version bumped to v0.6.0, docs and ADR-0013 updated, release published.
+541
View File
@@ -0,0 +1,541 @@
#!/usr/bin/env python3
"""Build the low-poly, skinned NPC base mesh used by Step Line.
The mesh, armature, UVs and neutral material maps are authored procedurally in
Blender and exported to `assets/models/generated/npc_base.glb`. Godot imports the
GLB as a PackedScene, tints the material slots per avatar, and drives the armature
bones (see `src/game/npc_avatar.gd`). The procedural capsule rig stays as the
offline fallback when the GLB is missing.
The tool runs inside Blender (the flatpak sandbox cannot see `/tmp`, so every
path must be absolute):
flatpak run org.blender.Blender --background \
--python "$PWD/tools/npc_pipeline.py" -- build
python3 tools/npc_pipeline.py prepare # layer AI fabric albedos (Pillow)
python3 tools/npc_pipeline.py palette # derive outfit colours -> palette.json
python3 tools/npc_pipeline.py audit
Contract (frozen in docs/plan-graphics-v0.6.md + ADR-0013):
* scene root "Rig" with a skinned mesh and an armature
* bones: Head, HipL/R, KneeL/R, ShoulderL/R, ElbowL/R, HandL/R
(plus Hips/Spine/Neck as parents)
* material slots: skin, cloth, pants, accessory, hair
* neutral albedo + normal + packed ORM maps, albedo tinted at runtime
* <= 6000 triangles / 4000 vertices, UVs in [0,1], feet at y ~= 0
"""
import argparse
import json
import math
import struct
import sys
import zlib
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
GLB_PATH = ROOT / "assets" / "models" / "generated" / "npc_base.glb"
BLEND_PATH = ROOT / "assets" / "models" / "npc" / "npc_base.blend"
TEX_DIR = ROOT / "assets" / "textures" / "npc"
SLOTS = ["skin", "cloth", "pants", "accessory", "hair"]
BONES = [
"Head", "HipL", "HipR", "KneeL", "KneeR",
"ShoulderL", "ShoulderR", "ElbowL", "ElbowR", "HandL", "HandR",
]
MAX_TRIANGLES = 6000
MAX_VERTICES = 4000
TEX_SIZE = 128
AI_DIR = TEX_DIR / "ai"
# Base material slots that get an AI fabric albedo (`prepare` layers them over
# the procedural weave). Outer layers (jacket / vest / coat / dress) have no
# material slot yet; Phase 3 adds them and can reuse this override mechanism.
AI_FABRICS = {"cloth": "shirt", "pants": "trousers"}
AI_FABRICS_ALL = ["shirt", "trousers", "jacket", "vest", "coat", "dress"]
try: # Blender's Python has bpy; the audit command works without it.
import bpy # type: ignore
except ImportError: # pragma: no cover - exercised outside Blender
bpy = None
# ---------------------------------------------------------------------------
# Texture helpers (stdlib only: Blender ships no Pillow)
# ---------------------------------------------------------------------------
SLOT_STYLE = {
# base sRGB tone, weave frequency, roughness, metallic, AO strength
"skin": ((222, 184, 150), 0.05, 150, 0, 0.10),
"cloth": ((232, 232, 228), 0.30, 220, 0, 0.18),
"pants": ((70, 78, 92), 0.22, 235, 0, 0.20),
"accessory": ((46, 48, 56), 0.12, 120, 180, 0.22),
"hair": ((58, 44, 34), 0.18, 170, 0, 0.16),
}
def _png(width: int, height: int, rgba: bytes) -> bytes:
rows = bytearray()
stride = width * 4
for y in range(height):
rows.append(0)
rows.extend(rgba[y * stride:(y + 1) * stride])
def chunk(tag: bytes, data: bytes) -> bytes:
return (struct.pack(">I", len(data)) + tag + data +
struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF))
header = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)
return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) +
chunk(b"IDAT", zlib.compress(bytes(rows), 9)) + chunk(b"IEND", b""))
def _fabric_pattern(slot: str, x: int, y: int) -> float:
"""A repeatable 0..1 height/weave signal per slot."""
u = x / TEX_SIZE
v = y / TEX_SIZE
if slot == "cloth":
return 0.5 + 0.5 * math.sin(u * math.tau * 22.0) * math.sin(v * math.tau * 18.0)
if slot == "pants":
return 0.5 + 0.5 * math.sin((u + v) * math.tau * 26.0)
if slot == "skin":
return 0.5 + 0.5 * math.sin(u * math.tau * 7.0 + v * math.tau * 11.0)
if slot == "hair":
return 0.5 + 0.5 * math.sin((u * 1.7 - v) * math.tau * 30.0)
return 0.5 + 0.5 * math.sin(u * math.tau * 9.0) * math.sin(v * math.tau * 9.0)
def write_slot_textures() -> None:
"""Write neutral sRGB albedo, tangent normal and packed ORM for each slot."""
TEX_DIR.mkdir(parents=True, exist_ok=True)
for slot in SLOTS:
base, freq, roughness, metallic, ao_strength = SLOT_STYLE[slot]
albedo = bytearray(TEX_SIZE * TEX_SIZE * 4)
normal = bytearray(TEX_SIZE * TEX_SIZE * 4)
orm = bytearray(TEX_SIZE * TEX_SIZE * 4)
fabric = AI_FABRICS.get(slot)
use_ai_albedo = fabric is not None and (AI_DIR / f"{fabric}.png").is_file()
for y in range(TEX_SIZE):
for x in range(TEX_SIZE):
pattern = _fabric_pattern(slot, x, y)
# Baked AO approximation: darker in creases (pattern valleys) and
# toward the bottom of the tile.
ao = 1.0 - ao_strength * (1.0 - pattern) - 0.08 * (y / TEX_SIZE)
r = min(255, int(base[0] * ao))
g = min(255, int(base[1] * ao))
b = min(255, int(base[2] * ao))
offset = (y * TEX_SIZE + x) * 4
albedo[offset:offset + 4] = bytes((r, g, b, 255))
dx = _fabric_pattern(slot, (x + 1) % TEX_SIZE, y) - pattern
dy = _fabric_pattern(slot, x, (y + 1) % TEX_SIZE) - pattern
nx = 128 + int(dx * 90.0)
ny = 128 + int(dy * 90.0)
normal[offset:offset + 4] = bytes((max(0, min(255, nx)),
max(0, min(255, ny)), 255, 255))
orm[offset:offset + 4] = bytes((int(ao * 255), roughness, metallic, 255))
# The prepared AI fabric replaces the procedural albedo; the baked AO now
# lives in the ORM R channel, so the albedo can stay unshaded.
if not use_ai_albedo:
(TEX_DIR / f"{slot}_albedo.png").write_bytes(_png(TEX_SIZE, TEX_SIZE, albedo))
(TEX_DIR / f"{slot}_normal.png").write_bytes(_png(TEX_SIZE, TEX_SIZE, normal))
(TEX_DIR / f"{slot}_orm.png").write_bytes(_png(TEX_SIZE, TEX_SIZE, orm))
kept = [s for s, f in AI_FABRICS.items() if (AI_DIR / f"{f}.png").is_file()]
print(f"wrote {len(SLOTS) * 3} textures to {TEX_DIR}" +
(f" (kept AI albedo for {', '.join(kept)})" if kept else ""))
# ---------------------------------------------------------------------------
# Blender build
# ---------------------------------------------------------------------------
def _material(slot: str):
mat = bpy.data.materials.new(slot)
mat.use_nodes = True
nodes = mat.node_tree.nodes
links = mat.node_tree.links
bsdf = nodes.get("Principled BSDF")
base, _, roughness, metallic, _ = SLOT_STYLE[slot]
bsdf.inputs["Base Color"].default_value = (
base[0] / 255.0, base[1] / 255.0, base[2] / 255.0, 1.0)
bsdf.inputs["Roughness"].default_value = roughness / 255.0
bsdf.inputs["Metallic"].default_value = metallic / 255.0
albedo = nodes.new("ShaderNodeTexImage")
albedo.image = bpy.data.images.load(str(TEX_DIR / f"{slot}_albedo.png"))
albedo.image.colorspace_settings.name = "sRGB"
links.new(albedo.outputs["Color"], bsdf.inputs["Base Color"])
normal_tex = nodes.new("ShaderNodeTexImage")
normal_tex.image = bpy.data.images.load(str(TEX_DIR / f"{slot}_normal.png"))
normal_tex.image.colorspace_settings.name = "Non-Color"
normal_map = nodes.new("ShaderNodeNormalMap")
links.new(normal_tex.outputs["Color"], normal_map.inputs["Color"])
links.new(normal_map.outputs["Normal"], bsdf.inputs["Normal"])
return mat
def _assign(obj, material, bone: str) -> None:
obj.data.materials.append(material)
group = obj.vertex_groups.new(name=bone)
group.add(list(range(len(obj.data.vertices))), 1.0, "REPLACE")
def _cube(name, size, loc, material, bone) -> None:
bpy.ops.mesh.primitive_cube_add(size=1.0, location=loc)
obj = bpy.context.object
obj.name = name
obj.scale = (size[0], size[1], size[2])
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
_assign(obj, material, bone)
def _cylinder(name, radius, depth, loc, material, bone, vertices=8) -> None:
bpy.ops.mesh.primitive_cylinder_add(vertices=vertices, radius=radius,
depth=depth, location=loc)
obj = bpy.context.object
obj.name = name
_assign(obj, material, bone)
def _sphere(name, radius, loc, material, bone, segments=12, rings=8,
scale=(1.0, 1.0, 1.0), rotation=(0.0, 0.0, 0.0)) -> None:
bpy.ops.mesh.primitive_uv_sphere_add(segments=segments, ring_count=rings,
radius=radius, location=loc)
obj = bpy.context.object
obj.name = name
obj.scale = scale
obj.rotation_euler = rotation
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)
_assign(obj, material, bone)
def _bone(edit_bones, name, head, tail, parent=None) -> None:
bone = edit_bones.new(name)
bone.head = head
bone.tail = tail
if parent is not None:
bone.parent = parent
def build_character() -> None:
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
for collection in (bpy.data.meshes, bpy.data.armatures, bpy.data.materials):
for datablock in list(collection):
if datablock.users == 0:
collection.remove(datablock)
write_slot_textures()
mats = {slot: _material(slot) for slot in SLOTS}
parts = []
# Body (Blender is Z-up; the glTF exporter converts to Godot Y-up. The face
# points toward Blender -Y, which becomes Godot +Z like the fallback rig).
_cube("Pelvis", (0.36, 0.23, 0.22), (0, 0, 0.71), mats["pants"], "Hips")
_cube("Torso", (0.42, 0.26, 0.56), (0, 0, 1.10), mats["cloth"], "Spine")
_cube("Shoulders", (0.50, 0.26, 0.12), (0, 0, 1.38), mats["cloth"], "Spine")
_cylinder("Neck", 0.07, 0.15, (0, 0, 1.47), mats["skin"], "Neck", vertices=8)
_sphere("Head", 0.17, (0, 0, 1.63), mats["skin"], "Head",
scale=(1.0, 0.95, 1.08))
_sphere("Hair", 0.178, (0, 0.01, 1.69), mats["hair"], "Head",
scale=(1.0, 1.0, 0.82))
for side in (-1.0, 1.0):
_sphere("Ear", 0.04, (0.165 * side, 0.0, 1.62), mats["skin"], "Head",
segments=8, rings=6)
_sphere("Eye", 0.027, (0.058 * side, -0.155, 1.655), mats["accessory"],
"Head", segments=8, rings=6)
_cube("Brow", (0.065, 0.02, 0.014), (0.058 * side, -0.158, 1.705),
mats["hair"], "Head")
_cube("Mouth", (0.085, 0.02, 0.014), (0, -0.155, 1.575),
mats["accessory"], "Head")
for side in (-1.0, 1.0):
x = 0.27 * side
_cylinder("UpperArm", 0.075, 0.30, (x, 0, 1.21), mats["cloth"],
"ShoulderL" if side < 0 else "ShoulderR")
_cylinder("Forearm", 0.065, 0.28, (x, 0, 0.92), mats["cloth"],
"ElbowL" if side < 0 else "ElbowR")
_sphere("Hand", 0.06, (x, 0, 0.745), mats["skin"],
"HandL" if side < 0 else "HandR", segments=10, rings=6)
hx = 0.12 * side
_cylinder("Thigh", 0.10, 0.36, (hx, 0, 0.46), mats["pants"],
"HipL" if side < 0 else "HipR")
_cylinder("Shin", 0.085, 0.30, (hx, 0, 0.15), mats["pants"],
"KneeL" if side < 0 else "KneeR")
_cube("Shoe", (0.16, 0.29, 0.10), (hx, 0.04, 0.05),
mats["accessory"], "KneeL" if side < 0 else "KneeR")
parts = [obj for obj in bpy.context.scene.objects if obj.type == "MESH"]
bpy.ops.object.select_all(action="DESELECT")
for obj in parts:
obj.select_set(True)
bpy.context.view_layer.objects.active = parts[0]
bpy.ops.object.join()
mesh = bpy.context.object
mesh.name = "npc_base"
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.02)
bpy.ops.object.mode_set(mode="OBJECT")
# Neutral flat shading keeps the low-poly read; smoothing is not needed.
for polygon in mesh.data.polygons:
polygon.use_smooth = False
arm_data = bpy.data.armatures.new("Armature")
arm = bpy.data.objects.new("Armature", arm_data)
bpy.context.scene.collection.objects.link(arm)
bpy.context.view_layer.objects.active = arm
bpy.ops.object.mode_set(mode="EDIT")
bones = arm_data.edit_bones
_bone(bones, "Hips", (0, 0, 0.62), (0, 0, 0.82))
_bone(bones, "Spine", (0, 0, 0.82), (0, 0, 1.40), bones["Hips"])
_bone(bones, "Neck", (0, 0, 1.40), (0, 0, 1.52), bones["Spine"])
_bone(bones, "Head", (0, 0, 1.52), (0, 0, 1.82), bones["Neck"])
for side, suffix in ((-1.0, "L"), (1.0, "R")):
x = 0.27 * side
_bone(bones, f"Shoulder{suffix}", (x, 0, 1.38), (x, 0, 1.06),
bones["Spine"])
_bone(bones, f"Elbow{suffix}", (x, 0, 1.06), (x, 0, 0.78),
bones[f"Shoulder{suffix}"])
_bone(bones, f"Hand{suffix}", (x, 0, 0.78), (x, 0, 0.68),
bones[f"Elbow{suffix}"])
hx = 0.12 * side
_bone(bones, f"Hip{suffix}", (hx, 0, 0.62), (hx, 0, 0.30),
bones["Hips"])
_bone(bones, f"Knee{suffix}", (hx, 0, 0.30), (hx, 0, 0.0),
bones[f"Hip{suffix}"])
bpy.ops.object.mode_set(mode="OBJECT")
mesh.parent = arm
modifier = mesh.modifiers.new("Armature", "ARMATURE")
modifier.object = arm
for obj in bpy.context.scene.objects:
obj.select_set(True)
bpy.context.view_layer.objects.active = mesh
GLB_PATH.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.export_scene.gltf(
filepath=str(GLB_PATH), export_format="GLB", use_selection=False,
export_apply=False, export_animations=False, export_skins=True,
export_yup=True, export_materials="EXPORT", export_image_format="AUTO",
)
BLEND_PATH.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.wm.save_as_mainfile(filepath=str(BLEND_PATH))
print(f"wrote {GLB_PATH} and {BLEND_PATH}")
# ---------------------------------------------------------------------------
# Audit (stdlib only)
# ---------------------------------------------------------------------------
def _read_accessor(gltf, binary, index):
accessor = gltf["accessors"][index]
width = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4}[accessor["type"]]
fmt = {5126: "f", 5125: "I", 5123: "H", 5121: "B"}[int(accessor["componentType"])]
view = gltf["bufferViews"][accessor["bufferView"]]
offset = int(view.get("byteOffset", 0)) + int(accessor.get("byteOffset", 0))
stride = int(view.get("byteStride", width * struct.calcsize(fmt)))
values = []
for i in range(int(accessor["count"])):
row = struct.unpack_from("<" + fmt * width, binary, offset + i * stride)
values.append(row[0] if width == 1 else row)
return values
def audit_glb(path: Path = GLB_PATH) -> dict:
issues = []
if not path.is_file():
return {"ok": False, "issues": ["missing npc_base.glb"], "triangles": 0,
"vertices": 0, "bones": []}
data = path.read_bytes()
try:
magic, version, length = struct.unpack_from("<III", data, 0)
json_len, json_type = struct.unpack_from("<II", data, 12)
gltf = json.loads(data[20:20 + json_len].decode("utf-8"))
_, bin_len, _ = struct.unpack_from("<III", data, 12 + 8 + json_len)
binary = data[12 + 8 + json_len + 8:12 + 8 + json_len + 8 + bin_len]
except (struct.error, ValueError) as exc:
return {"ok": False, "issues": [f"unreadable GLB: {exc}"],
"triangles": 0, "vertices": 0, "bones": []}
if magic != 0x46546C67 or version != 2 or length != len(data):
issues.append("bad GLB container")
if json_type != 0x4E4F534A:
issues.append("first chunk is not JSON")
nodes = gltf.get("nodes", [])
bones = []
for skin in gltf.get("skins", []):
for joint in skin.get("joints", []):
if 0 <= joint < len(nodes):
bones.append(nodes[joint].get("name", ""))
for bone in BONES:
if bone not in bones:
issues.append(f"missing bone {bone}")
if not gltf.get("skins"):
issues.append("no skin (mesh is not skinned)")
materials = [m.get("name", "") for m in gltf.get("materials", [])]
for slot in SLOTS:
if slot not in materials:
issues.append(f"missing material slot {slot}")
if len(gltf.get("images", [])) < len(SLOTS) * 2:
issues.append("expected albedo + normal textures per slot")
for slot in SLOTS:
if not (TEX_DIR / f"{slot}_orm.png").is_file():
issues.append(f"missing packed ORM map for {slot}")
triangles = 0
vertices = 0
min_y = 1e9
for mesh in gltf.get("meshes", []):
for primitive in mesh.get("primitives", []):
attrs = primitive.get("attributes", {})
for required in ("POSITION", "NORMAL", "TEXCOORD_0", "JOINTS_0", "WEIGHTS_0"):
if required not in attrs:
issues.append(f"primitive missing {required}")
if "POSITION" in attrs:
positions = _read_accessor(gltf, binary, attrs["POSITION"])
vertices += len(positions)
if positions:
min_y = min(min_y, min(p[1] for p in positions))
if "TEXCOORD_0" in attrs:
for u, v in _read_accessor(gltf, binary, attrs["TEXCOORD_0"]):
if not (0.0 <= u <= 1.0 and 0.0 <= v <= 1.0):
issues.append("UV outside [0, 1]")
break
if primitive.get("indices") is not None:
triangles += gltf["accessors"][primitive["indices"]]["count"] // 3
if min_y < 1e9 and abs(min_y) > 0.03:
issues.append(f"feet not on the ground (min y {min_y:.3f})")
if triangles > MAX_TRIANGLES:
issues.append(f"triangle budget exceeded ({triangles} > {MAX_TRIANGLES})")
if vertices > MAX_VERTICES:
issues.append(f"vertex budget exceeded ({vertices} > {MAX_VERTICES})")
if not BLEND_PATH.is_file():
issues.append("editable .blend source missing")
return {"ok": not issues, "issues": issues, "triangles": triangles,
"vertices": vertices, "bones": bones}
# ---------------------------------------------------------------------------
# AI fabric preparation (Pillow; runs outside Blender)
# ---------------------------------------------------------------------------
def prepare_albedos() -> list:
"""Layer the committed AI fabric albedos over the procedural slot albedos.
For each slot in AI_FABRICS, resize `assets/textures/npc/ai/<fabric>.png`
to TEX_SIZE and write it as `<slot>_albedo.png`. A later `build` then keeps
that albedo (write_slot_textures skips it) and still regenerates the
procedural normal + packed ORM maps, so the AI colour sits on top of the
baked normal/AO detail.
"""
from PIL import Image
TEX_DIR.mkdir(parents=True, exist_ok=True)
written = []
for slot, fabric in AI_FABRICS.items():
src = AI_DIR / f"{fabric}.png"
if not src.is_file():
print(f"skip {slot}: {src.relative_to(ROOT)} missing (procedural albedo stays)")
continue
with Image.open(src) as image:
image.load()
image.convert("RGB").resize(
(TEX_SIZE, TEX_SIZE), Image.Resampling.LANCZOS
).save(TEX_DIR / f"{slot}_albedo.png")
written.append(f"{slot}<={fabric}")
print("prepared albedos: " + (", ".join(written) if written else "none"))
return written
def write_palette() -> dict:
"""Derive one representative colour per AI fabric and write palette.json.
Reuses model_pipeline.dominant_colors so the colours come from the same
centre-crop + saturation ranking as the prop concepts. Phase 3 can consume
these as deterministic, committed outfit base colours.
"""
import model_pipeline
from PIL import Image
palette = {}
for fabric in AI_FABRICS_ALL:
src = AI_DIR / f"{fabric}.png"
if not src.is_file():
continue
with Image.open(src) as image:
image.load()
colors = model_pipeline.dominant_colors(image, count=1)
if not colors:
continue
r, g, b = colors[0]
palette[fabric] = {
"rgb": [round(c, 4) for c in (r, g, b)],
"hex": f"#{int(round(r * 255)):02x}{int(round(g * 255)):02x}"
f"{int(round(b * 255)):02x}",
}
(TEX_DIR / "palette.json").write_text(
json.dumps(palette, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(f"wrote {TEX_DIR / 'palette.json'}")
return palette
def list_contract() -> int:
print("npc_base contract")
print(f" glb: {GLB_PATH.relative_to(ROOT)}")
print(f" blend: {BLEND_PATH.relative_to(ROOT)}")
print(f" slots: {', '.join(SLOTS)}")
print(f" bones: {', '.join(BONES)} (+ Hips/Spine/Neck)")
print(f" budget: <= {MAX_TRIANGLES} tris / {MAX_VERTICES} verts")
return 0
def main(argv=None) -> int:
raw = list(argv if argv is not None else sys.argv[1:])
if "--" in raw:
raw = raw[raw.index("--") + 1:]
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("list")
sub.add_parser("build")
sub.add_parser("prepare")
sub.add_parser("palette")
audit = sub.add_parser("audit")
audit.add_argument("--json", action="store_true")
args = parser.parse_args(raw)
if args.command == "list":
return list_contract()
if args.command == "build":
if bpy is None:
print("build must run inside Blender:\n flatpak run org.blender.Blender "
"--background --python %s -- build" % Path(__file__).resolve(),
file=sys.stderr)
return 2
build_character()
result = audit_glb()
print(json.dumps(result, indent=2))
return 0 if result["ok"] else 1
if args.command == "prepare":
prepare_albedos()
return 0
if args.command == "palette":
write_palette()
return 0
result = audit_glb()
if args.json:
print(json.dumps(result, indent=2))
else:
print(f"{'OK ' if result['ok'] else 'BAD'} npc_base.glb "
f"tris={result['triangles']} verts={result['vertices']} "
f"bones={len(result['bones'])}")
for issue in result["issues"]:
print(" -", issue)
return 0 if result["ok"] else 1
if __name__ == "__main__":
sys.exit(main())