12 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):
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 4 — Environment and lighting lift (after NPCs land)
- 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 sameassets/models/generated/<prop>.glbcontract (ADR-0012's follow-up). - Higher-resolution environment textures: regenerate brick/concrete/asphalt
through
asset_pipeline.pywith normal maps, keeping the current UV scales. - Evaluate a desktop-only Forward+ preset (SSAO/SSIL/volumetrics) while the
Web export preset keeps
thread_support=falseand GL Compatibility. Decide explicitly in an ADR addendum; do not silently change the renderer. - Regenerate
docs/screenshots/withcapture_screenshots.gd, then runscreen_designs.py compareandjudgeas the regression gate.
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 are Blender-authored skinned meshes with baked maps; they read as people 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.- 208 headless + 56 Python tests stay green (Python audit tests skip when
Blender is absent); NPC-budget and movement smokes pass; screenshot
compare/judgeshow no regressions. - Version bumped to v0.6.0, docs and ADR-0013 updated, release published.