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

+2 -22
View File
@@ -191,30 +191,10 @@ func _build_guard() -> GuardController:
# front of the guard instead of the person or door being checked.
flashlight.rotation_degrees = Vector3.ZERO
flashlight.spot_range = 20.0
flashlight.spot_angle = 11.0
flashlight.light_energy = 3.6
flashlight.spot_angle = 14.0
flashlight.light_energy = 4.2
flashlight.spot_attenuation = 0.9
flashlight.spot_angle_attenuation = 0.9
# Visible beam: a fading additive cone parented to the lamp, because the
# Compatibility renderer has no volumetric light. It is offset forward so the
# near cap never fills the first-person view.
var beam := MeshInstance3D.new()
beam.name = "Beam"
var cone := CylinderMesh.new()
cone.top_radius = 3.0
cone.bottom_radius = 0.04
cone.height = 7.0
cone.radial_segments = 16
cone.rings = 1
cone.cap_top = false
cone.cap_bottom = false
beam.mesh = cone
# Cylinder axis is +Y; -90° about X aims the wide end along -Z (forward).
beam.rotation_degrees = Vector3(-90, 0, 0)
beam.position = Vector3(0, 0, -3.9)
beam.material_override = Materials.light_beam()
beam.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
flashlight.add_child(beam)
head.add_child(flashlight)
var collider := CollisionShape3D.new()
var shape := CapsuleShape3D.new()
-29
View File
@@ -128,35 +128,6 @@ static func emissive(color: Color, energy: float = 1.4) -> StandardMaterial3D:
mat.roughness = 0.4
return mat
## Fake light shaft for the flashlight (Compatibility has no volumetrics).
## Additive, unshaded, no depth write: it glows over the scene, is still occluded
## by walls, and fades out along its length instead of ending in a hard disc.
static func light_beam() -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.blend_mode = BaseMaterial3D.BLEND_MODE_ADD
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
mat.depth_draw_mode = BaseMaterial3D.DEPTH_DRAW_DISABLED
mat.albedo_color = Color(1.0, 0.95, 0.82, 1.0)
# A vertical ramp, not a GradientTexture1D: a cylinder's UV.y runs along its
# height, while the 1D gradient varies with U and would paint a bright ring.
var ramp := Image.create(1, 64, false, Image.FORMAT_RGBA8)
for y in 64:
var t := float(y) / 63.0
# Symmetric fade (bright in the middle, gone at both ends) so the look
# does not depend on which way CylinderMesh orients its V coordinate; the
# far end must never show as a bright ring.
var alpha := 0.0
if t < 0.5:
alpha = lerpf(0.0, 0.3, t / 0.5)
else:
alpha = lerpf(0.3, 0.0, (t - 0.5) / 0.5)
ramp.set_pixel(0, y, Color(1.0, 1.0, 1.0, alpha))
mat.albedo_texture = ImageTexture.create_from_image(ramp)
mat.texture_filter = BaseMaterial3D.TEXTURE_FILTER_LINEAR
return mat
## Dark glass for windows and the entrance door. The alpha keeps the interior
## visible, and the low roughness gives the facade a highlight instead of a hole.
static func glass(color: Color = Color(0.42, 0.56, 0.72, 0.75)) -> StandardMaterial3D:
+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.