Arms IK puts the model's wrist on the controller's aim-pose origin (the controller tip) #20

Open
opened 2026-10-25 08:46:44 +00:00 by hiccup444 · 0 comments

Type: bug (VR feel), needs headset confirmation Priority: medium Area: scenes/player_rig.tscn, src/player/player_rig.gd, src/player/arms_view.gd

Summary

The rig's hand nodes track the OpenXR aim pose, and ArmsView puts the model's Hand bone (the wrist) exactly on that pose's origin, with no positional offset.

The aim origin is where the pointing ray starts, usually at the front tip of the controller. So the rendered wrist sits near the front of the controller, roughly where the player's fingers are. The whole model hand sits forward of the real hand by something like a hand's length, and the forearm reads correspondingly long.

Evidence

scenes/player_rig.tscn:21-23 and :33-35: both controllers use the aim pose.

[node name="LeftHand" type="XRController3D" parent="Origin"]
tracker = "left_hand"
pose = "aim"

src/player/arms_view.gd:187, :216 and :230: the controller origin becomes the IK target, and the solved wrist goes on it.

var target := _skeleton.global_transform.affine_inverse() * hand.global_position
...
var wrist := shoulder + dn * dist
...
var hand_global := Transform3D(hand_basis, wrist)

HAND_OFFSET (arms_view.gd:58-61) is a rotation only, and its comment (:54-57) calls it a "blind first guess". It carries over the old HandView rotations from 4c9256d, which were set for the aim pose (hand pitched about 60 degrees below the aim ray).

What the poses mean. From the OpenXR 1.0 spec, Standard pose identifiers:

  • The grip pose is the one for rendering a virtual object held in the hand. On a motion controller, its position is a fixed point that roughly lines up with the palm centroid.
  • The aim pose is for pointing. In the spec's words, the aim ray "will often emerge from the frontmost tip of a motion controller".
  • The spec adds that an app drawing a virtual object in the hand will probably prefer to point using that object's own geometry.

What the vendored XR Tools does with the same choice:

  • addons/godot-xr-tools/hands/scenes/lowpoly/left_hand_low.tscn:47: its stock hand mesh sits at Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.03, -0.05, 0.15), 15 cm back, 5 cm down and 3 cm to the side of the controller origin. That is the offset XR Tools computes at runtime for an aim-pose parent: the aim offset in addons/godot-xr-tools/xr_tools.gd:352-357 combined with the base offset in addons/godot-xr-tools/hands/hand.gd:146. The comment at xr_tools.gd:352-353 says those aim offsets are no longer reliable on many headsets.
  • addons/godot-xr-tools/hands/hand_palm_offset.gd:81 raises a configuration warning on aim-pose controllers, saying the aim pose "is no longer suitable for hand position" and suggesting the grip pose.

DEAD AIR applies neither the offset nor the grip pose.

Proposed fix

Keep the aim pose for what it is for, the laser pointers and movement providers, and drive the arms from the grip pose.

  1. In player_rig.tscn, add grip-tracking controllers next to the existing ones. The action map already has a grip_pose action for both hands (openxr_action_map.tres:163).
[node name="LeftGrip" type="XRController3D" parent="Origin"]
tracker = "left_hand"
pose = "grip"

[node name="RightGrip" type="XRController3D" parent="Origin"]
tracker = "right_hand"
pose = "grip"
  1. In player_rig.gd, feed those to the arms. Input reads (get_float) go through the tracker, not the pose, so finger curl keeps working from the grip nodes.
@onready var _left_grip: XRController3D = $Origin/LeftGrip
@onready var _right_grip: XRController3D = $Origin/RightGrip

# in _process(), replacing the current update_arms call:
	arms_view.update_arms(_left_grip, _right_grip, flat_mode)
  1. In arms_view.gd, put the wrist behind the palm instead of on it. Add a per-side WRIST_OFFSET in grip space and apply it before solving:
## Where the model's wrist sits relative to the grip pose (palm centroid),
## in grip space. Measure in headset: open hand on the controller, the
## model palm should cover the real palm.
const WRIST_OFFSET := {
	"L": Vector3(0.0, 0.0, 0.0),
	"R": Vector3(0.0, 0.0, 0.0),
}

