Detail the NPC avatars and finish the realism follow-ups
Full build / Import + headless tests (push) Successful in 38s
Full build / Full desktop build + optional release (push) Skipped

Blender library
- Rebuild the face: sclera, iris and upper lid per eye, brow ridge, cheekbones,
  nose bridge and tip, ears with lobes, split upper/lower lips.
- Give each hand four fingers plus a thumb and end every sleeve in a cuff ring.
- Raise CHAR_MAX_VERTS 4000 -> 5000 (16 avatars still cost ~6.9 ms of a 50 ms
  frame), decimate the body to 26 %; characters are now 3,974-5,026 tris and
  3,744-4,807 verts. Rebuild all 8 variants and re-bake their AO maps.

Capture tooling
- Add a `portrait` stage to capture_realism.gd: freeze one NPC, park the crowd
  out of frame, stand the subject on open sidewalk, turn it into the camera and
  put the guard on the sunlit side. New --portrait-type=N picks the role.
- Add the npc_portrait_day concept and map the shot to it, so avatar detail is
  scored against a portrait instead of a wide street frame.

Flashlight
- Drop the fake additive beam (Materials.light_beam + the cone mesh): on GL
  Compatibility a first-person camera inside the cone renders it as a filled
  disc with a bright far ring. Keep the tight spot (14 deg, 20 m, energy 4.2)
  and its headless test; the visible shaft is now planned behind a Forward+
  preset.

Docs and task tracking
- Move the finished realism follow-ups (ground exposure, flashlight, NPC detail,
  judge re-check) to DONE.md and expand TODO.md with step-by-step plans for NPC
  gear fit, venue geometry plus the committed gallery, and the Forward+ preset.
- Record the decisions, gotchas and portrait recipe in MEMORY.md; update the
  Blender contract, the concept README and the capture section of README.md.
- Refresh the gallery captures affected by the avatar rebuild.

Checks: headless 228/228, Python 86/86, blender_assets audit exit 0,
scene_concepts audit all GOOD, screen_designs compare 11 good / 0 bad,
smoke_npc_budget OK, boot clean (only the known headless mesh warning).
This commit is contained in:
zwitschi committed 2026-11-01 17:46:25 +01:00
1 parent 2624b54b23
commit 1ee32215b0
53 files changed
+1853 -151

No files matched your search

