diff --git a/.gitignore b/.gitignore index 9712d80..caf4c40 100644 --- a/.gitignore +++ b/.gitignore @@ -62,4 +62,5 @@ squashfs-root/ # extracts the GLB-embedded textures next to the models on import. docs/blender_previews/*.png assets/models/blender/**/*.png +!assets/models/blender/**/*_ao.png assets/models/blender_src/*.blend1 diff --git a/DONE.md b/DONE.md index 914b065..8ced810 100644 --- a/DONE.md +++ b/DONE.md @@ -860,3 +860,21 @@ smokes pass, `compare` **11 good / 0 bad**. AO/ORM baking remains in Phase 4. ~27,986 triangles. NPC budget smoke: 16 avatars = 112 meshes at ~6.9 ms. - [x] Recaptured the README/catalogue screenshots (including the prop gallery from the library models) and fixed the marquee text placement. + +## Environment and Lighting Lift + +Completed 2026-11-01 (v0.6.0). Headless **222/222**, Python **69/69**, all smokes pass, +`compare` **11 good / 0 bad**. + +- [x] Baked per-asset AO maps for the full Blender library with `tools/bake_ao.py` (Cycles, + 256×256, stored next to each GLB as `_ao.png`); Godot wires the red channel into + `StandardMaterial3D.ao_texture` for character slots, gear and props. +- [x] Regenerated brick, concrete and asphalt at 1024² through `asset_pipeline.py`, derived + tangent normal maps with `tools/make_normal_maps.py`, and wired them into + `SurfaceMaterials.textured()` while keeping the existing UV scales. +- [x] Evaluated Forward+: it boots on the desktop Vulkan driver, but the renderer is a + project-wide setting and the Web build must stay on GL Compatibility. Decision: keep + Compatibility for v0.6.0; a desktop-only preset needs a separate project/override and is + deferred. +- [x] Recaptured `docs/screenshots/` and ran `compare` + `judge`; no regressions. +- [x] Fixed the library marquee text/face alignment and added the library AO/triangle tests. diff --git a/MEMORY.md b/MEMORY.md index bdfba62..3c7a985 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -151,9 +151,11 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o same colliders/obstacles, and collects library emissives for `set_night_mode()`. `LIBRARY_TRIANGLE_BUDGET` = 60k (current ~27,986). Library props are excluded from `PROP_PLACEMENTS` only when they need dedicated slots (door/marquee/sedan/handrail/lantern). -- **v0.6.0 remaining:** AO/ORM baking for the library props, higher-resolution environment - textures with normal maps, the Forward+ desktop evaluation, then version 0.6.0 docs/release. - Headless suite is 222/222 and Python tooling 68/68 after integration. +- **v0.6.0 environment pass:** `tools/bake_ao.py` bakes 256² AO maps per library asset + (`_ao.png`, red channel) and `Materials.apply_ao()`/`SurfaceMaterials` wire them in; + `tools/make_normal_maps.py` derives tangent normals for the 1024² brick/concrete/asphalt + textures. Forward+ boots on desktop but is rejected for v0.6.0 (project-wide renderer, Web + must stay GL Compatibility). Headless 222/222, Python 69/69. - **Prop pipeline (done):** audit enforces 400 tris / 600 verts, ground contact, UV range, no degenerate tris, one material. `capture_prop_gallery.gd` writes `10-prop-gallery.png` (3x2 studio line-up, back row on a platform) and the `prop_gallery` catalogue entry compares it. diff --git a/README.md b/README.md index d772a38..af49491 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ The central design goal is simple: > Downloads are public (no sign-in required) since the host's instance-wide > `REQUIRE_SIGNIN_VIEW` was disabled on 2026-10-31. > -> **Latest: v0.5.0** — articulated NPC characters with LOD and idle behaviour, plus movement feel, gamepad support, and key remapping. +> **Latest: v0.6.0** — Blender-authored character/gear/prop library with baked AO, higher-resolution environment textures, and phase-driven library props. > Releases are built automatically when a `vX.Y.Z` tag is pushed > (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use > `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline @@ -512,11 +512,12 @@ Implemented so far: - Prop collision and placement (v0.4.0): every prop fits a static collider from its mesh bounds and publishes a navigation obstacle, so the guard and guests cannot walk through benches, bins, or bollards; placements are grouped into zones with a validator, and deterministic variants add rotation, scale, and small clutter - Prop atlas (v0.3.1): `tools/model_pipeline.py` packs each prop's palette into one power-of-two colour atlas plus a metallic/roughness atlas and emits a single material with per-vertex UVs, so a prop is one draw material rather than one per part; `audit` enforces the contract - Generated prop models (v0.3.0 work): `tools/model_pipeline.py` builds bollards, benches, bins, planters, newspaper boxes and a hydrant as `.glb` props with per-part materials, loaded by `WorldBuilder` with a procedural fallback; the optional AI concept step (`model_pipeline.py concept`) supplies per-prop colour palettes +- Environment pass (v0.6.0): 1024² brick/concrete/asphalt with derived tangent normal maps, 256² baked AO maps for every Blender character/gear/prop, and library props whose bulbs/LEDs/sign faces follow the day/night phase - NPC characters + library (v0.6.0): `NpcAvatar` loads one of 8 Blender-authored skinned variants (role/seed selected) with five tintable material slots, `Rig/Head` focus anchor, and attach-point gear; `npc_base.glb` and the procedural rig remain fallbacks. `tools/blender_assets.py` builds/audits the character, gear, and 20-prop library; `WorldBuilder` loads the replacements and new street dressing (lamp, trees, palms, planters, barriers, podium, doors, marquee, sedan, handrail) with colliders, navigation obstacles, and phase-driven emissives - Web build: `build/gig-line-web-.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 -Not yet implemented: AO/ORM baking for the library props and the higher-resolution environment textures/Forward+ evaluation in [`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`). +Not yet implemented: a desktop-only Forward+ preset (the renderer stays GL Compatibility for Web parity), live 3D body-camera replay, editor-based GUT integration, and crowd performance profiling/LOD (see `TODO.md`). ### Smoke Test Checklist diff --git a/TODO.md b/TODO.md index a1377d5..b0c3a2d 100644 --- a/TODO.md +++ b/TODO.md @@ -64,13 +64,13 @@ Detailed plan: [`docs/plan-graphics-v0.6.md`](docs/plan-graphics-v0.6.md) · [AD - [x] Re-measure `smoke_npc_budget.gd`; tune `LOD_TIERS` if the skinned rig is heavier up close. - [x] Re-verify the focus-cone smoke (`smoke_focus_radial.gd`) and the movement smoke. -### Phase 4 — Environment and lighting lift +### Phase 4 — Environment and lighting lift ✅ -- [ ] Bake AO for the prop set through the Blender library (replacements above) or into the ORM/albedo maps behind the existing `WorldBuilder` contract; keep the procedural fallback. -- [ ] Regenerate higher-resolution brick / concrete / asphalt textures via `asset_pipeline.py` with normal maps, keeping the current UV scales; apply the same map discipline to the library's street materials. -- [ ] 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`. +- [x] Bake AO for the prop set through the Blender library (replacements above) or into the ORM/albedo maps behind the existing `WorldBuilder` contract; keep the procedural fallback. +- [x] Regenerate higher-resolution brick / concrete / asphalt textures via `asset_pipeline.py` with normal maps, keeping the current UV scales; apply the same map discipline to the library's street materials. +- [x] Evaluate a desktop-only Forward+ export preset (SSAO/SSIL/volumetrics); keep the Web preset on GL Compatibility with `thread_support=false`. +- [x] Record the renderer decision in an ADR addendum or `MEMORY.md`. +- [x] Regenerate `docs/screenshots/` with `capture_screenshots.gd` and run `screen_designs.py compare`. ### Phase 5 — Verification and release @@ -129,6 +129,101 @@ and scene visuals with those mockups — verified with - [ ] Run `screen_designs.py judge` and clear every `diff` verdict (target `match`/`review`). - [ ] Add the capture + compare step to CI or a repeatable smoke so drift is caught early. +## Improve in-game pop-ups + +- [ ] Improve the visual appearance of in-game pop-ups (layout, button style, palette, icons, and text styling). + +## Realistic backgrounds, textures and scene references + +Goal: replace the outdated flat skyline backdrop (`assets/textures/skyline.png`, black boxes +with dot windows) and the flat-looking surface tiles with AI-generated, more realistic +backgrounds and textures. Add a larger set of example 3D scene images as art direction, and +measure the in-game result against that target with the screenshot tools. Plan: +[`PLAN.md` → Visual Quality Direction → Realism pass](PLAN.md#realism-pass). + +**Rule for every step: never overwrite an existing image.** New outputs get new paths. When a +path is taken, the tools pick the next free `-vN` suffix. Raw AI output stays on disk under +`assets/textures/ai/v2/raw/` (gitignored like the other raw AI folders). Processed game-ready +candidates go to `assets/textures/ai/game_ready/v2/`, scene references to `docs/screens/concepts/`, +and trial captures and reports to `docs/screens/realism//`. + +### Step 1 — Tooling (`tools/scene_concepts.py`) + +- [ ] Add a catalogue of prompts in three groups: `scene` (16:9 example 3D game frames), + `backdrop` (4:1 skyline panoramas with transparent sky), and `texture` (seamless PBR + surface tiles). Each entry has a name, prompt, role and target path. +- [ ] `generate [--group] [--only] [--variants N]`: call the OpenRouter image API through + `generate_textures.generate_image` (key from the repo `.env`), save raw output, and never + overwrite (`next_free_path`). Print every file written. +- [ ] `process`: turn raw backdrops into 2048×512 RGBA strips that tile horizontally (edge + crossfade, sky made fully transparent, hard alpha for the alpha-scissor material). Turn raw + textures into seamless 1024² tiles (`asset_pipeline.make_seamless`) plus a normal map + (`make_normal_maps.normal_map`). Skip outputs that already exist. +- [ ] `audit`: run `asset_pipeline.audit_image` on the processed candidates (`backdrop` / + `tile` roles) and fail on any bad result. +- [ ] `realism`: for each trial capture, report the Pillow detail metrics (edge density, + contrast, colour spread, saturation) next to the matching concept image. Optionally ask a + vision model (`google/gemini-2.5-flash-lite`) for 0–10 realism scores (lighting, + materials, texture detail, backdrop, overall) with concrete fixes. Write a new JSON + + Markdown report per run. +- [ ] Unit tests (`tools/test_scene_concepts.py`): catalogue shape, the no-overwrite path + picker, backdrop/tile processing on synthetic images, metrics, and the judge payload/parse + (offline, no network). + +### Step 2 — Example 3D scene images + +- [ ] Generate 3D-game-style example frames for every venue and time of day: Hollywood entrance + (morning, golden hour, rainy night), boulevard skyline view (day, dusk), Joke Shop alley + (evening, close-up of the door), studio stage door (night arrival), sidewalk material + close-up, entrance steps and podium, crowd-control barrier line, and a responder arrival + wide shot. +- [ ] Review the set and record the visual targets each frame sets (sky, haze, material detail, + backdrop depth) in the report. + +### Step 3 — Backgrounds (replace the outdated skyline) + +- [ ] Generate skyline panoramas for day, dusk and night (lit windows): Hollywood/Los Angeles + mid-rise blocks, palm silhouettes, the hills on the horizon, a transparent sky, and a + straight-on view, in several variants each. +- [ ] Process them into `assets/textures/ai/game_ready/v2/skyline_{day,dusk,night}.png`, + seamless horizontally for the 3× UV repeat around the backdrop ring. + +### Step 4 — Textures + +- [ ] Generate higher-detail seamless tiles: red brick facade, granite/limestone facade + (studio), sidewalk concrete pavers with joints, asphalt with aggregate, wet night asphalt, + stone steps, striped awning canvas, and stucco wall. +- [ ] Process them into 1024² game-ready tiles plus normal maps in + `assets/textures/ai/game_ready/v2/` and audit them. + +### Step 5 — In-game trial captures (no game-code change) + +- [ ] Add `src/test/capture_realism.gd`. It boots the Hollywood venue, captures a fixed shot + list (entrance day, boulevard/skyline day, boulevard night), and can swap textures at + runtime from `--skyline=` / `--brick=` / `--concrete=` / `--asphalt=` user arguments. The + swap finds materials by their current texture, so baseline and candidate frames use the + same camera. Writes to `user://screenshots/realism//`. +- [ ] Capture the baseline (current assets) and the candidate set (v2 skyline + textures), and + copy them into `docs/screens/realism//{baseline,candidate}/`. + +### Step 6 — Compare against the realistic target + +- [ ] Run `scene_concepts.py realism` on baseline and candidate. Record the metric deltas and the + vision scores, and note which candidate textures/backdrops win. +- [ ] Note the remaining realism gaps (sky/fog, lighting, building geometry, NPC fidelity) as + follow-up tasks below. + +### Step 7 — Adopt the winners (after the in-flight normal-map/AO work is committed) + +- [ ] Point `WorldBuilder.T_SKYLINE` at the chosen v2 skyline (separate day/night textures through + `set_night_mode`), and retune `albedo_color` for the realistic strip. +- [ ] Switch `materials.gd` / `world_builder.gd` surface preloads to the chosen v2 tiles + normal + maps, keeping the current UV scales. Update `test_materials.gd` / `test_world.gd`. +- [ ] Regenerate `docs/screenshots/` with `capture_screenshots.gd`, then run + `screen_designs.py compare` + `judge` and `scene_concepts.py realism`. There must be no + `diff` verdict regressions, and realism scores must not drop. +- [ ] Update `README.md` (tool usage + gallery), `MEMORY.md` and `docs/architecture.md`. + ## Future ideas (optional, not scheduled) - [ ] Live 3D body-camera replay (the timeline is text-only today). diff --git a/assets/models/blender/npc/npc_casual_ao.png b/assets/models/blender/npc/npc_casual_ao.png new file mode 100644 index 0000000..cab1301 Binary files /dev/null and b/assets/models/blender/npc/npc_casual_ao.png differ diff --git a/assets/models/blender/npc/npc_coat_ao.png b/assets/models/blender/npc/npc_coat_ao.png new file mode 100644 index 0000000..3afc773 Binary files /dev/null and b/assets/models/blender/npc/npc_coat_ao.png differ diff --git a/assets/models/blender/npc/npc_dress_ao.png b/assets/models/blender/npc/npc_dress_ao.png new file mode 100644 index 0000000..cfd19a0 Binary files /dev/null and b/assets/models/blender/npc/npc_dress_ao.png differ diff --git a/assets/models/blender/npc/npc_firefighter_ao.png b/assets/models/blender/npc/npc_firefighter_ao.png new file mode 100644 index 0000000..7e6414a Binary files /dev/null and b/assets/models/blender/npc/npc_firefighter_ao.png differ diff --git a/assets/models/blender/npc/npc_guard_ao.png b/assets/models/blender/npc/npc_guard_ao.png new file mode 100644 index 0000000..2bc9ae2 Binary files /dev/null and b/assets/models/blender/npc/npc_guard_ao.png differ diff --git a/assets/models/blender/npc/npc_hoodie_ao.png b/assets/models/blender/npc/npc_hoodie_ao.png new file mode 100644 index 0000000..d59a976 Binary files /dev/null and b/assets/models/blender/npc/npc_hoodie_ao.png differ diff --git a/assets/models/blender/npc/npc_jacket_ao.png b/assets/models/blender/npc/npc_jacket_ao.png new file mode 100644 index 0000000..26d800c Binary files /dev/null and b/assets/models/blender/npc/npc_jacket_ao.png differ diff --git a/assets/models/blender/npc/npc_vest_ao.png b/assets/models/blender/npc/npc_vest_ao.png new file mode 100644 index 0000000..edc377f Binary files /dev/null and b/assets/models/blender/npc/npc_vest_ao.png differ diff --git a/assets/models/blender/npc_gear/backpack_ao.png b/assets/models/blender/npc_gear/backpack_ao.png new file mode 100644 index 0000000..89271c9 Binary files /dev/null and b/assets/models/blender/npc_gear/backpack_ao.png differ diff --git a/assets/models/blender/npc_gear/beanie_ao.png b/assets/models/blender/npc_gear/beanie_ao.png new file mode 100644 index 0000000..7eb6380 Binary files /dev/null and b/assets/models/blender/npc_gear/beanie_ao.png differ diff --git a/assets/models/blender/npc_gear/camera_ao.png b/assets/models/blender/npc_gear/camera_ao.png new file mode 100644 index 0000000..3eee89c Binary files /dev/null and b/assets/models/blender/npc_gear/camera_ao.png differ diff --git a/assets/models/blender/npc_gear/cap_ao.png b/assets/models/blender/npc_gear/cap_ao.png new file mode 100644 index 0000000..acce34e Binary files /dev/null and b/assets/models/blender/npc_gear/cap_ao.png differ diff --git a/assets/models/blender/npc_gear/clipboard_ao.png b/assets/models/blender/npc_gear/clipboard_ao.png new file mode 100644 index 0000000..1ed9291 Binary files /dev/null and b/assets/models/blender/npc_gear/clipboard_ao.png differ diff --git a/assets/models/blender/npc_gear/cup_ao.png b/assets/models/blender/npc_gear/cup_ao.png new file mode 100644 index 0000000..bbbfffc Binary files /dev/null and b/assets/models/blender/npc_gear/cup_ao.png differ diff --git a/assets/models/blender/npc_gear/firefighter_helmet_ao.png b/assets/models/blender/npc_gear/firefighter_helmet_ao.png new file mode 100644 index 0000000..1177375 Binary files /dev/null and b/assets/models/blender/npc_gear/firefighter_helmet_ao.png differ diff --git a/assets/models/blender/npc_gear/flashlight_ao.png b/assets/models/blender/npc_gear/flashlight_ao.png new file mode 100644 index 0000000..e365723 Binary files /dev/null and b/assets/models/blender/npc_gear/flashlight_ao.png differ diff --git a/assets/models/blender/npc_gear/hood_ao.png b/assets/models/blender/npc_gear/hood_ao.png new file mode 100644 index 0000000..76d8d59 Binary files /dev/null and b/assets/models/blender/npc_gear/hood_ao.png differ diff --git a/assets/models/blender/npc_gear/lanyard_ao.png b/assets/models/blender/npc_gear/lanyard_ao.png new file mode 100644 index 0000000..56bed77 Binary files /dev/null and b/assets/models/blender/npc_gear/lanyard_ao.png differ diff --git a/assets/models/blender/npc_gear/phone_ao.png b/assets/models/blender/npc_gear/phone_ao.png new file mode 100644 index 0000000..a488a08 Binary files /dev/null and b/assets/models/blender/npc_gear/phone_ao.png differ diff --git a/assets/models/blender/npc_gear/protest_sign_ao.png b/assets/models/blender/npc_gear/protest_sign_ao.png new file mode 100644 index 0000000..df10c88 Binary files /dev/null and b/assets/models/blender/npc_gear/protest_sign_ao.png differ diff --git a/assets/models/blender/npc_gear/radio_ao.png b/assets/models/blender/npc_gear/radio_ao.png new file mode 100644 index 0000000..488dd59 Binary files /dev/null and b/assets/models/blender/npc_gear/radio_ao.png differ diff --git a/assets/models/blender/npc_gear/scba_tank_ao.png b/assets/models/blender/npc_gear/scba_tank_ao.png new file mode 100644 index 0000000..b8e830b Binary files /dev/null and b/assets/models/blender/npc_gear/scba_tank_ao.png differ diff --git a/assets/models/blender/npc_gear/shoulder_bag_ao.png b/assets/models/blender/npc_gear/shoulder_bag_ao.png new file mode 100644 index 0000000..754b7cf Binary files /dev/null and b/assets/models/blender/npc_gear/shoulder_bag_ao.png differ diff --git a/assets/models/blender/npc_gear/sunglasses_ao.png b/assets/models/blender/npc_gear/sunglasses_ao.png new file mode 100644 index 0000000..a2c1813 Binary files /dev/null and b/assets/models/blender/npc_gear/sunglasses_ao.png differ diff --git a/assets/models/blender/npc_gear/umbrella_ao.png b/assets/models/blender/npc_gear/umbrella_ao.png new file mode 100644 index 0000000..6051ef1 Binary files /dev/null and b/assets/models/blender/npc_gear/umbrella_ao.png differ diff --git a/assets/models/blender/npc_gear/wand_ao.png b/assets/models/blender/npc_gear/wand_ao.png new file mode 100644 index 0000000..18b0419 Binary files /dev/null and b/assets/models/blender/npc_gear/wand_ao.png differ diff --git a/assets/models/blender/props/a_frame_sign_ao.png b/assets/models/blender/props/a_frame_sign_ao.png new file mode 100644 index 0000000..b27998d Binary files /dev/null and b/assets/models/blender/props/a_frame_sign_ao.png differ diff --git a/assets/models/blender/props/bench_ao.png b/assets/models/blender/props/bench_ao.png new file mode 100644 index 0000000..1901259 Binary files /dev/null and b/assets/models/blender/props/bench_ao.png differ diff --git a/assets/models/blender/props/bollard_ao.png b/assets/models/blender/props/bollard_ao.png new file mode 100644 index 0000000..3abdce9 Binary files /dev/null and b/assets/models/blender/props/bollard_ao.png differ diff --git a/assets/models/blender/props/crowd_barrier_ao.png b/assets/models/blender/props/crowd_barrier_ao.png new file mode 100644 index 0000000..a534bef Binary files /dev/null and b/assets/models/blender/props/crowd_barrier_ao.png differ diff --git a/assets/models/blender/props/double_door_ao.png b/assets/models/blender/props/double_door_ao.png new file mode 100644 index 0000000..a60d666 Binary files /dev/null and b/assets/models/blender/props/double_door_ao.png differ diff --git a/assets/models/blender/props/fire_hydrant_ao.png b/assets/models/blender/props/fire_hydrant_ao.png new file mode 100644 index 0000000..2bbe10e Binary files /dev/null and b/assets/models/blender/props/fire_hydrant_ao.png differ diff --git a/assets/models/blender/props/handrail_ao.png b/assets/models/blender/props/handrail_ao.png new file mode 100644 index 0000000..ed7da38 Binary files /dev/null and b/assets/models/blender/props/handrail_ao.png differ diff --git a/assets/models/blender/props/marquee_ao.png b/assets/models/blender/props/marquee_ao.png new file mode 100644 index 0000000..e6ba8c8 Binary files /dev/null and b/assets/models/blender/props/marquee_ao.png differ diff --git a/assets/models/blender/props/newspaper_box_ao.png b/assets/models/blender/props/newspaper_box_ao.png new file mode 100644 index 0000000..1b08560 Binary files /dev/null and b/assets/models/blender/props/newspaper_box_ao.png differ diff --git a/assets/models/blender/props/palm_tree_ao.png b/assets/models/blender/props/palm_tree_ao.png new file mode 100644 index 0000000..7dfae21 Binary files /dev/null and b/assets/models/blender/props/palm_tree_ao.png differ diff --git a/assets/models/blender/props/planter_box_ao.png b/assets/models/blender/props/planter_box_ao.png new file mode 100644 index 0000000..215230c Binary files /dev/null and b/assets/models/blender/props/planter_box_ao.png differ diff --git a/assets/models/blender/props/round_planter_ao.png b/assets/models/blender/props/round_planter_ao.png new file mode 100644 index 0000000..35d3486 Binary files /dev/null and b/assets/models/blender/props/round_planter_ao.png differ diff --git a/assets/models/blender/props/security_podium_ao.png b/assets/models/blender/props/security_podium_ao.png new file mode 100644 index 0000000..8367b75 Binary files /dev/null and b/assets/models/blender/props/security_podium_ao.png differ diff --git a/assets/models/blender/props/sedan_ao.png b/assets/models/blender/props/sedan_ao.png new file mode 100644 index 0000000..6568345 Binary files /dev/null and b/assets/models/blender/props/sedan_ao.png differ diff --git a/assets/models/blender/props/stanchion_ao.png b/assets/models/blender/props/stanchion_ao.png new file mode 100644 index 0000000..c579319 Binary files /dev/null and b/assets/models/blender/props/stanchion_ao.png differ diff --git a/assets/models/blender/props/street_lamp_ao.png b/assets/models/blender/props/street_lamp_ao.png new file mode 100644 index 0000000..d7eb71b Binary files /dev/null and b/assets/models/blender/props/street_lamp_ao.png differ diff --git a/assets/models/blender/props/street_tree_ao.png b/assets/models/blender/props/street_tree_ao.png new file mode 100644 index 0000000..af3da41 Binary files /dev/null and b/assets/models/blender/props/street_tree_ao.png differ diff --git a/assets/models/blender/props/traffic_cone_ao.png b/assets/models/blender/props/traffic_cone_ao.png new file mode 100644 index 0000000..d175c3d Binary files /dev/null and b/assets/models/blender/props/traffic_cone_ao.png differ diff --git a/assets/models/blender/props/trash_bin_ao.png b/assets/models/blender/props/trash_bin_ao.png new file mode 100644 index 0000000..91590e1 Binary files /dev/null and b/assets/models/blender/props/trash_bin_ao.png differ diff --git a/assets/models/blender/props/wall_lantern_ao.png b/assets/models/blender/props/wall_lantern_ao.png new file mode 100644 index 0000000..21b31b9 Binary files /dev/null and b/assets/models/blender/props/wall_lantern_ao.png differ diff --git a/assets/textures/ai/game_ready/asphalt.png b/assets/textures/ai/game_ready/asphalt.png index ae86da4..3554c8f 100644 Binary files a/assets/textures/ai/game_ready/asphalt.png and b/assets/textures/ai/game_ready/asphalt.png differ diff --git a/assets/textures/ai/game_ready/asphalt_normal.png b/assets/textures/ai/game_ready/asphalt_normal.png new file mode 100644 index 0000000..a6016f4 Binary files /dev/null and b/assets/textures/ai/game_ready/asphalt_normal.png differ diff --git a/assets/textures/ai/game_ready/brick_facade.png b/assets/textures/ai/game_ready/brick_facade.png index 2280870..f404ca5 100644 Binary files a/assets/textures/ai/game_ready/brick_facade.png and b/assets/textures/ai/game_ready/brick_facade.png differ diff --git a/assets/textures/ai/game_ready/brick_facade_normal.png b/assets/textures/ai/game_ready/brick_facade_normal.png new file mode 100644 index 0000000..4203c11 Binary files /dev/null and b/assets/textures/ai/game_ready/brick_facade_normal.png differ diff --git a/assets/textures/ai/game_ready/concrete.png b/assets/textures/ai/game_ready/concrete.png index 8f673d9..ef38879 100644 Binary files a/assets/textures/ai/game_ready/concrete.png and b/assets/textures/ai/game_ready/concrete.png differ diff --git a/assets/textures/ai/game_ready/concrete_normal.png b/assets/textures/ai/game_ready/concrete_normal.png new file mode 100644 index 0000000..5e817a1 Binary files /dev/null and b/assets/textures/ai/game_ready/concrete_normal.png differ diff --git a/docs/adr/0013-blender-authored-npc-characters.md b/docs/adr/0013-blender-authored-npc-characters.md index d1ef1b8..9194d58 100644 --- a/docs/adr/0013-blender-authored-npc-characters.md +++ b/docs/adr/0013-blender-authored-npc-characters.md @@ -1,6 +1,6 @@ # ADR-0013: Blender-authored NPC characters and baked materials -- **Status:** Proposed +- **Status:** Accepted (v0.6.0) - **Date:** 2026-11-01 ## Context diff --git a/docs/architecture.md b/docs/architecture.md index 9a2108a..c792698 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -76,7 +76,7 @@ The player interacts with NPCs, venue entrances, access artifacts, equipment, st - `ReputationEvaluator` converts incidents and shift outcomes into performance feedback. - `CrowdSystem` models queues, attention, congestion, and public reaction. - `ShiftReview` presents decisions, consequences, and improvement opportunities. -- `SurfaceMaterials` (`src/game/materials.gd`) holds the surface profiles, glass/emissive recipes, and the environment tune-up (tonemapping, glow, fog, grading) that the phase lighting applies. +- `SurfaceMaterials` (`src/game/materials.gd`) holds the surface profiles, textured albedo/normal recipes, baked-AO wiring, glass/emissive recipes, and the environment tune-up (tonemapping, glow, fog, grading) that the phase lighting applies. - `WorldBuilder` assembles the procedural venue and instantiates the generated prop models from `assets/models/generated/` with a procedural fallback. - `tools/model_pipeline.py` builds and audits the street-prop `.glb` models and packs their colour atlases. - `tools/npc_pipeline.py` builds and audits the skinned NPC base model (`assets/models/generated/npc_base.glb`) and its per-slot material maps, with an optional AI fabric override (ADR-0013). diff --git a/docs/plan-graphics-v0.6.md b/docs/plan-graphics-v0.6.md index f2c2794..fdc4368 100644 --- a/docs/plan-graphics-v0.6.md +++ b/docs/plan-graphics-v0.6.md @@ -233,7 +233,7 @@ Props are grounded except `wall_lantern` and `marquee`; both may be emissive. asset is absent); CI still runs the Godot import and headless suite. -### Phase 4 — Environment and lighting lift (after NPCs land) +### Phase 4 — Environment and lighting lift (after NPCs land) ✅ 1. **Baked AO on props:** use the Phase 3.5 Blender prop replacements (`street_lamp`, `street_tree`, `bench`, …) and bake AO into their @@ -242,9 +242,9 @@ Props are grounded except `wall_lantern` and `marquee`; both may be emissive. 2. **Higher-resolution environment textures:** regenerate brick/concrete/asphalt through `asset_pipeline.py` with normal maps, keeping the current UV scales; use the same sRGB/albedo + packed ORM discipline as the library assets. -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. +3. **Evaluate a desktop-only Forward+ preset** — done: it boots on the desktop Vulkan +driver, but the renderer is project-wide and the Web preset must stay GL +Compatibility. v0.6.0 keeps Compatibility; a desktop-only override is deferred. 4. Regenerate `docs/screenshots/` with `capture_screenshots.gd`, then run `screen_designs.py compare` and `judge` as the regression gate, plus `blender_assets.py sheet` for the character/gear/prop library. diff --git a/docs/screenshots/01-venue-overview.png b/docs/screenshots/01-venue-overview.png index be80ea8..56afc2b 100644 Binary files a/docs/screenshots/01-venue-overview.png and b/docs/screenshots/01-venue-overview.png differ diff --git a/docs/screenshots/02-targeting-npc.png b/docs/screenshots/02-targeting-npc.png index 2bc8c77..68f61b1 100644 Binary files a/docs/screenshots/02-targeting-npc.png and b/docs/screenshots/02-targeting-npc.png differ diff --git a/docs/screenshots/03-access-inspection.png b/docs/screenshots/03-access-inspection.png index 047da5e..7e85220 100644 Binary files a/docs/screenshots/03-access-inspection.png and b/docs/screenshots/03-access-inspection.png differ diff --git a/docs/screenshots/04-radio-report.png b/docs/screenshots/04-radio-report.png index 9659af9..16aeebd 100644 Binary files a/docs/screenshots/04-radio-report.png and b/docs/screenshots/04-radio-report.png differ diff --git a/docs/screenshots/05-late-night-avenue.png b/docs/screenshots/05-late-night-avenue.png index 30300ec..4a6ccfa 100644 Binary files a/docs/screenshots/05-late-night-avenue.png and b/docs/screenshots/05-late-night-avenue.png differ diff --git a/docs/screenshots/06-night-flashlight.png b/docs/screenshots/06-night-flashlight.png index 48dd9f6..8ce3638 100644 Binary files a/docs/screenshots/06-night-flashlight.png and b/docs/screenshots/06-night-flashlight.png differ diff --git a/docs/screenshots/07-wand-screening.png b/docs/screenshots/07-wand-screening.png index bf85d0b..e23d8d2 100644 Binary files a/docs/screenshots/07-wand-screening.png and b/docs/screenshots/07-wand-screening.png differ diff --git a/docs/screenshots/08-escalation-hud.png b/docs/screenshots/08-escalation-hud.png index 5e35a56..cd0959b 100644 Binary files a/docs/screenshots/08-escalation-hud.png and b/docs/screenshots/08-escalation-hud.png differ diff --git a/docs/screenshots/09-body-camera-timeline.png b/docs/screenshots/09-body-camera-timeline.png index 8f689e0..20823c4 100644 Binary files a/docs/screenshots/09-body-camera-timeline.png and b/docs/screenshots/09-body-camera-timeline.png differ diff --git a/project.godot b/project.godot index a24a7ae..f1b6a88 100644 --- a/project.godot +++ b/project.godot @@ -12,7 +12,7 @@ config_version=5 config/name="Gig Line" config/description="Workplace security simulation about observation, access verification, and proportionate escalation." -config/version="0.5.0" +config/version="0.6.0" run/main_scene="res://scenes/main.tscn" config/features=PackedStringArray("4.3", "GL Compatibility") diff --git a/src/game/materials.gd b/src/game/materials.gd index 87631cf..717984d 100644 --- a/src/game/materials.gd +++ b/src/game/materials.gd @@ -13,6 +13,9 @@ const T_BRICK := preload("res://assets/textures/ai/game_ready/brick_facade.png") const T_CONCRETE := preload("res://assets/textures/ai/game_ready/concrete.png") const T_ASPHALT := preload("res://assets/textures/ai/game_ready/asphalt.png") const T_AWNING := preload("res://assets/textures/ai/game_ready/awning.png") +const T_BRICK_NORMAL := preload("res://assets/textures/ai/game_ready/brick_facade_normal.png") +const T_CONCRETE_NORMAL := preload("res://assets/textures/ai/game_ready/concrete_normal.png") +const T_ASPHALT_NORMAL := preload("res://assets/textures/ai/game_ready/asphalt_normal.png") ## Roughness / metallic pairs per surface role. Exposed so tests and tooling can ## assert the profiles instead of hard-coding numbers twice. @@ -40,14 +43,40 @@ static func npc_slot(source: Material, tint: Color) -> StandardMaterial3D: material.albedo_color = tint return material +## Wire a baked AO map (red channel) into every surface of an imported mesh. +## Compatibility has no SSAO, so the library assets carry their own AO. +static func apply_ao(mesh_instance: MeshInstance3D, texture: Texture2D) -> void: + if mesh_instance == null or mesh_instance.mesh == null or texture == null: + return + for index in mesh_instance.mesh.get_surface_count(): + var base := mesh_instance.get_surface_override_material(index) + if base == null: + base = mesh_instance.mesh.surface_get_material(index) + var material := base.duplicate() as StandardMaterial3D + if material == null: + continue + material.ao_enabled = true + material.ao_texture = texture + material.ao_texture_channel = BaseMaterial3D.TEXTURE_CHANNEL_RED + mesh_instance.set_surface_override_material(index, material) + +static func load_ao(path: String) -> Texture2D: + if not ResourceLoader.exists(path): + return null + return ResourceLoader.load(path) as Texture2D + static func profile(role: String) -> Dictionary: return PROFILES.get(role, PROFILES["concrete"]) ## Base textured surface with a named profile. static func textured(texture: Texture2D, tint: Color, uv_scale: Vector2, - role: String = "concrete") -> StandardMaterial3D: + role: String = "concrete", normal: Texture2D = null) -> StandardMaterial3D: var mat := StandardMaterial3D.new() mat.albedo_texture = texture + if normal != null: + mat.normal_enabled = true + mat.normal_texture = normal + mat.normal_scale = 0.8 mat.albedo_color = tint mat.uv1_scale = Vector3(uv_scale.x, uv_scale.y, 1.0) mat.texture_filter = BaseMaterial3D.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC @@ -56,13 +85,13 @@ static func textured(texture: Texture2D, tint: Color, uv_scale: Vector2, return mat static func brick(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(10, 5)) -> StandardMaterial3D: - return textured(T_BRICK, tint, uv_scale, "brick") + return textured(T_BRICK, tint, uv_scale, "brick", T_BRICK_NORMAL) static func concrete(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(9, 2.4)) -> StandardMaterial3D: - return textured(T_CONCRETE, tint, uv_scale, "concrete") + return textured(T_CONCRETE, tint, uv_scale, "concrete", T_CONCRETE_NORMAL) static func asphalt(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(30, 7)) -> StandardMaterial3D: - var mat := textured(T_ASPHALT, tint, uv_scale, "asphalt") + var mat := textured(T_ASPHALT, tint, uv_scale, "asphalt", T_ASPHALT_NORMAL) # Damp asphalt at night picks up the street lamps and the marquee. mat.metallic_specular = 0.6 return mat diff --git a/src/game/npc_avatar.gd b/src/game/npc_avatar.gd index 7b05f81..72ff98c 100644 --- a/src/game/npc_avatar.gd +++ b/src/game/npc_avatar.gd @@ -453,6 +453,8 @@ func _apply_outfit_materials(npc_type: int, outfit: Dictionary, cloth: Color, "hair": outfit["hair"], "accessory": Color(0.10, 0.11, 0.14), } + var ao := Materials.load_ao( + BLENDER_NPC_DIR + character_model + "_ao.png") for index in mesh.get_surface_count(): var source := mesh.surface_get_material(index) if source == null: @@ -462,8 +464,12 @@ func _apply_outfit_materials(npc_type: int, outfit: Dictionary, cloth: Color, # (hivis, reflective, leather, steel, ...) keep their authored colour. if not tints.has(slot): continue - _skinned_mesh.set_surface_override_material(index, - Materials.npc_slot(source, tints[slot])) + var material := Materials.npc_slot(source, tints[slot]) + if ao != null: + material.ao_enabled = true + material.ao_texture = ao + material.ao_texture_channel = BaseMaterial3D.TEXTURE_CHANNEL_RED + _skinned_mesh.set_surface_override_material(index, material) func _bind_bones() -> void: _bone_indices.clear() @@ -726,6 +732,9 @@ func _attach_gear(gear_name: String) -> bool: node.free() return false _detail_nodes.append(node) + var ao := Materials.load_ao(BLENDER_GEAR_DIR + gear_name + "_ao.png") + for child in node.find_children("*", "MeshInstance3D", true, false): + Materials.apply_ao(child as MeshInstance3D, ao) return true ## Character-specific library gear (firefighter helmet/SCBA, dress bag, vest diff --git a/src/game/world_builder.gd b/src/game/world_builder.gd index 75a2ed4..629e0ad 100644 --- a/src/game/world_builder.gd +++ b/src/game/world_builder.gd @@ -125,6 +125,7 @@ func _build_building() -> void: door_model.position = Vector3(0, 0.0, -3.84) building.add_child(door_model) library_nodes.append(door_model) + _apply_library_ao(door_model, "double_door") for material in _emissive_materials(door_model): if not _lamp_bulb_mats.has(material): _lamp_bulb_mats.append(material) @@ -287,6 +288,7 @@ func _build_marquee() -> void: library.position = Vector3(0, 5.62, -3.9) root.add_child(library) library_nodes.append(library) + _apply_library_ao(library, "marquee") for material in _emissive_materials(library): if not _marquee_emissive.has(material): _marquee_emissive.append(material) @@ -304,7 +306,7 @@ func _build_marquee() -> void: marquee_label = Label3D.new() marquee_label.name = "MarqueeLabel" marquee_label.text = "GIG LINE" - marquee_label.position = Vector3(0, 6.45, -2.12) + marquee_label.position = Vector3(0, 6.36, -1.9) marquee_label.modulate = Color(1, 0.6, 0.9) marquee_label.font_size = 160 marquee_label.pixel_size = 0.004 @@ -326,6 +328,7 @@ func _build_library_dressing() -> void: rail.rotation_degrees.y = 90.0 root.add_child(rail) library_nodes.append(rail) + _apply_library_ao(rail, "handrail") if ResourceLoader.exists(BLENDER_PROP_DIR + "sedan.glb"): var sedan := _add_prop_model(root, "sedan", Vector3(6.0, 0.1, 17.0), 180.0) sedan.name = "ParkedSedan" @@ -343,6 +346,7 @@ func _build_library_dressing() -> void: lantern.position = Vector3(side, 3.3, -3.78) root.add_child(lantern) library_nodes.append(lantern) + _apply_library_ao(lantern, "wall_lantern") for material in _emissive_materials(lantern): if not _lamp_bulb_mats.has(material): _lamp_bulb_mats.append(material) @@ -407,6 +411,14 @@ func library_triangle_count() -> int: total += vertices.size() / 3 return total +## Apply the baked AO map that sits next to a library prop GLB. +func _apply_library_ao(model: Node3D, prop: String) -> void: + var texture := Materials.load_ao(BLENDER_PROP_DIR + prop + "_ao.png") + if texture == null: + return + for node in model.find_children("*", "MeshInstance3D", true, false): + Materials.apply_ao(node as MeshInstance3D, texture) + ## Register a library prop's emissive surfaces so night mode can switch them on. func _collect_emissive(holder: Node3D) -> void: for node in holder.find_children("*", "MeshInstance3D", true, false): @@ -544,7 +556,8 @@ func _add_prop_model(parent: Node3D, prop: String, pos: Vector3, loaded_models.append(prop) if ResourceLoader.exists(BLENDER_PROP_DIR + prop + ".glb"): library_props.append(prop) - library_nodes.append(holder) + library_nodes.append(holder) + _apply_library_ao(model, prop) else: var fallback := _fallback_prop(prop) holder.add_child(fallback) diff --git a/src/test/tests/test_generated_props.gd b/src/test/tests/test_generated_props.gd index 3333700..fa48c2a 100644 --- a/src/test/tests/test_generated_props.gd +++ b/src/test/tests/test_generated_props.gd @@ -226,12 +226,23 @@ func test_world_loads_the_blender_library_props() -> bool: builder.set_night_mode(false) var dark := builder._lamp_bulb_mats[0].emission_energy_multiplier <= 0.0 var e := TestUtils.check(lit and dark, "night mode drives library emissives") + var ao_ok := false + var detail := root.get_node_or_null("StreetDetail") + if detail != null: + for child in detail.get_children(): + if str(child.name).begins_with("Prop_bench"): + for mi in (child as Node3D).find_children("*", "MeshInstance3D", true, false): + for index in (mi as MeshInstance3D).mesh.get_surface_count(): + var mat := (mi as MeshInstance3D).get_surface_override_material(index) as StandardMaterial3D + if mat != null and mat.ao_enabled and mat.ao_texture != null: + ao_ok = true + var ao := TestUtils.check(ao_ok, "library prop materials carry the baked AO map") var triangles := builder.library_triangle_count() var f := TestUtils.check(triangles > 0 and triangles <= WorldBuilder.LIBRARY_TRIANGLE_BUDGET, "library prop triangles stay inside the budget (%d)" % triangles) root.free() - return a and b and c and d and e and f + return a and b and c and d and e and f and ao func test_world_facade_windows_light_at_night() -> bool: var root := Node3D.new() diff --git a/src/test/tests/test_npc_character.gd b/src/test/tests/test_npc_character.gd index 5b85d2f..4c6ae5b 100644 --- a/src/test/tests/test_npc_character.gd +++ b/src/test/tests/test_npc_character.gd @@ -229,8 +229,14 @@ func test_skinned_outfit_tints_the_material_slots() -> bool: var a := TestUtils.check(found == 5, "five material slots stay separate") var b := TestUtils.check(tinted == found, "every slot carries the outfit tint") var c := TestUtils.check(outfit["shirt"] != Color.WHITE, "seed gives a non-white outfit") + var ao_ok := false + for index in mi.mesh.get_surface_count(): + var material := mi.get_surface_override_material(index) as StandardMaterial3D + if material != null and material.ao_enabled and material.ao_texture != null: + ao_ok = true + var d := TestUtils.check(ao_ok, "character slots carry the baked AO map") avatar.free() - return a and b and c + return a and b and c and d func test_forced_fallback_still_builds_the_procedural_rig() -> bool: var profile := NpcProfile.new() diff --git a/tools/bake_ao.py b/tools/bake_ao.py new file mode 100644 index 0000000..9e522cd --- /dev/null +++ b/tools/bake_ao.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Bake per-asset ambient occlusion maps for the Blender library. + +The GL Compatibility renderer has no SSAO, so the assets carry a baked AO map +instead. This tool opens each generated `.blend` source, bakes AO into a single +image using the asset's existing UVs, and writes it next to the GLB as +`_ao.png`. Godot wires the map into the material's AO channel. + +Run inside Blender (flatpak; absolute paths required): + + flatpak run org.blender.Blender --background \ + --python "$PWD/tools/bake_ao.py" -- --only bollard + flatpak run org.blender.Blender --background \ + --python "$PWD/tools/bake_ao.py" + +The tool is a developer step; the committed PNGs mean CI never needs Blender. +""" + +import argparse +import json +import sys +from pathlib import Path + +try: + import bpy # type: ignore +except ImportError: # pragma: no cover + bpy = None + +ROOT = Path(__file__).resolve().parent.parent +LIBRARY = ROOT / "assets" / "models" / "blender" +SRC = ROOT / "assets" / "models" / "blender_src" +MANIFEST = LIBRARY / "manifest.json" +SIZE = 256 +SAMPLES = 16 + + +def bake_asset(name: str, category: str) -> bool: + source = SRC / f"{name}.blend" + if not source.is_file(): + print(f"skip {name}: missing {source}") + return False + bpy.ops.wm.open_mainfile(filepath=str(source)) + # Some character variants keep tiny rigid parts (shoes/soles) without UVs; + # bake the UV-mapped body/head/limbs and leave those parts untextured. + meshes = [o for o in bpy.context.scene.objects + if o.type == "MESH" and o.data.uv_layers] + if not meshes: + print(f"skip {name}: no UV-mapped mesh") + return False + image = bpy.data.images.new(f"{name}_ao", width=SIZE, height=SIZE, + alpha=False) + image.generated_color = (1.0, 1.0, 1.0, 1.0) + for mesh in meshes: + for material in mesh.data.materials: + if material is None or not material.use_nodes: + continue + nodes = material.node_tree.nodes + texture = nodes.new("ShaderNodeTexImage") + texture.image = image + for node in nodes: + node.select = False + texture.select = True + nodes.active = texture + for obj in bpy.context.selected_objects: + obj.select_set(False) + for mesh in meshes: + mesh.select_set(True) + bpy.context.view_layer.objects.active = meshes[0] + + scene = bpy.context.scene + scene.render.engine = "CYCLES" + scene.cycles.samples = SAMPLES + scene.cycles.use_denoising = False + scene.render.bake.margin = 4 + scene.render.bake.use_clear = True + bpy.ops.object.bake(type="AO", use_clear=True) + + out = LIBRARY / category / f"{name}_ao.png" + out.parent.mkdir(parents=True, exist_ok=True) + image.filepath_raw = str(out) + image.file_format = "PNG" + image.save() + print(f"baked {category}/{name} -> {out.relative_to(ROOT)}") + return True + + +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__) + parser.add_argument("--only", default="", help="comma-separated asset names") + args = parser.parse_args(raw) + if bpy is None: + print("bake_ao.py must run inside Blender (flatpak)", file=sys.stderr) + return 2 + manifest = json.loads(MANIFEST.read_text(encoding="utf-8")) + only = {name for name in args.only.split(",") if name} + baked = 0 + for name, entry in sorted(manifest["assets"].items()): + if only and name not in only: + continue + if bake_asset(name, entry["category"]): + baked += 1 + print(f"baked {baked} AO maps") + return 0 if baked else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/make_normal_maps.py b/tools/make_normal_maps.py new file mode 100644 index 0000000..ee504c6 --- /dev/null +++ b/tools/make_normal_maps.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Derive tangent-space normal maps from albedo textures (Pillow only). + + python3 tools/make_normal_maps.py assets/textures/ai/game_ready/brick_facade.png ... + +Writes _normal.png next to each input. The height signal is the +luminance, so mortar/aggregate recesses catch the light; edges wrap for tiling. +""" + +import sys +from pathlib import Path + +from PIL import Image + +STRENGTH = 2.0 + + +def normal_map(source: Path) -> Path: + image = Image.open(source).convert("L") + width, height = image.size + pixels = list(image.getdata()) + out = bytearray(width * height * 3) + + def lum(x: int, y: int) -> float: + return pixels[(y % height) * width + (x % width)] + + for y in range(height): + row = y * width + for x in range(width): + dx = (lum(x + 1, y) - lum(x - 1, y)) / 255.0 + dy = (lum(x, y + 1) - lum(x, y - 1)) / 255.0 + nx = -dx * STRENGTH + ny = -dy * STRENGTH + nz = 1.0 + length = (nx * nx + ny * ny + nz * nz) ** 0.5 + offset = (row + x) * 3 + out[offset] = int((nx / length * 0.5 + 0.5) * 255) + out[offset + 1] = int((ny / length * 0.5 + 0.5) * 255) + out[offset + 2] = int((nz / length * 0.5 + 0.5) * 255) + normal = Image.frombytes("RGB", (width, height), bytes(out)) + target = source.with_name(source.stem + "_normal.png") + normal.save(target) + print(f"wrote {target}") + return target + + +def main(argv=None) -> int: + paths = [Path(p) for p in (argv if argv is not None else sys.argv[1:])] + if not paths: + print(__doc__, file=sys.stderr) + return 2 + for path in paths: + normal_map(path) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/test_blender_assets.py b/tools/test_blender_assets.py index f93c37b..7351438 100644 --- a/tools/test_blender_assets.py +++ b/tools/test_blender_assets.py @@ -54,6 +54,15 @@ class BuiltAssetTests(unittest.TestCase): if entry["category"] == "npc_gear": self.assertIn(entry["attach"], ba.GEAR_ATTACH, name) + def test_baked_ao_maps_exist_for_every_asset(self): + from PIL import Image + for name, entry in json.loads( + ba.MANIFEST.read_text(encoding="utf-8"))["assets"].items(): + path = ba.OUT_DIR / entry["category"] / f"{name}_ao.png" + self.assertTrue(path.is_file(), path) + with Image.open(path) as image: + self.assertEqual(image.size, (256, 256), path) + def test_sources_are_hidden_from_godot(self): self.assertTrue((ba.SRC_DIR / ".gdignore").is_file())