diff --git a/MEMORY.md b/MEMORY.md index 7074fdd..028ae68 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -11,8 +11,12 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o direct `set_bone_pose_rotation` on HipL/KneeL/Spine/ShoulderL/ElbowL/Head changes rendered pixels; a bone *position* offset moves the head; a standalone probe running the same `_animate()` sequence renders the crouch. A staged in-game `film` frame also showed the arms - down, so live venue animation may be affected too. Do not treat the pose sheets as evidence; - a scene test (posed frame differs from rest frame) is the next step. + Standalone SceneTree probes (avatar under `root`, under a `Node3D` stage, and created + mid-`_process`) all render the seated crouch, so the rig and animation math are fine and the + harness capture path is the outlier. A headless test now locks the proxy→bone contract + (`test_pose_reaches_the_skeleton_bones`, suite 273). Next leads: reproduce the stage inside + `capture_npc_review.gd` (known-good captures) or capture via `SubViewport` + + `RenderingServer.force_draw()`. - **3D review baseline (2026-11-03):** `docs/3d-model-review.md` records the first skill-guided measurements: 46/46 assets clean, library 59,012 tris, characters 4,482–5,474 tris (68–69% of the 8,000 caps), `npc_base` only 1,204/2,488, 24-NPC 6.94 ms, and authored texel density diff --git a/TODO.md b/TODO.md index d44b9cf..f3d81e8 100644 --- a/TODO.md +++ b/TODO.md @@ -64,10 +64,12 @@ except the rebuilt assets themselves. frames still show the rest pose; direct bone writes deform the mesh, so this is a staging/ capture discrepancy that may also affect live venue frames. See `docs/3d-model-review.md` §"Pose evidence is blocked". -- [ ] Diagnose the pose-capture discrepancy: compare the harness staging with the working - standalone probe (SceneTree lifetime, node parents, first-frame skin bind, capture timing) - and add a scene test asserting a posed avatar renders differently from its rest frame. - Do not trust pose sheets or claim rig deformation until that test passes. +- [ ] Finish the pose-capture diagnosis. Done so far: the pose reaches proxies and skeleton + bones (new headless test, 273/273), direct bone rotations deform the rendered mesh, and + three standalone probes render the seated crouch — only `capture_npc_pose.gd` saves the + rest pose. Remaining leads: reproduce the stage inside `capture_npc_review.gd` (known to + render) or capture via `SubViewport` + `RenderingServer.force_draw()`, then add a scene + test that a posed avatar renders differently from its rest frame. - [ ] UV/texel density: measure the 256² slot maps; decide whether atlas, UDIM or a resolution change is justified. Do not change `CHAR_MAX_TRIS`/`CHAR_MAX_VERTS` without re-running the budget smoke and the Web/Compatibility path. diff --git a/docs/3d-model-review.md b/docs/3d-model-review.md index 1a370ba..c23aeea 100644 --- a/docs/3d-model-review.md +++ b/docs/3d-model-review.md @@ -165,26 +165,27 @@ for presence, not re-reviewed for art quality. ## Pose evidence is blocked (2026-11-03) -The weights/pose column cannot be filled yet. A pose harness was added -(`src/test/capture_npc_pose.gd`, staged in `tmp/model-pose*`), and its output does not match the -state it reports: +The weights/pose column cannot be filled yet. The pose logic is verified; the *capture* is not. +`src/test/capture_npc_pose.gd` stages idle / walk-contact / walk-pass / seated for all eight +variants (full-body + shoulder views) and its output does not match the state it reports. -- `_animate()` applies the seated pose (logged `rig_y=-0.339`, `leg0=-1.357`) and - `Skeleton3D.get_bone_pose_rotation()` returns the rotated quaternion at capture time, yet the - saved frame shows the rest pose. -- Ruled out: missing skeleton (one `Skeleton3D`, mesh bound to it, `same=true`), hidden mesh - (hiding `npc_base` removes the body), pose not applied (proxies and bone poses verified), - and unbound skinning: writing a single bone rotation directly (`HipL`, `KneeL`, `Spine`, - `ShoulderL`, `ElbowL`, `Head`) changes the rendered pixels, and a bone *position* offset moves - the head. A standalone probe that calls the same `_animate()` sequence renders the crouch. -- Also observed: a staged in-game `film` pose frame showed the arms down, so this may affect the - live game rather than only the harness. That is why it is a P0/P1 candidate rather than a QA - tooling nit. +| Check | Result | +| --- | --- | +| Pose reaches proxies | Seated pose logs `rig_y=-0.339`, `leg0=-1.357` | +| Pose reaches skeleton bones | `Skeleton3D.get_bone_pose_rotation(HipL/KneeL)` differs from rest; new headless test `test_pose_reaches_the_skeleton_bones` locks it (suite 273/273) | +| Idle restores rest | Same test asserts the hip returns to its rest orientation | +| Skinning bound | One `Skeleton3D`, mesh bound to it (`same=true`); hiding `npc_base` removes the body | +| Bone rotations deform the mesh | Direct writes change rendered pixels: Spine 1,719 / HipL 1,052 / KneeL 810 / ElbowL 745 / Head 671 / ShoulderL 640 sampled pixels | +| Bone translation deforms the mesh | A +0.5 m Head offset moves the head in frame | +| Standalone probes render the pose | Three variants (avatar under `root`, under a `Node3D` stage, created mid-`_process`) all render the seated crouch (`posed` vs rest diff ≈ 2,200–2,360 samples) | +| The harness renders the pose | **No.** All 8 variants × 4 poses save the rest pose while the logged state and bone poses are posed | -Next step is a focused diagnosis: compare the harness/venue staging with the standalone probe -(SceneTree lifetime, node parents, first-frame skin bind, capture timing) and add a scene test -that asserts a posed avatar renders differently from its rest frame. Do not mark the pose/weight -scorecard row until that test exists. +The cause is therefore in the harness's capture path, not in the rig or the animation math. Two +leads are left and both are cheap: reproduce the stage inside a harness that is known to render +correctly (`capture_npc_review.gd` already produces 96 valid frames), or capture through a +`SubViewport` with `RenderingServer.force_draw()` so the frame cannot lag the state. Until a +scene test asserts that a posed avatar renders differently from its rest frame, do not treat pose +sheets as evidence and do not claim rig deformation in the review. ## Ranked candidate gaps diff --git a/src/test/capture_npc_pose.gd b/src/test/capture_npc_pose.gd index c804b30..a9e1c59 100644 --- a/src/test/capture_npc_pose.gd +++ b/src/test/capture_npc_pose.gd @@ -261,8 +261,6 @@ func _shoot() -> void: 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]) diff --git a/src/test/tests/test_npc_character.gd b/src/test/tests/test_npc_character.gd index d93dbe0..47b4876 100644 --- a/src/test/tests/test_npc_character.gd +++ b/src/test/tests/test_npc_character.gd @@ -147,6 +147,38 @@ func test_skinned_model_loads_and_keeps_the_head_contract() -> bool: avatar.free() return a and b and c +## The pose harness captures frames, but a regression here is cheap to catch: +## the pose logic must reach the skeleton bones, not only the proxy pivots. +func test_pose_reaches_the_skeleton_bones() -> bool: + var avatar := _avatar(Rules.NpcType.TOURIST, 55) + var sk := avatar._skeleton + var hip: int = int(avatar._bone_indices["HipL"]) + var knee: int = int(avatar._bone_indices["KneeL"]) + var rest_hip := sk.get_bone_rest(hip).basis.get_rotation_quaternion() + var rest_knee := sk.get_bone_rest(knee).basis.get_rotation_quaternion() + avatar.arrived = true + avatar.npc.intent = "sit" + for _i in 45: + avatar._animate(1.0 / 60.0) + var sit_hip := sk.get_bone_pose_rotation(hip) + var sit_knee := sk.get_bone_pose_rotation(knee) + var a := TestUtils.check(not sit_hip.is_equal_approx(rest_hip), + "seated pose rotates the HipL bone away from rest") + var b := TestUtils.check(not sit_knee.is_equal_approx(rest_knee), + "seated pose rotates the KneeL bone away from rest") + var c := TestUtils.check(avatar._rig.position.y < -0.25, + "seated pose lowers the rig (%.3f)" % avatar._rig.position.y) + # Returning to idle must restore the rest orientation, not leave the sit pose. + avatar.arrived = false + avatar.npc.intent = "roam" + avatar.velocity = Vector3.ZERO + for _i in 120: + avatar._animate(1.0 / 60.0) + var d := TestUtils.check(sk.get_bone_pose_rotation(hip).is_equal_approx(rest_hip), + "idle restores the HipL rest orientation") + avatar.free() + return a and b and c and d + func test_character_selection_is_deterministic_and_complete() -> bool: var expected := { Rules.NpcType.TOURIST: "npc_casual",