Initial commit: VR Messs workspace (grab v5.3.1, body-physics research)

This commit is contained in:
Foxyz1302 committed 2026-10-01 21:32:13 +03:00
commit 5d2c5bf265
57 files changed
+4990

No files matched your search

+184
View File
@@ -0,0 +1,184 @@
# Research: patched godot-box3d — solver-owned rigid weld via ConeTwistJoint3D
The "Tier B" enabling change from `vr-two-handed-grab.md`, implemented and proven. The addon
at `addons/godot-box3d` (bearlikelion/godot-box3d v0.2.6) now maps **ConeTwistJoint3D →
Box3D's b3WeldJoint**, giving grab a solver-owned rigid weld (swing_span 0 + twist_span 0)
instead of the pin-joint + script-servo arrangement. Label: [S] = verified against source on
this machine, [I] = inference/judgment.
## Why the weld joint (recap from the playtest evidence)
The grab is a PinJoint3D (point constraint) + script orientation servo; the playtest read it
as "hanging an object on a winch that magically aligns it" — a ping pong ball orbits the palm
until settling, an anvil's grab surface drifts against the palm. The pose relative to the hand
must be RIGID and owned by the solver, while still loading the hand's force budget. Box3D
upstream has exactly this joint; only the wrapper path was missing.
## b3WeldJointDef — the full field list [S]
`box3d/include/box3d/types.h` (submodule, pinned commit `3fc20f5b`), verbatim:
```c
typedef struct b3WeldJointDef
{
/// Base joint definition
b3JointDef base;
/// Linear stiffness expressed as Hertz (cycles per second). Use zero for maximum stiffness.
float linearHertz;
/// Angular stiffness as Hertz (cycles per second). Use zero for maximum stiffness.
float angularHertz;
/// Linear damping ratio, non-dimensional. Use 1 for critical damping.
float linearDampingRatio;
/// Linear damping ratio, non-dimensional. Use 1 for critical damping.
float angularDampingRatio;
} b3WeldJointDef;
```
`b3DefaultWeldJointDef()` zeroes everything except the base defaults (`src/joint.c:94-99`),
i.e. **hertz 0 = maximum stiffness**. Verified behavior in `box3d/src/weld_joint.c`:
- `b3PrepareWeldJoint` (lines 125-141): `if (linearHertz == 0) linearSpring =
base->constraintSoftness` — hertz 0 falls back to the world's engine-clamped softness
(`b3PrepareJoint` clamps `constraintHertz` to **0.25 × tick rate**, the same clamp the pin
joint's positional constraint lives under). So rigid weld ≠ infinitely hard: it is the
engine's own stability-clamped stiffest, exactly the "0.25×tick clamp pattern" asked for.
- With hertz > 0 the weld becomes a soft constraint via `b3MakeSoft(hertz, zeta, h)`
(`src/solver.h:264-293`) — the softness path for "stretch before something explodes".
- The angular constraint drives `relQ` toward `targetQuat = b3Quat_identity` **relative to
the two local frames** (`b3SolveWeldJoint`, lines 223-247). Frames are computed at prepare
as `bodyRotation × localFrame` (lines 117-120), and the joint enforces zero relative motion
from creation onward — so the frames must capture the grab-time relative rotation.
- Live setters exist and warm-start-preserving: `b3WeldJoint_SetLinearHertz /
_SetAngularHertz / _SetLinearDampingRatio / _SetAngularDampingRatio` (`box3d.h:1588-1609`).
## The mapping implemented [S]
**CONE_TWIST_JOINT → b3WeldJoint.** Chosen over the b3SphericalJoint-with-limits alternative
because the weld is the engine's purpose-built "connects two bodies rigidly" and its hertz
tuning directly implements the wanted soft-rigid spectrum; the spherical route (cone 0 +
twist [0,0]) would instead repurpose limit constraints never designed as a weld and drag
along the spherical spring machinery (identity target — the v5 bug). Nonzero swing/twist
spans: the weld has NO limits, so they are **warned + treated as fully rigid**.
### Files (all in `.vendor/godot-box3d`)
| File | Change |
|---|---|
| `src/joints/box3d_weld_joint_impl_3d.hpp/.cpp` | NEW — `Box3DWeldJointImpl3D` (mirrors the pin impl): `_create_joint_id` builds the `b3WeldJointDef`; `get_type()` returns `JOINT_TYPE_CONE_TWIST`; `set_param`/`get_param` dispatch `ConeTwistJointParam` |
| `src/servers/box3d_physics_server_3d.cpp` | `_joint_make_cone_twist` now constructs `Box3DWeldJointImpl3D` (was `ERR_FAIL_MSG` "not supported"); `_cone_twist_joint_set_param/_get_param` dispatch to it; include added |
### Frame capture — the load-bearing detail [S]
Godot 4.7 core (`scene/3d/physics/joints/cone_twist_joint_3d.cpp`, `_configure_joint`)
passes `local_a = bodyA_global⁻¹ × jointGT`, `local_b = bodyB_global⁻¹ × jointGT`
(orthonormalized). Fed straight in, the weld would drive the bodies to the JOINT NODE's
relative orientation — the identity-target bug all over again. `_create_joint_id` therefore
pre-corrects frame B with the bodies' **current** relative rotation:
```c
const b3Quat quat_a = b3Body_GetRotation(p_body_a);
const b3Quat quat_b = b3Body_GetRotation(p_body_b);
const b3Quat world_frame_a = b3MulQuat(quat_a, p_local_frame_a.q);
def.base.localFrameB.q = b3InvMulQuat(quat_b, world_frame_a);
```
so the weld locks the **relative pose at creation time** no matter how the joint node was
oriented/rotated. Consequence for the C# side: the weld freezes the pose the bodies had when
the joint entered the tree — grab placement and configure-before-AddChild ordering matter
exactly as with the pin joint. Joint3D (4.7) has NO transform-notification recreation for
ConeTwist (only POST_ENTER_TREE/EXIT_TREE), so moving the joint node afterwards does nothing
— unlike PinJoint3D's every-tick anchor rewrite; a weld is NOT a hand-following pivot.
### Param mapping table [S]
| Godot ConeTwistJoint3D | default | Box3D weld | Notes |
|---|---|---|---|
| `swing_span` | 45° | warn + ignored | no limits in a weld; treated rigid |
| `twist_span` | 180° | warn + ignored | same |
| `bias` | 0.3 | linear+angular Hertz = bias × 30, live via `b3WeldJoint_Set*Hertz` | **0 = MAX stiffness** (engine-clamped world softness); larger = softer/stretchier. ×30 mirrors the wrapper's pin-joint convention (bias→spring hertz). Damping ratios fixed at 1.0 (critical) |
| `softness` | 0.8 | warn + ignored | use `bias` to soften |
| `relaxation` | 1.0 | warn + ignored | no equivalent |
| `exclude_nodes_from_collision` | true | `b3Joint_SetCollideConnected` | via base class, unchanged |
**Bias semantics inversion to remember**: for the PIN joint bias 0 = spring OFF (pure ball
joint); for the WELD bias 0 = MAX STIFFNESS (rigid). Grab tuning: rigid weld = bias 0;
"slightly soft" = small bias (0.1 → 3 Hz, 0.3 → 9 Hz, 0.5 → 15 Hz). At the default 0.3 the
weld is a 9 Hz soft weld.
Pin/hinge/slider behavior untouched (only the two cone-twist server methods changed; the old
rejection string is gone from the binary, everything else byte-identical behavior).
## Build recipe [S]
Reality differed from the original plan: the repo builds with **CMake, not scons**, and
godot-cpp is fetched by CMake (ExternalProject, pinned `godot-4.3-stable` — GDExtension ABI
is forward-compatible, this is what the shipped binary was built as too); box3d is the only
git submodule. No venv/scons needed.
```sh
cd /home/foxyz/Projects/Games/vr-messs/.vendor/godot-box3d # clone of v0.2.6, .vendor has .gdignore
git checkout v0.2.6 && git submodule update --init --recursive
cmake -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_GENERATOR=Ninja
cmake --build build --parallel 12 # ~4 min on 12 cores; godot-cpp dominates
# → bin/libgodot-box3d.so (serves BOTH linux.debug and linux.release .gdextension entries)
```
Apply the patch after a future addon update (rebase onto the new tag, resolve, re-verify):
`git apply .vendor/box3d-weld.patch` (paths are repo-relative). Then swap
`bin/libgodot-box3d.so` into `addons/godot-box3d/bin/` — stock binaries are preserved in
`addons/godot-box3d/bin-stock/` (unpatched v0.2.6 linux .so + win/mac binaries, never
touched). The `.gdextension` file resolves unchanged (same filename).
## Proof (headless, Godot 4.7.2 mono, `godot-mono --headless --path . scenes/...`) [S]
- Regression: `physics_spike.tscn` → "SPIKE: overall PASS"; `grab_spike.tscn` → "GRAB:
overall PASS" — pin/hinge/slider (and the whole v5.2 grab path) work identically on the
patched binary.
- New regression `scenes/weld_spike.tscn` + `scripts/WeldSpike.cs` (kept as the permanent
weld regression): 1 kg box + 0.05 kg box welded via ConeTwistJoint3D (swing 0, twist 0),
server joint type verified `ConeTwist`. Phase 1 rigid (bias 0): 0.5 N·m torque on A +
0.5 N force on B for 1.5 s — max relative-rotation drift **0.80°** (limit 2°), max anchor
separation **4.2 mm** (limit 10 mm), max speed 8.86 m/s. Phase 2 soft (bias 0.5 = 15 Hz),
re-welded at the current pose, sustained ±4 N pull on the 0.05 kg body (~84 m/s²):
excursion stays bounded (max 72° / 26 cm) with speeds ≤ 5.91 m/s, and after load release
it **fully recovers** in ~1 s (settle 0.41° / 2.0 mm). "WELD: overall PASS" 3× runs,
bit-identical numbers (deterministic).
- Notes from bring-up: a 15 Hz weld under that load visibly pumps (the excursion above) —
in grab terms the stretch-break logic will fire long before that; bias closer to 0 for
rigid grabs.
## Risks / limitations [S unless noted]
- A weld is NOT a hand-following pivot: moving the joint node post-creation does nothing
(no anchor rewrite like PinJoint3D). The grabber must NOT re-set its GlobalPosition every
tick expecting the pin behavior. [I] likely wants one weld per grab, created at the palm.
- Recreating the joint recaptures the pose at recreation (used by phase 2 of the spike; the
grabber's takeover can exploit this deliberately).
- Godot-side ConeTwistJoint3D docs still describe swing/twist semantics that the Box3D
backend does not implement (warned, rigid) — C# code must not read meaning into nonzero
spans on Box3D. [S] warning strings in the patch.
- The patched .so is Linux-only; the shipped Windows/macOS binaries in `bin/` (and the
stock backups) do NOT have the weld — ConeTwist on those still errors. Rebuilding those
platforms requires their toolchains (CI or mingw for Windows).
- Generic6DOFJoint3D remains rejected (unchanged).
- `.vendor/` carries `.gdignore` so the Godot importer never scans the source tree; the
patch lives at `.vendor/box3d-weld.patch`.
## C# usage sketch for the grab (orchestrator's next step) [S]
```csharp
var weld = new ConeTwistJoint3D {
ExcludeNodesFromCollision = true,
Position = palmPoint, // world pose captured; node basis irrelevant (frame normalized)
NodeA = handBody.GetPath(), // configure EVERYTHING before AddChild (same as pin)
NodeB = prop.GetPath(),
SwingSpan = 0f, TwistSpan = 0f, // rigid; nonzero only warns
Bias = grabSoftness, // 0 = rigid; 0.1–0.3 = slight give (3–9 Hz)
};
AddChild(weld);
// do NOT move weld.GlobalPosition afterwards — it is not a hand-following pivot
```
+41
View File
@@ -0,0 +1,41 @@
# Research: arm IK stability, elbow poles, torso yaw, overextension (follow-up)
Second research pass for VR Messs, after v1.1 of the placeholder rig showed: residual shaking
when the arm tugs the body near (but not at) full extension, twitchy elbows driven by wrist
rotation, and pure-HMD chest yaw. Label: [S] = sourced, [I] = inference.
See also `vr-arm-ik.md` (prior art) and `vr-grabbing.md` (grab architecture).
## 1. Full-extension elbow jitter = the classic 2-bone singularity
- [S] At cos(±1) the elbow solution is bistable and tiny target noise flips it (robotics "elbow singularity"; FINAL IK threads confirm the "elbow pops to fully straight" symptom: https://discussions.unity.com/t/final-ik-full-body-ik-aim-look-at-fabrik-ccd-ik-1-0-released/525769).
- [S] The established fix is SOFT IK: clamp the effective target distance to a fraction of chain length (recommended 0.98) and ease over the last percent of reach (MSMotion docs "Soft IK Ratio": https://maharajastudiocustomerservice-cell.github.io/MaharajaStudioWeb/ms-motion-docs.html; Blender soft-IK threads: https://blenderartists.org).
- [S] VRIK docs: elbow bones should be slightly bent in their natural bend direction — production solvers NEVER allow the perfectly-straight configuration (http://www.root-motion.com/finalikdox/html/page16.html).
- [I] Pole smoothing alone does NOT fix a singular solve — the pole is undefined exactly at full extension.
- **Recommendation applied/usable**: clamp shoulder→hand distance to reach×0.98 before the law-of-cosines solve; keep ~5–8° minimum elbow bend; slerp the elbow over 2–3 frames; hysteresis 0.97/0.99 on the straight-arm state so the transition can't oscillate.
## 2. Elbow direction (pole) heuristics
- [S] VRIK does NOT use hand rotation as the primary pole driver — it samples the avatar's start pose for the natural bend direction, then uses twist weights (`shoulderTwistWeight` etc.) and optional bend goals (docs above; Resonite VRIK mirror: https://wiki.resonite.com/Component:VRIK).
- [S] Community practice for aiming: a bend goal at the elbow's down/back/outward position as a SOFT bias (Unreal forums on driving the middle joint from hand orientation: https://forums.unrealengine.com).
- [I] Controller-rotation-driven poles (ours) are common in physics-hands recreations but are the twitchiest variant; Boneworks-style arms derive elbow direction mostly from body-relative geometry.
- **Recommendation**: build the pole in a stable torso frame ("down + back + outward"), PROJECT OUT the shoulder→hand axis so wrist spin can't rotate it, add hand-forward only as a small weight (~0.2–0.3) that ramps in when aiming, and exponentially smooth the final pole; never let a blend weight cross zero (flip risk).
## 3. Chest/torso yaw from HMD + hands
- [S] Academic prior art: Ponton et al. 2022, "Combining Motion Matching and Orientation Prediction to Animate Avatars for Consumer-Grade VR Devices" (https://arxiv.org/abs/2209.11478) — HMD-forward alone has 14.5° mean body-orientation error vs 5.4° for a learned predictor; AvatarPoser (https://arxiv.org/abs/2107.04044), HMD-NeMo (https://arxiv.org/html/2308.11261v1), MirrorPoser are the NN lineage.
- [S] Classic game heuristic: torso yaw from the controller-to-controller line blended with HMD yaw (https://forums.unrealengine.com).
- [S] Ponton et al. smooth predicted orientation with a spring-damper/slerp and DECOUPLE orientation from the head (never hard-blended against head yaw).
- **Recommendation (non-NN)**: yaw = HMD yaw base blended toward a hands-derived yaw with weight proportional to how far forward/raised the hands are (0 idle → ~0.6 aiming), applied through a critically damped spring with ~150–250 ms response.
## 4. Overextension with physics hands
- [S] VRIF-style frameworks render an "arm stretch" but clamp within a limit; soft IK is the standard clamp (VRIF: https://discussions.unity.com; MSMotion above).
- [S] Boneworks is fully physics-driven: at max reach the arm simply straightens and the whole physics body is pulled/leaning rather than the arm stretching (GDC coverage: https://www.gamedeveloper.com/design/how-classical-game-mechanics-and-physics-converge-in-vr-hit-i-boneworks-i/; https://www.reddit.com/r/StressLevelZero/comments/frucx5/).
- [I] No documented game stretches the visual arm beyond bone length — everyone clamps the target to a sphere around the shoulder; the physics hand continuing past reach reads fine because the straight arm still points at it.
- **Recommendation**: clamp the VISUAL hand target to a sphere around the shoulder (~98% reach, soft-eased) while the physics hand body keeps moving freely; the reach-budget torso shift covers the remainder.
## Applied to VR Messs (BodyRig.cs)
Soft IK factor 0.98; pole projected in torso frame with hand weight 0.25 and exponential
smoothing; torso yaw spring (HMD base + hands-derived yaw × forwardness, exp smoothing ~12/s);
reach-budget hysteresis unchanged. Next dial if shaking persists: SoftIKFactor → 0.95.
+33
View File
@@ -0,0 +1,33 @@
# Research: VR arm IK approaches (for physics-hands rigs)
Researched by an agent for VR Messs (Godot 4.7, C#, physics hands = force-clamped rigid palm
bodies chasing controllers). Superseded by nothing; see also `vr-arm-ik-deep.md` for the
follow-up on stability and torso yaw. Label: [S] = sourced, [I] = inference.
## Prior art with physics hands
- [S] Boneworks/Bonelab (GDC 2020 talk: https://www.gamedeveloper.com/design/how-classical-game-mechanics-and-physics-converge-in-vr-hit-i-boneworks-i/, Marrow wiki: https://boneworks.fandom.com/wiki/1Marrow_Interaction_Engine): the whole virtual body is a physics object; arms are 2-bone chains driven by constraints toward controller targets with a clamped force/velocity ("heuristic force limit") — when the force budget is exceeded the BODY moves, not the hand. Internal solver details are not public beyond this.
- [S] HL:Alyx shipped HANDS ONLY, deliberately (Valve dev commentary: https://combineoverwiki.net/wiki/Developer_commentary/Half-Life:_Alyx): IK arms mislead because there is no proprioception for elbow/shoulder. "Alyx-style arm IK" is not a Valve technique.
- [S] Final IK VRIK (docs: http://www.root-motion.com/finalikdox/html/page16.html): analytic 2-bone IK per limb; elbow direction is UNDERDETERMINED from HMD+hands alone — bend direction is sampled from the calibrated start pose + shoulder orientation, plus twist weights and optional explicit bend goals. Rated best-in-class for 3-point body estimation (ISM 2021: https://www.computer.org/csdl/proceedings-article/ism/2021/373400a123/1A3j8qqoU8g).
## Shoulder / torso handling
- [S] Prior art FOR "idle shoulders, reach budget pulls the spine": VRIK's spine solver stage pulls the root toward hands past a reach threshold; VRChat clamped overshooting arms at arm length rather than rotating shoulders (https://docs.vrchat.com/docs/vrchat-202221); Ponton et al. 2023 self-avatar paper covers torso compensation (https://www.cs.upc.edu/~npelechano/VR2023_fittedAvatars.pdf).
- [S] Pitfalls: wrong shoulder width/height → permanent elbow strain (VRChat calibration issues); pull torso TRANSLATIONALLY first, lean second (rotation reads as drunkenness).
- [I] The Go-Go arm-extension alternative (stretch arms instead of leaning) is rejected for physics-hands setups — breaks hand-body consistency.
## Godot 4.7 API facts (verified against docs.godotengine.org/en/4.7)
- [S] `SkeletonIK3D` still exists but is DEPRECATED (https://docs.godotengine.org/en/4.7/classes/class_skeletonik3d.html).
- [S] The modern system: `SkeletonModifier3D` base class — override `_process_modification_with_delta(delta)` (the `_process_modification()` variant is deprecated; `influence` auto-blends, write full-strength poses) (https://docs.godotengine.org/en/4.7/classes/class_skeletonmodifier3d.html).
- [S] Built-in modifiers: `ChainIK3D` → `IterateIK3D` → `CCDIK3D` / `FABRIK3D` / `JacobianIK3D` (https://docs.godotengine.org/en/4.7/classes/class_chainik3d.html), and `LookAtModifier3D` for single-bone look-at with angle limits and damping, stackable neck-under-head (https://docs.godotengine.org/en/4.7/classes/class_lookatmodifier3d.html).
- [S] No built-in analytic 2-bone solver with a pole target (ChainIK3D ignores twisting). [I] Write our own law-of-cosines 2-bone solve in a SkeletonModifier3D subclass.
- [S] Reference implementations: Godot-Dojo XR-Avatar (GDScript, deprecated: https://github.com/Godot-Dojo/Deprecated-Godot-XR-Avatar), godot-xr-tools (rig, no real IK: https://github.com/GodotVR/godot-xr-tools).
## Head/neck pattern
- [S] Point the head bone directly at the HMD; distribute residual rotation into the neck at lower weight (30–50%) with clamped pitch/yaw; `LookAtModifier3D` provides limits + damping natively; stack the neck modifier ABOVE the head modifier. [I] The exact weights are taste.
## Chosen architecture for VR Messs
- Kinematic spine + analytic 2-bone arms in a custom SkeletonModifier3D (when a real skeleton exists; placeholder uses raw transforms) + existing physics hands. Matches Boneworks' observable behavior without simulating the skeleton. Full-physics-body = highest fidelity but highest risk; pure IK-over-animation = where the jank lives.
+69
View File
@@ -0,0 +1,69 @@
# Research: body physics on Godot 4.7 + Box3D — verified capabilities
Companion to `vr-body-physics-hugs.md` (prior art + recommendation). This file grounds that
recommendation in what the LOCAL stack actually supports. Everything [S] was verified by the
orchestrator directly against `.vendor/godot-box3d/` source and the pinned docs at
`/home/foxyz/Projects/Games/.shared/godot-docs-4.7/`. Researched 2026-10-01.
## 1. Joint inventory on Box3D (v0.2.6, verified in source)
- [S] Implemented joint types (`src/joints/`, dispatch in
`src/servers/box3d_physics_server_3d.cpp`): **pin** (`_joint_make_pin`, line 942),
**hinge** (`_joint_make_hinge`, line 1003), **slider** (line 1068), **cone-twist**
(line 1100 — LOCALLY PATCHED to b3WeldJoint, see `godot-box3d-weld.md`).
- [S] **Generic6DOFJoint3D is NOT supported** — `_joint_make_generic_6dof` (line 1126) is a
hard `ERR_FAIL_MSG("Box3D: Generic6DOFJoint3D is not supported; use PinJoint3D, HingeJoint3D,
or SliderJoint3D instead.")`. Any design must not rely on 6DOF drives.
- [S] **Hinge motors WORK** (`src/joints/box3d_hinge_joint_impl_3d.cpp`): `HINGE_JOINT_FLAG_ENABLE_MOTOR`
→ `b3RevoluteJoint_EnableMotor`; params `HINGE_JOINT_MOTOR_TARGET_VELOCITY` +
`HINGE_JOINT_MOTOR_MAX_IMPULSE` → `b3RevoluteJoint_SetMotorSpeed/SetMaxMotorTorque` (lines 22–24,
128–131). It is a VELOCITY motor with a torque clamp — position control = per-tick PD:
`targetVelocity = (targetAngle − angle) × gain`, clamped, with `maxImpulse` as the force budget.
This is the Box3D primitive for active-ragdoll limb drives (phase 2 of the hugs design).
- [S] Body modes exist (`_body_set_mode`, line 448); constant forces/torques work
(`_body_add_constant_torque` line 749) — already proven in production by PhysicsHands'
90 N chase (RigidBody3D, mass 0.4, gravity off, CCD, `ApplyTorque` verified earlier).
## 2. Godot 4.7 skeleton/modifier APIs (pinned docs, `classes/*.rst`)
- [S] Available and current: `SkeletonModifier3D` (base, `_process_modification_with_delta`),
`IKModifier3D`, `LookAtModifier3D`, `CopyTransformModifier3D`, `ConvertTransformModifier3D`,
`ModifierBoneTarget3D`, `LimitAngularVelocityModifier3D`, `RetargetModifier3D`,
`BoneAttachment3D`, `PhysicalBone3D` + `PhysicalBoneSimulator3D`, `SkeletonProfileHumanoid`.
- [S] `SkeletonIK3D` remains deprecated (known trap, HANDOFF §2). `XRBodyModifier3D` and
`XRFaceModifier3D` exist (OpenXR body/face tracking — future spike material).
- [I] For the placeholder rig (no Skeleton3D), none of these apply yet — the plan keeps the
procedural `BodyRig` boxes and adds physics BODIES that the boxes follow (CopyTransform-style
reads in `_Process`), then re-home the same chase bodies onto a real skeleton later via
`BoneAttachment3D`/`SkeletonModifier3D`. Migration path stays intact.
## 3. Drive patterns that work on this stack
- [S] **PD-driven rigid follow is PROVEN on Box3D at 90 Hz** — PhysicsHands is exactly that
(desired velocity `(target−pos)/dt`, force-clamped to 90 N). A heavier torso body is the same
pattern with a larger mass and larger budget; the 0.4 kg hands' stability across grab v5's
saga is the empirical evidence the pattern holds.
- [S] Kinematic drive + physical reaction alternative: `AnimatableBody3D` pushes dynamics in
Godot; but a kinematic chest is INFINITE mass — it would shove NPC bodies without ever being
resisted, breaking the mutual-force requirement from `vr-body-physics-hugs.md` §4. [I] Rejected
for the chest; fine for the WorldPanel.
- [I] No built-in "spring to target pose" body/joint in Godot or Box3D — confirmed absence;
every drive is hand-rolled PD (hands already do it; hinge motor is the solver-native version).
## 4. Collision strategy
- [S] Layers/masks live on `CollisionObject3D` (standing trap). Current use: 1 = world, 2 = UI;
hands layer 1/mask 1. Proposal: 3 = player body (torso + future arm segments), torso mask = 1|3
(world + itself-if-needed), hands mask 1|3 so palms can rest on the own chest; exclude
torso↔own-forearm pairs later via joint `ExcludeNodesFromCollision` equivalents — Box3D has no
pair-exclusion API [I, verify when needed]; simplest is disjoint masks per limb until proven
otherwise.
## 5. Recommendation for VR Messs
Follow `vr-body-physics-hugs.md` §5. Verifications add two facts to it:
- Phase 2 limb drives are buildable TODAY on the patched addon: hinge velocity motors + per-tick
PD toward the analytic IK angles (no Generic6DOF, no 6DOF drives — design accordingly).
- Phase 1 needs NO new addon work: torso = RigidBody3D chase-driven (hands pattern, bigger mass),
arms stay analytic IK, palms + torso give mutual contact. Sickness guard: camera stays
kinematic; torso chases the HMD, never welds to it; cap tug coupling below the chase budget.
+157
View File
@@ -0,0 +1,157 @@
# Research: physics player bodies + hugging/contact in VR
Fourth research pass for VR Messs (Godot 4.7, C#, physics hands = force-clamped rigid palms
chasing controllers at 90 N; placeholder box rig = procedural torso + 2-bone analytic arms,
no Skeleton3D yet; Box3D physics server, joints: pin/hinge/slider/cone-twist→weld-patched).
Goal: player arms+chest react physically — hugging characters, body reacting when grabbed.
Label: [S] = sourced, [I] = inference. See also `vr-arm-ik.md`, `vr-arm-ik-deep.md`,
`vr-grabbing.md`, `vr-two-handed-grab.md`.
## 1. Prior art — player body physics in VR
- [S] **Boneworks/Bonelab** (GDC 2020 "Heuristic Based Approaches to Physics Systems"; coverage:
https://www.gamedeveloper.com/design/how-classical-game-mechanics-and-physics-converge-in-vr-hit-i-boneworks-i/,
Marrow wiki: https://boneworks.fandom.com/wiki/1Marrow_Interaction_Engine): the ENTIRE virtual
body is one physics object, 82 kg, "no infinite forces" — every interaction draws on the body's
force budget; jumping = physically thrusting feet down; exploits die because classical mechanics
is the ruleset. Arms = 2-bone chains driven by constraints toward controller targets with a
heuristic force/velocity limit; over budget → the BODY moves, not the hand. Internal solver
details beyond that are not public. Motion sickness is the accepted cost: players report it as
one of the most nauseating VR games because the physics body can shove the camera off the real
head pose (https://www.resetera.com/threads/highly-anticipated-vr-title-boneworks-is-a-collection-of-vr-sins.158449/page-5).
- [S] **HL:Alyx** shipped HANDS ONLY, deliberately — IK arms mislead because there is no
proprioception for elbow/shoulder (dev commentary:
https://combineoverwiki.net/wiki/Developer_commentary/Half-Life:_Alyx). The strongest evidence
that "collision-enabled limbs" were considered not worth it.
- [S] **Stormland** (Insomniac): fully ANIMATED rendered arms + hip/shoulder holsters that ground
body awareness; body position handled by "invisible logic" for player intent, not a simulated
chain (https://roadtovr.com/stormland-behind-the-scenes-insomniac-games/2/). Kinematic body,
fake arms — feels great, can't hug anyone.
- [S] **Vertigo 2**: solo dev moved to a full physics-driven first-person body + hand physics and
committed to "full physics going forward" (devblog:
https://www.reddit.com/r/ValveIndex/comments/d48pex/vertigo_2_devblog_new_first_person_body_hand);
no postmortem numbers published. Proof one person can ship it in a standard engine.
- [S] **Open-source / community implementations**: Valem's Boneworks-style full-body-physics Unity
tutorial series (https://www.youtube.com/watch?v=D9qQb3EvU8c — rigidbody torso + jointed limbs
chasing trackers); RealMotionVR physics player controller
(https://github.com/pierricklyons/real-motion-vr); community full-body-physics WIPs with the
recurring pattern "rigidbody hands diverge from ghost hands → haptic + visual snap-back"
(https://www.reddit.com/r/Unity3D/comments/9sjsm9/vr_fullbody_physics_because_im_tired_of_reaching/,
https://www.reddit.com/r/ValveIndex/comments/ircdm3/wip_full_body_physics_high_fidelity_hands_in).
[I] No repo named "Hurdy" surfaced in any search — likely misremembered.
- [I] Pattern across all of them: simulate at most ONE powered body (the chest/torso root) driven
toward the HMD, and let limbs be either kinematic (Stormland/Alyx) or weakly-driven physics
chains (Boneworks). Nobody ships a fully self-colliding humanoid player rig — self-collision +
comfort + tuning cost is why.
## 2. Active ragdoll / puppet techniques (how pose-tracking-on-physics is done)
- [S] **Canonical recipe** (Gang Beasts-style; https://www.reddit.com/r/Unity3D/comments/3v6kt8/does_anyone_know_how_gang_beasts_physics_are):
semi-procedural blend of keyframed pose-matching + active ragdoll. Implementation lineage:
- Joint drive to animated "master" pose: duplicate physical body, each ConfigurableJoint's
angular drive target-rotation = the animated counterpart's rotation
(https://github.com/sergioabreu-g/active-ragdolls).
- PD control per joint: proportional-derivative torques toward target pose — the standard
formulation (EggyStudio Unity.Humanoid.ActiveRagdoll, "follows an animation using a simple
PD controller" with configurable joints, weakening drives on collision:
https://github.com/CanTalat-Yakan/Unity.Humanoid.ActiveRagdoll).
- Research tier: DReCon (Bergamin et al. 2019, ACM TOG) = motion matching picks the target
pose, RL-trained policy applies PD torques + root drive for balance
(https://www.researchgate.net/publication/337117918_DReCon_data-driven_responsive_control_of_physics-based_characters);
SIMBICON-style feedback and stepping controllers for balance
(https://arxiv.org/html/2210.14730v1; survey: Geijtenbeek et al. 2012,
https://onlinelibrary.wiley.com/doi/abs/10.1111/j.1467-8659.2012.03189.x).
- Balance practicals: lock hips X/Z rotation so the ragdoll can't fall while still being pushed
(https://sergioabreu-g.medium.com/how-to-make-active-ragdolls-in-unity-35347dcb952d), or apply
an external upright torque/com-height controller — joint motors alone don't balance
(https://medium.com/@jacasch/balancing-of-active-ragdolls-in-games-367f146b25fb).
- Practical tips for making them look alive, not spastic:
https://www.gamedeveloper.com/design/8-tips-for-animating-active-ragdolls.
- [S] **Human Fall Flat** never published a technique talk (closest is design retrospectives);
the joint-motor active-ragdoll recipe above is the community-reconstructed equivalent
(https://www.reddit.com/r/godot/comments/1fnwu2v/how_to_make_an_active_ragdoll_using_joint_motors).
- [I] **Failure modes** (consistent across sources): too-high drive gains inject energy →
jitter/explosions at solver limits; too-low → mush that can't hold a pose; the balance layer is
the whole difficulty — pose tracking is easy, staying upright while pushed is not. Gain tuning
is per-body and iterative (tens of iterations), and (per wirewhiz Alyx-hands notes in
`vr-grabbing.md`) "overly aggressive joint values destabilize the sim" is a physics-server-level
truth, not engine-specific.
## 3. Hugging / close contact with characters in VR
- [S] **In-game examples are almost nonexistent.** The closest known artifact is a Unity dev
clip "Added arm physics so now you can hug stuff in VR!" — physics-driven arms colliding with
bodies produce a passable hug for free (https://www.reddit.com/r/Unity3D/comments/jnfau2/added_arm_physics_so_now_you_can_hug_stuff_in_vr);
"The Hug Game" makes the other player's arms the hug implement
(https://automaton-media.com/en/news/20220225-10037). No shipped AAA VR game has a simulated
embrace — the user's "not even Bonelab had it" observation is correct.
- [S] **VRChat avatar "hugging"** is theater, not physics: PhysBones are spring chains for
secondary motion that can collide but carry no character-to-character force exchange
(https://creators.vrchat.com/common-components/physbones); Contacts are overlap event triggers —
sender touches receiver → animator parameter — auto-placed on hand/finger colliders
(https://creators.vrchat.com/common-components/contacts). So VRChat hugs = arms clip through +
contact event plays an animation. It still sells, which is evidence contact EVENTS + reaction
matter more than force fidelity.
- [S] **HCI/haptics research on hugs** (what makes a hug read as a hug):
Hugvie — a hug-shaped pillow that improves conversation affect, i.e. the SHAPE of contact does
the work (https://eng.irl.sys.es.osaka-u.ac.jp/projects/hugvie; study:
https://www.semanticscholar.org/paper/3a11fff7f3945f1a103ef233aba2d46de1122f35);
HuggieBot — established design tenets of natural robotic hugging, haptic REACTIVITY (responding
during the hug) significantly improves perception (https://arxiv.org/html/2101.07679v1,
https://is.mpg.de/hi/projects/huggiebot-evolution-of-an-interactive-hugging-robot-with-visual-and-haptic-perception);
intra-hug gestures the huggee should respond to: holding, rubbing, patting, squeezing
(https://dl.acm.org/doi/fullHtml/10.1145/3526110);
"Anatomy of a Hug in Virtual Reality" (Deusdado & Antunes, Procedia CS 239, 2024) — hug =
wrap + pressure + duration, haptic presence over fidelity
(https://www.sciencedirect.com/science/article/pii/S187705092401528X).
- [I] Synthesis: a hug reads correctly when (1) your arms VISIBLY wrap (no clipping, no
IK explosion), (2) contact is mutual — you can push and feel resistance, (3) the other party
RESPONDS (pressure → squeeze-back, pat → shift). Force simulation provides 1+2 cheaply; 3 is
gameplay code reading contact events.
## 4. What sells contact — full sim vs pseudo-physics
- [S] Boneworks' GDC claim in essence: physics contact sells because forces are CONSISTENT
(one 82 kg budget, no infinite forces) — not because bones are individually simulated.
- [S] Alyx cut arm physics and shipped the best-feeling hands of its generation; Stormland faked
arms entirely and scored on body presence. Both prove partial approaches can carry immersion.
- [S] But neither can HUG: pseudo-physics limbs clip through characters by design, and clip = no
hug, no lean, no tug. Boneworks CAN be leaned on things and shove bodies with its chest.
- [I] Decision rule: pseudo-physics (kinematic torso collider + IK arms) is enough for
self-presence, NOT enough for contact mechanics. The minimum real-physics set for hugging is
exactly: one physics torso/chest collider + two physical arm segments (we already have palms)
with weak pose drives — i.e. an active-ragdoll-torso, not a full skeleton sim.
## 5. Recommended approach for VR Messs
Constraints: Box3D (pin/hinge/slider/weld-patched-cone-twist), placeholder box rig, no
Skeleton3D, physics hands exist. [I] throughout this section unless noted.
1. **Torso = one RigidBody3D (capsule/box), PD-driven toward an HMD-derived target** — position
from HMD, yaw from the torso-yaw spring already specced in `vr-arm-ik-deep.md`. Force-CLAMPED
chase (same 90 N-class budget pattern as the palms). This body grounds all contact: hug
pressure, tug response, door pushes. DO NOT weld the HMD to it — camera stays kinematic
(Boneworks' sickness lesson, §1); torso chases the camera, never the reverse.
2. **Arms phase 1 = visual-only 2-bone analytic IK (existing) + existing palm bodies.** Chest
contact comes from the torso body; arms don't need physics to hug at chest height. Palms
colliding with the NPC's body already provide "arms resist contact" below the face.
3. **Arms phase 2 (only if phase 1 reads as clipping) = active-ragdoll upper-arm/forearm
segments**: rigidbodies per segment, hinge joints, hinge MOTOR targets = the analytic IK
solution's angles, low gain + velocity cap (§2 recipe, adapted to Box3D's hinge motors).
Joint motors beat per-frame force application — let the solver own it (same lesson as
`vr-grabbing.md`). Skip self-collision entirely (torso↔arm excluded pairs); nobody notices.
4. **Hug detection = contact events, not force analysis**: both palms (or forearm segments) in
contact with NPC torso + player chest collider touching NPC torso + sustained ≥0.5 s →
"embracing" state; read total contact impulse for a pressure value. NPC response = squeeze
force ramp on the player torso body + reaction gestures (HuggieBot reactivity finding, §3).
Optional: fade the visual arms into a scripted wrap pose during the state — VRChat proves
players forgive clipped/faked hugs if the reaction is right.
5. **Grabbed/tugged**: free — any joint from an NPC/prop to the torso body makes the whole body
react, because the torso is now the anchored mass the palms were implicitly missing. Cap the
coupling force below the torso chase budget so tugs can't yank the camera frame (sickness).
6. **Box3D-specific risks**: [I] cone-twist is weld-patched → use hinge+motor for limb drives and
pin for torso coupling; verify hinge motor max-impulse behavior at 90 Hz before building on
it (the `godot-box3d-weld.md` patch is the precedent for upstreaming what's missing).
7. **Explicitly rejected**: full per-bone simulated player skeleton (§1 — nobody ships it;
comfort + tuning cost), VRChat-style event-only hugs (no mutual force = no resistance = hollow),
Go-Go stretch arms (rejected before in `vr-arm-ik.md`).
+38
View File
@@ -0,0 +1,38 @@
# Research: grabbing/picking up physics objects in VR (for physics-hands rigs)
Third research pass for VR Messs, after script-level grab welds (PD force weld, then
rigid-follow-with-yield) proved unstable: launched objects, hand drift, limit cycles.
Label: [S] = sourced, [I] = inference. Companion to `vr-arm-ik.md` / `vr-arm-ik-deep.md`.
## Sources verified (primary where possible)
- [S] **godot-xr-tools** — actual source read (pickable.gd, grab_driver.gd, grab_point_hand.gd, master: https://github.com/GodotVR/godot-xr-tools/blob/master/addons/godot-xr-tools/objects/pickable.gd): pickables are RigidBody3Ds that get `freeze = true` on grab; a GrabDriver node kinematically drives the frozen body to the hand's grab point per physics tick; on release, freeze is restored and `linear_velocity` is set for the throw. Second-hand enum IGNORE/SWAP/SECOND — in SECOND mode both grab points drive, blended by each point's `drive_position`. NO JOINTS anywhere.
- [S] **Unity XR Interaction Toolkit** (https://docs.unity3d.com/Packages/com.unity.xr.interaction.toolkit@3.1/manual/xr-grab-interactable.html): three movement types — VelocityTracking (sets velocity/angular velocity per FixedUpdate; best collision fidelity + weight feel, "can appear to lag"), Kinematic (move-toward), Instantaneous (teleport; clips). Two-handed = multiple grab points + grab transformers. Zero joints.
- [S] **BNG/VRIF** (https://wiki.beardedninjagames.com/en/Core/Grabbable): GrabPhysics = Physics Joint (ConfigurableJoint grabber→grabbable, rigidity dynamically retuned by collisions), Kinematic, Velocity, or None; Break Distance for force-release; secondary grab "DualGrab currently unsupported" — single primary hand only.
- [S] **Blade & Sorcery** (BasSDK wiki: https://kospy.github.io/BasSDK/Components/ThunderRoad/Items/Handle.html): items stay DYNAMIC rigidbodies; Handle defines grab axis/radius; `twoHandedRequireSamePhysicbody` — dual grip = both hands gripping handles on the SAME body; hand colliders disabled while gripping. [I] Joint type per grip (decompiled ThunderRoad).
- [S] **Boneworks/Bonelab** (MarrowSDK: https://github.com/StressLevelZero/MarrowSDK; GDC: https://www.gamedeveloper.com/design/how-classical-game-mechanics-and-physics-converge-in-vr-hit-i-boneworks-i/): hands are rigidbodies force-driven to controllers; grab = JOINT from hand body to object at the grab point → mass "tug of war"; philosophy: "no infinite force", the 82 kg body grounds all force budgets.
- [S] **HL:Alyx** (dev commentary: https://combineoverwiki.net/wiki/Developer_commentary/Half-Life:_Alyx): two-handed weapons were CUT because "the object itself provides a physical connection between the two hands" and VR couldn't fake it. [I] Attachment = Source 2 constraint (undocumented).
- [I] **H3VR**: no public implementation writeup (interview: https://voicesofvr.com/1717-h3vr-10-year-retrospective-with-anton-hand-origins-design-evolution-of-hot-dogs-horseshoes-hand-grenades/); community consensus says spring/joint attach.
- [I] **Auto Hand**: physics-based, two-handed grabs, break events (https://assetstore.unity.com/packages/tools/game-toolkits/auto-hand-vr-interaction-165323); joint details not public.
- [S] **Instability prior art**: ODE manual on joint motors with force limits — letting the SOLVER integrate beats per-step velocity override, which overshoots/oscillates (https://ode.org/wiki/index.php); wirewhiz Alyx-hands writeup: joint-driven hands at 90 Hz, "greatly increased stability" from a hand-root collider, overly aggressive joint values destabilize the sim (https://wirewhiz.com/making-half-life-alyx-physics-hands-in-unity/).
## Taxonomy
| Approach (who uses it) | Stability | Weight feel | Two hands | Cost in Box3D |
|---|---|---|---|---|
| Freeze + kinematic drive (xr-tools, XRIT Kinematic) | High (boring-safe) | None real; faked via clamps | Blend two drivers; source-verified | Low |
| Velocity-tracking hold (XRIT VelocityTracking, VRIF Velocity) | Medium — lag/jitter documented | Good (real mass resists) | Weak; lag reported | Low — **but this was our unstable design** |
| Joint hand→object, force-limited hand (Boneworks, B&S, VRIF Physics Joint, H3VR [I]) | High IF the solver owns the coupling | Best: hand lags under load, the hand's force budget does the work | Second joint to the same body (B&S model), or single-hand-only (VRIF) | Medium |
| Rigid weld/parenting (Alyx-style) | High but fake | Tuned by illusions | Cut by Valve for good reason | High (needs 6DOF) |
## Recommended architecture for VR Messs
1. [S-backed] **Position coupling: PinJoint3D hand→object at the grab point.** Stop setting object velocity — velocity override + reaction force = two controllers fighting one integrator (the limit cycle we hit). Let the engine's joint solver own both bodies (ODE/force-limit literature; matches Boneworks/B&S/VRIF). One authority, no fight.
2. [I] **Rotation: clamped angular spring** on the held object toward the hand's grip orientation (angular-velocity chase toward the basis delta, capped ~20 rad/s), owned by the PRIMARY hand only. With only Pin/Hinge/Slider, the pin gives position for free; the clamped spring supplies rigid-grip feel without 6DOF.
3. [S] **Weight feel = the existing hand force budget.** Don't clamp object mass; heavy objects drag the hand through the joint (Boneworks tug-of-war). Optionally scale the rotational spring by mass (nose-heavy guns).
4. [S] **Grip break**: Godot joints have no break force — monitor the joint anchor separation; sustained error over N ticks = grip ripped away (same effect as VRIF Break Distance, manually).
5. [S] **Two hands: BOTH hands PinJoint to the same object** (B&S `twoHandedRequireSamePhysicbody` model). Primary hand owns the rotation spring; secondary adds position only. Do NOT connect the hands through the object — Valve cut that.
6. [S] **Fallback** if Box3D's pin joint proves too soft/unstable: the xr-tools pattern — freeze the held body, drive it kinematically to the hand each tick, restore velocity on release, blend second-hand drivers. Zero instability, weight feel becomes theater.
Note: our physics hands already chase with forces, so the joint approach slots straight in —
the hand body IS the Boneworks setup; only the grab coupling was wrong.
+401
View File
@@ -0,0 +1,401 @@
# 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.