30 KiB
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 frombin/libgodot-box3d.soviastrings: 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.cppdiffed IDENTICAL to main): PinJoint3D maps to a Box3D spherical joint withenableSpring = trueHARDCODED,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 hasenableSpring/hertz/dampingRatio(rotational spring towardtargetRotation, 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_pinpasses identity-basis frames and_pin_joint_set_local_a/brewrite 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):b3DefaultJointDefsetsconstraintHertz = 60,constraintDampingRatio = 2.0, andb3PrepareJointclamps 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";
FinalJointMaxAngle15° +FinalJointTimeout0.25 s (snap the last rotation error after a timeout);PerpendicularThresholdbreaks 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.AngularVelocityevery 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)
- Keep two PinJoint3D hand→prop (position coupling stays solver-owned — correct in v5).
- 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). - 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. - 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).
- 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), andEnableConeLimit/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)
- DELETE the per-tick
body.AngularVelocityset 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. - 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. - Optional stabilization while held [I, starting values]: raise the prop's
AngularDampto 2.0 on grab, restore on release. Cheap; test with and without. - Tier B servo (after the patch): retarget the primary joint's
targetRotationfrom the controller each tick, or enable the spherical motor withSetMotorVelocity ≈ 15 × rotation error (rad/s per rad)andSetMaxMotorTorque ≈ 8 N·mfor 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
- 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.)
- 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 >
GripRipDistance0.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. - 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).
- 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 oflibgodot-box3d.so); upstreamsrc/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.mdremains 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 helperb3Joint_SetSpringRotationTarget).- The wrapper never calls
b3SphericalJoint_SetTargetRotation(b3SphericalJoint_SetTargetRotationexists atsrc/spherical_joint.c:154-161but has no wrapper path), andb3DefaultSphericalJointDef()is what the wrapper starts from — see B below. - Consequence [I]: with identity local frames (wrapper), the solver's error
relQ = b3InvMulQuat(quatA, quatB)(wherequatA/B = body rotation × localFrame.q,spherical_joint.c:452-455, 326-329) is the pure hand↔prop relative rotation, and the spring pulls it towardtargetRotation = 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):whereb3Vec3 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;rotationMass = b3InvertMatrix( invInertiaSum )(spherical_joint.c:362-369) — the combined inverse-inertia tensor, so torque auto-scales with the paired rotational inertia; andspringSoftness = b3MakeSoft( joint->hertz, joint->dampingRatio, context->h )(line 371). It is a PD in Box3D's soft-constraint form (stiffness viabiasRate, damping via thecdotterm), 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 towardtarget.
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 atspherical_joint.c:458still runs but contributes nothing, not even angular-velocity coupling).- The point-to-point block (
spherical_joint.c:605-647) usesbase->constraintSoftnessonly, whichb3PrepareJointsets independently (src/joint.c:1484-1486):hertz = b3MinFloat( joint->constraintHertz, 0.25f * context->inv_h )withb3DefaultJointDef()defaultsconstraintHertz = 60.0f; constraintDampingRatio = 2.0f;(joint.c:30-31) — completely independent of the spring'shertz/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_parammirrors it live viab3SphericalJoint_SetSpringHertz.src/servers/box3d_physics_server_3d.cpp:941-955_joint_make_pin(): passesTransform3D(Basis(), p_local_a)/Transform3D(Basis(), p_local_b)— identity basis ⇒ identity frame quats; Box3D then computes world frame rotations astransform.q × localFrame.qat 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) +ConstantTorqueproperty (: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_torqueall 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-143overrides_body_apply_torque/_body_apply_torque_impulse/_body_add_constant_torque/...;src/objects/box3d_body_impl_3d.cpp:325-329apply_torque_impulse → b3Body_ApplyAngularImpulse(..., true);:346-351apply_torqueaccumulatesapplied_torqueand wakes the body;:454-460pre_step()applies the accumulated sum viab3Body_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-70implementsinverse_inertia/inverse_inertia_tensor(real values from b3, not stubs);:114-127forwards stateApplyTorque*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)
- PinJoint3D per hand stays, but both at
Bias = 0.0,Dampingirrelevant → 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. - Script-side orientation servo on the prop (one authority: the primary hand) in the
prop's
_IntegrateForces(or a fixed-tick controller usingRigidBody3D.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 withf = 6.0,damping = 1.0,dt = 1/90; torqueτ = ksg·axis·a − kdg·ω_propapplied through the inertia tensor (InverseInertiaTensor); hard cap|τ| ≤ τ_maxbefore applying. - Starting constants [I]:
f = 6.0 Hz,damping = 1.0,τ_max = 20 N·mfor 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 propAngularDampuntouched (the kd term IS the damping; two dampers fight). - Secondary hand: joint at Bias 0 (position-only). On second grab, record
q_offset = q_secondary_hand_world⁻¹ × q_target_worldand keep the servo target atq_secondary_hand_world × q_offsetwhile 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, newq_offsetcaptured from ITS grab pose — orientation continuity without any joint recreation semantics. - (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.