Phase 4 of the v0.6.0 graphics plan: - tools/bake_ao.py bakes 256² AO maps for every Blender character/gear/prop (Cycles, existing UVs) next to each GLB; Materials.apply_ao() wires the red channel into character slots, gear, and props. Compatibility has no SSAO. - Regenerated brick/concrete/asphalt at 1024² with OpenRouter, derived tangent normal maps via tools/make_normal_maps.py, and wired them through SurfaceMaterials.textured() without changing UV scales. - Evaluated Forward+: boots on desktop Vulkan, but the renderer is project-wide and Web must stay GL Compatibility; v0.6.0 keeps Compatibility (decision in MEMORY/plan). A desktop-only override is deferred. - Recaptured screenshots; compare 11 good / 0 bad. Fixed marquee text alignment. - Tests: baked AO maps exist for all 46 assets; prop/character AO material checks; headless 222/222, Python 69/69, all smokes pass. - Version 0.6.0; ADR-0013 accepted; README/architecture/blender-assets/plan and TODO/DONE updated.
294 lines
17 KiB
Markdown
294 lines
17 KiB
Markdown
# 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). No
|
||
animation clips are exported: `_animate()` and `_idle_layer()` drive the
|
||
armature bones directly at runtime, so the GLB only needs the skinned rest
|
||
mesh, the armature, and the material maps.
|
||
- `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 3.5 — Blender asset library (characters, gear, props)
|
||
|
||
The library generator is `tools/blender_assets.py` (flatpak Blender, headless).
|
||
**Status: integrated** — `NpcAvatar` selects characters/gear and `WorldBuilder`
|
||
loads the replacement/new props plus phase-driven emissives; the procedural rig
|
||
and primitive props remain fallbacks. Integration notes:
|
||
[`blender-assets.md`](blender-assets.md).
|
||
|
||
**Outputs and contract**
|
||
|
||
| Kind | Path | Notes |
|
||
| --- | --- | --- |
|
||
| Characters | `assets/models/blender/npc/<name>.glb` | 8 variants: `npc_casual`, `npc_jacket`, `npc_coat`, `npc_vest`, `npc_dress`, `npc_hoodie`, `npc_firefighter`, `npc_guard` |
|
||
| Gear | `assets/models/blender/npc_gear/<name>.glb` | 18 pieces; authored in attach-point space (`head`, `hand`, `back`, `chest`) |
|
||
| Props | `assets/models/blender/props/<name>.glb` | 20 pieces; the first nine replace current generated/primitive street furniture, the rest are new venue dressing |
|
||
| Manifest | `assets/models/blender/manifest.json` | path, category, triangles, vertices, bounds, materials, roles, tint slots, attach points |
|
||
| Sources | `assets/models/blender_src/<name>.blend` | committed, editable source |
|
||
| Previews | `docs/blender_previews/sheet_<category>.jpg` (committed) + per-asset `*.png` (local) | `render` / `sheet` for visual review |
|
||
|
||
All characters reuse the `npc_base` contract: mesh node `npc_base`, bones
|
||
`Head/HipL/R/KneeL/R/ShoulderL/R/ElbowL/R/HandL/R` (+ Hips/Spine/Neck),
|
||
material slots `skin/cloth/pants/accessory/hair`, front toward Blender `-Y`
|
||
(Godot `+Z`), feet at `y ~= 0`. Gear origins are the attach points (head at
|
||
`Rig/Head`, hand at the hand proxy, back at the spine, chest at the sternum).
|
||
Props are grounded except `wall_lantern` and `marquee`; both may be emissive.
|
||
|
||
**Integration steps**
|
||
|
||
1. **Audit the finished build.** `python3 tools/blender_assets.py audit` and
|
||
`sheet`; fix assets or reconcile the budgets. **Done 2026-11-01:** the
|
||
characters were simplified to fit the unchanged 6,000 / 4,000 contract
|
||
(3,788–4,640 tris), and all 46 assets audit clean.
|
||
2. **Commit the library and sources.** Add `assets/models/blender/**`,
|
||
`assets/models/blender_src/**`, the generator, manifest, previews, and
|
||
`docs/blender-assets.md`; generated folders that CI can rebuild offline may
|
||
stay ignored. `blender_assets.py audit` must work without Blender.
|
||
3. **Select characters by role.** Extend `NpcAvatar` to pick a variant from the
|
||
manifest using the profile's role and `outfit_for`/`layer_for` (casual,
|
||
jacket, coat, vest, dress, hoodie, firefighter, guard). Fall back to
|
||
`npc_base.glb`, then to the procedural rig, and record the chosen path.
|
||
4. **Tint and anchor.** Apply `outfit_for` to every variant's five slots via
|
||
`SurfaceMaterials.npc_slot()`; keep `Rig/Head` for the focus circle, name tag,
|
||
reaction tag, and accessories. Keep the walk/idle/escalation bone-driving
|
||
wrapper.
|
||
5. **Replace procedural gear.** Use the `npc_gear` catalogue for caps, hoods,
|
||
helmets, backpacks, lanyards, hand items (phone, cup, clipboard, umbrella,
|
||
camera, sign, flashlight, wand, radio) and the SCBA tank; attach at the
|
||
documented points and fall back to the current primitives when an asset is
|
||
missing.
|
||
6. **Replace and extend props.** Load the replacement set through
|
||
`WorldBuilder` (`bollard`, `bench`, `trash_bin`, `planter_box`,
|
||
`newspaper_box`, `fire_hydrant`, `street_lamp`, `street_tree`,
|
||
`traffic_cone`), then place the new dressing (`palm_tree`, `round_planter`,
|
||
`a_frame_sign`, `wall_lantern`, `crowd_barrier`, `stanchion`, `handrail`,
|
||
`security_podium`, `double_door`, `marquee`, `sedan`) using the existing
|
||
placement table/collider/avoidance contract. No prop may enter the
|
||
door/steps/guest lane.
|
||
7. **Wire emissives to the phase.** Street-lamp bulbs, wall lanterns, the door
|
||
lock LED, marquee bulbs/sign face, and sedan headlights should follow the
|
||
existing day/night lighting rather than staying always on.
|
||
8. **Measure and gate.** Re-run `smoke_npc_budget.gd` with the new characters
|
||
before raising `MAX_LIVE_NPCS`; add a prop triangle/draw budget check. Then
|
||
recapture `docs/screenshots/` and run `screen_designs.py compare` + `judge`
|
||
plus a close-up target shot for character readability.
|
||
9. **Fallbacks stay offline.** A fresh checkout with no Blender and no network
|
||
must boot on the committed library (or `npc_base.glb`/procedural rig when an
|
||
asset is absent); CI still runs the Godot import and headless suite.
|
||
|
||
|
||
### Phase 4 — Environment and lighting lift (after NPCs land) ✅
|
||
|
||
1. **Baked AO on props:** use the Phase 3.5 Blender prop replacements
|
||
(`street_lamp`, `street_tree`, `bench`, …) and bake AO into their
|
||
albedo/ORM maps so contact shadows read without SSAO. Keep the same
|
||
`WorldBuilder` load/fallback 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;
|
||
use the same sRGB/albedo + packed ORM discipline as the library assets.
|
||
3. **Evaluate a desktop-only Forward+ preset** — done: it boots on the desktop Vulkan
|
||
driver, but the renderer is project-wide and the Web preset must stay GL
|
||
Compatibility. v0.6.0 keeps Compatibility; a desktop-only override is deferred.
|
||
4. Regenerate `docs/screenshots/` with `capture_screenshots.gd`, then run
|
||
`screen_designs.py compare` and `judge` as the regression gate, plus
|
||
`blender_assets.py sheet` for the character/gear/prop library.
|
||
|
||
### 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 and street furniture are Blender-authored meshes with baked maps; the
|
||
character variants, gear, and props read as people/objects 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. 212 headless + 60 Python tests stay green (Blender-dependent Python tests
|
||
skip with a reason when Blender is absent); `npc_pipeline.py audit` and
|
||
`blender_assets.py audit` pass; NPC-budget and movement smokes pass;
|
||
screenshot `compare`/`judge` and the library contact sheets show no
|
||
regressions.
|
||
6. The library is committed with its generator/manifest/previews, runtime
|
||
selection and fallbacks are covered by tests, version bumped to v0.6.0,
|
||
docs and ADR-0013 updated, release published.
|