Bake library AO and lift environment textures

Phase 4 of the v0.6.0 graphics plan:

- tools/bake_ao.py bakes 256² AO maps for every Blender character/gear/prop
  (Cycles, existing UVs) next to each GLB; Materials.apply_ao() wires the red
  channel into character slots, gear, and props. Compatibility has no SSAO.
- Regenerated brick/concrete/asphalt at 1024² with OpenRouter, derived tangent
  normal maps via tools/make_normal_maps.py, and wired them through
  SurfaceMaterials.textured() without changing UV scales.
- Evaluated Forward+: boots on desktop Vulkan, but the renderer is project-wide
  and Web must stay GL Compatibility; v0.6.0 keeps Compatibility (decision in
  MEMORY/plan). A desktop-only override is deferred.
- Recaptured screenshots; compare 11 good / 0 bad. Fixed marquee text alignment.
- Tests: baked AO maps exist for all 46 assets; prop/character AO material
  checks; headless 222/222, Python 69/69, all smokes pass.
- Version 0.6.0; ADR-0013 accepted; README/architecture/blender-assets/plan and
  TODO/DONE updated.
This commit is contained in:
zwitschi committed 2026-11-01 15:07:27 +01:00
1 parent d8cfddbf89
commit 385b0c23c5
78 files changed
+390 -28

No files matched your search

