Plan the v0.6.0 graphics pass and add Blender-authored NPC assets
This commit is contained in:
1 parent
a27e8cb74c
commit
b24d0aeb61
33 files changed
+1005
-2
No files matched your search
@@ -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).
|
||||
@@ -161,6 +161,7 @@ Architecture decisions are recorded as ADRs:
|
||||
- [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-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
|
||||
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user