# in update_arms(), per side:
var wrist_world: Vector3 = hand.global_transform * WRIST_OFFSET[side]
var target := _skeleton.global_transform.affine_inverse() * wrist_world

Then re-tune HAND_OFFSET for grip space. Its current values were set against the aim pose's axes (see the 4c9256d note above).

The offset values are deliberately left at zero here. The grip axes are defined relative to the hand, but the right numbers depend on this model's wrist-to-palm proportions, so measure rather than guess.

Once the target sits at the real wrist, re-check ARM_LENGTH_SCALE (1.2, arms_view.gd:52). It came in with 1dd3cf2 as the fix for hands stopping short, and today part of the extra reach it buys is spent reaching a target that sits too far forward. With a correct target, the same multiplier may leave elbows too straight. It is Zach's carried-over Resonite setting, so this is a feel check, not a change request.

Timing

Tuning needs a headset and should happen after PR #14 lands, since #14 changes the same rig: the arms anchor in player_rig.gd, plus the chest offset inside ArmsView.solve_side that the #14 review agreed on and that has not been pushed yet. Offsets measured earlier would be measured against a moving target.

How to verify

In the headset, with the arms visible:

  1. Hold the controller with your hand open and flat against the grip. Before the fix, the model's wrist sits near the controller's front tip and the model palm is ahead of your real palm. After the fix, they overlap.
  2. Bring the controller up until your hand touches your cheek. The model hand should meet the face too, not stop short of it or pass through it.

The debug bridge's solve_arm (addons/da_debug_bridge/debug_bridge.gd:358) takes a world-space target and calls ArmsView.solve_side directly, so it skips update_arms and is unaffected by the fix. The bridge has no command to set a controller pose, so it can't reproduce the bug on its own. A camera-POV bridge screenshot can still compare the two placements if you pass the old and new wrist positions as targets yourself.

