Initial commit: VR Messs workspace (grab v5.3.1, body-physics research)
This commit is contained in:
commit
5d2c5bf265
57 files changed
+4990
No files matched your search
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user