Files
VR-Mess/research/godot-box3d-6dof.md

8.0 KiB
Raw Permalink Blame History

Addon patch 2: Generic6DOFJoint3D on Box3D (2026-10-02)

Second local patch to the godot-box3d fork (after the ConeTwist→weld patch, godot-box3d-weld.md). Purpose: the joint primitives for the Project-TX-style muscle ragdoll (nimso-project-tx.md, vr-physics-rig-hexabody.md §4).

  • Fork branch generic-6dof-joint (on top of rigid-weld-joint) in .vendor/godot-box3d, commit "Expose Box3D joints through Godot's Generic6DOFJoint3D"; patch file .vendor/box3d-generic-6dof.patch. NOT pushed to github.com/Foxyz1302/godot-box3d yet (owner decision). Linux binary only (same rebuild recipe as the weld patch: cmake --build build --parallel 12 in .vendor/godot-box3d, copy bin/libgodot-box3d.so to addons/godot-box3d/bin/; stock binaries stay in addons/godot-box3d/bin-stock/).
  • Files: src/joints/box3d_generic_6dof_joint_impl_3d.{hpp,cpp} (new), src/servers/box3d_physics_server_3d.cpp (_joint_make_generic_6dof + param/flag get/set dispatch; was a hard error).

Mapping (configuration-based — Box3D has no general 6-DOF constraint)

Godot configuration Box3D joint Notes
all 3 linear axes locked (limit on, lower ≥ upper, no linear spring/motor), angular not fully locked or angular motor/spring on b3SphericalJoint = BALL joint X = twist axis (angular X limits = twist range); swing cone symmetric, half-angle = largest |limit| of angular Y/Z (warned if Y/Z differ); angular spring → rotation spring to the angular equilibrium (Euler, Godot default YXZ); angular motor = torque-capped velocity motor
everything locked, no angular motor/spring (the Godot default) b3WeldJoint = WELD rigid at the creation pose
any linear axis free/springy/motored b3MotorJoint = DRIVE springs pull frame B onto frame A shifted by the equilibrium points (linear + Euler); force/torque caps = Linear/Angular MotorForceLimit (0 = uncapped); motor target velocities add a velocity drive; linear limits NOT enforced (warned); angular limits enforced (patch 3, below) once any angular axis has a real range
  • Units differ from Godot/Jolt: spring STIFFNESS = frequency in Hz, DAMPING = damping ratio (Box3D soft constraints are mass-independent). Choose ~5–15 Hz, ratio ~1.
  • Motor target velocities are per joint axis in joint frame A; the impl converts to Box3D's world-space relative angular/linear velocity at set time using body A's current rotation → set them EVERY TICK (they are a PD output).
  • Flags and limits rebuild the Box3D joint (warm start lost — set them once); motor velocities, force limits, springs and equilibrium points apply live. Configure before AddChild (as with every joint here).
  • Bullet-era params (limit softness/restitution/damping, ERP, angular force limit) are ignored.

