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

185 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```