185 lines
10 KiB
Markdown
185 lines
10 KiB
Markdown
# 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
|
||
```
|