402 lines
30 KiB
Markdown
402 lines
30 KiB
Markdown
# 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.
|