**Type:** bug (VR feel), needs headset confirmation **Priority:** medium **Area:** `scenes/player_rig.tscn`, `src/player/player_rig.gd`, `src/player/arms_view.gd` ## Summary The rig's hand nodes track the OpenXR **aim** pose, and `ArmsView` puts the model's `Hand` bone (the wrist) exactly on that pose's origin, with no positional offset. The aim origin is where the pointing ray starts, usually at the front tip of the controller. So the rendered wrist sits near the front of the controller, roughly where the player's fingers are. The whole model hand sits forward of the real hand by something like a hand's length, and the forearm reads correspondingly long. ## Evidence `scenes/player_rig.tscn:21-23` and `:33-35`: both controllers use the aim pose. ``` [node name="LeftHand" type="XRController3D" parent="Origin"] tracker = "left_hand" pose = "aim" ``` `src/player/arms_view.gd:187`, `:216` and `:230`: the controller origin becomes the IK target, and the solved wrist goes on it. ```gdscript var target := _skeleton.global_transform.affine_inverse() * hand.global_position ... var wrist := shoulder + dn * dist ... var hand_global := Transform3D(hand_basis, wrist) ``` `HAND_OFFSET` (`arms_view.gd:58-61`) is a rotation only, and its comment (`:54-57`) calls it a "blind first guess". It carries over the old HandView rotations from `4c9256d`, which were set for the aim pose (hand pitched about 60 degrees below the aim ray). **What the poses mean.** From the OpenXR 1.0 spec, [Standard pose identifiers](https://registry.khronos.org/OpenXR/specs/1.0/html/xrspec.html#semantic-path-standard-pose-identifiers): - The **grip** pose is the one for rendering a virtual object held in the hand. On a motion controller, its position is a fixed point that roughly lines up with the palm centroid. - The **aim** pose is for pointing. In the spec's words, the aim ray "will often emerge from the frontmost tip of a motion controller". - The spec adds that an app drawing a virtual object in the hand will probably prefer to point using that object's own geometry. **What the vendored XR Tools does with the same choice:** - `addons/godot-xr-tools/hands/scenes/lowpoly/left_hand_low.tscn:47`: its stock hand mesh sits at `Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.03, -0.05, 0.15)`, 15 cm back, 5 cm down and 3 cm to the side of the controller origin. That is the offset XR Tools computes at runtime for an aim-pose parent: the aim offset in `addons/godot-xr-tools/xr_tools.gd:352-357` combined with the base offset in `addons/godot-xr-tools/hands/hand.gd:146`. The comment at `xr_tools.gd:352-353` says those aim offsets are no longer reliable on many headsets. - `addons/godot-xr-tools/hands/hand_palm_offset.gd:81` raises a configuration warning on aim-pose controllers, saying the aim pose "is no longer suitable for hand position" and suggesting the grip pose. DEAD AIR applies neither the offset nor the grip pose. ## Proposed fix Keep the aim pose for what it is for, the laser pointers and movement providers, and drive the arms from the grip pose. 1. In `player_rig.tscn`, add grip-tracking controllers next to the existing ones. The action map already has a `grip_pose` action for both hands (`openxr_action_map.tres:163`). ``` [node name="LeftGrip" type="XRController3D" parent="Origin"] tracker = "left_hand" pose = "grip" [node name="RightGrip" type="XRController3D" parent="Origin"] tracker = "right_hand" pose = "grip" ``` 2. In `player_rig.gd`, feed those to the arms. Input reads (`get_float`) go through the tracker, not the pose, so finger curl keeps working from the grip nodes. ```gdscript @onready var _left_grip: XRController3D = $Origin/LeftGrip @onready var _right_grip: XRController3D = $Origin/RightGrip # in _process(), replacing the current update_arms call: arms_view.update_arms(_left_grip, _right_grip, flat_mode) ``` 3. In `arms_view.gd`, put the wrist behind the palm instead of on it. Add a per-side `WRIST_OFFSET` in grip space and apply it before solving: ```gdscript ## Where the model's wrist sits relative to the grip pose (palm centroid), ## in grip space. Measure in headset: open hand on the controller, the ## model palm should cover the real palm. const WRIST_OFFSET := { "L": Vector3(0.0, 0.0, 0.0), "R": Vector3(0.0, 0.0, 0.0), } # in update_arms(), per side: var wrist_world: Vector3 = hand.global_transform * WRIST_OFFSET[side] var target := _skeleton.global_transform.affine_inverse() * wrist_world ``` Then re-tune `HAND_OFFSET` for grip space. Its current values were set against the aim pose's axes (see the `4c9256d` note above). The offset values are deliberately left at zero here. The grip axes are defined relative to the hand, but the right numbers depend on this model's wrist-to-palm proportions, so measure rather than guess. Once the target sits at the real wrist, re-check `ARM_LENGTH_SCALE` (1.2, `arms_view.gd:52`). It came in with `1dd3cf2` as the fix for hands stopping short, and today part of the extra reach it buys is spent reaching a target that sits too far forward. With a correct target, the same multiplier may leave elbows too straight. It is Zach's carried-over Resonite setting, so this is a feel check, not a change request. ## Timing Tuning needs a headset and should happen after PR #14 lands, since #14 changes the same rig: the arms anchor in `player_rig.gd`, plus the chest offset inside `ArmsView.solve_side` that the #14 review agreed on and that has not been pushed yet. Offsets measured earlier would be measured against a moving target. ## How to verify In the headset, with the arms visible: 1. Hold the controller with your hand open and flat against the grip. Before the fix, the model's wrist sits near the controller's front tip and the model palm is ahead of your real palm. After the fix, they overlap. 2. Bring the controller up until your hand touches your cheek. The model hand should meet the face too, not stop short of it or pass through it. The debug bridge's `solve_arm` (`addons/da_debug_bridge/debug_bridge.gd:358`) takes a world-space target and calls `ArmsView.solve_side` directly, so it skips `update_arms` and is unaffected by the fix. The bridge has no command to set a controller pose, so it can't reproduce the bug on its own. A camera-POV bridge screenshot can still compare the two placements if you pass the old and new wrist positions as targets yourself.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
dead-air/engine#20
No description provided.