Detail the NPC avatars and finish the realism follow-ups
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:
1 parent
2624b54b23
commit
1ee32215b0
53 files changed
+1853
-151
No files matched your search
+148
-5
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user