Files
VR-Mess/research/godot-box3d-weld.md

10 KiB
Raw Permalink Blame History

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 (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:

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 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]

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