Muscle recipe (C#, used by scripts/SixDofSpike.cs)

// BALL joint: angular limits as the anatomical range, EnableMotor on X/Y/Z, AngularMotorForceLimit = strength (N·m)
Quaternion rel = frameAWorld.Inverse() * frameBWorld;            // current joint rotation, frame-A coordinates
Vector3 err = BodyMath.RotationError(targetRel, rel);            // axis*angle in frame-A coordinates
Vector3 w = (err * Kp).LimitLength(maxSpin);                     // PD velocity
joint.SetParamX(Generic6DofJoint3D.Param.AngularMotorTargetVelocity, w.X); // …Y, …Z

The solver applies at most AngularMotorForceLimit torque per step — strength is a real cap, leverage emerges.

Proof — scenes/sixdof_spike.tscn ("6DOF: overall PASS", 3× identical)

server type Type6Dof; BALL pivot holds (≤ 0.7 mm) while swinging 100°; cone 30° / twist 10° hold under hard torque (31.3° / 11.6° peak — Box3D soft-limit overshoot); muscle PD holds a 1 kg × 0.6 m lever (2.94 N·m gravity load) horizontal with a 10 N·m cap (0.0°) and FAILS with 1.5 N·m (drops 107°); tracks a 60° raise to 0.0°; DRIVE follows a moving target to 0.000 m; a hand driven into a wall never penetrates it and pushes the 10 kg anchor body AWAY (−0.82 m — push-off reaction; pulling toward a fixed point is the climbing case); all-locked = rigid (0.13°); 90 live flag toggles stay finite. All existing spikes (weld, physics, grab, body, collapse, arm) PASS 3× on the new binary.

Patch 3 (2026-10-02): angular limits on the DRIVE — the shoulder range of motion

Problem: the shoulder is a DRIVE (linear motor = moving girdle socket, angular motor = muscle), and Box3D's b3MotorJoint had no angular limits — an external force could bend the arm into the chest or behind the back. A second joint can't add them (b3SphericalJoint always pins its anchor and would fight the moving socket), so the limits went into Box3D's motor joint itself.

  • Fork branch shoulder-cone-limit (on generic-6dof-joint), commit "Angular limits for drive-mode Generic6DOFJoint3D". The Box3D change is patches/box3d/0001-motor-joint-angular-limits.patch in the fork, applied to the box3d/ checkout by CMake at configure time (marker b3MotorJointSwingLimit, idempotent) — the submodule stays pinned to upstream. Rebuild recipe unchanged (cmake -B build …, cmake --build build). Linux binary only.
  • Box3D side (b3MotorJointDef): limitRotationA/B (own limit frames, so moving the drive target never moves the range), enableSwingLimit + lower/upperSwingX/Y (per-quadrant ELLIPSE on the swing rotation vector — asymmetric bounds), enableTwistLimit + lower/upperTwistAngle. Solved after the angular motor/spring, Jacobians rebuilt every sub-step: swing J = −n + r′(φ)(z − cot θ · m) (the radius change along an eccentric boundary — without it a fast arm sank 36° into the corners), exact twist Jacobian (Box3D's spherical tan(θ/2) form lags ~5° at 100° swing). Warm-started, included in joint torque reports and recordings (def size tripwire 168 → 232).
  • Wrapper mapping: a DRIVE gets limits when any angular axis has the limit flag on with lower < upper. Joint X = twist (X limits), Y/Z = swing bounds: the swing rotation-vector components about joint Y and Z must stay inside the ellipse whose semi-axes are the per-sign bounds (lower ≤ 0 ≤ upper, up to ±0.99π). Flag off = that axis free; lower ≥ upper = locked. Measured between the joint frames AS CREATED (relative rotation identity = centre), not the equilibrium-shifted target. Limit softness 120 Hz (joint default 60 Hz let the twist sink ~5° while the arm slid along the swing boundary under 25 N·m). Limits rebuild the joint — configure before AddChild.
  • Plain drives (angular limit flags off, or Godot's locked default with no real range) are byte-for-byte unchanged: ragdoll_spike prints identical numbers on the old and new binary.

Shoulder values (for PlayerRagdoll)

Joint frame at build: X = the humerus as built (dir), Y = world up made square to it, Z = X × Y. Fitted against target arm directions (chest frame): bounds Y ∈ [−90°, +100°] (right arm; left mirrors to [−100°, +90°]), Z ∈ [−85°, +155°], twist ±80°. Inside with ≥ 2.6° margin: hanging, 45° extension, forward, overhead, overhead 10° behind, lateral, lateral 30° behind, 45°-across hug, hand on the opposite shoulder, hand behind the back, up-and-across. Out by ≥ 8°: medial horizontal, 80° extension, straight back, behind-up, behind the head across, medial behind-down. (Arm hanging medially in front of the torso stays allowed — real, and own-body collision is off anyway.)

Proof (added to sixdof_spike, 3× identical; weld_spike 3× PASS on the same binary)

Shoulder-style drive (2 kg × 0.3 m arm, socket = linear velocity motor 2000 N, bounds Y [−20°, 60°], Z [−40°, 100°], twist ±45°): a 25 N·m torque sweeping every swing direction + 6 N·m alternating twist → swing overshoot 1.1° max, every bound reached (61/20/101/41°), twist max 46.3°, socket 1.8 mm. A 30 N·m muscle PD reaches targets inside the range (0.0 / 0.0 / 1.3° error) and stops at 60.2° when commanded to 85° (beyond the 60° bound). Moving the socket 5.8 cm (shrug) tracks to 0.0 mm with the arm still (0.0° swing).