Add a pose review harness and record the blocked pose capture
Full build / Import + headless tests (push) Successful in 43s
Full build / Full desktop build + optional release (push) Skipped

This commit is contained in:
zwitschi committed 2026-11-03 10:59:23 +01:00
1 parent 2d67e14e52
commit 7a5b902797
7 files changed
+358 -13

No files matched your search

+268
View File
@@ -0,0 +1,268 @@
extends SceneTree
## Pose/deformation review harness for the 3D model re-evaluation.
##
## The neutral baseline (`capture_npc_baseline.gd`) deliberately disables
## animation, so it cannot show shoulder/hip/neck collapse, leg contact or how
## coats and dresses behave while walking or seated. This harness stages the same
## eight characters against the same studio and drives the real pose code:
##
## idle rest pose, animation settled
## walk_contact mid-stride with the legs at maximum split
## walk_pass half a stride later, opposite leg forward
## sit seated theater pose (crouch, knees, hem)
##
## Two views per pose: a full-body frame for silhouette/proportion and a
## shoulder close-up for deformation. The avatar's own `_process` drives the real
## animation path, and the pose settles over the recorded tick count, so a run is
## deterministic enough for a before/after comparison.
##
## STATUS: BLOCKED (2026-11-03). Captured frames still show the rest pose even
## though `_animate()` reports the seated/walking pose (`rig_y=-0.339`,
## `leg0=-1.357`) and `Skeleton3D.get_bone_pose_rotation()` returns the posed
## quaternion at capture time. A near-identical standalone probe that calls the
## same `_animate()` sequence *does* render the crouch, and writing a single
## bone rotation directly does deform the mesh, so this is a staging/capture
## discrepancy, not a rig failure. Do not use these sheets as pose evidence.
## Repro and what was ruled out: `docs/3d-model-review.md` section
## "Pose evidence is blocked".
##
## Usage (windowed; `xvfb-run -a` works for automation):
## godot --path . --script res://src/test/capture_npc_pose.gd -- \
## --out=res://tmp/npc-pose
##
## Output is local QA art. Build sheets with the shared tool:
## python3 tools/npc_baseline.py sheet --dir tmp/npc-pose \
## --views idle,walk_contact,walk_pass,sit --out tmp/npc-pose/sheet_pose.jpg
## python3 tools/npc_baseline.py sheet --dir tmp/npc-pose \
## --views idle_shoulder,walk_contact_shoulder,walk_pass_shoulder,sit_shoulder \
## --out tmp/npc-pose/sheet_shoulder.jpg
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},
]
## Ticks of `_animate(1/60)` per pose. The walk phase advances by
## dt * (6 + speed * 1.7) ≈ 0.148 rad/tick at 1.7 m/s, so 30 ticks lands on a
## near-maximum leg split and 52 ticks on the opposite one.
const POSES := [
{"name": "idle", "ticks": 40, "speed": 0.0, "sit": false},
{"name": "walk_contact", "ticks": 30, "speed": 1.7, "sit": false},
{"name": "walk_pass", "ticks": 52, "speed": 1.7, "sit": false},
{"name": "sit", "ticks": 45, "speed": 0.0, "sit": true},
]
const VIEWS := [
{"name": "full", "yaw": 0.0, "distance": 4.2, "height": 0.95,
"target_y": 0.95, "fov": 38.0, "suffix": ""},
{"name": "shoulder", "yaw": 35.0, "distance": 1.25, "height": 1.50,
"target_y": 1.42, "fov": 32.0, "suffix": "_shoulder"},
]
const SETTLE_FRAMES := 8
const MAX_FRAMES := 8000
var out_dir := "user://screenshots/npc_pose"
var only_pose := ""
var _poses: Array = POSES
var frames := 0
var _phase := "stage"
var _variant_i := 0
var _pose_i := 0
var _view_i := 0
var _wait := 0
var _settle_ticks := 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)
elif str(arg).begins_with("--pose="):
only_pose = str(arg).substr(7)
if only_pose != "":
_poses = POSES.filter(func(pose): return str(pose["name"]) == only_pose)
if _poses.is_empty():
push_error("unknown pose %s" % only_pose)
root.size = Vector2i(1280, 720)
_build_stage()
func _process(_delta: float) -> bool:
frames += 1
if frames > MAX_FRAMES:
push_error("capture_npc_pose timed out")
quit(1)
return true
match _phase:
"clear":
if _avatar != null and is_instance_valid(_avatar):
_avatar.queue_free()
_avatar = null
_phase = "stage"
return false
"stage":
_stage_pose()
# The skinned mesh must be bound for a frame before pose writes stick.
_wait = 2
_phase = "bind"
"bind":
_wait -= 1
if _wait <= 0:
_apply_pose()
_wait = SETTLE_FRAMES
_phase = "settle"
"settle":
_wait -= 1
if _wait <= 0:
_frame_view()
_wait = 1
_phase = "frame"
"frame":
_wait -= 1
if _wait <= 0:
_phase = "shoot"
"shoot":
_shoot()
_view_i += 1
if _view_i >= VIEWS.size():
_view_i = 0
_pose_i += 1
if _pose_i >= _poses.size():
_pose_i = 0
_variant_i += 1
if _variant_i >= VARIANTS.size():
print("capture_npc_pose done -> %s" % out_dir)
quit(0)
return true
_phase = "clear"
else:
_wait = 1
_phase = "settle"
return false
func _build_stage() -> void:
_stage = Node3D.new()
_stage.name = "NpcPoseStage"
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 = "PoseCamera"
_camera.current = true
_stage.add_child(_camera)
func _stage_pose() -> void:
var variant: Dictionary = VARIANTS[_variant_i]
var pose: Dictionary = _poses[_pose_i]
var profile := NpcProfile.new()
profile.id = "pose_" + str(variant["name"])
profile.display_name = str(variant["name"]).to_upper()
profile.npc_type = int(variant["type"])
var avatar := NpcAvatar.new()
avatar.name = "PoseSubject%d" % (_variant_i * 10 + _pose_i)
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
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
# Drive the real pose code deterministically: the walk branch reads
# `velocity`, the theater branch needs `arrived` + the sit intent.
## Drive the real pose code deterministically. `_animate` lives in
## `_physics_process`, which stays disabled so navigation cannot overwrite the
## velocity we set for the walk poses.
func _apply_pose() -> void:
if _avatar == null or not is_instance_valid(_avatar):
return
var pose: Dictionary = _poses[_pose_i]
_avatar.velocity = Vector3(0.0, 0.0, float(pose["speed"]))
_avatar.arrived = bool(pose["sit"])
_avatar.npc.intent = "sit" if bool(pose["sit"]) else "roam"
for _tick in int(pose["ticks"]):
_avatar._animate(1.0 / 60.0)
print("pose %s/%s rig_y=%.3f leg0=%.3f" % [
VARIANTS[_variant_i]["name"], pose["name"], _avatar._rig.position.y,
_avatar._legs[0].rotation.x])
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 pose: Dictionary = _poses[_pose_i]
var view: Dictionary = VIEWS[_view_i]
var dir := ProjectSettings.globalize_path(out_dir)
DirAccess.make_dir_recursive_absolute(dir)
var base := "%s__%s%s" % [variant["name"], pose["name"], view["suffix"]]
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 hip_bone: int = int(_avatar._bone_indices.get("HipL", -1))
var knee_bone: int = int(_avatar._bone_indices.get("KneeL", -1))
var img: Image = root.get_texture().get_image()
var err := img.save_png(path)
print("%s -> err=%d" % [base, err])