+148 -5
View File
@@ -15,6 +15,7 @@ extends SceneTree
## --tint=res://assets/textures/skyline.png=1,1,1 \
## --normal=res://assets/textures/ai/game_ready/brick_facade.png=res://.../brick_facade_normal.png
## # Or capture only some stages: --stages=day,late
## # Or feature one NPC role in the portrait: --portrait-type=2
##
## `--swap=OLD=NEW` replaces every StandardMaterial3D albedo texture loaded
## from OLD. `--tint=OLD=r,g,b` sets those materials' albedo colour.
@@ -23,6 +24,17 @@ extends SceneTree
## swaps can target the same material. Existing files in `--out` are never
## overwritten (a `-vN` suffix is added).
## NPC roles that wear the hood prop (see NpcAvatar.ACCESSORIES); their faces are
## hidden, so they are poor portrait subjects. Indices follow `Rules.NpcType`.
const HOODED_TYPES := [5, 6, 7] # TRESPASSER, VIOLENT_THREAT, MENTAL_CRISIS
## Optional `--portrait-type=N` picks the NpcType a portrait should feature.
var _portrait_type := -1
## Where the portrait subject is placed, relative to the guard's spawn.
var _portrait_spot := Vector3(0.0, 0.5, 9.0)
var _portrait_subject: Node3D = null
var _last_positions := {}
## Guard position/rotation per shot, matching the README capture poses.
const SHOTS_DAY := [
{"name": "entrance_day", "pos": Vector3(0, 0.1, 10.5), "yaw": 0.0, "pitch": 2.0},
@@ -43,9 +55,17 @@ const SHOTS_JOKE := [
const SHOTS_STUDIO := [
{"name": "studio_door", "pos": Vector3(0, 0.1, 6.2), "yaw": 0.0, "pitch": 2.0},
]
## A face-on portrait: staged relative to a live NPC by `_stage_portrait()`.
const SHOTS_PORTRAIT := [
{"name": "npc_portrait", "pos": Vector3.ZERO, "yaw": 0.0, "pitch": 0.0,
"portrait": true, "spot": Vector3(0.0, 0.5, 9.0)},
]
## Stage list: shift time selects the phase, `venue` switches the scenario first.
const STAGES := [
{"name": "day", "elapsed": 0.0, "venue": "", "shots": SHOTS_DAY, "night": false},
# Portrait next, while the day crowd is still on the street.
{"name": "portrait", "elapsed": 0.0, "venue": "", "shots": SHOTS_PORTRAIT,
"night": false},
{"name": "evening", "elapsed": 620.0, "venue": "", "shots": SHOTS_EVENING, "night": true},
{"name": "late", "elapsed": 760.0, "venue": "", "shots": SHOTS_LATE, "night": true},
{"name": "joke_shop", "elapsed": 700.0, "venue": "joke_shop", "shots": SHOTS_JOKE, "night": true},
@@ -100,7 +120,7 @@ func _pair(text: String, into: Dictionary) -> void:
return
into[text.substr(0, cut)] = text.substr(cut + 1)
func _process(_delta: float) -> bool:
func _process(delta: float) -> bool:
frames += 1
if frames > MAX_FRAMES:
push_error("capture_realism timed out")
@@ -126,7 +146,7 @@ func _process(_delta: float) -> bool:
if _phase != "done":
_phase = "shoot"
"shoot":
_run_queue()
_run_queue(delta)
"done":
print("capture_realism done -> %s" % out_dir)
quit(0)
@@ -142,8 +162,9 @@ func _begin_stage() -> void:
_phase = "done"
return
var stage: Dictionary = STAGES[_stage]
if str(stage["venue"]) != "":
gm.call("switch_scenario", str(stage["venue"]))
var venue := str(stage["venue"])
if venue != "" and venue != str(gm.get("active_scenario_id")):
gm.call("switch_scenario", venue)
gm.coordinator.elapsed_s = float(stage["elapsed"])
_apply(swaps)
if bool(stage["night"]):
@@ -152,11 +173,29 @@ func _begin_stage() -> void:
_shot_frames = 0
print("capture_realism: stage %s at %d ms" % [stage["name"], Time.get_ticks_msec()])
func _run_queue() -> void:
func _run_queue(delta: float) -> void:
if _queue.is_empty():
_phase = "stage"
return
var shot: Dictionary = _queue[0]
var portrait := bool(shot.get("portrait", false))
if portrait:
# NPCs spawn over time; re-stage every frame until one exists, then shoot.
if shot.has("spot"):
_portrait_spot = shot["spot"]
if not _stage_portrait(delta, float(shot.get("angle", 0.0))):
_shot_frames += 1
if _shot_frames >= SETTLE_FRAMES * 10:
push_warning("capture_realism: no NPC for %s" % shot["name"])
_queue.pop_front()
_shot_frames = 0
return
_shot_frames += 1
if _shot_frames >= SETTLE_FRAMES:
_shoot(str(shot["name"]))
_queue.pop_front()
_shot_frames = 0
return
if _shot_frames == 0:
gm.guard.global_position = shot["pos"]
gm.guard.rotation.y = deg_to_rad(float(shot["yaw"]))
@@ -167,6 +206,110 @@ func _run_queue() -> void:
_queue.pop_front()
_shot_frames = 0
## Frame a nearby NPC from the front, with the sun behind the camera so the face
## is lit. The subject's own yaw is re-written every frame by its FSM, so the
## portrait turns the visual `Rig` child instead and stands on the sunlit side.
func _stage_portrait(delta: float, angle_deg: float = 0.0) -> bool:
var guard_pos: Vector3 = gm.guard.global_position
var light := _sun_direction()
var speeds := _speeds(delta)
var best: Node3D = null
var best_distance := 999.0
for node in get_nodes_in_group("npc"):
var npc := node as Node3D
if npc == null or _faces_covered(npc):
continue
if _portrait_type >= 0 and int(_profile_of(npc).get("npc_type")) != _portrait_type:
continue
var distance: float = npc.global_position.distance_to(guard_pos)
if distance > 25.0:
continue
# Close, standing still, and easy to reach: the crowd keeps moving.
var score: float = distance / 25.0
if float(speeds.get(npc.get_instance_id(), 9.9)) < 0.05:
score -= 0.5
if not bool(npc.get("ephemeral")):
score -= 0.2
if best == null or score < best_distance:
best = npc
best_distance = score
if best == null:
return false
if _portrait_subject != best:
_portrait_subject = best
# Park the rest of the crowd far away: a close-up is a character still and
# a passer-by walking through the frame would block the subject.
for node in get_nodes_in_group("npc"):
var other := node as Node3D
if other == null or other == best:
continue
other.set_physics_process(false)
other.global_position += Vector3(0.0, 0.0, 140.0)
# Freeze the subject: its FSM re-writes the yaw every physics tick, which
# would turn it away from the camera again.
best.set_physics_process(false)
# Clear standing spot on the sidewalk, away from the queue: the close-up is a
# character still, not a slice of the scene, and the crowd would block it.
var spot := Vector3(0.0, 0.0, 0.0)
if gm.guard.get("spawn_position") != null:
spot = gm.guard.get("spawn_position")
best.global_position = spot + _portrait_spot
best.rotation.y = 0.0
# Stand between the subject and the sun, so the face is front-lit.
var to_light := -light
to_light = to_light.rotated(Vector3.UP, deg_to_rad(angle_deg))
gm.guard.global_position = best.global_position + to_light * 1.3
gm.guard.global_position.y = 0.1
var look: Vector3 = best.global_position - gm.guard.global_position
look.y = 0.0
gm.guard.rotation.y = atan2(-look.x, -look.z)
gm.guard.head.rotation.x = deg_to_rad(1.0)
# Turn the drawn rig (not the root) so the subject looks at the camera.
best.rotation.y = atan2(look.x, look.z)
return true
## Per-instance walking speed in m/s, from the position delta of the last frame.
func _speeds(delta: float) -> Dictionary:
var now := {}
var speeds := {}
for node in get_nodes_in_group("npc"):
var npc := node as Node3D
if npc == null:
continue
var id := npc.get_instance_id()
now[id] = npc.global_position
if _last_positions.has(id) and delta > 0.0:
speeds[id] = npc.global_position.distance_to(_last_positions[id]) / delta
_last_positions = now
return speeds
## Horizontal direction the sun light comes from (the way the light travels).
func _sun_direction() -> Vector3:
for node in root.find_children("*", "DirectionalLight3D", true, false):
var sun := node as DirectionalLight3D
if sun == null or not sun.visible:
continue
var dir := -sun.global_transform.basis.z
dir.y = 0.0
if dir.length() > 0.01:
return dir.normalized()
return Vector3(0, 0, 1)
## True when the avatar wears something over its face (see NpcAvatar.ACCESSORIES).
func _profile_of(npc: Node3D) -> Variant:
var brain = npc.get("npc")
return brain.get("profile") if brain != null else null
func _faces_covered(npc: Node3D) -> bool:
var brain = npc.get("npc")
if brain == null:
return false
var profile = brain.get("profile")
if profile == null:
return false
return int(profile.get("npc_type")) in HOODED_TYPES
## Apply swaps, tints and normals to every material whose original albedo path
## matches. Called once per stage; materials are visited through the tree.
func _apply(table: Dictionary) -> void:
@@ -28,6 +28,24 @@ func test_unlocked_door_admits_force() -> bool:
var b := TestUtils.check_eq(result["outcome"], "entered", "entered outcome")
return a and b
func test_flashlight_aims_a_tight_forward_spot() -> bool:
## The lamp sits on the guard's head and aims straight down the view axis with
## a narrow cone: a wide cone washed the whole entrance instead of lighting the
## door being checked (realism pass, 2026-11-01).
var root := Node3D.new()
var gm: Node = load("res://src/game/game_manager.gd").new()
root.add_child(gm)
var guard: Node3D = gm.call("_build_guard")
var lamp: SpotLight3D = guard.get_node_or_null("Head/Flashlight") as SpotLight3D
var a := TestUtils.check(lamp != null, "flashlight lives on the guard's head")
var b := TestUtils.check(lamp != null and lamp.rotation_degrees.is_equal_approx(Vector3.ZERO),
"flashlight aims straight ahead")
var c := TestUtils.check(lamp != null and lamp.spot_angle <= 20.0,
"flashlight cone is narrow enough to read as a torch")
var d := TestUtils.check(lamp != null and not lamp.visible, "flashlight starts off")
root.free()
return a and b and c and d
func test_flashlight_widens_perception() -> bool:
## Without the flashlight, an NPC at AWARE_RADIUS + 3 m is unaware.
## With the flashlight bonus (4 m), the same distance registers as aware.