# Blender asset library Architecture context: the asset contracts and fallback path are summarized in [`architecture.md`](architecture.md#content-and-asset-pipeline-architecture). 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. The 0.9.4 neutral baseline and its acceptance criteria are in [`npc-visual-review.md`](npc-visual-review.md); the committed 8 × 6 contact sheet is [`blender_previews/sheet_npc_baseline.jpg`](blender_previews/sheet_npc_baseline.jpg). 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/.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_.jpg python3 tools/blender_assets.py audit # stdlib only; exit 1 on any issue python3 tools/blender_assets.py fit # head/clothing fit + bag-body clearance, 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/.glb` | 8 | | Gear (static) | `assets/models/blender/npc_gear/.glb` | 18 | | Props (static) | `assets/models/blender/props/.glb` | 20 | | Editable sources | `assets/models/blender_src/.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/_{albedo,normal}.png` maps, so `Materials.npc_slot()` tints them the same way it tints `npc_base`. - budget ≤ **8,000 triangles and ≤ 8,000 glTF vertices** for the eight library variants (vertex count after seam splits; `CHAR_MAX_TRIS`/`CHAR_MAX_VERTS` in `tools/blender_assets.py`, raised from 6,000/5,000 on 2026-11-02). The frozen `npc_base` fallback keeps its original 6,000/4,000 contract. The current range is 4,226–5,218 triangles and 4,119–5,240 vertices, leaving room for the remaining grip and clothing detail. 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. `NpcAvatar.GEAR_OFFSETS` applies two final-context nudges after attachment: the lanyard moves 4 cm forward so its badge clears the jacket, and the firefighter helmet moves 3 cm up so its brim sits above the eyes. The `fit` report remains an asset-space measurement of the untouched GLBs. ## Props | Prop | Replaces / role | Notes | | --- | --- | --- | | `bollard`, `bench`, `trash_bin`, `planter_box`, `newspaper_box`, `fire_hydrant` | `assets/models/generated/.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) plus the committed 256² baked AO maps (`_ao.png`) wired by `WorldBuilder`; emissive materials are tied to `WorldBuilder.set_night_mode()`. Characters carry baked AO and the shared neutral material maps described above. ## 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 (library characters 8000/8000, base fallback 6000/4000, 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 `_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. - The 0.9.4 baseline found three P1 portrait issues. The tourist's photo prop now only appears in photo/film poses and the backpack straps were refit as flattened chest bands; the face is still shallow/faceted at portrait range. The full ranked list, measurements and before/after sheet are in [`npc-visual-review.md`](npc-visual-review.md).