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.
17 KiB
Development Plan — Graphics Quality v0.6.0 (Blender-authored NPCs)
Status: Proposed · Target version: v0.6.0 (MINOR) Companion decision: ADR-0013
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.
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.godotconfig/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.ymlruns 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.gdbefore raisingMAX_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—GuardControllerraycasts 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 byrotation.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_nodesare 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)
-
Confirm headless Blender works in this environment. Blender 5.2.0 LTS flatpak is installed; the sandbox cwd is
/app/blender, so--pythonand all input/output paths must be absolute:flatpak run org.blender.Blender --background \ --python /var/home/zwitschi/Documents/06-games/security-game/tools/npc_pipeline.py -- --buildRecord the working invocation in
MEMORY.md(the Im-Dornäcker notes already warn about the cwd gotcha). -
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,haireach carry a neutral albedo + normal + ORM (occlusion/roughness/metallic) map.outfit_forcolors 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.gdrun).
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):
- 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_foraccessories still attach. - 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). - 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_foralready uses), - ambient occlusion baked into the albedo or the ORM R channel (this is the Compatibility renderer's only AO source).
- Export
npc_base.glbwith materials referenced as Godot-importable textures (or export textures alongside and wire them in GDScript — decide in the spike). - 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 frommodel_pipeline.py(glTFbaseColorFactoris linear; albedo textures are sRGB — do not mix them up). - Commit the
.blendsource (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:
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 itsauditvalidates. Runseamlessfor tile-edge blending andauditto reject wrong sizes.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_forkeeps determinism: the AI supplies palettes and textures, never per-frame randomness.- Normal/roughness detail (fabric weave, denim) baked procedurally in Blender, with the AI albedos layered on top.
- 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)
- Add a
_load_model()path inNpcAvatarthat instantiates the importednpc_base.glbPackedScene, exactly likeWorldBuilder._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 aloaded_model/fallback_avatarfield for tests). - 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 underRig/Headas they do today. - Add
SurfaceMaterialsrecipes for the baked maps — e.g.npc()/skin()/cloth()— so_material()gains a textured path and the body stops callingMaterials.flat(). - Abstract the animation targets so
_animate()/_idle_layer()write to either the proceduralNode3Dpivots (fallback) orSkeleton3Dbones (skinned). Keep the existingrotation.xmath; the abstraction is a thin_set_limb_rotation(part, axis, radians)wrapper. - Keep
Rig/Headas 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 andtest_npc_character/test_npc_theater/test_target_cycle. - Re-measure
smoke_npc_budget.gd; adjustLOD_TIERSif 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.
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
- Audit the finished build.
python3 tools/blender_assets.py auditandsheet; 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. - Commit the library and sources. Add
assets/models/blender/**,assets/models/blender_src/**, the generator, manifest, previews, anddocs/blender-assets.md; generated folders that CI can rebuild offline may stay ignored.blender_assets.py auditmust work without Blender. - Select characters by role. Extend
NpcAvatarto pick a variant from the manifest using the profile's role andoutfit_for/layer_for(casual, jacket, coat, vest, dress, hoodie, firefighter, guard). Fall back tonpc_base.glb, then to the procedural rig, and record the chosen path. - Tint and anchor. Apply
outfit_forto every variant's five slots viaSurfaceMaterials.npc_slot(); keepRig/Headfor the focus circle, name tag, reaction tag, and accessories. Keep the walk/idle/escalation bone-driving wrapper. - Replace procedural gear. Use the
npc_gearcatalogue 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. - 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. - 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.
- Measure and gate. Re-run
smoke_npc_budget.gdwith the new characters before raisingMAX_LIVE_NPCS; add a prop triangle/draw budget check. Then recapturedocs/screenshots/and runscreen_designs.py compare+judgeplus a close-up target shot for character readability. - 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) ✅
- 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 sameWorldBuilderload/fallback contract (ADR-0012's follow-up). - Higher-resolution environment textures: regenerate brick/concrete/asphalt
through
asset_pipeline.pywith normal maps, keeping the current UV scales; use the same sRGB/albedo + packed ORM discipline as the library assets. - 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.
- Regenerate
docs/screenshots/withcapture_screenshots.gd, then runscreen_designs.py compareandjudgeas the regression gate, plusblender_assets.py sheetfor the character/gear/prop library.
Phase 5 — Verification and release
- Headless: fallback build,
npc_pipeline.py audit(viatest_model_pipelinestyle Python tests if Blender is present, otherwise skip with reason), outfit determinism,Rig/Headpresence, material recipe assertions. - Windowed smokes:
smoke_npc_budget.gd,smoke_movement.gd,smoke_focus_radial.gd,smoke_props.gd; screenshot comparison. - Bump
config/versionto0.6.0, updateREADME.md,docs/architecture.md(Level 2 gainstools/npc_pipeline.py), and publish perdocs/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)
- 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.
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.- The game boots and plays from a fresh checkout with no Blender and no network (committed assets + procedural fallback).
npc_pipeline.py buildis deterministic andauditenforces the contract.- 212 headless + 60 Python tests stay green (Blender-dependent Python tests
skip with a reason when Blender is absent);
npc_pipeline.py auditandblender_assets.py auditpass; NPC-budget and movement smokes pass; screenshotcompare/judgeand the library contact sheets show no regressions. - 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.