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.
No files matched your search
@@ -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
|
||||
@@ -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 `<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.
|
||||
@@ -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
|
||||
(`<name>_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.
|
||||
|
||||
@@ -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-<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
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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/<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)
|
||||
|
||||
- [ ] Live 3D body-camera replay (the timeline is text-only today).
|
||||
|
||||
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 597 KiB After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 503 KiB After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 521 KiB After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 864 KiB After Width: | Height: | Size: 891 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 530 KiB After Width: | Height: | Size: 592 KiB |
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
|
||||
|
||||