Establish the 0.9.4 NPC visual baseline
Full build / Import + headless tests (push) Successful in 38s
Full build / Full desktop build + optional release (push) Skipped

Add a neutral 48-frame character contact sheet, acceptance criteria and ranked baseline gaps, plus repeatable sheet/comparison tooling. No character GLB or runtime mesh changes.
This commit is contained in:
zwitschi committed 2026-11-02 18:13:57 +01:00
1 parent c1cd4898e8
commit e7ee177c31
13 files changed
+628 -11

No files matched your search

+8 -1
View File
@@ -61,6 +61,13 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
gallery is 13 good / 0 bad, the screen-design judge is 13/13 and a clean
portrait measured a 0.732 vision gap against the photoreal concept. No
release build, version bump or tag was created at this gate.
- **NPC visual baseline (0.9.4, 2026-11-02):** `src/test/capture_npc_baseline.gd` is the
neutral-studio 8-variant × 6-view baseline (front, three-quarter, profile, back, full body,
crowd). It uses a fixed orbit camera, mid-grey studio backdrop, key/fill lights, no HUD/crowd/animation;
raw frames stay in `tmp/`, and `tools/npc_baseline.py sheet` builds the committed
`sheet_npc_baseline.jpg`. Criteria and measurements are in `docs/npc-visual-review.md`. The first
ranked gaps are: tourist photo prop duplicates the backpack silhouette; shallow/faceted face;
backpack straps read as rigid bars. No model/GLB changes were made in the baseline step.
- **UI flow smoke (2026-11-02):** `smoke_ui_flows.gd` boots the real scene and
exercises inspection allow/deny, normal vs emergency radio, Help filtering,
wand allow/deny and review return-to-menu. Keep this smoke in the 0.9.6
@@ -734,7 +741,7 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
- Venue `door_staff_lines` are logged through `GameManager` (inspect / wand / refuse). They are not a separate dialogue UI.
- Access loadout is implemented; do not re-fix `override_access` as if it were still unread.
- `Save.SETTINGS_FILE` is declared and unused; settings persistence goes through `Save.load_settings()` / `save_settings()`.
- Headless suite is **261/261**; Python tooling is **97/97**; screenshot compare is **21 good / 0 bad** and the automatic judge is **21/21 match, 0 diff**. Remaining visual follow-ups are in `TODO.md`.
- Headless suite is **261/261**; Python tooling is **99/99**; screenshot compare is **21 good / 0 bad** and the automatic judge is **21/21 match, 0 diff**. Remaining visual follow-ups are in `TODO.md`.
- Typed spawn signal extra args: handlers must accept the 4th `event: Dictionary` or Godot will not call them (`test_shift_coordinator_runs_a_shift` failed until `_on_spawn` took four args).
- GDScript inference trap that broke the main scene: a `var` typed as a base class
(`radial: CanvasLayer`) exposes script members as `Variant`, so `var x := radial.is_open` is a
+1
View File
@@ -121,6 +121,7 @@ Full setup, every run/test/smoke command, and the controls table are in
- [`docs/development-status.md`](docs/development-status.md) — Implemented features and what's next.
- [`docs/smoke-checklist.md`](docs/smoke-checklist.md) — Manual smoke-test checklist.
- [`docs/blender-assets.md`](docs/blender-assets.md) — Blender asset library: characters, gear, props, attach points.
- [`docs/npc-visual-review.md`](docs/npc-visual-review.md) — 0.9.4 NPC baseline, acceptance criteria and ranked visual gaps.
- [`docs/adr/`](docs/adr/) — Architecture Decision Records.
- [`TODO.md`](TODO.md) — Active tasks plus the 0.x gap analysis and milestone roadmap.
+20 -9
View File
@@ -7,7 +7,7 @@ see `AGENTS.md` for the workflow and `MEMORY.md` for the decisions behind these
Baseline: **v0.9.3** (2026-11-02). Three playable venues, tutorial/campaign/checkpoint resume,
difficulty/help, audio/accessibility settings, a 24-NPC budget baseline, a complete
escalation/access/radio loop, shift review, the Blender asset library, 261 headless + 97 Python
escalation/access/radio loop, shift review, the Blender asset library, 261 headless + 99 Python
tests, a 21/21 clean screen-design judge and an optional Forward+ Desktop HQ build. This is a strong
release-candidate foundation, not yet a 0.10.0 release candidate.
@@ -58,15 +58,26 @@ fallback behavior.
### Reference and acceptance criteria
- [ ] Establish a portrait/contact-sheet baseline for all eight variants: front, three-quarter,
profile, back, full body and a crowd-distance silhouette.
- [ ] Define acceptance criteria for proportions, face readability, hair, hands, shoes, clothing
silhouette, gear fit and animation at the gameplay camera height (1.7 m).
- [ ] Record the camera/lighting/background used for comparison so model changes can be judged
consistently; keep the existing portrait capture path deterministic.
- [ ] Identify the highest-impact gaps from the current portraits (e.g. face depth, hair mass,
- [x] Establish a portrait/contact-sheet baseline for all eight variants: front, three-quarter,
profile, back, full body and a crowd-distance silhouette. (`capture_npc_baseline.gd`
produces 48 frames; `sheet_npc_baseline.jpg` is committed.)
- [x] Define acceptance criteria for proportions, face readability, hair, hands, shoes, clothing
silhouette, gear fit and animation at the gameplay camera height (1.7 m). (Eight criteria
in `docs/npc-visual-review.md`; the current 6,000-triangle/5,000-vertex contract is explicit.)
- [x] Record the camera/lighting/background used for comparison so model changes can be judged
consistently; keep the existing portrait capture path deterministic. (Neutral studio, fixed
orbit cameras, no HUD/crowd/animation; commands recorded in `docs/npc-visual-review.md`.)
- [x] Identify the highest-impact gaps from the current portraits (e.g. face depth, hair mass,
neck/shoulder transition, hand/foot shape, coat/dress hem) and rank them by player visibility.
- [ ] Publish a before/after NPC comparison sheet for review before changing the library.
(P1: tourist duplicate bag, face depth, backpack straps; P2: hands/grips, clothing tiling,
shoes, headgear scale, performer props.)
- [x] Publish a before/after NPC comparison sheet for review before changing the library.
(`sheet_npc_baseline.jpg` is the before artifact; `tools/npc_baseline.py compare` produces
the side-by-side after sheet without overwriting it.)
Completed 2026-11-02: 48-frame neutral studio baseline, eight criteria, current fit and
triangle/vertex measurements, and ranked P1/P2 gaps are in `docs/npc-visual-review.md`. No
character model was changed in this pass.
### Body proportions and silhouette
+1
View File
@@ -11,6 +11,7 @@ This folder holds the project documentation, visual references and generated QA
- [`architecture.md`](architecture.md) — arc42 system architecture
- [`getting-started.md`](getting-started.md) — setup, run/test commands and controls
- [`asset-tooling.md`](asset-tooling.md) — image/model pipelines and screenshot gates
- [`npc-visual-review.md`](npc-visual-review.md) — NPC visual baseline, acceptance criteria and ranked gaps
- [`development-status.md`](development-status.md) — implemented features and current gaps
- [`smoke-checklist.md`](smoke-checklist.md) — manual verification
- [`ui-copy.md`](ui-copy.md) — screen, copy and button inventory
+14
View File
@@ -213,6 +213,20 @@ and firefighter helmet so the badge clears the jacket and the brim does not sit
over the eyes; these are `NpcAvatar.GEAR_OFFSETS`, not changes to the gear
contract.
`capture_npc_baseline.gd` is the 0.9.4 neutral baseline: eight characters ×
front/three-quarter/profile/back/full-body/crowd = 48 frames with a fixed
camera, studio lighting and no venue/HUD/animation. The raw PNGs stay in
`tmp/`; `tools/npc_baseline.py sheet` builds the committed contact sheet, and
`tools/npc_baseline.py compare --before DIR --after DIR` builds the review
comparison after a model change. See
[`npc-visual-review.md`](npc-visual-review.md).
```bash
godot --path . --script res://src/test/capture_npc_baseline.gd -- \
--out=res://tmp/npc-baseline
python3 tools/npc_baseline.py sheet --dir tmp/npc-baseline
```
`capture_realism.gd` runs seven stages (day, portrait, evening, late, Joke Shop day, Joke Shop night, studio);
`--stages=day,late` limits it while tuning; `--flashlight` turns on the guard lamp for
night stages (used by the Desktop HQ A/B). The `portrait` shot freezes one NPC on open sidewalk
+8 -1
View File
@@ -5,7 +5,10 @@ Architecture context: the asset contracts and fallback path are summarized in
Status: built, audited, and integrated (2026-11-01). `NpcAvatar` selects the
character variants and attaches the gear; `WorldBuilder` loads the prop library
with colliders and phase-driven emissives.
with colliders and phase-driven emissives. The 0.9.4 neutral baseline and its
acceptance criteria are in [`npc-visual-review.md`](npc-visual-review.md);
the committed 8 × 6 contact sheet is
[`blender_previews/sheet_npc_baseline.jpg`](blender_previews/sheet_npc_baseline.jpg).
Generator: [`tools/blender_assets.py`](../tools/blender_assets.py). Manifest (the
source of truth for integration):
@@ -197,3 +200,7 @@ carry baked AO and the shared neutral material maps described above.
the legs through the hem.
- Preview colours (`PREVIEW_TINTS`) only stand in for `outfit_for()`. The GLBs
stay neutral.
- The 0.9.4 baseline found three P1 portrait issues: the tourist's photo prop
duplicates the backpack silhouette, the face is shallow/faceted at portrait
range, and the backpack straps read as rigid bars. The full ranked list and
accepted/deferred items are in [`npc-visual-review.md`](npc-visual-review.md).
+1
View File
@@ -43,6 +43,7 @@ Committed contact sheets and local Blender renders for the character, gear and p
| <img src="security_podium.png" width="280" alt="security_podium.png"> | [`security_podium.png`](security_podium.png) |
| <img src="sedan.png" width="280" alt="sedan.png"> | [`sedan.png`](sedan.png) |
| <img src="sheet_npc.jpg" width="280" alt="sheet_npc.jpg"> | [`sheet_npc.jpg`](sheet_npc.jpg) |
| <img src="sheet_npc_baseline.jpg" width="280" alt="sheet_npc_baseline.jpg"> | [`sheet_npc_baseline.jpg`](sheet_npc_baseline.jpg) |
| <img src="sheet_npc_gear.jpg" width="280" alt="sheet_npc_gear.jpg"> | [`sheet_npc_gear.jpg`](sheet_npc_gear.jpg) |
| <img src="sheet_props.jpg" width="280" alt="sheet_props.jpg"> | [`sheet_props.jpg`](sheet_props.jpg) |
| <img src="shoulder_bag.png" width="280" alt="shoulder_bag.png"> | [`shoulder_bag.png`](shoulder_bag.png) |
Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

+1
View File
@@ -39,6 +39,7 @@ Implemented so far:
- Aim-free selection pass (v0.3.0 work, [ADR-0011](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
- Visual alignment: start/venue menu, pause, settings, shift review, radio, wand, inspection, timeline, HUD target chip/escalation bar, and radial menu now use or match their AI references in `assets/images/ui/`. The runtime art loader (`src/game/ui/art.gd`) falls back cleanly when a file is missing. The wand panel lists the guest's carried items, and multiline HUD/wand strings render real line breaks.
- NPC visual baseline (0.9.4 work): `src/test/capture_npc_baseline.gd` captures all eight library characters from front, three-quarter, profile, back, full-body and crowd views in a neutral studio (48 frames). The criteria, measurements and ranked gaps are in [`npc-visual-review.md`](npc-visual-review.md); the committed sheet is [`blender_previews/sheet_npc_baseline.jpg`](blender_previews/sheet_npc_baseline.jpg). No model changes were made in this baseline step.
- UI-state capture pass (0.9.3 work): `src/test/capture_ui_states.gd` adds committed captures 14–21 for the start menu, pause, settings, shift review, emergency radio, action wheel, crowd panic and responder arrival. Every one of the 21 catalogue entries now has a current-build capture; `tools/check_screenshots.sh` reports 21 good / 0 bad and the automatic judge reports 21/21 match, 0 diff. Crowd panic and responder arrival are staged presentation frames, not gameplay changes.
- Realism adoption (post-v0.6.0): v2 per-phase skylines and surface tiles with normal maps, retuned sun/exposure/fog, wet asphalt, denser foliage, a visible additive flashlight beam, and refreshed staging captures. `tools/check_screenshots.sh` is the repeatable capture/compare gate; judge is clean of `diff` verdicts.
- Input/reliability fixes: selecting a venue releases the start-menu input lock and recaptures the mouse; the body-camera overlay no longer covers the shift status. Mouse look now reads at `_input`, restores capture on window focus/click-back, and the HUD is mouse-transparent. Normal radio reports submit on click; emergency calls still require the hold-to-confirm path.
+183
View File
@@ -0,0 +1,183 @@
# NPC visual baseline and acceptance criteria
Status: baseline captured 2026-11-02. No character GLB, texture, gear or runtime
mesh was changed in this step. The baseline is the reference for the 0.9.4 NPC
visual optimization.
## What was captured
`src/test/capture_npc_baseline.gd` stages all eight library characters in a
neutral studio and captures six views per character:
| View | Camera | Purpose |
| --- | --- | --- |
| `front` | 1.85 m, 32° FOV, head/upper body | face, hair, collar, shoulders |
| `three_quarter` | 1.85 m, yaw 35° | cheek/jaw, ear, profile transition |
| `profile` | 1.85 m, yaw 90° | head depth, nose, ear, chest silhouette |
| `back` | 1.85 m, yaw 180° | hair cap, neck, coat/backpack fit |
| `full_body` | 4.2 m, 38° FOV | proportions, hands, shoes, hem |
| `crowd` | 8.0 m, 45° FOV | silhouette read at normal crowd distance |
The stage is deliberately separate from the venue review harness: a mid-grey
solid background, ground plane, one key light and one cool fill light, no HUD,
no crowd, no flashlight, no phase lighting and no animation. Front is Godot +Z,
matching `NpcAvatar.yaw_towards()`. The camera orbits at the recorded yaw rather
than rotating the avatar, so the pose and foot contact stay fixed between views.
The full command is:
```bash
godot --path . --script res://src/test/capture_npc_baseline.gd -- \
--out=res://tmp/npc-baseline
python3 tools/npc_baseline.py sheet --dir tmp/npc-baseline
```
The 48 raw PNGs are local QA under `tmp/` (gitignored). The committed contact
sheet is [`blender_previews/sheet_npc_baseline.jpg`](blender_previews/sheet_npc_baseline.jpg).
The capture is deterministic apart from engine rasterisation; re-running into
the same directory keeps the earlier files and writes `-vN` copies rather than
overwriting them.
## Acceptance criteria
The criteria below are for the stylized target, not photorealism. A change must
pass every applicable item before a library rebuild is accepted.
1. **Asset contract:** `npc_base` mesh node, the required bones, five tint slots,
front +Z, feet within 3 cm of y=0, 1.6–2.1 m height, at most 6,000 triangles
and 5,000 glTF vertices per character. Gear remains at most 1,500 triangles.
2. **Proportion and silhouette:** head, neck, shoulders, torso, hips, limbs and
feet read as one body; front/three-quarter/profile silhouettes agree with the
rest of the eight-character library; broad/slim/average build variation is
preserved.
3. **Face:** at 1.85 m the eyes, iris, brows, nose, mouth and ears are distinct;
the head volume has a readable jaw/cheek/chin transition; no face-plane seams,
inverted normals or z-fighting. The face direction must remain unambiguous.
4. **Hair and headgear:** hair covers the scalp without gaps or scalp-through
seams; fringes, buns and beards keep their silhouette. Caps, beanies, hoods,
sunglasses and the firefighter helmet clear the brow, eyes and ears. Headgear
may sit outside the 1.55–1.81 m face band, but its brim must not cover the
eyes.
5. **Hands and footwear:** at conversation distance the fingers and thumb read
as a hand, not a mitten; hand items contact the palm rather than floating;
shoes and boots are grounded with a readable toe/heel silhouette and preserve
the responder/firefighter footwear language.
6. **Clothing:** collars, cuffs, plackets, waistbands, pockets, badges and
reflective bands transition cleanly; no z-fighting or floating detail; coat,
dress and skirt hems do not show legs through the fabric during idle, walk,
block, sit or turn; the five tint slots and fixed detail colours remain
unchanged.
7. **Gear:** head gear clears the refined face/hair; the lanyard badge remains
readable in front of the jacket; backpack, shoulder bag and SCBA tank straps
lie outside the torso and do not read as a second bag; hand items use the
authored grip points.
8. **Crowd and performance:** the silhouette remains readable at the 8 m crowd
frame and the LOD/impostor tiers match the full mesh; the 24-NPC budget stays
within the existing 6.93 ms baseline (no regression beyond the measured
variance), and no new per-frame material or mesh churn is introduced.
Asset-space `fit` values are measurements of the untouched GLBs, not a pass/fail
result. A negative `x_clearance` means the gear's narrowest face is inside the
body reference; a negative `radial_clearance` means it extends beyond the body
silhouette (normal for a backpack, suspicious when it is a strap). Runtime
`NpcAvatar.GEAR_OFFSETS` is applied after attachment and is not part of the GLB
measurement.
## Baseline measurements
From `python3 tools/blender_assets.py fit` and the manifest:
| Character | Tris | Verts | Head width | Clothing radius | Notable gear clearances |
| --- | ---: | ---: | ---: | ---: | --- |
| `npc_casual` | 3,974 | 3,744 | 0.315 m | 0.191 m (hip) | backpack x −0.041, radial −0.162 |
| `npc_jacket` | 4,394 | 4,175 | 0.315 m | 0.191 m (hip) | lanyard x −0.116, radial +0.023 (runtime +4 cm forward) |
| `npc_coat` | 5,026 | 4,807 | 0.315 m | 0.225 m (skirt) | sunglasses width ratio 0.641; bag x +0.037, radial −0.130 |
| `npc_vest` | 4,474 | 4,340 | 0.315 m | 0.191 m (hip) | cap width ratio 1.067 |
| `npc_dress` | 4,428 | 4,026 | 0.315 m | 0.260 m (skirt) | bag x +0.002, radial −0.095 |
| `npc_hoodie` | 4,826 | 4,504 | 0.315 m | 0.190 m (hip) | — |
| `npc_firefighter` | 4,848 | 4,735 | 0.315 m | 0.255 m (skirt) | helmet width ratio 1.651; SCBA x −0.105, radial −0.076 |
| `npc_guard` | 4,560 | 4,529 | 0.315 m | 0.194 m (hip) | cap width ratio 1.067 |
The widest character is `npc_coat` at 5,026 triangles and 4,807 vertices, so
the current headroom is 974 triangles and 193 vertices per character. Any face
or hand refinement must come out of that budget or replace existing detail.
## Baseline findings, ranked
### P1 — fix in the first model pass
1. **The tourist carries two bag silhouettes.** `_add_props()` creates a
0.30 × 0.36 × 0.14 box at `(-0.34, 1.15, -0.18)` for the tourist photo pose
while `_add_accessory("backpack")` also attaches the library backpack. From
the three-quarter/back view the two read as separate bags. The photo prop
should be hidden outside the photo pose, merged into the backpack, or moved to
a clearly held camera.
2. **The face is the largest portrait gap.** The eyes, nose and lips are
readable but sit on a shallow, faceted head: the eye whites protrude, the
eyelid/cheek transition is missing, and the mouth is a dark slot. This is
intentionally stylized, but the head volume and eye socket are the first
things a player sees at conversation range.
3. **Backpack straps read as rigid black bars.** The back gear's straps cross
the chest as thin, straight geometry with visible gaps from the shoulders,
not as fabric sitting on the torso. A player sees this on the most common
tourist silhouette in every crowd.
### P2 — fix after the first P1 pass
4. **Hands and grips are not proven at conversation distance.** The full-body
frame shows small block hands; the camera, phone and clipboard need a
close-up grip check before the hand mesh is accepted.
5. **Clothing detail is tile-driven and transitional seams are visible.** The
fabric pattern repeats strongly on the casual, coat and dress variants, and
collar/cuff/waist edges are abrupt. This is more visible than the shoe/hem
details in the current portraits.
6. **Shoes are simple blocks.** They are grounded, but the toe/heel silhouette
is minimal; the responder and firefighter footwear should remain distinct
after any refinement.
7. **Headgear scale needs a visual, not just numeric, check.** The cap and
helmet width ratios are 1.067 and 1.651; those are expected for headgear, but
the brim/ear clearance and the helmet's relationship to the head still need a
face-on check.
8. **Performer props dominate the silhouette.** The red cone hat and the
primitive sign are intentionally large stage props, but they should be
checked during the pose pass so they do not read as head/body geometry.
### Accepted or deferred for this baseline
- The hooded variants intentionally cover part of the face; face readability is
judged on the visible lower face and silhouette, not on hidden eyes.
- The low-poly style is the target. The acceptance bar is readability and clean
construction, not photoreal skin or hair.
- Hem-through-leg clipping during a walk cannot be judged from a rest pose; it
stays in the body/clothing workstream.
- The 8 m `crowd` view is still LOD 0. The separate LOD/impostor check remains
required; this baseline does not claim to validate it.
## Before/after workflow
Keep the baseline directory, rebuild the same capture into a new directory after
the model changes, then produce a comparison sheet. Do not overwrite the
committed baseline image:
```bash
godot --path . --script res://src/test/capture_npc_baseline.gd -- \
--out=res://tmp/npc-baseline-after
python3 tools/npc_baseline.py compare \
--before tmp/npc-baseline --after tmp/npc-baseline-after \
--out tmp/npc-baseline-compare.jpg
```
The comparison sheet places the before/after pair for each view side by side.
If the change is accepted, copy the after sheet to a new committed name
(`sheet_npc_baseline_after.jpg`) and link both from the preview index. The
baseline is not replaced.
## Next implementation order
1. Remove the duplicate tourist bag silhouette and refit the backpack straps.
2. Refine the head/eye socket/jaw volume without changing the face direction or
the five tint slots.
3. Add a conversation-distance hand/grip capture and fix hand geometry before
judging hand items.
4. Audit hems and footwear in walk/sit/block poses.
5. Re-run the 48-frame baseline, the 96-frame final-context review, the 24-NPC
budget and `blender_assets.py audit/fit`; publish the before/after sheet.
+194
View File
@@ -0,0 +1,194 @@
extends SceneTree
## Neutral-studio contact-sheet baseline for the 0.9.4 NPC visual pass.
##
## Stages all eight library characters at a fixed camera height against a
## mid-grey studio backdrop: front, three-quarter, profile and back portraits, then a
## full-body front and a crowd-distance silhouette. No venue, crowd, HUD,
## flashlight or phase lighting is involved, so the same frames are directly
## comparable before and after a model change.
##
## Usage (windowed):
## godot --path . --script res://src/test/capture_npc_baseline.gd -- \
## --out=res://tmp/npc-baseline
##
## The outputs are local QA art. Build the contact sheet with:
## python3 tools/npc_baseline.py sheet --dir tmp/npc-baseline
const Rules := preload("res://src/core/rules.gd")
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
const NpcProfile := preload("res://src/core/npc_profile.gd")
const VARIANTS := [
{"name": "npc_casual", "type": Rules.NpcType.TOURIST},
{"name": "npc_jacket", "type": Rules.NpcType.AUTHORIZED_GUEST},
{"name": "npc_coat", "type": Rules.NpcType.INFLUENCER},
{"name": "npc_vest", "type": Rules.NpcType.DELIVERY},
{"name": "npc_dress", "type": Rules.NpcType.PERFORMER},
{"name": "npc_hoodie", "type": Rules.NpcType.VIOLENT_THREAT},
{"name": "npc_firefighter", "type": Rules.NpcType.RESPONDER},
{"name": "npc_guard", "type": Rules.NpcType.CONFUSED_VISITOR},
]
## Camera orbits the subject at yaw 0 (front = Godot +Z). Portrait views keep
## the eye/head in frame; the last two show the full body and crowd silhouette.
const VIEWS := [
{"name": "front", "yaw": 0.0, "distance": 1.85, "height": 1.55,
"target_y": 1.52, "fov": 32.0},
{"name": "three_quarter", "yaw": 35.0, "distance": 1.85, "height": 1.55,
"target_y": 1.52, "fov": 32.0},
{"name": "profile", "yaw": 90.0, "distance": 1.85, "height": 1.55,
"target_y": 1.52, "fov": 32.0},
{"name": "back", "yaw": 180.0, "distance": 1.85, "height": 1.55,
"target_y": 1.52, "fov": 32.0},
{"name": "full_body", "yaw": 0.0, "distance": 4.2, "height": 0.95,
"target_y": 0.95, "fov": 38.0},
{"name": "crowd", "yaw": 0.0, "distance": 8.0, "height": 1.45,
"target_y": 1.0, "fov": 45.0},
]
const SETTLE_FRAMES := 4
const MAX_FRAMES := 4000
var out_dir := "user://screenshots/npc_baseline"
var frames := 0
var _phase := "stage_variant"
var _variant_i := 0
var _view_i := 0
var _wait := 0
var _avatar: NpcAvatar = null
var _camera: Camera3D = null
var _stage: Node3D = null
func _initialize() -> void:
for arg in OS.get_cmdline_user_args():
if str(arg).begins_with("--out="):
out_dir = str(arg).substr(6)
root.size = Vector2i(1280, 720)
_build_stage()
func _process(_delta: float) -> bool:
frames += 1
if frames > MAX_FRAMES:
push_error("capture_npc_baseline timed out")
quit(1)
return true
match _phase:
"stage_variant":
_stage_variant()
_wait = SETTLE_FRAMES
_phase = "settle"
"settle":
_wait -= 1
if _wait <= 0:
_frame_view()
_phase = "shoot"
"shoot":
_shoot()
_view_i += 1
if _view_i >= VIEWS.size():
_view_i = 0
_variant_i += 1
if _variant_i >= VARIANTS.size():
print("capture_npc_baseline done -> %s" % out_dir)
quit(0)
return true
_phase = "stage_variant"
else:
_wait = 1
_phase = "settle"
return false
func _build_stage() -> void:
_stage = Node3D.new()
_stage.name = "NpcBaselineStage"
root.add_child(_stage)
var env := Environment.new()
env.background_mode = Environment.BG_COLOR
env.background_color = Color(0.56, 0.59, 0.63)
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = Color(0.72, 0.75, 0.80)
env.ambient_light_energy = 0.55
env.tonemap_mode = Environment.TONE_MAPPER_ACES
var world_env := WorldEnvironment.new()
world_env.environment = env
_stage.add_child(world_env)
var sun := DirectionalLight3D.new()
sun.name = "KeyLight"
sun.rotation_degrees = Vector3(-38.0, -32.0, 0.0)
sun.light_energy = 1.35
sun.light_color = Color(1.0, 0.97, 0.92)
sun.shadow_enabled = true
_stage.add_child(sun)
var fill := DirectionalLight3D.new()
fill.name = "FillLight"
fill.rotation_degrees = Vector3(-12.0, 145.0, 0.0)
fill.light_energy = 0.45
fill.light_color = Color(0.75, 0.82, 1.0)
_stage.add_child(fill)
var ground := MeshInstance3D.new()
var plane := PlaneMesh.new()
plane.size = Vector2(24.0, 24.0)
ground.mesh = plane
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(0.30, 0.32, 0.35)
mat.roughness = 0.92
ground.material_override = mat
_stage.add_child(ground)
_camera = Camera3D.new()
_camera.name = "BaselineCamera"
_camera.current = true
_stage.add_child(_camera)
func _stage_variant() -> void:
if _avatar != null and is_instance_valid(_avatar):
_avatar.queue_free()
_avatar = null
var variant: Dictionary = VARIANTS[_variant_i]
var profile := NpcProfile.new()
profile.id = "baseline_" + str(variant["name"])
profile.display_name = str(variant["name"]).to_upper()
profile.npc_type = int(variant["type"])
var avatar := NpcAvatar.new()
avatar.name = "BaselineSubject"
avatar.character_override = str(variant["name"])
avatar.setup(profile, "roam", 4242, Vector3.ZERO, Vector3(0, 0, 2),
Vector3(0, 0.5, 1.2), str(profile.id))
_stage.add_child(avatar)
avatar.global_position = Vector3.ZERO
avatar.rotation.y = 0.0
avatar.set_physics_process(false)
avatar.set_process(false)
for node in avatar.find_children("*", "Label3D", true, false):
(node as Label3D).visible = false
for node in avatar.find_children("FocusCircle", "MeshInstance3D", true, false):
(node as MeshInstance3D).visible = false
_avatar = avatar
func _frame_view() -> void:
if _avatar == null or not is_instance_valid(_avatar):
return
var view: Dictionary = VIEWS[_view_i]
var yaw := deg_to_rad(float(view["yaw"]))
var distance := float(view["distance"])
var target := Vector3(0.0, float(view["target_y"]), 0.0)
_camera.position = target + Vector3(sin(yaw) * distance, float(view["height"]) - target.y,
cos(yaw) * distance)
_camera.fov = float(view["fov"])
_camera.look_at(target, Vector3.UP)
func _shoot() -> void:
var variant: Dictionary = VARIANTS[_variant_i]
var view: Dictionary = VIEWS[_view_i]
var dir := ProjectSettings.globalize_path(out_dir)
DirAccess.make_dir_recursive_absolute(dir)
var base := "%s__%s" % [variant["name"], view["name"]]
var path := dir.path_join(base + ".png")
var index := 2
while FileAccess.file_exists(path):
path = dir.path_join("%s-v%d.png" % [base, index])
index += 1
var img: Image = root.get_texture().get_image()
var err := img.save_png(path)
print("%s -> err=%d" % [base, err])
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""Assemble the local NPC visual-baseline contact sheet.
The frames are produced by `src/test/capture_npc_baseline.gd`; this script only
labels and tiles them so the eight variants and six views can be reviewed in
one image. It is deliberately offline and has no game-engine dependency.
"""
from __future__ import annotations
import argparse
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
VARIANTS = [
"npc_casual",
"npc_jacket",
"npc_coat",
"npc_vest",
"npc_dress",
"npc_hoodie",
"npc_firefighter",
"npc_guard",
]
VIEWS = ["front", "three_quarter", "profile", "back", "full_body", "crowd"]
def expected_files() -> list[tuple[str, str, str]]:
"""Return (variant, view, filename) for the complete 8 x 6 baseline."""
return [(variant, view, f"{variant}__{view}.png")
for variant in VARIANTS for view in VIEWS]
def latest_for(directory: Path, stem: str) -> Path | None:
"""Return the newest capture for a stem, ignoring the no-overwrite -vN copies."""
candidates = [directory / f"{stem}.png"]
candidates.extend(directory.glob(f"{stem}-v*.png"))
existing = [path for path in candidates if path.is_file()]
if not existing:
return None
return max(existing, key=lambda path: path.stat().st_mtime_ns)
def next_free_path(path: Path) -> Path:
if not path.exists():
return path
index = 2
while True:
candidate = path.with_name(f"{path.stem}-v{index}{path.suffix}")
if not candidate.exists():
return candidate
index += 1
def build_sheet(source: Path, output: Path) -> Path:
from PIL import Image, ImageDraw
cell_w, cell_h = 320, 180
label_h = 22
header_h = 28
sheet_w = cell_w * len(VIEWS)
sheet_h = header_h + (cell_h + label_h) * len(VARIANTS)
sheet = Image.new("RGB", (sheet_w, sheet_h), (24, 26, 30))
draw = ImageDraw.Draw(sheet)
for column, view in enumerate(VIEWS):
draw.text((column * cell_w + 8, 8), view.replace("_", " ").title(),
fill=(220, 224, 230))
for row, variant in enumerate(VARIANTS):
for column, view in enumerate(VIEWS):
path = latest_for(source, f"{variant}__{view}")
if path is None:
raise FileNotFoundError(
f"missing baseline frame: {variant}__{view}.png in {source}")
with Image.open(path) as image:
tile = image.convert("RGB").resize((cell_w, cell_h),
Image.Resampling.LANCZOS)
x = column * cell_w
y = header_h + row * (cell_h + label_h)
sheet.paste(tile, (x, y))
draw.text((x + 8, y + cell_h + 4), variant, fill=(230, 230, 230))
output.parent.mkdir(parents=True, exist_ok=True)
sheet.save(output, quality=86, optimize=True)
return output
def build_comparison(before: Path, after: Path, output: Path) -> Path:
"""Tile the before and after frames as one review sheet.
The layout is variant-major, with each view represented by a before/after
pair. Frames are deliberately smaller than the single-run sheet so all 96
cells remain viewable on a normal desktop.
"""
from PIL import Image, ImageDraw
cell_w, cell_h = 200, 112
label_h = 16
header_h = 28
pair_w = cell_w * 2
sheet_w = pair_w * len(VIEWS)
sheet_h = header_h + (cell_h + label_h) * len(VARIANTS)
sheet = Image.new("RGB", (sheet_w, sheet_h), (24, 26, 30))
draw = ImageDraw.Draw(sheet)
for column, view in enumerate(VIEWS):
x = column * pair_w
draw.text((x + 4, 8), f"{view.replace('_', ' ').title()} before",
fill=(220, 224, 230))
draw.text((x + cell_w + 4, 8), "after", fill=(220, 224, 230))
for row, variant in enumerate(VARIANTS):
for column, view in enumerate(VIEWS):
for offset, source in enumerate((before, after)):
path = latest_for(source, f"{variant}__{view}")
if path is None:
raise FileNotFoundError(
f"missing {variant}__{view}.png in {source}")
with Image.open(path) as image:
tile = image.convert("RGB").resize((cell_w, cell_h),
Image.Resampling.LANCZOS)
x = column * pair_w + offset * cell_w
y = header_h + row * (cell_h + label_h)
sheet.paste(tile, (x, y))
draw.text((x + 4, y + cell_h + 2), variant, fill=(230, 230, 230))
output.parent.mkdir(parents=True, exist_ok=True)
sheet.save(output, quality=86, optimize=True)
return output
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="NPC baseline contact sheet")
sub = parser.add_subparsers(dest="command", required=True)
sheet = sub.add_parser("sheet")
sheet.add_argument("--dir", required=True, type=Path,
help="directory containing the capture_npc_baseline.gd PNGs")
sheet.add_argument("--out", type=Path, default=None,
help="output JPEG (default: <dir>/sheet.jpg; never overwritten)")
compare = sub.add_parser("compare")
compare.add_argument("--before", required=True, type=Path)
compare.add_argument("--after", required=True, type=Path)
compare.add_argument("--out", type=Path, required=True)
args = parser.parse_args(argv)
if args.command == "sheet":
source = args.dir if args.dir.is_absolute() else ROOT / args.dir
output = args.out or source / "sheet.jpg"
if not output.is_absolute():
output = ROOT / output
output = next_free_path(output)
try:
written = build_sheet(source, output)
except FileNotFoundError as exc:
print(exc)
return 2
print(f"wrote {written.relative_to(ROOT)}")
return 0
if args.command == "compare":
before = args.before if args.before.is_absolute() else ROOT / args.before
after = args.after if args.after.is_absolute() else ROOT / args.after
output = args.out if args.out.is_absolute() else ROOT / args.out
output = next_free_path(output)
try:
written = build_comparison(before, after, output)
except FileNotFoundError as exc:
print(exc)
return 2
print(f"wrote {written.relative_to(ROOT)}")
return 0
return 1
if __name__ == "__main__":
raise SystemExit(main())
+28
View File
@@ -0,0 +1,28 @@
import os
import tempfile
import unittest
from pathlib import Path
import npc_baseline
class NpcBaselineTests(unittest.TestCase):
def test_expected_files_cover_all_variants_and_views(self):
rows = npc_baseline.expected_files()
self.assertEqual(len(rows), 48)
self.assertEqual({row[0] for row in rows}, set(npc_baseline.VARIANTS))
self.assertEqual({row[1] for row in rows}, set(npc_baseline.VIEWS))
self.assertEqual(len({row[2] for row in rows}), 48)
def test_latest_capture_prefers_the_full_stem(self):
with tempfile.TemporaryDirectory() as tmp:
folder = Path(tmp)
(folder / "npc_casual__front.png").write_bytes(b"old")
newer = folder / "npc_casual__front-v2.png"
newer.write_bytes(b"new")
os.utime(newer, (2_000_000_000, 2_000_000_000))
self.assertEqual(npc_baseline.latest_for(folder, "npc_casual__front"), newer)
if __name__ == "__main__":
unittest.main()