`blender_assets.py fit` measures each character head width (skin vertices weighted to Head in y 1.55-1.81) and clothing radius (cloth skirt in y 0.30-0.52, pants hip fallback), then prints every attached head/back/chest gear bounding box with head-band status and x/radial skirt clearance. The report confirms the glasses width and shoulder-bag coat clip, giving the next fit fixes numbers. Python tooling 87/87; asset audit clean.
194 lines
11 KiB
Markdown
194 lines
11 KiB
Markdown
# Blender asset library
|
||
|
||
Status: built, audited, and integrated (2026-11-01). `NpcAvatar` selects the
|
||
character variants and attaches the gear; `WorldBuilder` loads the prop library
|
||
with colliders and phase-driven emissives. See the Phase 3.5 integration in
|
||
[`plan-graphics-v0.6.md`](plan-graphics-v0.6.md).
|
||
|
||
Generator: [`tools/blender_assets.py`](../tools/blender_assets.py). Manifest (the
|
||
source of truth for integration):
|
||
[`assets/models/blender/manifest.json`](../assets/models/blender/manifest.json).
|
||
|
||
## Art direction
|
||
|
||
The references were the AI mockups in `docs/screens/designs/` and the current
|
||
captures in `docs/screenshots/`.
|
||
|
||
- **People** (`targeting_npc`, `joke_shop_queue`, `studio_arrival`,
|
||
`crowd_panic`, `responder_arrival`): ordinary adults in sweaters, jackets,
|
||
long coats, hoodies and beanies; a security guard in a dark uniform with a cap
|
||
and belt; a firefighter in a dark turnout coat with yellow reflective bands,
|
||
a helmet and an SCBA tank. The current skinned `npc_base` is a set of boxes
|
||
and cylinders with a flat face, so silhouette was the main gap.
|
||
- **Props** (`prop_gallery`, `venue_overview`, `late_night_avenue`,
|
||
`joke_shop_queue`): the gallery mockup has an octagonal tapered bollard, a
|
||
slatted bench with thin metal legs, an octagonal green litter bin with a slot
|
||
and recessed panels, a stone planter with a lumpy shrub, a blue news box with
|
||
a window and a slanted top, and a hydrant with caps and nozzles. The venue
|
||
shots add ornate street lamps, palm trees, round stone pots with topiary, a
|
||
chalkboard A-frame sign, a wall lantern, a bulb-lit marquee, glazed double
|
||
doors, stair handrails and a dark sedan. Today's in-game props are primitive
|
||
boxes, and the trees are cones.
|
||
|
||
## Running it
|
||
|
||
```bash
|
||
# Build every asset (or --only a,b,c). Writes GLBs, .blend sources, manifest.
|
||
flatpak run org.blender.Blender --background \
|
||
--python "$PWD/tools/blender_assets.py" -- build
|
||
# Cycles previews (CPU, ~3 s each) -> docs/blender_previews/<name>.png (gitignored)
|
||
flatpak run org.blender.Blender --background \
|
||
--python "$PWD/tools/blender_assets.py" -- render
|
||
python3 tools/blender_assets.py sheet # -> docs/blender_previews/sheet_<category>.jpg
|
||
python3 tools/blender_assets.py audit # stdlib only; exit 1 on any issue
|
||
python3 tools/blender_assets.py fit # head/clothing fit, per character
|
||
python3 tools/blender_assets.py list
|
||
godot --headless --path . --import # after any rebuild
|
||
```
|
||
|
||
The flatpak cannot read `/tmp` and its cwd is `/app/blender`, so the script
|
||
derives every path from its own location. A full build takes about 5 s; a full
|
||
render about 2.5 min.
|
||
|
||
## Layout
|
||
|
||
| Kind | Path | Count |
|
||
| --- | --- | --- |
|
||
| Characters (skinned) | `assets/models/blender/npc/<name>.glb` | 8 |
|
||
| Gear (static) | `assets/models/blender/npc_gear/<name>.glb` | 18 |
|
||
| Props (static) | `assets/models/blender/props/<name>.glb` | 20 |
|
||
| Editable sources | `assets/models/blender_src/<name>.blend` (`.gdignore`d) | 46 |
|
||
| Review sheets | `docs/blender_previews/sheet_{npc,npc_gear,props}.jpg` | 3 |
|
||
|
||
## Conventions
|
||
|
||
- Blender is Z-up. The glTF exporter converts to Godot Y-up.
|
||
- **Front = Blender −Y = Godot +Z**, the face direction that
|
||
`NpcAvatar.yaw_towards()` assumes.
|
||
- **Foliage** (`street_tree`, `planter_box`, `round_planter`) is a cluster of many small
|
||
flat-shaded clumps plus outer clumps that break the silhouette, not one smooth blob. That is
|
||
how the realism pass made the street greenery read as leaves; the three assets carry fresh AO
|
||
maps (`street_tree` is 2,592 triangles).
|
||
- Grounded assets have their origin on the floor (`min y ≈ 0`). The two
|
||
exceptions are `wall_lantern` (origin on the wall plate, at mount height) and
|
||
`marquee` (origin at the wall where the canopy meets it, at canopy height).
|
||
- Units are metres, at the same scale as the current world (1.82 m characters).
|
||
|
||
## Characters
|
||
|
||
All eight use the `npc_base` contract, so each one can replace
|
||
`npc_base.glb` without code changes:
|
||
|
||
- mesh node `npc_base` under an armature that imports as `Skeleton3D`
|
||
- bones `Head`, `HipL/R`, `KneeL/R`, `ShoulderL/R`, `ElbowL/R`, `HandL/R`
|
||
(plus `Hips`, `Spine`, `Neck`) at the **same rest positions as `npc_base`**.
|
||
`test_character_joints_line_up_with_npc_base` checks this within 1 cm, so the
|
||
animation proxies in `_build_skinned_body()` stay valid.
|
||
- material slots `skin`, `cloth`, `pants`, `accessory`, `hair` carry the same
|
||
neutral `assets/textures/npc/<slot>_{albedo,normal}.png` maps, so
|
||
`Materials.npc_slot()` tints them the same way it tints `npc_base`.
|
||
- budget ≤ 6000 triangles and ≤ 5000 vertices (glTF vertex count, after seam
|
||
splits; `CHAR_MAX_VERTS` in `tools/blender_assets.py`, raised from 4000 for
|
||
the 2026-11-01 face/hand pass). The current range is 3,974–5,026 triangles
|
||
and 3,744–4,807 vertices.
|
||
|
||
Construction: the torso and limbs are one continuous Skin-modifier mesh,
|
||
subdivided twice, decimated to 26 % with X symmetry, and cut at the waist, cuffs,
|
||
sleeves and neck so the material borders are clean edge loops. The head is a
|
||
separate part carrying the face detail: brow ridge, sclera/iris/upper-lid per eye,
|
||
cheekbones, a nose with a bridge and tip, ears with lobes, and upper and lower
|
||
lips. Hands have four fingers plus a thumb; sleeves end in a cuff ring.
|
||
Hair, shoes and clothing layers are separate parts. Weights come from distance to
|
||
the bone segments, limited by body region. Parts that should stay rigid (head,
|
||
hands, shoes) get a single bone.
|
||
|
||
| Character | `layer_for()` / look | Suggested roles | Preview gear |
|
||
| --- | --- | --- | --- |
|
||
| `npc_casual` | `none`: sweater/tee, short sleeves | tourist, confused visitor, intoxicated, guest | backpack, camera |
|
||
| `npc_jacket` | `jacket`: collar + placket | authorized staff, protester, guest | lanyard, clipboard |
|
||
| `npc_coat` | `coat`: knee-length coat, long hair | influencer, trespasser, violent threat | sunglasses, shoulder bag, phone |
|
||
| `npc_vest` | `vest`: hi-vis vest, reflective bands | delivery | cap, clipboard |
|
||
| `npc_dress` | `dress`: slim build, bun, tights | performer | shoulder bag |
|
||
| `npc_hoodie` | `hood` accessory: hood up, beard, pocket | trespasser, mental crisis, violent threat | cup |
|
||
| `npc_firefighter` | responder: broad build, banded turnout coat, boots | responder | helmet, SCBA tank, flashlight |
|
||
| `npc_guard` | uniform: belt, buckle, badge, shoulder patches, radio | player guard / venue security | cap, wand |
|
||
|
||
**Fixed-colour materials.** Detail materials outside the five slots (`hivis`,
|
||
`reflective`, `leather`, `steel`, `patch`, `plastic_black`, `rubber`) carry a 4×4
|
||
colour texture. The current `_apply_outfit_materials()` sets
|
||
`albedo_color = WHITE` on any slot it does not recognise, and these keep their
|
||
colour through that. When integrating, tint only the five contract slots.
|
||
|
||
**Runtime tint notes (implemented).** Only the five contract slots are tinted;
|
||
fixed detail materials keep their authored colour. On `npc_vest` the vest is the
|
||
fixed `hivis` material and `cloth` is the undershirt, so the yellow override is
|
||
skipped. `npc_firefighter`/`npc_guard` use dark turnout/uniform tints instead of
|
||
the role's `TYPE_COLORS`.
|
||
|
||
## Gear attach points
|
||
|
||
Gear is authored in attach-point space, so the origin of each GLB is its
|
||
attach point:
|
||
|
||
| Attach | Godot parent / position | Gear |
|
||
| --- | --- | --- |
|
||
| `head` | `Rig/Head` marker (0, 1.62, 0) | cap, beanie, hood, sunglasses, firefighter_helmet |
|
||
| `back` | `Rig` at (0, 1.10, 0), spine centre; items sit behind the body (Blender +Y = Godot −Z) | backpack, shoulder_bag, scba_tank |
|
||
| `chest` | `Rig` at (0, 1.30, 0), sternum | lanyard |
|
||
| `hand` | Hand proxy (±0.27, 0.78, 0.02); grip 5 cm below the origin | phone, cup, clipboard, umbrella, camera, protest_sign, flashlight, wand, radio |
|
||
|
||
The character previews in `sheet_npc.jpg` are built by importing the gear at
|
||
exactly these points, which gives a quick visual check of the convention.
|
||
|
||
## Props
|
||
|
||
| Prop | Replaces / role | Notes |
|
||
| --- | --- | --- |
|
||
| `bollard`, `bench`, `trash_bin`, `planter_box`, `newspaper_box`, `fire_hydrant` | `assets/models/generated/<name>.glb` | same footprint and height as the generated set, so colliders and `PROP_PLACEMENTS` still fit |
|
||
| `street_lamp` | the box lamps in `_build_street_furniture()` | 5 m; the arm points to −X with the bulb at (−0.85, 4.8, 0). Rotate 180° for the other side of the street. Emissive `bulb` |
|
||
| `street_tree` | the cone trees | 3.6 m, with a tree grate |
|
||
| `traffic_cone` | the `cone` prop accessory | 0.69 m |
|
||
| `palm_tree` | new (boulevard) | 7 m, 14 fronds |
|
||
| `round_planter` | new (mockup pots) | stone pot with topiary |
|
||
| `a_frame_sign` | new (comedy club queue) | chalkboard |
|
||
| `wall_lantern` | new (club door) | wall-mounted; emissive `bulb` |
|
||
| `crowd_barrier`, `stanchion` | new (queue / crowd control) | 2.2 m bike-rack barricade; chrome post with velvet rope |
|
||
| `handrail` | new (steps) | 2 m run that climbs 0.8 m along +X |
|
||
| `security_podium` | new (door check-in) | tablet screen is mildly emissive |
|
||
| `double_door` | the procedural entrance door | 2.0 × 2.7 m; emissive `lock_led` on the card reader |
|
||
| `marquee` | the procedural marquee | 6 m canopy and sign box; emissive `bulb` + `sign_face`. Overlay the text with the existing `Label3D` |
|
||
| `sedan` | new (studio arrival) | 4.6 m; emissive `headlight`, red tail lamps |
|
||
|
||
Props use plain PBR factors (base colour, roughness, metallic, emission) and
|
||
have no textures. They are not yet AO-baked; that is still a Phase 4 task.
|
||
Emissive materials are always on in the GLB. The integration should tie them to
|
||
`WorldBuilder.set_night_mode()`.
|
||
|
||
## Checks
|
||
|
||
- `python3 tools/blender_assets.py audit`: container, `POSITION`/`NORMAL`/
|
||
`TEXCOORD_0`, UVs in [0, 1], no zero-area triangles (Godot warns about them
|
||
during LOD generation), budgets (characters 6000/5000, gear 1500, props 4000
|
||
triangles), grounding, and an editable `.blend`. For characters it also
|
||
checks skinning, bones, slots, the `npc_base` mesh node and the height.
|
||
- `tools/test_blender_assets.py`: catalogue and contract consistency with
|
||
`npc_pipeline.py`, plus a clean audit and full manifest coverage when the
|
||
library is built.
|
||
- `src/test/tests/test_blender_assets.gd`: every asset imports as a scene
|
||
with geometry, characters keep their bones and slots, joints match
|
||
`npc_base`, the bones accept `NpcAvatar`'s pose composition, and props are
|
||
grounded.
|
||
|
||
## Known limitations
|
||
|
||
- Characters carry a baked `<name>_ao.png` (red channel, wired by
|
||
`_apply_outfit_materials()`), but no ORM texture of their own. They reuse the
|
||
tiling slot maps, so the runtime `SurfaceMaterials` recipes still supply
|
||
roughness.
|
||
- There is a single body topology per build (average, slim, broad) and no
|
||
shape keys. Height variety still comes from `outfit_for()["height_scale"]`.
|
||
- Skirts and coats follow the hip and knee bones, so a full stride can clip
|
||
the legs through the hem.
|
||
- Preview colours (`PREVIEW_TINTS`) only stand in for `outfit_for()`. The GLBs
|
||
stay neutral.
|