Plan the v0.6.0 graphics pass and add Blender-authored NPC assets
No files matched your search
@@ -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**.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 510 KiB |
|
After Width: | Height: | Size: 465 KiB |
|
After Width: | Height: | Size: 463 KiB |
|
After Width: | Height: | Size: 470 KiB |
|
After Width: | Height: | Size: 597 KiB |
|
After Width: | Height: | Size: 452 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 1003 B |
|
After Width: | Height: | Size: 7.1 KiB |
@@ -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
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 685 B |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 897 B |
|
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).
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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())
|
||||||