10 KiB
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:
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 (b3PrepareJointclampsconstraintHertzto 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
relQtowardtargetQuat = b3Quat_identityrelative to the two local frames (b3SolveWeldJoint, lines 223-247). Frames are computed at prepare asbodyRotation × 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:
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.
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 verifiedConeTwist. 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.gdignoreso 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]
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