+1
View File
@@ -62,4 +62,5 @@ squashfs-root/
# extracts the GLB-embedded textures next to the models on import. # extracts the GLB-embedded textures next to the models on import.
docs/blender_previews/*.png docs/blender_previews/*.png
assets/models/blender/**/*.png assets/models/blender/**/*.png
!assets/models/blender/**/*_ao.png
assets/models/blender_src/*.blend1 assets/models/blender_src/*.blend1
+18
View File
@@ -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. ~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 - [x] Recaptured the README/catalogue screenshots (including the prop gallery from the library
models) and fixed the marquee text placement. 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 `<name>_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.
+5 -3
View File
@@ -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()`. same colliders/obstacles, and collects library emissives for `set_night_mode()`.
`LIBRARY_TRIANGLE_BUDGET` = 60k (current ~27,986). Library props are excluded from `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). `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 - **v0.6.0 environment pass:** `tools/bake_ao.py` bakes 256² AO maps per library asset
textures with normal maps, the Forward+ desktop evaluation, then version 0.6.0 docs/release. (`<name>_ao.png`, red channel) and `Materials.apply_ao()`/`SurfaceMaterials` wire them in;
Headless suite is 222/222 and Python tooling 68/68 after integration. `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 - **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 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. line-up, back row on a platform) and the `prop_gallery` catalogue entry compares it.
+3 -2
View File
@@ -35,7 +35,7 @@ The central design goal is simple:
> Downloads are public (no sign-in required) since the host's instance-wide > Downloads are public (no sign-in required) since the host's instance-wide
> `REQUIRE_SIGNIN_VIEW` was disabled on 2026-10-31. > `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 > Releases are built automatically when a `vX.Y.Z` tag is pushed
> (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use > (see [`docs/RELEASING.md`](docs/RELEASING.md)); manual builds use
> `./tools/build_releases.sh`. Installer/AppImage packaging runs in the same pipeline > `./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 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 - 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 - 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 - 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-<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/gig-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: 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 ### Smoke Test Checklist
+101 -6
View File
@@ -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-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. - [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. - [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.
- [ ] 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] 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`. - [x] 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`. - [x] 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] Regenerate `docs/screenshots/` with `capture_screenshots.gd` and run `screen_designs.py compare`.
### Phase 5 — Verification and release ### 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`). - [ ] 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. - [ ] 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/<run>/`.
### 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/<tag>/`.
- [ ] Capture the baseline (current assets) and the candidate set (v2 skyline + textures), and
copy them into `docs/screens/realism/<run>/{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) ## Future ideas (optional, not scheduled)
- [ ] Live 3D body-camera replay (the timeline is text-only today). - [ ] Live 3D body-camera replay (the timeline is text-only today).
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 597 KiB

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 503 KiB

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 521 KiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

@@ -1,6 +1,6 @@
# ADR-0013: Blender-authored NPC characters and baked materials # ADR-0013: Blender-authored NPC characters and baked materials
- **Status:** Proposed - **Status:** Accepted (v0.6.0)
- **Date:** 2026-11-01 - **Date:** 2026-11-01
## Context ## Context
+1 -1
View File
@@ -76,7 +76,7 @@ The player interacts with NPCs, venue entrances, access artifacts, equipment, st
- `ReputationEvaluator` converts incidents and shift outcomes into performance feedback. - `ReputationEvaluator` converts incidents and shift outcomes into performance feedback.
- `CrowdSystem` models queues, attention, congestion, and public reaction. - `CrowdSystem` models queues, attention, congestion, and public reaction.
- `ShiftReview` presents decisions, consequences, and improvement opportunities. - `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. - `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/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). - `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).
+4 -4
View File
@@ -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. 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 1. **Baked AO on props:** use the Phase 3.5 Blender prop replacements
(`street_lamp`, `street_tree`, `bench`, …) and bake AO into their (`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 2. **Higher-resolution environment textures:** regenerate brick/concrete/asphalt
through `asset_pipeline.py` with normal maps, keeping the current UV scales; through `asset_pipeline.py` with normal maps, keeping the current UV scales;
use the same sRGB/albedo + packed ORM discipline as the library assets. use the same sRGB/albedo + packed ORM discipline as the library assets.
3. **Evaluate a desktop-only Forward+ preset** (SSAO/SSIL/volumetrics) while the 3. **Evaluate a desktop-only Forward+ preset** — done: it boots on the desktop Vulkan
Web export preset keeps `thread_support=false` and GL Compatibility. Decide driver, but the renderer is project-wide and the Web preset must stay GL
explicitly in an ADR addendum; do not silently change the renderer. Compatibility. v0.6.0 keeps Compatibility; a desktop-only override is deferred.
4. Regenerate `docs/screenshots/` with `capture_screenshots.gd`, then run 4. Regenerate `docs/screenshots/` with `capture_screenshots.gd`, then run
`screen_designs.py compare` and `judge` as the regression gate, plus `screen_designs.py compare` and `judge` as the regression gate, plus
`blender_assets.py sheet` for the character/gear/prop library. `blender_assets.py sheet` for the character/gear/prop library.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 864 KiB

After

Width:  |  Height:  |  Size: 891 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 530 KiB

After

Width:  |  Height:  |  Size: 592 KiB

+1 -1
View File
@@ -12,7 +12,7 @@ config_version=5
config/name="Gig Line" config/name="Gig Line"
config/description="Workplace security simulation about observation, access verification, and proportionate escalation." 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" run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.3", "GL Compatibility") config/features=PackedStringArray("4.3", "GL Compatibility")
+33 -4
View File
@@ -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_CONCRETE := preload("res://assets/textures/ai/game_ready/concrete.png")
const T_ASPHALT := preload("res://assets/textures/ai/game_ready/asphalt.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_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 ## Roughness / metallic pairs per surface role. Exposed so tests and tooling can
## assert the profiles instead of hard-coding numbers twice. ## 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 material.albedo_color = tint
return material 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: static func profile(role: String) -> Dictionary:
return PROFILES.get(role, PROFILES["concrete"]) return PROFILES.get(role, PROFILES["concrete"])
## Base textured surface with a named profile. ## Base textured surface with a named profile.
static func textured(texture: Texture2D, tint: Color, uv_scale: Vector2, 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() var mat := StandardMaterial3D.new()
mat.albedo_texture = texture 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.albedo_color = tint
mat.uv1_scale = Vector3(uv_scale.x, uv_scale.y, 1.0) mat.uv1_scale = Vector3(uv_scale.x, uv_scale.y, 1.0)
mat.texture_filter = BaseMaterial3D.TEXTURE_FILTER_LINEAR_WITH_MIPMAPS_ANISOTROPIC 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 return mat
static func brick(tint: Color = Color(1, 1, 1), uv_scale: Vector2 = Vector2(10, 5)) -> StandardMaterial3D: 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: 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: 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. # Damp asphalt at night picks up the street lamps and the marquee.
mat.metallic_specular = 0.6 mat.metallic_specular = 0.6
return mat return mat
+11 -2
View File
@@ -453,6 +453,8 @@ func _apply_outfit_materials(npc_type: int, outfit: Dictionary, cloth: Color,
"hair": outfit["hair"], "hair": outfit["hair"],
"accessory": Color(0.10, 0.11, 0.14), "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(): for index in mesh.get_surface_count():
var source := mesh.surface_get_material(index) var source := mesh.surface_get_material(index)
if source == null: 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. # (hivis, reflective, leather, steel, ...) keep their authored colour.
if not tints.has(slot): if not tints.has(slot):
continue continue
_skinned_mesh.set_surface_override_material(index, var material := Materials.npc_slot(source, tints[slot])
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: func _bind_bones() -> void:
_bone_indices.clear() _bone_indices.clear()
@@ -726,6 +732,9 @@ func _attach_gear(gear_name: String) -> bool:
node.free() node.free()
return false return false
_detail_nodes.append(node) _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 return true
## Character-specific library gear (firefighter helmet/SCBA, dress bag, vest ## Character-specific library gear (firefighter helmet/SCBA, dress bag, vest
+15 -2
View File
@@ -125,6 +125,7 @@ func _build_building() -> void:
door_model.position = Vector3(0, 0.0, -3.84) door_model.position = Vector3(0, 0.0, -3.84)
building.add_child(door_model) building.add_child(door_model)
library_nodes.append(door_model) library_nodes.append(door_model)
_apply_library_ao(door_model, "double_door")
for material in _emissive_materials(door_model): for material in _emissive_materials(door_model):
if not _lamp_bulb_mats.has(material): if not _lamp_bulb_mats.has(material):
_lamp_bulb_mats.append(material) _lamp_bulb_mats.append(material)
@@ -287,6 +288,7 @@ func _build_marquee() -> void:
library.position = Vector3(0, 5.62, -3.9) library.position = Vector3(0, 5.62, -3.9)
root.add_child(library) root.add_child(library)
library_nodes.append(library) library_nodes.append(library)
_apply_library_ao(library, "marquee")
for material in _emissive_materials(library): for material in _emissive_materials(library):
if not _marquee_emissive.has(material): if not _marquee_emissive.has(material):
_marquee_emissive.append(material) _marquee_emissive.append(material)
@@ -304,7 +306,7 @@ func _build_marquee() -> void:
marquee_label = Label3D.new() marquee_label = Label3D.new()
marquee_label.name = "MarqueeLabel" marquee_label.name = "MarqueeLabel"
marquee_label.text = "GIG LINE" 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.modulate = Color(1, 0.6, 0.9)
marquee_label.font_size = 160 marquee_label.font_size = 160
marquee_label.pixel_size = 0.004 marquee_label.pixel_size = 0.004
@@ -326,6 +328,7 @@ func _build_library_dressing() -> void:
rail.rotation_degrees.y = 90.0 rail.rotation_degrees.y = 90.0
root.add_child(rail) root.add_child(rail)
library_nodes.append(rail) library_nodes.append(rail)
_apply_library_ao(rail, "handrail")
if ResourceLoader.exists(BLENDER_PROP_DIR + "sedan.glb"): if ResourceLoader.exists(BLENDER_PROP_DIR + "sedan.glb"):
var sedan := _add_prop_model(root, "sedan", Vector3(6.0, 0.1, 17.0), 180.0) var sedan := _add_prop_model(root, "sedan", Vector3(6.0, 0.1, 17.0), 180.0)
sedan.name = "ParkedSedan" sedan.name = "ParkedSedan"
@@ -343,6 +346,7 @@ func _build_library_dressing() -> void:
lantern.position = Vector3(side, 3.3, -3.78) lantern.position = Vector3(side, 3.3, -3.78)
root.add_child(lantern) root.add_child(lantern)
library_nodes.append(lantern) library_nodes.append(lantern)
_apply_library_ao(lantern, "wall_lantern")
for material in _emissive_materials(lantern): for material in _emissive_materials(lantern):
if not _lamp_bulb_mats.has(material): if not _lamp_bulb_mats.has(material):
_lamp_bulb_mats.append(material) _lamp_bulb_mats.append(material)
@@ -407,6 +411,14 @@ func library_triangle_count() -> int:
total += vertices.size() / 3 total += vertices.size() / 3
return total 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. ## Register a library prop's emissive surfaces so night mode can switch them on.
func _collect_emissive(holder: Node3D) -> void: func _collect_emissive(holder: Node3D) -> void:
for node in holder.find_children("*", "MeshInstance3D", true, false): 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) loaded_models.append(prop)
if ResourceLoader.exists(BLENDER_PROP_DIR + prop + ".glb"): if ResourceLoader.exists(BLENDER_PROP_DIR + prop + ".glb"):
library_props.append(prop) library_props.append(prop)
library_nodes.append(holder) library_nodes.append(holder)
_apply_library_ao(model, prop)
else: else:
var fallback := _fallback_prop(prop) var fallback := _fallback_prop(prop)
holder.add_child(fallback) holder.add_child(fallback)
+12 -1
View File
@@ -226,12 +226,23 @@ func test_world_loads_the_blender_library_props() -> bool:
builder.set_night_mode(false) builder.set_night_mode(false)
var dark := builder._lamp_bulb_mats[0].emission_energy_multiplier <= 0.0 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 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 triangles := builder.library_triangle_count()
var f := TestUtils.check(triangles > 0 var f := TestUtils.check(triangles > 0
and triangles <= WorldBuilder.LIBRARY_TRIANGLE_BUDGET, and triangles <= WorldBuilder.LIBRARY_TRIANGLE_BUDGET,
"library prop triangles stay inside the budget (%d)" % triangles) "library prop triangles stay inside the budget (%d)" % triangles)
root.free() 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: func test_world_facade_windows_light_at_night() -> bool:
var root := Node3D.new() var root := Node3D.new()
+7 -1
View File
@@ -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 a := TestUtils.check(found == 5, "five material slots stay separate")
var b := TestUtils.check(tinted == found, "every slot carries the outfit tint") 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 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() 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: func test_forced_fallback_still_builds_the_procedural_rig() -> bool:
var profile := NpcProfile.new() var profile := NpcProfile.new()
+110
View File
@@ -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
`<name>_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())
+58
View File
@@ -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 <stem>_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())
+9
View File
@@ -54,6 +54,15 @@ class BuiltAssetTests(unittest.TestCase):
if entry["category"] == "npc_gear": if entry["category"] == "npc_gear":
self.assertIn(entry["attach"], ba.GEAR_ATTACH, name) 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): def test_sources_are_hidden_from_godot(self):
self.assertTrue((ba.SRC_DIR / ".gdignore").is_file()) self.assertTrue((ba.SRC_DIR / ".gdignore").is_file())