Files
VR-Mess/research/vr-two-handed-grab.md
T

402 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Research: two-handed grab, held-rotation stabilization, hand snap-back vs grip, and what Box3D joints really are
Fourth research pass for VR Messs, after the user playtest of grab v5 (PinJoint3D per hand):
two-handed hold feels "jointed-on" not welded, held-object rotation gets out of control, and
PhysicsHands' stuck-safety teleports the hand out from under a live joint. Label: [S] = sourced
(verified against source/docs this pass), [I] = inference. Companion to `vr-grabbing.md` — that
report's taxonomy still holds; this one covers what it didn't.
## Sources verified this pass
### Box3D / godot-box3d (all read directly — the most load-bearing section)
- [S] **Local addon has no source** — `/home/foxyz/Projects/Games/vr-messs/addons/godot-box3d/`
contains only LICENSE, `.gdextension`, and 3 binaries. Class surface extracted from
`bin/libgodot-box3d.so` via `strings`: registered joint impls are
**Box3DPinJointImpl3D, Box3DHingeJointImpl3D, Box3DSliderJointImpl3D, Box3DFilterJointImpl3D**
(+ base Box3DJointImpl3D). Strings also carry the rejections: "ConeTwistJoint3D is not
supported in this version", "Generic6DOFJoint3D is not supported; use PinJoint3D,
HingeJoint3D, or SliderJoint3D instead", "PinJoint3D's IMPULSE_CLAMP parameter has no Box3D
equivalent and is ignored", HingeJoint BIAS/LIMIT_BIAS/LIMIT_RELAXATION/LIMIT_SOFTNESS ignored.
- [S] **Upstream bearlikelion/godot-box3d** (https://github.com/bearlikelion/godot-box3d, tag
v0.2.6 — `src/joints/box3d_pin_joint_impl_3d.cpp` diffed IDENTICAL to main): PinJoint3D maps
to a Box3D **spherical joint with `enableSpring = true` HARDCODED, `hertz = bias * 30.0f`,
`dampingRatio = damping`**. IMPULSE_CLAMP = warn + no-op. Defaults: bias 0.3 → **9 Hz
rotational spring**, damping 1.0 → critically damped. So Godot's bias/damping DO apply —
but to the joint's ROTATIONAL spring, not to the positional constraint.
- [S] **Box3D = Erin Catto's engine** (https://github.com/erincatto/box3d), Rubikon/Source-2
lineage (bearlikelion README, https://bearlikelion.github.io/godot-box3d/). The wrapper README
admits it is "behind box3d-godot on: joint types, ConeTwist and 6DOF" — PhysicsServer3D has no
entry points for Box3D's other joints, so a drop-in backend *can't* surface them.
- [S] **The underlying spherical joint is a near-cone-twist** (`include/box3d/types.h`,
`src/spherical_joint.c`): b3SphericalJointDef has `enableSpring/hertz/dampingRatio`
(rotational spring toward `targetRotation`, captured at creation), `targetRotation` (b3Quat),
`enableConeLimit`/`coneAngle` [0, π] (cone around frameA z-axis), `enableTwistLimit`/
lower/upperTwistAngle (±0.99π around frameB z-axis), `enableMotor`/`motorVelocity` (relative
angular velocity, world space)/`maxMotorTorque`. **None of this is reachable through the
wrapper** — `_joint_make_pin` passes identity-basis frames and `_pin_joint_set_local_a/b`
rewrite the frame ORIGIN only (src/servers/box3d_physics_server_3d.cpp), so the spring target
is permanently the relative rotation at grab time.
- [S] **The positional (point-to-point) part of the pin joint is tuned separately and the
wrapper never touches it** (`src/joint.c`): `b3DefaultJointDef` sets `constraintHertz = 60`,
`constraintDampingRatio = 2.0`, and `b3PrepareJoint` clamps hertz to **0.25 × tick rate** →
at our 90 Hz physics the positional constraint runs at **22.5 Hz, overdamped (ζ 2.0)**. This
is the sag/drift in the playtest and it is NOT tunable via PinJoint3D.Bias/Damping.
- [S] **b3Joint_SetLocalFrameA/B apply to the live joint without rebuilding** (warm start kept)
— `src/joints/box3d_joint_impl_3d.cpp` — this is the mechanism behind our hand-following pivot.
- [S] **Box3D also has** (public API, all unreachable from the wrapper): weld joint ("connects
two bodies together rigidly", linearHertz 0 = max stiffness, with the note "the approximate
solver in Box3D cannot hold many bodies together rigidly"), **motor joint** (desired relative
linear+angular velocity with max velocity force/torque, plus spring hertz/dampingRatio with
max spring force/torque — b3MotorJointDef, include/box3d/types.h), distance/parallel/wheel
joints. The separate project **box3d-godot** (Stink-O) exposes 8 joint types but as its own
node hierarchy (no PhysicsServer3D replacement) — not usable without abandoning RigidBody3D.
- [S] **HingeJoint3D DOES expose its motor** through the wrapper
(`box3d_hinge_joint_impl_3d.cpp` → b3RevoluteJoint_EnableMotor/SetMotorSpeed/
SetMaxMotorTorque) — usable later for cranks/wheels, irrelevant to grab.
### Prior art — two-handed hold
- [S] **Unity Discussions, "Need help with two Configurable Joints! (Two hands grab in VR)"**
(https://discussions.unity.com/t/need-help-with-two-configurable-joints-two-hands-grab-in-vr/220913,
found via search; scheme corroborated by search-result synthesis): two joints hand→object;
BOTH lock position always; only the FIRST hand's joint locks rotation; the working scheme is
"primary owns rotation, secondary is position-only" — giving both joints rotation locks made
the weapon flip (the two rotational authorities fight).
- [I] **Blade & Sorcery** (decompile-derived, consistent with the wiki we verified in
`vr-grabbing.md`): the second hand does NOT create a second joint — Handle.jointType "Rigid"
is a fully-locked ConfigurableJoint on the primary hand only, and going two-handed adjusts
the PRIMARY joint's limits. Secondary hand contributes pose/limits, not an independent
constraint.
- [S] **Meta ISDK** (https://developers.meta.com/horizon/documentation/unity/unity-isdk-two-handed-grab-interaction/):
kinematic transformers — position = centroid of both grab points, rotation "computed from
relative hand movement" (TwoGrabRotateTransformer, axis-constrained with min/max degrees).
This is the documented kinematic form of "orient from the two hands", no physics.
- [S] **HurricaneVR** (release notes read from a public mirror,
https://code.fbi.h-da.de/sttijahnk/Relax/-/blob/ae2026c6e21d8215cbfd7c05e80a2c4c00bea5f2/Relax/Assets/HurricaneVR/Documentation/ReleaseNotes.txt):
joint-based physics hands; separate JointSettings for one- vs two-handed grabbing;
"Two hand grabs are much more stable with the physics hand updates"; `FinalJointMaxAngle`
15° + `FinalJointTimeout` 0.25 s (snap the last rotation error after a timeout);
`PerpendicularThreshold` breaks the joint on stretch.
- [I] **Rigid-body theory**: two ideal point constraints remove 5 of 6 DOF and leave rotation
about the axis through the two anchors free. It is killed by (a) a third non-collinear point
constraint (over-constrains; soft solvers leak), (b) an angular spring/motor about that axis,
or (c) aim-the-anchor-axis-at-the-hand-hand-vector + roll. **Box3D's pin joints already
contain (b) implicitly** — every pin joint carries a rotational spring toward the grab-time
relative rotation — so the playtest's visible axial rotation/drift is the 9 Hz DEFAULT
spring being too weak, not a missing constraint.
### Prior art — held-object rotation stabilization
- [S] **Box3D manual** (https://github.com/erincatto/box3d, docs/simulation.md): joint motors
control position "by specifying a joint velocity that is proportional to the difference
between the actual and desired position" with a torque cap (servo); friction = zero target
velocity + small max torque; joint spring hertz "should be less than half the frequency of
the time step" (60 Hz step → < 30 Hz). Same doctrine as ODE (already cited in
`vr-grabbing.md`): let the SOLVER integrate the correction, never per-step velocity override.
- [S] **Unity ConfigurableJoint reference** (https://docs.unity3d.com/Manual/class-ConfigurableJoint.html):
rotation control = drive spring + damper + **Maximum Force clamp** — clamping the drive force
is the standard stabilizer.
- [I] Community fix patterns (Unity/Unreal threads found via search): clamp
|angularVelocity| each tick while held as a *safety net*, raise angular damping while held,
apply counter-torque ∝ angular error rather than hard velocity sets. No public writeup gives
tuned gain values for 90 Hz — the Box3D spring hertz limits above are the only hard numbers.
- [S] **The current code's oscillation source is now precisely identifiable**: PropGrabber.cs
sets `body.AngularVelocity` every tick (cap 20 rad/s) while the primary pin joint's own 9 Hz
rotational spring pulls toward the grab rotation — two authorities on the same DOF, the
documented fight.
### Prior art — hand snap-back safety vs a live grip
- [S] **HurricaneVR**: joints carry JointBreakForce/JointBreakTorque (default infinite) and a
stretch break (`PerpendicularThreshold`); the hand is never teleported while jointed — the
GRIP breaks instead.
- [S] **Blade & Sorcery** (BasSDK wiki, verified in `vr-grabbing.md`): hand colliders disabled
while gripping; grips break by distance/timeout.
- [S] **godot-xr-tools** (pickable.gd re-read): kinematic drive (freeze + move-to-hand each
tick) has NO joint, so a lagging hand is impossible by construction — the snap-back conflict
only exists in joint-based grabs.
- [S] **Unreal forum, "Picking up object in VR gets stuck in hand"**
(https://forums.unrealengine.com/t/picking-up-object-in-vr-gets-stuck-in-hand-when-letting-go/658445):
teleport while holding must move the held object with the rig, else yank/stuck — the joint
has to go (or move) when the reference frame jumps.
## Comparison table
| Scheme (who) | Rotation authority | Two-handed behavior | Hand-safety conflict | Usable in Box3D wrapper today |
|---|---|---|---|---|
| Two ball joints, both default (our v5) | Primary script velocity-SET + two weak 9 Hz springs (fight) | Free-ish axial DOF, anchor drift | Stuck-snap yanks joint | Yes — this is the bug list |
| Primary-owns-rotation two-joint scheme (Unity thread) | Primary joint's rotational lock | Secondary = position only | Same conflict, solved by break-first | Yes — stiffen primary spring, weaken secondary |
| Primary joint only, secondary pose-only (B&S [I]) | Fully-locked primary joint | Primary joint limits adjust | Grips break by distance | Partially (no locked joint; stiff spring approximates) |
| Kinematic centroid + relative-hand rotation (Meta ISDK) | Transformer math, no physics | Native | None (no joint) | Yes (freeze fallback) but loses weight feel |
| Box3D spherical joint full surface (cone+twist limits, motor, targetRotation) | Solver servo, torque-capped | Cone/twist limits = true weld feel | Joint break first | **No — needs a small wrapper patch** |
## Recommended architecture for VR Messs
Everything below assumes 90 Hz physics (dt ≈ 11.1 ms), Box3D backend, and no wrapper changes
unless marked Tier B. Constants are starting values for in-headset tuning, not gospel.
### (a) Two-handed constraint scheme (wrapper-level, works today)
1. **Keep two PinJoint3D hand→prop** (position coupling stays solver-owned — correct in v5).
2. **PRIMARY hand's joint: `PinJoint3D.Bias = 0.75`, `Damping = 1.0`** → its implicit
rotational spring runs at 0.75×30 = **22.5 Hz** — exactly Box3D's own 0.25×tick stability
clamp at 90 Hz; critically damped. This single change welds orientation to the primary hand
solver-side and kills the free axial rotation (the axial DOF was never truly free — it was
held by a 9 Hz default spring). Tuning range: 0.6–1.0 (never above 1.5 = 45 Hz = half tick
rate; Box3D clamps only the positional tuning, the spring hertz is unclamped in
spherical_joint.c and CAN go unstable).
3. **SECONDARY hand's joint: `Bias = 0.3` (default), `Damping = 1.0`** → 9 Hz weak spring:
contributes position + a whisper of rotation, and cannot fight the primary's 22.5 Hz spring
(the Unity-thread fight). Do NOT add a third anchor point/joint — redundant constraints in
Box3D's soft solver just leak (weld-joint note), and B&S ships two-handed without any.
4. Anchor drift between hands: leave it to the existing break logic; optionally (cheap spike)
re-capture the SECONDARY joint's anchors (destroy + recreate the joint node at the current
pose, the same configure-before-AddChild ordering as grab) if its anchor separation exceeds
~0.15 m while both hands hold. [I] optional, only if drift still reads wrong after (2).
5. **Tier B (enabling change, ask before doing): fork/PR godot-box3d to expose** on pin joints:
`b3Joint_SetConstraintTuning` (positional stiffness — fixes the 22.5 Hz/ζ2.0 sag),
`b3SphericalJoint_SetTargetRotation` + `EnableMotor`/`SetMotorVelocity`/`SetMaxMotorTorque`
(true solver-side orientation servo), and `EnableConeLimit`/`EnableTwistLimit` (true weld
feel: cone 0 + twist [0,0] on the primary joint). Each is a one-line b3 call behind the
existing joint_impl plumbing; upstream README already concedes these are unreached.
### (b) Per-tick rotation control (replaces the velocity-SET)
1. **DELETE the per-tick `body.AngularVelocity` set in PropGrabber.cs** (the spin = ... clamp +
assign block). Two authorities on one DOF (velocity override vs the joint's rotational
spring) is the documented oscillation source; the spring IS the orientation servo now.
2. **Safety clamp only**: each tick while held, if prop |angular velocity| > **30 rad/s**
(`MaxHeldSpin`), scale it to 30. This is a guard, not a controller — it should almost never
fire once (1) is gone.
3. Optional stabilization while held [I, starting values]: raise the prop's
`AngularDamp` to **2.0** on grab, restore on release. Cheap; test with and without.
4. Tier B servo (after the patch): retarget the primary joint's `targetRotation` from the
controller each tick, or enable the spherical motor with `SetMotorVelocity ≈ 15 × rotation
error (rad/s per rad)` and `SetMaxMotorTorque ≈ 8 N·m` for props ≤ 5 kg (scale ∝ mass);
keep the spring enabled at 10–20 Hz as the position term. Servo-with-torque-cap is the
Box3D manual's own recipe.
### (c) Hand safety policy while gripping
1. **While a hand holds a live grab joint, SUSPEND both safeties for that hand** in
PhysicsHands.cs: the stuck-snap (StuckDistance 0.2 m / StuckTime 1.0 s) and the
SnapDistance 2 m teleport. A joint legitimately holds the hand back — that's weight feel,
not a bug; teleporting a jointed hand out from under itself is the yank. (Prior art: HVR
and B&S break the GRIP instead of moving the hand; kinematic systems have no joint to
yank.)
2. **Grip break always runs first** and is unchanged in spirit: existing BreakDistance
0.25 m / BreakTime 0.25 s, both-hands 0.4 m. Add a hard rip: separation >
**`GripRipDistance` 0.8 m → immediate release, no timer** (analog of HVR's
PerpendicularThreshold stretch break) so a genuinely stuck hand recovers in one tick
instead of one second.
3. After release, the normal safety logic resumes next tick; the one-frame arm jump on snap
stays a known cosmetic issue (already in HANDOFF §5).
4. Do NOT teleport the prop with the hand. Move-object-with-teleport applies to kinematic
holds; here the joint must break first, then everything is ordinary again.
### (d) Box3D joint facts (evidence for the above)
- Wrapper joint classes (local .so strings + upstream `src/joints/`): Pin → b3SphericalJoint
(spring hertz = bias×30, damping = dampingRatio, spring ALWAYS on, targetRotation = grab-time
relative rotation, unreachable), Hinge (limits + motor reachable), Slider, Filter (collision
exception). Cone-twist/6DOF explicitly rejected by design; weld/motor/distance/parallel/wheel
joints exist in Box3D but have no wrapper path.
- PinJoint3D.IMPULSE_CLAMP is a warned no-op; HingeJoint3D's BIAS/LIMIT_* params are ignored.
- Positional constraint tuning: world default 60 Hz/ζ2.0, clamped to 0.25×tick (22.5 Hz at 90
Hz physics), not exposed by the wrapper — the floor under all "softness" complaints.
- Local evidence paths: `/home/foxyz/Projects/Games/vr-messs/addons/godot-box3d/bin/`
(strings of `libgodot-box3d.so`); upstream `src/joints/box3d_pin_joint_impl_3d.{hpp,cpp}`,
`src/joints/box3d_joint_impl_3d.cpp`, `src/servers/box3d_physics_server_3d.cpp`
(bearlikelion/godot-box3d v0.2.6 = main); `include/box3d/types.h`, `src/joint.c`,
`src/spherical_joint.c`, `docs/simulation.md` (erincatto/box3d main).
- Jolt-fallback caveat (existing): on Jolt, PinJoint3D reverts to vanilla origin-pinning and
bias/damping semantics — grab v5.1 is even more Box3D-specific than v5; the freeze+drive
fallback in `vr-grabbing.md` remains the lossless escape hatch.
**Order of attack for the implementing agent**: (b1)+(b2) first (delete velocity-SET, add
clamp — one file, likely fixes "out of control"), then (a2)/(a3) bias split (second file,
likely fixes "jointed-on"), then (c) safety gating (third file), then decide on Tier B only
after a playtest of those three.
---
## CORRECTION + verification (2026-10-01) — the spring's target is IDENTITY, not "grab-time relative rotation"
The original claim above — "the spring target is permanently the relative rotation at grab
time" (and in (d): "targetRotation = grab-time relative rotation") — was **WRONG**. It was an
invalid inference from the wrapper passing identity-basis frames. Re-verification against
actual source (below) shows the target is **IDENTITY**, i.e. the spring drives the two bodies
to the SAME WORLD ORIENTATION (prop springs to the hand's orientation). This exactly explains
playtest observation 1 (~180° forced rotation on grab) and reframes 2–4. Everything else in
the original sections (spring always on, hertz = bias×30, positional tuning unreachable,
wrapper API surface, hand-safety prior art) re-verified correct.
### What was verified, with quotes
**Pinned version note [S]**: godot-box3d v0.2.6 (tag `791ed422`) vendors box3d as a git
submodule pinned to commit `3fc20f5b` (2026-07-30). box3d `main` is 11 commits ahead, 0
behind; `src/spherical_joint.c` differs from main by ONE COMMENT LINE, and the `joint.c` /
`solver.h` diffs are unrelated (contact-destroy refactor, restitution). All quotes below are
identical in the pinned commit and main.
**A1. targetRotation at creation = the def default = identity; nothing re-captures the
current relative rotation.** [S]
- `src/joint.c:86-92` — `b3DefaultSphericalJointDef()`: `b3SphericalJointDef def = { 0 };`
`def.base = b3DefaultJointDef(); def.targetRotation = b3Quat_identity;`
- `src/joint.c:645` — `b3CreateSphericalJoint()` just copies it:
`joint->sphericalJoint.targetRotation = def->targetRotation;` — there is NO code anywhere
that initializes targetRotation from the bodies' current qA/qB. The ONLY recompute from
live relative rotation (`base->sphericalJoint.targetRotation = relQ;`, `joint.c:1472`)
sits inside `#if 0 ... #endif` (disabled helper `b3Joint_SetSpringRotationTarget`).
- The wrapper never calls `b3SphericalJoint_SetTargetRotation`
(`b3SphericalJoint_SetTargetRotation` exists at `src/spherical_joint.c:154-161` but has no
wrapper path), and `b3DefaultSphericalJointDef()` is what the wrapper starts from —
see B below.
- Consequence [I]: with identity local frames (wrapper), the solver's error
`relQ = b3InvMulQuat(quatA, quatB)` (where `quatA/B = body rotation × localFrame.q`,
`spherical_joint.c:452-455, 326-329`) is the pure hand↔prop relative rotation, and the
spring pulls it toward `targetRotation = identity` ⇒ **prop world orientation is driven to
the hand's world orientation**, from the first solver tick after grab. Observation 1
CONFIRMED at source level.
**A2. Spring "torque" formula: soft-constraint PD, inertia-scaled, NO torque cap.** [S]
- `src/spherical_joint.c:457-475` (`b3SolveSphericalJoint`):
```
b3Vec3 deltaRotation = b3DeltaQuatToRotation( relQ, joint->targetRotation );
b3Vec3 c = b3Neg( b3RotateVector( quatA, deltaRotation ) ); // position error (rad)
b3Vec3 bias = b3MulSV( joint->springSoftness.biasRate, c ); // velocity bias ∝ error
impulse = -massScale * rotationMass * ( wB - wA + bias ) - impulseScale * springImpulse;
```
where `rotationMass = b3InvertMatrix( invInertiaSum )` (`spherical_joint.c:362-369`) — the
combined inverse-inertia tensor, so torque auto-scales with the paired rotational inertia;
and `springSoftness = b3MakeSoft( joint->hertz, joint->dampingRatio, context->h )` (line
371). It is a PD in Box3D's soft-constraint form (stiffness via `biasRate`, damping via the
`cdot` term), NOT a literal k·θ torque — hence no explicit cap: **the rotational spring has
no max-torque clamp**; only the motor is capped (`spherical_joint.c:483-489`,
`maxImpulse = joint->maxMotorTorque * context->h`).
- Error convention: `b3DeltaQuatToRotation(q, target) = 2·vec((target − ±q)·conj(q))`
(`src/math_internal.h:221-232`) — shortest-arc (polarity-corrected) error toward `target`.
**A3. hertz = 0 (wrapper bias = 0) kills ONLY the spring; position is untouched.** [S]
- `src/solver.h:265-273` — `b3MakeSoft( hertz, zeta, h )`:
`if ( hertz == 0.0f ) return { biasRate = 0.0f, massScale = 0.0f, impulseScale = 0.0f };`
⇒ every term of the spring impulse is multiplied by 0 ⇒ **spring impulse is exactly zero**
(not merely weak — the solve block at `spherical_joint.c:458` still runs but contributes
nothing, not even angular-velocity coupling).
- The point-to-point block (`spherical_joint.c:605-647`) uses `base->constraintSoftness`
only, which `b3PrepareJoint` sets independently (`src/joint.c:1484-1486`):
`hertz = b3MinFloat( joint->constraintHertz, 0.25f * context->inv_h )` with
`b3DefaultJointDef()` defaults `constraintHertz = 60.0f; constraintDampingRatio = 2.0f;`
(`joint.c:30-31`) — completely independent of the spring's `hertz/dampingRatio`. So
**PinJoint3D.Bias = 0.0 ⇒ pure ball joint (position-only), no rotational behavior at all**.
**A4. The spring torques BOTH bodies, equal and opposite.** [S]
- `spherical_joint.c:473-474`: `wA = b3Sub( wA, b3MulMV( iA, impulse ) );`
`wB = b3Add( wB, b3MulMV( iB, impulse ) );` — the hand (dynamic) receives the reaction, and
the identity target means the hand is ALSO being pulled toward the prop's orientation. This
is the source of observation 3 ("hand can rotate around the grab joint" — visibly dragged
by the spring) and part of observation 4.
**B. Wrapper confirmation.** [S]
- `src/joints/box3d_pin_joint_impl_3d.cpp` `_create_joint_id()`:
`b3SphericalJointDef def = b3DefaultSphericalJointDef(); ... def.enableSpring = true;
def.hertz = (float)bias * 30.0f; def.dampingRatio = (float)damping;` — BIAS feeds ONLY the
spring hertz (nothing positional; the positional constraint is never touched by the
wrapper). `set_param` mirrors it live via `b3SphericalJoint_SetSpringHertz`.
- `src/servers/box3d_physics_server_3d.cpp:941-955` `_joint_make_pin()`: passes
`Transform3D(Basis(), p_local_a)` / `Transform3D(Basis(), p_local_b)` — **identity basis**
⇒ identity frame quats; Box3D then computes world frame rotations as
`transform.q × localFrame.q` at prepare (`spherical_joint.c:326-329`) and recomputes
NOTHING into targetRotation at creation. Target stays identity forever.
**Reframe of playtest observations 2–4 [I]**: a spring pulling toward the WRONG target
(identity = hand's orientation) reads as "weak hold": the 3.5 kg brick is simultaneously
sprung toward the hand's orientation (which changes as the wrist turns) and yanked by
gameplay forces, so it never settles where the player wants it. Two hands = two springs
pulling toward two different orientations (each hand's) at 9 Hz and 22.5 Hz — the lower-rate
secondary spring is simply the loser, and re-creating the primary joint at bias 0.75 cannot
help because the target is still identity and still fights the secondary's 9 Hz identity
target from a different hand pose. The original recommendation to RAISE primary bias to 0.75
would have made observation 1 WORSE (stronger snap-to-hand-orientation), not better. The
"(a2)/(a3) bias split" and "order of attack" in the original sections are retracted; (b1)
(delete velocity-SET) and (c) (safety gating) stand unchanged.
### C. Replacement design evidence — script-side orientation servo
**Godot 4.7 C# torque APIs [S]** (pinned docs `/home/foxyz/Projects/Games/.shared/godot-docs-4.7/classes/`):
- `RigidBody3D`: `ApplyTorque(Vector3)` (class_rigidbody3d.rst:886-890),
`ApplyTorqueImpulse(Vector3)` (:900-904), `AddConstantTorque(Vector3)` (:814-820) +
`ConstantTorque` property (:447-456). (The hypothesis's "RigidBody3D has no ApplyTorque"
was wrong — it has had it since 4.x.)
- `PhysicsDirectBodyState3D`: `ApplyTorque` (:521+), `ApplyTorqueImpulse`,
`InverseInertia` (Vector3, :235-243), `InverseInertiaTensor` (Basis, :251-259) — usable in
`_IntegrateForces`.
- `PhysicsServer3D`: `body_apply_torque` / `body_apply_torque_impulse` /
`body_add_constant_torque` all exist (class_physicsserver3d.rst:107-121).
**Box3D wrapper honors all of them [S]** (`bearlikelion/godot-box3d` main):
- `src/servers/box3d_physics_server_3d.hpp:131-143` overrides
`_body_apply_torque/_body_apply_torque_impulse/_body_add_constant_torque/...`;
`src/objects/box3d_body_impl_3d.cpp:325-329` `apply_torque_impulse →
b3Body_ApplyAngularImpulse(..., true)`; `:346-351` `apply_torque` accumulates
`applied_torque` and wakes the body; `:454-460` `pre_step()` applies the accumulated sum
via `b3Body_ApplyTorque(body_id, ..., false)` each physics tick (then clears it — so call
it every tick from `_IntegrateForces`/`_PhysicsProcess`).
- `src/objects/box3d_physics_direct_body_state_3d.cpp:57-70` implements
`inverse_inertia`/`inverse_inertia_tensor` (real values from b3, not stubs); `:114-127`
forwards state `ApplyTorque*` to the body.
**Prior art for the gains [S]** — David Wu's backward-PD ("Controlling Objects With Forces
and Torques", digitalopus.ca/site/pd-controllers/): gains parameterized by frequency f
("1/f seconds to ~95% convergence at damping 1") and damping d, inertia-tensor-scaled so one
gain set fits any mass:
`kp = (6f)² × 0.25 = 9f²`, `kd = 4.5·f·d`,
`g = 1/(1 + kd·dt + kp·dt²)`, `ksg = kp·g`, `kdg = (kd + kp·dt)·g`,
`torque = ksg·(axis·angle_rad) − kdg·ω`, then transformed through the inertia tensor. The
canonical community form (Unity forums/Reddit, e.g. discussions.unity.com/t/rigidbody-lookat-torque/483110)
is the same shape without inertia scaling: `torque = axis·angle·kp − ω·kd` — works only
per-object, which is why Wu's form is preferred here. Unity ConfigurableJoint practice
(docs.unity3d.com class-ConfigurableJoint) corroborates the hard part: always cap the drive
(Maximum Force), which the Box3D spring lacks and the script servo must add.
**Servo vs raising bias — the verdict [I]**: raising bias cannot be right even in principle
because the target is identity (wrong by construction) and unreachable without a wrapper
patch. The script servo can be re-targeted every tick (target = hand's CURRENT world basis,
optionally offset by the grab-pose delta), is inertia-scaled (one gain set for brick →
rifle), torque-capped, and numerically safe: with Wu's form at 90 Hz (dt = 1/90) and f = 6 Hz
the effective natural frequency 3f = 18 rad/s gives ω_n·dt ≈ 0.2 ≪ 1 — far inside the
stability Box3D's own manual demands of solver springs ("< half the frequency of the time
step"). Holding the 3.5 kg brick (principal inertias ≈ 0.004–0.015 kg·m² for a
0.2×0.1×0.05 m block) level at a 0.5 rad error needs τ ≈ I·9f²·θ ≈ 1–3 N·m — well under any
sane cap. Instability risk lives in the HAND (light body, big reaction), so the cap and the
existing hand-follow logic absorb it; this is the same authority split HVR/Unity-thread
prior art uses (one rotational authority, position stays solver-owned).
### Recommended grab v5.2 architecture (supersedes (a)/(b) rotation parts above)
1. **PinJoint3D per hand stays, but both at `Bias = 0.0`, `Damping` irrelevant** → spring
impulse is EXACTLY zero (A3) ⇒ pure ball joints: position coupling stays solver-owned,
all rotational behavior is now script-owned. This kills observations 1, 3 (spring part),
and the 4 fight in one line each.
2. **Script-side orientation servo on the prop (one authority: the primary hand)** in the
prop's `_IntegrateForces` (or a fixed-tick controller using `RigidBody3D.ApplyTorque`):
`q_err = q_hand_world × Quaternion(q_prop_world).Inverse()`; `q_err.ToAngleAxis(out a, out
axis)` (wrap a to (−π, π]); gains via Wu's backward-PD with `f = 6.0`, `damping = 1.0`,
`dt = 1/90`; torque `τ = ksg·axis·a − kdg·ω_prop` applied through the inertia tensor
(`InverseInertiaTensor`); **hard cap `|τ| ≤ τ_max`** before applying.
3. **Starting constants [I]**: `f = 6.0 Hz`, `damping = 1.0`, `τ_max = 20 N·m` for props
≤ 5 kg (scale `τ_max` ∝ mass for heavier props; the inertia scaling inside the formula
already handles shape). Keep the v5 (b2) safety spin clamp (30 rad/s). Keep prop
`AngularDamp` untouched (the kd term IS the damping; two dampers fight).
4. **Secondary hand**: joint at Bias 0 (position-only). On second grab, record
`q_offset = q_secondary_hand_world⁻¹ × q_target_world` and keep the servo target at
`q_secondary_hand_world × q_offset` while the primary holds — secondary hand drags the
servo target (Blade & Sorcery's "secondary adjusts pose", no second rotational authority).
On primary release, promote the secondary: new servo owner, new `q_offset` captured from
ITS grab pose — orientation continuity without any joint recreation semantics.
5. **(c) hand-safety policy unchanged** (suspend stuck-snap/snap-teleport while a live joint
exists; grip break first; rip threshold). Tier B (wrapper patch to expose
`SetTargetRotation`/motor/cone-twist) is now OPTIONAL polish, not the fix path — the
script servo needs no patch and is retargetable, which the joint spring is not.