Narrow the pose-capture discrepancy to the harness and lock bone posing
Full build / Import + headless tests (push) Successful in 45s
Full build / Full desktop build + optional release (push) Skipped

This commit is contained in:
zwitschi committed 2026-11-03 11:24:44 +01:00
1 parent 7a5b902797
commit 1d4f718376
5 files changed
+63 -26

No files matched your search

+6 -2
View File
@@ -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
+6 -4
View File
@@ -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.
+19 -18
View File
@@ -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
-2
View File
@@ -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])
+32
View File
@@ -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",