Initial commit: VR Messs workspace (grab v5.3.1, body-physics research)

This commit is contained in:
Foxyz1302 committed 2026-10-01 21:32:13 +03:00
commit 5d2c5bf265
57 files changed
+4990

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
root = true
[*]
charset = utf-8
+2
View File
@@ -0,0 +1,2 @@
# Normalize EOL for all files that Git considers text files.
* text=auto eol=lf
+5
View File
@@ -0,0 +1,5 @@
.godot/
.vendor/godot-box3d/
.vendor/venv/
.export/
*.translation
View File
Whitespace-only changes.
+154
View File
@@ -0,0 +1,154 @@
diff --git a/src/joints/box3d_weld_joint_impl_3d.cpp b/src/joints/box3d_weld_joint_impl_3d.cpp
new file mode 100644
index 0000000..ed4c687
--- /dev/null
+++ b/src/joints/box3d_weld_joint_impl_3d.cpp
@@ -0,0 +1,64 @@
+#include "box3d_weld_joint_impl_3d.hpp"
+
+#include "../misc/type_conversions.hpp"
+
+#include <box3d/box3d.h>
+
+b3JointId Box3DWeldJointImpl3D::_create_joint_id(b3WorldId p_world_id, b3BodyId p_body_a, b3BodyId p_body_b, b3Transform p_local_frame_a, b3Transform p_local_frame_b) {
+ b3WeldJointDef def = b3DefaultWeldJointDef();
+ def.base.bodyIdA = p_body_a;
+ def.base.bodyIdB = p_body_b;
+ def.base.localFrameA = p_local_frame_a;
+
+ // The weld enforces relQ == identity against the two local frames, so frame B's rotation
+ // is pre-corrected with the bodies' CURRENT relative rotation: the joint then locks the
+ // relative pose at creation time, no matter how the joint node was oriented.
+ 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);
+ def.base.localFrameB.p = p_local_frame_b.p;
+
+ // hertz 0 = maximum stiffness (Box3D falls back to its engine-clamped world constraint
+ // softness); bias > 0 softens the weld: hertz = bias * 30, mirroring the pin joint.
+ def.linearHertz = (float)bias * 30.0f;
+ def.angularHertz = (float)bias * 30.0f;
+ def.linearDampingRatio = 1.0f;
+ def.angularDampingRatio = 1.0f;
+ return b3CreateWeldJoint(p_world_id, &def);
+}
+
+real_t Box3DWeldJointImpl3D::get_param(PhysicsServer3D::ConeTwistJointParam p_param) const {
+ switch (p_param) {
+ case PhysicsServer3D::CONE_TWIST_JOINT_BIAS:
+ return bias;
+ default:
+ return 0.0;
+ }
+}
+
+void Box3DWeldJointImpl3D::set_param(PhysicsServer3D::ConeTwistJointParam p_param, real_t p_value) {
+ switch (p_param) {
+ case PhysicsServer3D::CONE_TWIST_JOINT_BIAS:
+ bias = p_value;
+ if (has_joint_id()) {
+ b3WeldJoint_SetLinearHertz(get_joint_id(), (float)bias * 30.0f);
+ b3WeldJoint_SetAngularHertz(get_joint_id(), (float)bias * 30.0f);
+ }
+ break;
+ case PhysicsServer3D::CONE_TWIST_JOINT_SWING_SPAN:
+ case PhysicsServer3D::CONE_TWIST_JOINT_TWIST_SPAN:
+ if (p_value > 0.0) {
+ WARN_PRINT_ONCE("Box3D: ConeTwistJoint3D's swing/twist spans have no Box3D equivalent (the weld joint has no limits) and are treated as fully rigid.");
+ }
+ break;
+ case PhysicsServer3D::CONE_TWIST_JOINT_SOFTNESS:
+ WARN_PRINT_ONCE("Box3D: ConeTwistJoint3D's SOFTNESS parameter has no Box3D equivalent and is ignored; use BIAS to soften the weld.");
+ break;
+ case PhysicsServer3D::CONE_TWIST_JOINT_RELAXATION:
+ WARN_PRINT_ONCE("Box3D: ConeTwistJoint3D's RELAXATION parameter has no Box3D equivalent and is ignored.");
+ break;
+ default:
+ break;
+ }
+}
diff --git a/src/joints/box3d_weld_joint_impl_3d.hpp b/src/joints/box3d_weld_joint_impl_3d.hpp
new file mode 100644
index 0000000..408fd3b
--- /dev/null
+++ b/src/joints/box3d_weld_joint_impl_3d.hpp
@@ -0,0 +1,30 @@
+#pragma once
+
+#include "box3d_joint_impl_3d.hpp"
+
+// CONE_TWIST_JOINT mapped to Box3D's weld joint: a solver-owned rigid weld that locks the
+// two bodies at the relative pose captured when the joint is created. Godot's
+// ConeTwistJoint3D with swing_span 0 + twist_span 0 is therefore a rigid grab weld; nonzero
+// spans have no Box3D equivalent (the weld has no limits) and are warned + treated as rigid.
+class Box3DWeldJointImpl3D final : public Box3DJointImpl3D {
+public:
+ Box3DWeldJointImpl3D(
+ Box3DBodyImpl3D* p_body_a,
+ Box3DBodyImpl3D* p_body_b,
+ const Transform3D& p_local_frame_a,
+ const Transform3D& p_local_frame_b) :
+ Box3DJointImpl3D(p_body_a, p_body_b, p_local_frame_a, p_local_frame_b) {
+ }
+
+ PhysicsServer3D::JointType get_type() const override { return PhysicsServer3D::JOINT_TYPE_CONE_TWIST; }
+
+ real_t get_param(PhysicsServer3D::ConeTwistJointParam p_param) const;
+
+ void set_param(PhysicsServer3D::ConeTwistJointParam p_param, real_t p_value);
+
+protected:
+ b3JointId _create_joint_id(b3WorldId p_world_id, b3BodyId p_body_a, b3BodyId p_body_b, b3Transform p_local_frame_a, b3Transform p_local_frame_b) override;
+
+private:
+ real_t bias = 0.0;
+};
diff --git a/src/servers/box3d_physics_server_3d.cpp b/src/servers/box3d_physics_server_3d.cpp
index d7de27e..01565a2 100644
--- a/src/servers/box3d_physics_server_3d.cpp
+++ b/src/servers/box3d_physics_server_3d.cpp
@@ -5,6 +5,7 @@
#include "../joints/box3d_filter_joint_impl_3d.hpp"
#include "../joints/box3d_pin_joint_impl_3d.hpp"
#include "../joints/box3d_slider_joint_impl_3d.hpp"
+#include "../joints/box3d_weld_joint_impl_3d.hpp"
#include "../misc/type_conversions.hpp"
#include "../objects/box3d_area_impl_3d.hpp"
#include "../objects/box3d_body_impl_3d.hpp"
@@ -1093,15 +1094,33 @@ double Box3DPhysicsServer3D::_slider_joint_get_param(const RID& p_joint, Physics
return joint->get_param(p_param);
}
+// Box3D's weld joint has no cone/twist limits: a ConeTwistJoint3D with swing_span 0 +
+// twist_span 0 is exactly a rigid weld at the captured relative pose, and nonzero spans are
+// warned + treated as fully rigid (see Box3DWeldJointImpl3D).
void Box3DPhysicsServer3D::_joint_make_cone_twist(const RID& p_joint, const RID& p_body_a, const Transform3D& p_local_ref_a, const RID& p_body_b, const Transform3D& p_local_ref_b) {
- ERR_FAIL_MSG("Box3D: ConeTwistJoint3D is not supported in this version of the Box3D extension.");
+ _joint_clear(p_joint);
+
+ Box3DBodyImpl3D* body_a = body_owner.get_or_null(p_body_a);
+ Box3DBodyImpl3D* body_b = body_owner.get_or_null(p_body_b);
+ ERR_FAIL_NULL(body_a);
+ ERR_FAIL_NULL(body_b);
+
+ auto* joint = memnew(Box3DWeldJointImpl3D(body_a, body_b, p_local_ref_a, p_local_ref_b));
+ joint->set_rid(p_joint);
+ joint_owner.replace(p_joint, joint);
+ joint->rebuild();
}
void Box3DPhysicsServer3D::_cone_twist_joint_set_param(const RID& p_joint, PhysicsServer3D::ConeTwistJointParam p_param, double p_value) {
+ auto* joint = dynamic_cast<Box3DWeldJointImpl3D*>(joint_owner.get_or_null(p_joint));
+ ERR_FAIL_NULL(joint);
+ joint->set_param(p_param, p_value);
}
double Box3DPhysicsServer3D::_cone_twist_joint_get_param(const RID& p_joint, PhysicsServer3D::ConeTwistJointParam p_param) const {
- return 0.0;
+ auto* joint = dynamic_cast<Box3DWeldJointImpl3D*>(joint_owner.get_or_null(p_joint));
+ ERR_FAIL_NULL_V(joint, 0.0);
+ return joint->get_param(p_param);
}
void Box3DPhysicsServer3D::_joint_make_generic_6dof(const RID& p_joint, const RID& p_body_a, const Transform3D& p_local_ref_a, const RID& p_body_b, const Transform3D& p_local_ref_b) {
+236
View File
@@ -0,0 +1,236 @@
# VR Messs
VR anime-character "scene viewer" (Koikatsu-VR-class, but with real physics hands).
Not a game: no gameplay loops, quests, or progression. Gameplay features are the
owner's domain; explore, interact, touch, pose — that's the product.
TERMINOLOGY (owner-corrected, use precisely): **"getting a body"** = the physical rig
(torso/arms/hands — the interaction body). **"embodying"** = literally viewing from the
perspective of a character in an animation (the view-follow + avatar system). They are
separate systems; never call the rig milestone "embodying".
This repo is the experimentation lab (hence the name). The designs below are working hypotheses
to be validated with cheap, throwaway spikes — a failed experiment is progress, not rework. The
embodiment/view system in particular is theory until proven.
## Toolchain pins (do not drift)
- **Godot 4.7.2** (current stable, Forward Plus renderer), **C# / .NET 8**. Desktop-only (PCVR).
- **Physics: Box3D** via the `godot-box3d` GDExtension (server-replacement package by
bearlikelion) — **installed, v0.2.6**, setting value `physics/3d/physics_engine="Box3D
Physics"`. Validation spike (`scenes/physics_spike.tscn`, run headless) PASSES: drop+contact
and velocity-chase-into-wall both work from C#. WARNING: exactly ONE `.gdextension` file may
exist — a stray duplicate (e.g. at `addons/` root) double-loads the library and breaks the
server with "required virtual method" errors. Jolt (built-in) is the fallback: reverting is
the one project-setting line, and all gameplay code must stay standard-Godot-physics-node
based so that flip is lossless.
- Character import (Koikatsu etc.) is a separate pipeline owned by someone else. We consume a
character scene that provides: `Skeleton3D` with humanoid bone mapping + `AnimationPlayer`.
Don't touch import code; if the interface is insufficient, say so instead of hacking around it.
- Dev runtime stack: **WiVRN + WayVR** on Linux. Headset and runtime both support hand tracking —
treat hand tracking as available. View-follow milestone verified working (OpenXR + WiVRN).
- Graphics are deliberately **stylized** — fits the anime look and buys performance headroom.
Renderer choice (currently Forward+; docs recommend Mobile for desktop VR) is deferred until
profiling justifies a change.
## Documentation rules (hard rules — this project's #1 failure mode)
- Local pinned docs: `/home/foxyz/Projects/Games/.shared/godot-docs-4.7` — full manual +
class reference in `.rst` (source of docs.godotengine.org, branch `4.7`). Grep it first.
- For ANY engine API detail, class name, property, or signal: **trust only these 4.7 docs or a
live spike.** Training-memory APIs and older-version knowledge are banned — verify or don't write it.
- Web fallback: `https://docs.godotengine.org/en/4.7/...` only — the version goes IN the URL.
Never `/latest/`, never another version path.
- Code examples: rst sources contain `code-tab::` blocks with BOTH gdscript and csharp — grep for
`code-tab:: csharp`. Class reference lives in `classes/class_*.rst`.
- XR tutorials: `tutorials/xr/` (setting_up_xr, xr_action_map, xr_room_scale,
openxr_body_tracking, basic_xr_locomotion). Physics: `tutorials/physics/` (rigid_body,
ragdoll_system, using_jolt_physics, physics_introduction).
## Godot/C# traps
- Addons that are GDScript-only can't be referenced from C# — **Godot XR Tools is GDScript-only**:
treat it as a reference implementation to port ideas from, never a dependency.
- Default OpenXR action map names (verified from engine source, 4.7): `trigger`/`grip` (Float),
`ax_button` (A/X), `by_button` (B/Y), `menu_button` (menu/system — often eaten by the runtime,
e.g. WiVRN screenshot+anchor-hold), `primary` (thumbstick). `select_button` is Pico-only.
Poses: `default_pose`, `aim_pose`, `grip_pose`, `palm_pose`.
- C# in Godot covers desktop + mobile only (fine — PCVR target).
- Signals: C# `+=`/`-=` events; check the class ref for exact C# signatures.
- The C# binding can diverge from engine class names: `AudioStreamWAV` is `AudioStreamWav`
in C#. When docs and compiler disagree, grep the shipped `GodotSharp.dll` (strings) —
it's at `/usr/lib/godot-mono/GodotSharp/Api/Debug/GodotSharp.dll` on this machine.
## Architecture (working hypotheses — test cheap before committing)
Four layers, bottom-up, applied per frame in this order:
1. **Animation** — `AnimationPlayer` drives the skeleton (any pose, any animation).
2. **Tracked override / view embodiment** — several input modes, all blended over whatever
animation is playing:
- **View-follow (core feature, Koikatsu-VR-style)**: the view follows the character's head
frame including tilt, so weird animations stay correct. The player's HMD look is remapped
through a one-time calibration transform captured relative to the character's chest
("chest calibration", e.g. once while lying flat) and drives a look override on the
head+neck (possibly slight chest) bones. Skeleton-space only — the physics layer stays
separate from this. Pivot behavior (rotation-only snap vs positional head offset) and the
calibration UX are open experiments.
- **Free-view**: ordinary playspace (`XROrigin3D`); "lean back mode" = origin offset +
recline calibration, zero physics involved.
- **Single-controller mode**: one controller as master tracker — hand override, or strapped
to the hip driving the animation root/hip (hip-tracker mode).
- Full-body trackers are a far-future extension of this same layer (more override targets),
not a separate system.
3. **Physics embodiment** — a small set of rigid bodies (hand L/R, head, torso/hip capsule) that
CHASE layer-1/2 targets: velocity-clamped steering (`v = clamp((target - pos) / dt)`), CCD on,
no joints between them. Unobstructed → effectively 1:1 tracking; obstructed → pushes with
tunable strength. The chase deviation is the feedback signal: it clamps the visual hand offset,
pushes the character pose via spring-damped offsets ("pushable from animation"), and fires
logical touch-reaction events. Boneworks calls the general technique "heuristic force limit".
4. **Secondary motion (hair/skirt/cloth) — custom spring-bone solver.** Koikatsu DynamicBones and
VRM springBone are poorly interactive and/or expensive; VRChat PhysBones are the gold standard
but proprietary. So: original PhysBones-*style* math (spring-damper chains on the skeleton,
animation-space) with analytic sphere/capsule colliders (body, hands, floor). The physics
engine contributes only its QUERY API (throttled ray/shape casts) so bones respect scene
geometry — **never rigid-body chains for jiggle**. Performance is a first-class goal of this
project: chain budgets, screen-size/distance LOD, simplified colliders. If the per-bone C#
loop profiles hot, the hot loop moves to a GDExtension — profile first, don't pre-optimize.
Do NOT build anything that needs 6DOF/cone-twist joints between tracked bodies (the wrapper lacks
them; velocity steering replaces them). Scene objects stay standard nodes (`RigidBody3D`,
`StaticBody3D`, `CharacterBody3D`) so arbitrary imported scene content works and the Jolt fallback
stays real.
## XR
OpenXR is built into Godot 4.7 — no plugin. Node set: `XROrigin3D` / `XRCamera3D` /
`XRController3D` (+ `XRBodyTracker`/`XRHandTracker` for body/hand tracking data). OpenXR runs on
device only; the editor has an XR simulator for dev. Trackers/action map: see `xr_action_map.rst`.
## Interaction & UI (design directions — experiments welcome)
- **Touch-first hybrid cursor**: interactive surfaces prefer *touch*. A pointer tip (controller
tip or index fingertip) acts as a cursor when in proximity of a surface — push, hold, slide,
physical press feel. Out of proximity, fall back to a laser ray for distant precision. Lasers
ruin immersion and are hard to aim at distance; they are the fallback, not the default.
- **Hand tracking**: cheap spike early, full support later. Interaction logic must not assume
controllers — controller tip and fingertip feed the same cursor concept.
- **Locomotion** (implemented, play-feel tuning ongoing): spawn re-anchoring
happens ONCE on first focus gained (HMD xz at `SpawnPoint` + spawn yaw; real head height and
pitch kept) — physical room position never matters. `pose_recentered` is deliberately NOT a
placement trigger AND its log is throttled to one line: measured on WiVRN it fires
continuously at refresh rate (~90 Hz, 22k lines/session) — reacting to it was the original
view-locked-forward bug. `Locomotion.cs` is the manager (spawn placement,
teleport markers, lasers, fade); **grab movement lives in `AnchorMoveMode.cs` as its own
movement mode** — scene-viewer-first, other modes (walk, tp-only variants) are future nodes
beside it. Teleport markers (`scenes/teleport_marker.tscn`):
aim + trigger, with fade; near markers (within `MarkerNearDistance` 1.2 m) fully hide
(disc, beam, label) AND are not teleport-targetable. Grab semantics are
ANCHOR-based: hands are anchors to push/pull from — one-hand grip
moves the player OPPOSITE to hand motion in full 3D, both grips = anchor translation +
IN-PLACE yaw turn around the head. All grab deltas and turn pivots are computed in
WORLD space (tracking-space math made turns orbit the map center — do not regress).
Spawn/teleport placement pins arrival height (head height preserved). Locomotion drives
`XROrigin3D` directly; physics reconciliation comes with the embodiment layer. Grip/trigger
are polled Floats with thresholds per docs
(bool actions unreliable across runtimes). Engine logs land in
`~/.local/share/godot/app_userdata/VR Messs/logs/godot.log` — agents: read this after user
playtests instead of guessing.
- **UI direction — physical prop controls first**: buttons, knobs, sliders as real 3D objects
(touch interaction, later Box3D for feel). Godot has NO native world-space widget system —
`Control`s are screen-space; world UI is always Control-rendered-into-3D via `SubViewport`
(optionally `SubViewportCompositionLayer` for XR-native sharpness/perf). Use at most one
SubViewport/composition-layer screen for dense content (file browser, settings) if needed;
everything else is props. World-space text is native and cheap: `Label3D` (SDF, crisp at
distance) and `TextMesh` (extruded) — no SubViewport required.
- **`WorldPanel.cs` is the first prop-UI prototype**: summoned/dismissed via `ax_button` (A/X —
menu_button belongs to the runtime). It is PARENTED UNDER `XROrigin3D` — Koikatsu-VR-style
"fixed in the player's room": IRL movement doesn't move it relative to you, game locomotion
carries it. If that feel is wrong, reverting = reparenting to `Main` and fixing the `_Ready`
node paths. Floats in front of the summoning hand facing the player (full 3D facing — works
from lying poses), draggable directly or via laser at fixed ray distance while gripped
(continuously re-orients toward the head). The Spawn
knob also recalls the panel in front of the head. The pointer
tip is a protruding stylus cone (apex ~12 cm ahead of the controller) and is THE cursor:
knobs hover-highlight and PRESS ON CONTACT (tip within 4.5 cm, with cooldown) — physical
buttons, no trigger needed; trigger still presses laser-hovered knobs (knobs are
laser-targetable). Presses play a synthesized click (code-generated `AudioStreamWav` PCM
— no asset). Spawn/Ping/Close knobs. Locomotion defers
grab/teleport to the panel when a hand is near it (`WorldPanel.IsBlocking`). Laser lines are
owned by `Locomotion.UpdatePointer`: shown only when aiming at something interactive (marker
or panel) AND beyond `LaserMinDistance` (0.35 m) — near targets are touched via the pointer
tip instead, which is the touch-first hybrid cursor in miniature. Grab deltas and turn
pivots are computed in WORLD space (tracking-space math made two-hand turns orbit the map
center — do not regress this).
- **Control scheme is deliberate**: decide what lives on controllers vs. what lives in menus
before stacking bindings — controller button budget is scarce, menus carry the rest.
## Order of work
1. ~~Get VR working at all~~ — **done**: OpenXR + WiVRN verified in-headset, flat preview
fallback works without a headset.
2. ~~Input foundation (partial)~~ — grip/trigger Float polling + `ax_button` summon verified
from engine source; the deliberate full action-map pass happens with the UI milestone.
**Hand tracking spike still TODO** (`XRHandTracker` through WiVRN, see
`openxr_hand_tracking.rst`).
3. ~~Locomotion: anchor-grab mode~~ — **done** (`AnchorMoveMode.cs` + teleport markers +
spawn anchoring); other movement modes are future nodes beside it.
4. ~~UI: first prop panel~~ — **done** (`WorldPanel.cs`); SubViewport/composition-layer screen
deferred until dense content (file browser) is actually needed.
5. ~~Box3D validation spike~~ — **done, PASS** (drop+contact, velocity-chase vs wall, headless
via `scenes/physics_spike.tscn`).
6. **Physics hands & props sandbox — grab v5.3 = SOLVER-OWNED RIGID WELD (patched addon).**
Research first (see `research/vr-grabbing.md`, `vr-two-handed-grab.md` + its dated
CORRECTION, `godot-box3d-weld.md` — sourced taxonomy, Box3D internals, the whole v5.0→5.2
history). The owner rejected every point-joint variant as "hanging on a winch" (ball
orbits the palm, anvil surface drifts, palms clip) — a grab must lock the object's POSE
relative to the hand, not one point + a rotator. **The addon is now locally patched**
(source + recipe in `.vendor/`, originals in `addons/godot-box3d/bin-stock/`, Linux binary
only): Godot's ConeTwistJoint3D maps to Box3D's `b3WeldJoint`, pre-corrected so the weld
locks the relative pose AT CREATION (no identity-target snap; bias semantics INVERTED vs
pin — 0 = max stiffness). `PropGrabber.cs`: grab creates a ConeTwistJoint3D weld at the
palm point (`SwingSpan/TwistSpan 0`, `ExcludeNodesFromCollision`, configured BEFORE
AddChild — mandatory ordering). NO script forces/torques/damp anywhere and NO per-tick
joint re-positioning (the weld is not a hand-following pivot) — the solver owns everything
and weight flows into the hand's 90 N budget honestly. BOTH hands weld RIGID (bias 0 —
v5.3.1: the owner rejected the soft secondary; hand₁+prop+hand₂ welded = one composite
body, conflicting controller demands resolve through hand lag, not object stretch);
takeover is automatic — the survivor's weld persists, and the brief ~3° ring when one
hand releases is stored weld stress discharging (honest physics). Break: stretch-based (rip 0.8 m / 0.25 m for 0.25 s / both-hands
0.4 m) + "grip dragged off" when the hand body lags its controller > `PhysicsHands.SnapDistance`.
Headless regressions: `scenes/weld_spike.tscn` (addon patch), `scenes/physics_spike.tscn`,
`scenes/grab_spike.tscn` (rigid pose under drag + fast spin — the winch regression; don't
tighten rotation limits below ~2°, that's the gravity floor for off-center grabs).
`PhysicsHands.cs`: physical palm bodies (0.4 kg, 90 N chase, rotation lock, stuck-safety
snap — SUSPENDED for a hand while it grips, HAND ghosts > 10 cm lag). Collision layers:
1 = world, 2 = UI (panel mask 0 — props pass through even when shown); layers live on
CollisionObject3D, NOT CollisionShape3D. Panel = prop-UI prototype (ax_button summon,
contact-press knobs, Reset). Research reports live in `research/` (arm IK, IK
stability/deep, grabbing, two-handed grab/Box3D internals, addon weld patch) — READ before
re-researching or changing grab/IK. Stink-O's box3d-godot (custom components) = documented
plan-B. Laser/distant grab REMOVED (user never knew it existed); grab = palm-proximity only,
per-hand palm-face offset (left +X, right −X — taste). Box3D PinJoint quirks: anchors are
CAPTURED AT CREATION; the joint node's position is READ EVERY TICK (keep it on the palm =
hand-following pivot); break must measure the prop-local anchor vs the palm point. Headless
regression tests: `scenes/physics_spike.tscn` AND `scenes/grab_spike.tscn` — both must print
overall PASS. `PhysicsHands.cs`: physical palm bodies (0.4 kg, 90 N chase, rotation lock,
stuck-safety snap, HAND ghosts > 10 cm lag). Collision layers: 1 = world, 2 = UI (panel mask
0 — props pass through even when shown); layers live on CollisionObject3D, NOT
CollisionShape3D. Panel = prop-UI prototype (ax_button summon, contact-press knobs, Reset).
Research reports live in `research/` (arm IK, IK stability/deep, grabbing,
two-handed grab/Box3D joint internals) — READ before re-researching or changing grab/IK.
7. **Getting a body (rig) — placeholder v1.2 (cube character, agent-built).** `BodyRig.cs`:
all-BOX primitives (owner preference) — head 0.15³ behind the eyes, slim torso retracted,
wrist-anchored 2-bone arms. **Soft IK applied**: shoulder→hand distance clamped to
reach×0.98 before the solve (the straight-arm singularity was the overextension shake; if it
persists, lower SoftIKFactor toward 0.95). Elbow pole: torso frame, shoulder→hand axis
PROJECTED OUT, hand weight 0.25, exp-smoothed 10/s (twitchy wrists fixed). **Torso yaw
spring**: HMD yaw base blended toward hands-derived yaw (weight 0.6 × forwardness over
0.3 m), exp-smoothed 12/s, decoupled from the head — the "chest = headset + hands" ask.
Reach budget hysteresis unchanged. Full sourced findings in `research/vr-arm-ik.md` and
`research/vr-arm-ik-deep.md` (HL:Alyx went hands-only on purpose; SkeletonIK3D deprecated →
custom SkeletonModifier3D when a real skeleton arrives; LookAtModifier3D for head→neck).
Physical torso/head capsules later. EMBODYING (view-from-character-perspective) comes after
the head/neck look override exists. **HANDOFF.md at project root is the takeover document**
(constant table verified against code — code wins on drift); keep it updated when systems
change.
+296
View File
@@ -0,0 +1,296 @@
# VR Messs — Agent Handoff
Read `AGENTS.md` in the repo root first — it is the authoritative project brain. This file
orients you; AGENTS.md governs. Verify every claim here against the code when you touch it
(code wins over both docs if they disagree).
## 1. What this project is
VR anime-character **scene viewer** — Koikatsu-VR-class, but with real physics hands.
**Not a game**: no loops, quests, progression. Explore, interact, touch, pose — that's the product.
This repo is the experimentation lab: designs are hypotheses validated with cheap throwaway spikes.
TERMINOLOGY (owner-corrected — use precisely):
- **"getting a body"** = the physical rig (torso/arms/hands, the interaction body).
- **"embodying"** = viewing from a character's perspective in an animation (view-follow + avatar).
Two separate systems. Never call the rig milestone "embodying".
## 2. Toolchain & environment
- **Godot 4.7.2 mono** (binary `godot-mono`), **C# / .NET 8** (dotnet SDK 10 present). PCVR, desktop-only.
- **Physics: Box3D** via `addons/godot-box3d` GDExtension (bearlikelion server-replacement, v0.2.6);
setting `physics/3d/physics_engine="Box3D Physics"`. Exactly ONE `.gdextension` file may exist —
a stray duplicate double-loads the library and breaks the server ("required virtual method" errors).
**The addon is LOCALLY PATCHED** (grab v5.3 depends on it): the wrapper now maps Godot's
ConeTwistJoint3D → Box3D's b3WeldJoint (rigid grab weld). Originals in `addons/godot-box3d/bin-stock/`;
source + rebuild recipe + patch in `.vendor/` (`.vendor/box3d-weld.patch`,
`.vendor/godot-box3d/`, see `research/godot-box3d-weld.md`) — **only the Linux binary is
patched**; Windows/macOS binaries are stock (ConeTwist still errors there until rebuilt).
Jolt fallback = flip that one project-setting line; all gameplay code stays standard
RigidBody3D/StaticBody3D/CharacterBody3D so the flip is lossless.
- Runtime stack: **WiVRN + WayVR** on Linux. Headset + runtime support hand tracking (treat as available).
- Renderer **Forward+** (Mobile is the docs' VR recommendation; deferred until profiling justifies).
- Build: `dotnet build`. Headless validation — physics: `godot-mono --headless --path . scenes/physics_spike.tscn`
must print **"SPIKE: overall PASS"**; grab: `godot-mono --headless --path . scenes/grab_spike.tscn`
must print **"GRAB: overall PASS"**. Both PASS as of this handoff. Headless asset import:
`godot-mono --headless --import`.
- Engine logs: `~/.local/share/godot/app_userdata/VR Messs/logs/godot.log` — **read after user
playtests instead of guessing**.
- **Docs rules (project's #1 failure mode)**: only the local pinned 4.7 docs at
`/home/foxyz/Projects/Games/.shared/godot-docs-4.7` (full manual + class ref in `.rst`; rg it).
Web fallback only `https://docs.godotengine.org/en/4.7/...` — version goes IN the URL, never `/latest/`.
NEVER trust training-memory API names. Known traps: `AudioStreamWAV` is `AudioStreamWav` in C#;
collision layer/mask live on `CollisionObject3D`, NOT `CollisionShape3D`; `SkeletonIK3D` is
deprecated in 4.7. If docs and compiler disagree, grep the shipped
`/usr/lib/godot-mono/GodotSharp/Api/Debug/GodotSharp.dll`.
## 3. Current systems (scripts/)
- **Main.cs** — OpenXR startup: finds `OpenXRInterface`, `UseXR`, VSync off, VRS XR mode; wires
session signals (begun/visible/focussed/stopping/`PoseRecentered` → re-emitted as C# signals
FocusLost/FocusGained/PoseRecentered). On session begin: picks highest refresh rate ≤
`MaximumRefreshRate` (export, 90) and sets `Engine.PhysicsTicksPerSecond` to it. Pauses tree on
focus lost. No OpenXR → flat preview fallback (PushWarning, scene still runs).
- **Locomotion.cs** — locomotion manager. Spawn anchoring ONCE on first `FocusGained` (HMD xz to
`SpawnPoint` + spawn yaw; real head height and pitch kept — `PlacePlayer` pins arrival height).
`PoseRecentered` is deliberately IGNORED for placement (WiVRN spams it at ~90 Hz; logged once).
Teleport: aim + trigger edge, fade to black 0.12 s in / 0.25 s out, near markers within
`MarkerNearDistance` (1.2 m) hide fully (disc/beam/label) and are not targetable. Lasers via
`UpdatePointer`: shown only when aiming at something interactive (marker or panel) AND beyond
`LaserMinDistance` (0.35 m). Also builds the stylus pointer tip per controller. `ReturnToSpawn()`
is public (panel Spawn knob calls it).
- **AnchorMoveMode.cs** — grab-world movement, ANCHOR semantics. One-hand grip moves the player
OPPOSITE to hand motion (full 3D); both grips = anchor translation + in-place yaw turn around the
head. Grip is suppressed while that hand prop-grabs (`PropGrabber.IsGrabbing`) or the panel is
near (`WorldPanel.IsBlocking`). **All deltas and turn pivots in WORLD space** (tracking-space math
made turns orbit the map center — do not regress). Drives `XROrigin3D` directly.
- **PhysicsHands.cs** — physical palm bodies chasing the controller poses: 0.4 kg boxes (flat slab,
thumb-side face), gravity off, CCD, layer 1/mask 1. Chase = desired velocity `(target-pos)/dt`
force-clamped to `HandMaxForce` 90 N; rotation hard-locked by setting angular velocity to the
controller's rotation error (clamped to 120 rad/s). Safety: stuck (> 0.2 m for 1 s) → snap +
log; > `SnapDistance` 2 m → silent teleport snap — BOTH SUSPENDED for a hand while
`PropGrabber.IsGrabbing(controller)` (a joint legitimately holds the hand back; teleporting a
jointed hand yanks the prop — the grabber's grip break runs first instead, rip at 0.8 m).
Hand ghosts: translucent palm boxes shown at the controller pose when the physical hand lags
> 0.1 m — ghosts belong to the HANDS, not props. Static `GetBody(controller)` lookup used by
grabber and rig.
- **PropGrabber.cs** — grab v5.3: SOLVER-OWNED RIGID WELD via a LOCALLY PATCHED addon (needs
`research/godot-box3d-weld.md` + the v5.2 history in `research/vr-two-handed-grab.md` — the
pin joint's hidden spring targets IDENTITY and made every earlier iteration a "winch":
point constraint + rotator, which the owner rejected as not-how-grab-works: ball orbiting
the palm, anvil surface drifting under the palm). Grip edge grabs nearest prop in group
"prop" from palm point (`GrabRadius` 0.16 m, fallback hand center `CenterGrabRadius` 0.12 m)
— palm-proximity only. On grab: creates a **`ConeTwistJoint3D`** (mapped by the patch to
Box3D's `b3WeldJoint`) parented under PropGrabber, NodeA = physical hand body, NodeB = prop,
`SwingSpan = 0`, `TwistSpan = 0`, `Position` = palm point, `ExcludeNodesFromCollision = true`.
The weld LOCKS THE RELATIVE POSE AT CREATION (the patch pre-corrects frame B with both
bodies' rotations — no identity-target snap). Configure Position + NodeA + NodeB + spans +
bias BEFORE `AddChild` — mandatory ordering (configuring after raced Box3D and produced dead
joints). **NO script forces/torques/damp anywhere** — v5.2's whole servo (PD, gravity
joints) — v5.2's whole servo (PD, gravity
feed-forward, MaxHeldSpin, AngularDamp raise) is DELETED; the solver owns everything and
weight flows through the weld into the hand's 90 N budget honestly. **NO per-tick joint
re-positioning** — the weld is not a hand-following pivot (moving the node after creation
does nothing). **BOTH hands weld RIGID (bias 0) — v5.3.1** (the owner rejected the soft
secondary: it gave ~10-15 cm, "feels like a joint again"; hand₁+prop+hand₂ rigidly welded
is one composite body, and incompatible controller demands resolve through the HANDS
lagging, not the object stretching — spike-measured anchors 2–3 mm under full opposite
tug). If a future jitter appears under wrist-conflict, reintroducing a soft secondary bias
is the fallback. NO promotion machinery — with both welds rigid from grab, takeover is
automatic (the survivor's weld simply persists). Grip break:
prop-local anchor vs live palm point > `GripRipDistance` (0.8 m) instant rip;
> `BreakDistance` (0.25 m) sustained > `BreakTime` (0.25 s); both hands > `BreakDistanceBoth`
(0.4 m) breaks two-handed (no promotion on simultaneous break); with a rigid weld these
rarely fire — they are the stretch/anomaly safety net; when a two-handed hold releases one
hand, the composite rings briefly (~3° peak, decays < 1 s — stored weld stress discharging). NEW escape hatch: hand body lagging
its controller beyond `PhysicsHands.SnapDistance` (2 m) → "grip dragged off" instant break
(heavy props can drag the hand since PhysicsHands' snap is suspended while gripping).
- **PropShop.cs** — builds table (StaticBody3D) + spawns prop table from `Specs[]` (ping pong 2.7 g
→ anvil 50 kg), Label3D captions, CCD for < 0.1 kg, group "prop". `Respawn()` = Reset knob.
- **WorldPanel.cs** — prop-UI panel, PARENTED UNDER `XROrigin3D` (fixed in player's room; revert =
reparent to Main + fix node paths). Summoned/dismissed via `ax_button` (A/X; menu_button belongs
to the runtime). Knobs: Spawn (return to spawn + recall panel in front of head), Ping, Reset
(PropShop.Respawn), Close. Panel/knob bodies: **CollisionLayer 2, CollisionMask 0** (props pass
through); all CollisionShapes disabled while hidden (`SetShapesEnabled`, deferred). Pointer tip is
a 12 cm stylus cone; knobs hover-highlight and PRESS ON CONTACT (tip within 4.5 cm, 400 ms
cooldown) — physical buttons; trigger still presses laser-hovered knobs. Click = code-generated
`AudioStreamWav` PCM (no asset). Draggable direct or via laser at fixed ray distance, re-orients
toward head while gripped. `IsBlocking(controller)` (within 0.45 m) makes Locomotion defer
grab/teleport; `IsPanelCollider(node)` identifies interactive colliders for lasers.
- **TeleportMarker.cs** — `scenes/teleport_marker.tscn`: disc + beam + Label3D, group
"teleport_marker", StaticBody pick box. `SetNear` hides visuals, `SetHighlighted` swaps colors.
- **BodyRig.cs** — placeholder cube "getting a body" v1.1 (all primitives boxes per owner). Head
0.15³ pushed 6 cm behind the eyes (`HeadBackOffset`); torso 0.22×0.5×0.13, dropped 0.42 and
retracted 6 cm; yaw basis from HMD (flattened forward). Proportions calibrated at runtime from
head height (once, after height > 0.5 m): armTotal = 0.46×headHeight clamped [0.5, 0.72],
50/50 upper/fore split, shoulder half-width 0.16 + 0.02×height. Arms: wrist-anchored IK target
(palm base = hand body +Z·`WristOffset` 0.06, not palm center) with SOFT IK — shoulder→hand
distance clamped to reach×`SoftIKFactor` (0.98) BEFORE the law-of-cosines solve, so the arm is
never perfectly straight (if near-extension shake persists, lower `SoftIKFactor` toward 0.95).
Elbow pole = down + back + slight outward, projected OUT of the shoulder→hand axis, minus hand
forward × `PoleHandWeight` (0.25), exp-smoothed at `PoleSmoothRate` (10/s). Torso yaw SPRING,
decoupled from the head: HMD yaw base blended toward hands-derived yaw by forwardness ×
`TorsoYawHandsWeight` (forwardness = horizontal dot / `TorsoYawForwardness` 0.3 m, clamped 0–1),
exp smoothing `TorsoYawSmoothRate` (12/s ≈ 200 ms response). Torso reach budget WITH HYSTERESIS:
push only past full reach (0.8 m/s), decay
only below 92% of reach (0.4 m/s, `MoveToward` zero); offset clamped horizontal ≤ 0.25 m,
vertical [−0.05, +0.12]. Hidden until calibrated.
- **GrabSpike.cs** + `scenes/grab_spike.tscn` — headless grab regression (v5.3): a hand stand-in
(0.4 kg body, `HandMaxForce` 90 N chase) grabs a 1 kg prop via ConeTwist weld (server type
verified, configure-before-AddChild); drag 2 m + 360° fast-spin sub-phase — asserts RIGID
relative pose (grab-rotation error < 2° drag / < 3.5° spin, anchor separation < 1 cm — the
winch/orbit regression; 2° is the gravity-on floor for an off-center grab, don't tighten
below that); two-handed phase (second weld soft, bias 0.3): primary anchor < 1 cm rigid
under full tug, tug give bounded < 0.25 m (recovers), follow-regime anchor < 0.05 m; then
primary releases, survivor weld recreated rigid (takeover) and rigidity re-asserted.
Reach-timeout guards; must pass 3× in a row (never accept flaky).
- **WeldSpike.cs** + `scenes/weld_spike.tscn` — regression for the PATCHED ADDON itself: two
bodies welded via ConeTwistJoint3D (bias 0 rigid, then bias 0.5 soft); asserts relative-pose
rigidity under torque/force (rot < 2°, sep < 1 cm), bounded excursion + full recovery for
the soft weld. Run this FIRST whenever the addon binaries change.
Research (`research/`): seven full sourced reports — `vr-arm-ik.md`, `vr-arm-ik-deep.md`,
`vr-grabbing.md`, `vr-two-handed-grab.md` (grab v5.0→5.2 history + the verified Box3D pin
joint internals + dated CORRECTION section), `godot-box3d-weld.md` (the addon patch: weld
mapping, param table, build recipe, risks), `vr-body-physics-hugs.md` (player-body physics +
hug prior art: Boneworks' one-powered-body lesson, active-ragdoll recipes, hug = wrap +
mutual force + reactivity; phased design), `vr-body-physics-godot.md` (verified Box3D
capabilities: no Generic6DOF, hinge VELOCITY motors work, PD-follow proven by the hands).
**READ these before re-researching those topics or changing the grab/IK/body implementations**
— they carry the reasoning behind the current designs. Stink-O's box3d-godot (custom components, 8 joint types) remains the documented
plan-B if the patched-wrapper path is ever abandoned (owner pointed at it; adopting it means
leaving the standard RigidBody3D stack).
Scene wiring (`scenes/main.tscn`): Main → WorldEnvironment, Sun, Floor, XROrigin3D (XRCamera3D,
LeftHand, RightHand, WorldPanel), SpawnPoint, 3 TeleportMarkers, Locomotion, AnchorMoveMode,
PropShop, PropGrabber, PhysicsHands, BodyRig, FadeLayer/Fade. Action map: `openxr_action_map.tres`.
## 4. Architecture decisions that must not regress
- **World-space grab/turn math**: all grab deltas and two-hand turn pivots computed in WORLD space.
Tracking-space math made two-hand turns orbit the map center. Do not regress.
- **Box3D PinJoint3D quirks (grab history v5.0–v5.2)**: Box3D's PinJoint3D is a TRUE ball joint
with ANCHORS CAPTURED AT CREATION (not vanilla Godot origin-pinning). The joint node's
position is READ EVERY TICK (re-setting `GlobalPosition` each tick = hand-following pivot).
Its hidden rotational spring (hertz = bias × 30) targets IDENTITY — aligns the two bodies'
world orientations, uncapped, hits both bodies; that was the root of the v5.0–v5.2 grab
misbehavior (see the CORRECTION section of `research/vr-two-handed-grab.md`). The positional
(point-to-point) tuning (engine default 60 Hz/ζ2.0 clamped to 0.25×tick) is NOT reachable
from the wrapper. IMPULSE_CLAMP is a warned no-op.
- **Box3D WeldJoint via the local patch (grab v5.3 lives on it)**: the patch maps Godot's
ConeTwistJoint3D → `b3WeldJoint`, PRE-CORRECTING frame B with both bodies' rotations so the
weld locks the relative pose at creation. Bias semantics are INVERTED vs the pin joint:
bias 0 = MAX stiffness (rigid), bias 0.3 = 9 Hz give. Swing/twist spans > 0 and
softness/relaxation are warned no-ops (the weld has no limits). The weld is NOT a
hand-following pivot — moving the joint node after creation does nothing, and none is needed.
Only the Linux binary is patched (`bin-stock/` holds originals; `.vendor/` the source,
patch, and rebuild recipe). Pin/hinge/slider paths untouched — grab v5.2.1 remains runnable
as fallback (git history). Stink-O's box3d-godot (custom components, more joint types) is
the plan-B.
- **Collision layers**: 1 = world, 2 = UI. Panel/knob bodies are layer 2 mask 0 (props pass through
the menu); hands layer 1. Layers/masks live on `CollisionObject3D`, never `CollisionShape3D`.
- **Ghosts belong to hands, not props** — the whole arm gets a ghost later.
- **Standard-RigidBody3D-only** for scene objects and tracked bodies, so the Jolt fallback stays lossless.
- **`pose_recentered` is never used for placement** — WiVRN fires it continuously (~90 Hz);
reacting to it was the original view-locked-forward bug.
- Panel parented under `XROrigin3D` (deliberate feel); grip thresholds polled as Floats (0.6),
bool actions unreliable across runtimes.
## 5. Known issues / open questions
- **Grab v5.3 feel NOT yet validated in-headset** — v5.2's playtest rejected the whole
point-joint + servo category ("hanging on a winch": ball orbits the palm, anvil surface
drifts, palms clip). v5.3 replaces it with a solver-owned rigid weld (locally patched addon,
ConeTwistJoint3D → b3WeldJoint). Headless: grab spike (rigidity under drag + fast spin,
two-handed, takeover), weld spike, physics spike — all PASS 3× deterministic. Known honest
physics, not bugs: ~1.2° elastic sag on off-center grabs under gravity (lever-dependent,
mass-independent), ~15 cm of give if the second hand pulls at full force (recovers; 0.25 m
break is the backstop), and a heavy prop genuinely strains/drags the 0.4 kg hand (that IS
the weight feel; > 2 m hand-controller lag = "grip dragged off" break).
- **Hand reaction / weight-through-hand** — restored for real in v5.3: the weld transmits
prop weight/forces through the solver into the 0.4 kg hand's 90 N chase budget. Validate
the heft in-headset.
- Palm offset signs (left +, right −) are taste choices — flip if grabs feel mirrored.
- Near-extension arm shake should be fixed by soft IK (`SoftIKFactor` 0.98) — unvalidated
in-headset; lower toward 0.95 if shake persists.
- One-frame arm jumps on hand-body snap/teleport (stuck snap, SnapDistance, teleport).
- IK research applied except anything marked open in `research/vr-arm-ik*.md`.
- Hand tracking spike not started (`XRHandTracker` through WiVRN, see `openxr_hand_tracking.rst`).
- Head cube front face can peek at extreme down-angles (bump `HeadBackOffset` if it bothers).
- Real character model: import pipeline owned elsewhere (consumes Skeleton3D + AnimationPlayer).
Migration path for IK: `SkeletonIK3D` deprecated in 4.7 → custom `SkeletonModifier3D`
(`_process_modification_with_delta`) with our own analytic 2-bone solve (no built-in supports an
elbow pole) + `LookAtModifier3D` for head→neck look distribution (head authoritative, neck
cosmetic, stacked above head's). HL:Alyx shipped hands-only deliberately (arms mislead without
proprioception) — keep arms cheap and honest.
## 6. Tuning knobs
| Constant | File | Value | Controls |
|---|---|---|---|
| `HandMaxForce` | PhysicsHands.cs | 90 N | hand chase force; also used by GrabSpike stand-in |
| `HandMass` | PhysicsHands.cs | 0.4 kg | hand body mass |
| `HandMaxSpin` | PhysicsHands.cs | 120 rad/s | hand rotation-lock clamp |
| `StuckDistance` / `StuckTime` | PhysicsHands.cs | 0.2 m / 1.0 s | stuck snap (logged) — SUSPENDED while that hand grips |
| `SnapDistance` | PhysicsHands.cs | 2 m | silent teleport snap of hand body — SUSPENDED while that hand grips |
| `GhostLagDistance` | PhysicsHands.cs | 0.1 m | when the hand ghost shows |
| `RigidWeldBias` | PropGrabber.cs | 0 | weld bias for BOTH hands (v5.3.1): 0 = MAX stiffness (rigid) — INVERTED vs pin-joint bias semantics; raise only as a fallback if two-handed wrist-conflict jitters |
| `GripRipDistance` | PropGrabber.cs | 0.8 m | instant release, no timer (checked before the break timer) |
| `BreakDistance` / `BreakTime` / `BreakDistanceBoth` | PropGrabber.cs | 0.25 m / 0.25 s / 0.4 m | grip break: sustained anchor separation (single hand) / both-hands threshold |
| `GrabRadius` / `CenterGrabRadius` | PropGrabber.cs | 0.16 m / 0.12 m | palm-point / hand-center grab range |
| `PalmGrabOffset` | PropGrabber.cs | ±0.045 m along hand +X (left +, right −) | grab point on the palm (taste choice) |
| Grip/trigger thresholds | several files | 0.6 | polled Float edges |
| `SoftIKFactor` | BodyRig.cs | 0.98 | shoulder→hand distance clamp before IK solve (lower toward 0.95 if shake persists) |
| `PoleHandWeight` / `PoleSmoothRate` | BodyRig.cs | 0.25 / 10 | elbow pole: hand-forward push / exp smoothing |
| `TorsoYawHandsWeight` / `TorsoYawForwardness` / `TorsoYawSmoothRate` | BodyRig.cs | 0.6 / 0.3 m / 12 | torso yaw spring: hands blend / forwardness normalizer / exp smoothing (≈200 ms) |
| `armTotal` | BodyRig.cs | clamp(0.46×headHeight, 0.5, 0.72) | total arm length, 50/50 split |
| `ReachPushSpeed` / `ReachDecaySpeed` | BodyRig.cs | 0.8 / 0.4 m/s | torso lean toward / back from overreach |
| `ReachDecayFactor` | BodyRig.cs | 0.92 | hysteresis band (decay only below 92% of reach) |
| `ReachBudgetMax` | BodyRig.cs | 0.25 m | horizontal torso offset clamp |
| `ReachVerticalMin/Max` | BodyRig.cs | −0.05 / +0.12 m | vertical torso offset clamp (stops upward oscillation) |
| `HeadBackOffset` / `TorsoBackOffset` / `TorsoDrop` | BodyRig.cs | 0.06 / 0.06 / 0.42 m | keeps boxes out of the view |
| `LaserMinDistance` | Locomotion.cs | 0.35 m | laser hidden nearer than this (tip touch instead) |
| `MarkerNearDistance` | Locomotion.cs | 1.2 m | near markers hide + become untargetable |
| Fade times | Locomotion.cs | 0.12 s in / 0.25 s out | teleport fade |
| `PressContactDistance` / `PressCooldownMs` | WorldPanel.cs | 0.045 m / 400 ms | knob press on contact |
| `DirectGrabRadius` / `TipHoverRadius` / `TipLength` | WorldPanel.cs | 0.45 / 0.07 / 0.12 m | panel grab / tip hover / stylus reach |
| `MaximumRefreshRate` | Main.cs | 90 | physics tick = chosen refresh rate |
## 7. Next steps (in order)
1. **Body physics milestone (player arms + chest, for hugs)** — designed in
`research/vr-body-physics-hugs.md` §5 + verified against the stack in
`research/vr-body-physics-godot.md`. Phase 1: torso = one RigidBody3D chase-driven toward an
HMD-derived target (PD, force-clamped — the PhysicsHands pattern at bigger mass), camera stays
kinematic (Boneworks sickness lesson), arms stay analytic IK + palm bodies, collision layer 3
for the body. Phase 2 (only if arms clip on contact): active-ragdoll arm segments on Box3D
hinge velocity motors, per-tick PD toward the analytic IK angles. Hug detection = contact
events (palms + chest vs NPC torso, sustained ~0.5 s), NPC reactivity on top. NOT STARTED —
awaiting owner go-ahead + grab v5.3.1 playtest verdict.
2. **Validate grab v5.3.1 in-headset** — rigid-rigid two-handed welds (secondary no longer
gives ~10 cm; expect hands to strain instead when demands conflict, plus a brief ~3° ring
when one hand releases). Watch for jitter under opposed wrist twist — if it appears,
`RigidWeldBias` on the secondary is the fallback knob. "Angular strength underwhelming" on
heavy props one-handed is the Box3D solver stiffness floor (0.25×tick clamp ≈ 22.5 Hz at
90 Hz) — next lever if it still bothers: raise `Engine.PhysicsTicksPerSecond` above the
refresh rate (the clamp scales with tick), an experiment with global consequences.
2. **Grab points on special objects** — grab is palm-proximity to the prop origin today; special
objects may want authored grab anchors (see `research/vr-grabbing.md` taxonomy).
3. **Physical torso/head capsules** — replace the mesh-only placeholders with physics bodies.
4. **Hand tracking spike** — `XRHandTracker` through WiVRN; controller tip and fingertip must feed
the same cursor concept (interaction must not assume controllers).
5. **Neck/head look system** — `LookAtModifier3D` head→neck distribution (needs a real skeleton).
6. **EMBODYING** — view-follow + avatar remapping (chest calibration transform); experiments only
after head/neck look override exists. Theory until proven.
7. **UI milestone** — deliberate full action-map pass + SubViewport/composition-layer decision for
dense screens (at most ONE such screen; everything else stays props).
## 8. Working agreements
- Read `AGENTS.md` fully before working; it overrides this file where they differ.
- Agent-orchestration workflow: the orchestrator delegates to subagents and reviews their output.
- The USER playtests in VR and reports feel — nothing "works" until the owner confirms in-headset.
- Engine logs (`~/.local/share/godot/app_userdata/VR Messs/logs/godot.log`) are the diagnosis
source after playtests — read them instead of guessing.
- Spikes are cheap and throwaway; a failed experiment is progress, not rework.
- No GDScript-only addon dependencies (Godot XR Tools is reference-only); C# everywhere.
+8
View File
@@ -0,0 +1,8 @@
<Project Sdk="Godot.NET.Sdk/4.7.2">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<EnableDynamicLoading>true</EnableDynamicLoading>
<AssemblyName>VR Messs</AssemblyName>
<RootNamespace>VRMesss</RootNamespace>
</PropertyGroup>
</Project>
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Mark Arneman
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,14 @@
[configuration]
entry_symbol = "godot_box3d_main"
compatibility_minimum = "4.3"
reloadable = true
[libraries]
linux.debug.x86_64 = "res://addons/godot-box3d/bin/libgodot-box3d.so"
linux.release.x86_64 = "res://addons/godot-box3d/bin/libgodot-box3d.so"
windows.debug.x86_64 = "res://addons/godot-box3d/bin/godot-box3d.dll"
windows.release.x86_64 = "res://addons/godot-box3d/bin/godot-box3d.dll"
macos.debug = "res://addons/godot-box3d/bin/libgodot-box3d.dylib"
macos.release = "res://addons/godot-box3d/bin/libgodot-box3d.dylib"
@@ -0,0 +1 @@
uid://cwrhma7nfgvfm
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"><rect width="124" height="124" x="2" y="2" fill="#363d52" stroke="#212532" stroke-width="4" rx="14"/><g fill="#fff" transform="translate(12.322 12.322)scale(.101)"><path d="M105 673v33q407 354 814 0v-33z"/><path fill="#478cbf" d="m105 673 152 14q12 1 15 14l4 67 132 10 8-61q2-11 15-15h162q13 4 15 15l8 61 132-10 4-67q3-13 15-14l152-14V427q30-39 56-81-35-59-83-108-43 20-82 47-40-37-88-64 7-51 8-102-59-28-123-42-26 43-46 89-49-7-98 0-20-46-46-89-64 14-123 42 1 51 8 102-48 27-88 64-39-27-82-47-48 49-83 108 26 42 56 81zm0 33v39c0 276 813 276 814 0v-39l-134 12-5 69q-2 10-14 13l-162 11q-12 0-16-11l-10-65H446l-10 65q-4 11-16 11l-162-11q-12-3-14-13l-5-69z"/><path d="M483 600c0 34 58 34 58 0v-86c0-34-58-34-58 0z"/><circle cx="725" cy="526" r="90"/><circle cx="299" cy="526" r="90"/></g><g fill="#414042" transform="translate(12.322 12.322)scale(.101)"><circle cx="307" cy="532" r="60"/><circle cx="717" cy="532" r="60"/></g></svg>

After

Width:  |  Height:  |  Size: 995 B

+43
View File
@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://ci36bypqhu2oq"
path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://icon.svg"
dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false
+621
View File
@@ -0,0 +1,621 @@
[gd_resource type="OpenXRActionMap" format=3 uid="uid://l8snyoaxq5hf"]
[sub_resource type="OpenXRAction" id="OpenXRAction_6ivru"]
resource_name = "trigger"
localized_name = "Trigger"
toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right")
[sub_resource type="OpenXRAction" id="OpenXRAction_vfhwq"]
resource_name = "trigger_click"
localized_name = "Trigger click"
action_type = 0
toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right")
[sub_resource type="OpenXRAction" id="OpenXRAction_5w03k"]
resource_name = "trigger_touch"
localized_name = "Trigger touching"
action_type = 0
toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right")
[sub_resource type="OpenXRAction" id="OpenXRAction_typ1r"]
resource_name = "grip"
localized_name = "Grip"
toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right")
[sub_resource type="OpenXRAction" id="OpenXRAction_clvbf"]
resource_name = "grip_click"
localized_name = "Grip click"
action_type = 0
toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right")
[sub_resource type="OpenXRAction" id="OpenXRAction_5bppb"]
resource_name = "grip_force"
localized_name = "Grip force"
toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right")
[sub_resource type="OpenXRAction" id="OpenXRAction_3k6la"]
resource_name = "primary"
localized_name = "Primary joystick/thumbstick/trackpad"
action_type = 2
toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right")
[sub_resource type="OpenXRAction" id="OpenXRAction_i8esw"]
resource_name = "primary_click"
localized_name = "Primary joystick/thumbstick/trackpad click"
action_type = 0
toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right")
[sub_resource type="OpenXRAction" id="OpenXRAction_um1hv"]
resource_name = "primary_touch"
localized_name = "Primary joystick/thumbstick/trackpad touching"
action_type = 0
toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right")
[sub_resource type="OpenXRAction" id="OpenXRAction_oqnsu"]
resource_name = "secondary"
localized_name = "Secondary joystick/thumbstick/trackpad"
action_type = 2
toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right")
[sub_resource type="OpenXRAction" id="OpenXRAction_r5bl7"]
resource_name = "secondary_click"
localized_name = "Secondary joystick/thumbstick/trackpad click"
action_type = 0
toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right")
[sub_resource type="OpenXRAction" id="OpenXRAction_ytptc"]
resource_name = "secondary_touch"
localized_name = "Secondary joystick/thumbstick/trackpad touching"
action_type = 0
toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right")
[sub_resource type="OpenXRAction" id="OpenXRAction_iphn4"]
resource_name = "menu_button"
localized_name = "Menu button"
action_type = 0
toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right")
[sub_resource type="OpenXRAction" id="OpenXRAction_3p2as"]
resource_name = "select_button"
localized_name = "Select button"
action_type = 0
toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right")
[sub_resource type="OpenXRAction" id="OpenXRAction_wdehm"]
resource_name = "ax_button"
localized_name = "A/X button"
action_type = 0
toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right")
[sub_resource type="OpenXRAction" id="OpenXRAction_clfly"]
resource_name = "ax_touch"
localized_name = "A/X touching"
action_type = 0
toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right")
[sub_resource type="OpenXRAction" id="OpenXRAction_e1frq"]
resource_name = "by_button"
localized_name = "B/Y button"
action_type = 0
toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right")
[sub_resource type="OpenXRAction" id="OpenXRAction_l7aq8"]
resource_name = "by_touch"
localized_name = "B/Y touching"
action_type = 0
toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right")
[sub_resource type="OpenXRAction" id="OpenXRAction_oi0ij"]
resource_name = "default_pose"
localized_name = "Default pose"
action_type = 3
toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right", "/user/vive_tracker_htcx/role/left_foot", "/user/vive_tracker_htcx/role/right_foot", "/user/vive_tracker_htcx/role/left_shoulder", "/user/vive_tracker_htcx/role/right_shoulder", "/user/vive_tracker_htcx/role/left_elbow", "/user/vive_tracker_htcx/role/right_elbow", "/user/vive_tracker_htcx/role/left_knee", "/user/vive_tracker_htcx/role/right_knee", "/user/vive_tracker_htcx/role/waist", "/user/vive_tracker_htcx/role/chest", "/user/vive_tracker_htcx/role/camera", "/user/vive_tracker_htcx/role/keyboard", "/user/vive_tracker_htcx/role/left_wrist", "/user/vive_tracker_htcx/role/right_wrist", "/user/vive_tracker_htcx/role/left_ankle", "/user/vive_tracker_htcx/role/right_ankle", "/user/eyes_ext")
[sub_resource type="OpenXRAction" id="OpenXRAction_m08eo"]
resource_name = "aim_pose"
localized_name = "Aim pose"
action_type = 3
toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right")
[sub_resource type="OpenXRAction" id="OpenXRAction_c4j1d"]
resource_name = "grip_pose"
localized_name = "Grip pose"
action_type = 3
toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right")
[sub_resource type="OpenXRAction" id="OpenXRAction_sopde"]
resource_name = "palm_pose"
localized_name = "Palm pose"
action_type = 3
toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right")
[sub_resource type="OpenXRAction" id="OpenXRAction_sow2k"]
resource_name = "haptic"
localized_name = "Haptic"
action_type = 4
toplevel_paths = PackedStringArray("/user/hand/left", "/user/hand/right", "/user/vive_tracker_htcx/role/left_foot", "/user/vive_tracker_htcx/role/right_foot", "/user/vive_tracker_htcx/role/left_shoulder", "/user/vive_tracker_htcx/role/right_shoulder", "/user/vive_tracker_htcx/role/left_elbow", "/user/vive_tracker_htcx/role/right_elbow", "/user/vive_tracker_htcx/role/left_knee", "/user/vive_tracker_htcx/role/right_knee", "/user/vive_tracker_htcx/role/waist", "/user/vive_tracker_htcx/role/chest", "/user/vive_tracker_htcx/role/camera", "/user/vive_tracker_htcx/role/keyboard", "/user/vive_tracker_htcx/role/left_wrist", "/user/vive_tracker_htcx/role/right_wrist", "/user/vive_tracker_htcx/role/left_ankle", "/user/vive_tracker_htcx/role/right_ankle")
[sub_resource type="OpenXRActionSet" id="OpenXRActionSet_ngwcy"]
resource_name = "godot"
localized_name = "Godot action set"
actions = [SubResource("OpenXRAction_6ivru"), SubResource("OpenXRAction_vfhwq"), SubResource("OpenXRAction_5w03k"), SubResource("OpenXRAction_typ1r"), SubResource("OpenXRAction_clvbf"), SubResource("OpenXRAction_5bppb"), SubResource("OpenXRAction_3k6la"), SubResource("OpenXRAction_i8esw"), SubResource("OpenXRAction_um1hv"), SubResource("OpenXRAction_oqnsu"), SubResource("OpenXRAction_r5bl7"), SubResource("OpenXRAction_ytptc"), SubResource("OpenXRAction_iphn4"), SubResource("OpenXRAction_3p2as"), SubResource("OpenXRAction_wdehm"), SubResource("OpenXRAction_clfly"), SubResource("OpenXRAction_e1frq"), SubResource("OpenXRAction_l7aq8"), SubResource("OpenXRAction_oi0ij"), SubResource("OpenXRAction_m08eo"), SubResource("OpenXRAction_c4j1d"), SubResource("OpenXRAction_sopde"), SubResource("OpenXRAction_sow2k")]
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_r3qn1"]
action = SubResource("OpenXRAction_oi0ij")
binding_path = "/user/hand/left/input/aim/pose"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_n01b8"]
action = SubResource("OpenXRAction_oi0ij")
binding_path = "/user/hand/right/input/aim/pose"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_pjtev"]
action = SubResource("OpenXRAction_m08eo")
binding_path = "/user/hand/left/input/aim/pose"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_nqyri"]
action = SubResource("OpenXRAction_m08eo")
binding_path = "/user/hand/right/input/aim/pose"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_86uui"]
action = SubResource("OpenXRAction_c4j1d")
binding_path = "/user/hand/left/input/grip/pose"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_nrtxc"]
action = SubResource("OpenXRAction_c4j1d")
binding_path = "/user/hand/right/input/grip/pose"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_qovyo"]
action = SubResource("OpenXRAction_sopde")
binding_path = "/user/hand/left/input/grip_surface/pose"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_d6uso"]
action = SubResource("OpenXRAction_sopde")
binding_path = "/user/hand/right/input/grip_surface/pose"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_hvi7v"]
action = SubResource("OpenXRAction_wdehm")
binding_path = "/user/hand/left/input/primary/click"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_7dxun"]
action = SubResource("OpenXRAction_wdehm")
binding_path = "/user/hand/right/input/primary/click"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_rp8ih"]
action = SubResource("OpenXRAction_e1frq")
binding_path = "/user/hand/left/input/secondary/click"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_0uca0"]
action = SubResource("OpenXRAction_e1frq")
binding_path = "/user/hand/right/input/secondary/click"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_rjtq8"]
action = SubResource("OpenXRAction_6ivru")
binding_path = "/user/hand/left/input/trigger/value"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_lce2q"]
action = SubResource("OpenXRAction_6ivru")
binding_path = "/user/hand/right/input/trigger/value"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_ckeh6"]
action = SubResource("OpenXRAction_vfhwq")
binding_path = "/user/hand/left/input/trigger/value"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_538mi"]
action = SubResource("OpenXRAction_vfhwq")
binding_path = "/user/hand/right/input/trigger/value"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_548p5"]
action = SubResource("OpenXRAction_typ1r")
binding_path = "/user/hand/left/input/squeeze/value"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_6o0wr"]
action = SubResource("OpenXRAction_typ1r")
binding_path = "/user/hand/right/input/squeeze/value"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_fsghu"]
action = SubResource("OpenXRAction_clvbf")
binding_path = "/user/hand/left/input/squeeze/value"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_88umk"]
action = SubResource("OpenXRAction_clvbf")
binding_path = "/user/hand/right/input/squeeze/value"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_4uneg"]
action = SubResource("OpenXRAction_3k6la")
binding_path = "/user/hand/left/input/thumbstick"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_67o31"]
action = SubResource("OpenXRAction_3k6la")
binding_path = "/user/hand/right/input/thumbstick"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_lf1a1"]
action = SubResource("OpenXRAction_i8esw")
binding_path = "/user/hand/left/input/thumbstick/click"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_x1adc"]
action = SubResource("OpenXRAction_i8esw")
binding_path = "/user/hand/right/input/thumbstick/click"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_j1vtv"]
action = SubResource("OpenXRAction_sow2k")
binding_path = "/user/hand/left/output/haptic"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_tud50"]
action = SubResource("OpenXRAction_sow2k")
binding_path = "/user/hand/right/output/haptic"
[sub_resource type="OpenXRInteractionProfile" id="OpenXRInteractionProfile_akdt0"]
interaction_profile_path = "/interaction_profiles/khr/generic_controller"
bindings = [SubResource("OpenXRIPBinding_r3qn1"), SubResource("OpenXRIPBinding_n01b8"), SubResource("OpenXRIPBinding_pjtev"), SubResource("OpenXRIPBinding_nqyri"), SubResource("OpenXRIPBinding_86uui"), SubResource("OpenXRIPBinding_nrtxc"), SubResource("OpenXRIPBinding_qovyo"), SubResource("OpenXRIPBinding_d6uso"), SubResource("OpenXRIPBinding_hvi7v"), SubResource("OpenXRIPBinding_7dxun"), SubResource("OpenXRIPBinding_rp8ih"), SubResource("OpenXRIPBinding_0uca0"), SubResource("OpenXRIPBinding_rjtq8"), SubResource("OpenXRIPBinding_lce2q"), SubResource("OpenXRIPBinding_ckeh6"), SubResource("OpenXRIPBinding_538mi"), SubResource("OpenXRIPBinding_548p5"), SubResource("OpenXRIPBinding_6o0wr"), SubResource("OpenXRIPBinding_fsghu"), SubResource("OpenXRIPBinding_88umk"), SubResource("OpenXRIPBinding_4uneg"), SubResource("OpenXRIPBinding_67o31"), SubResource("OpenXRIPBinding_lf1a1"), SubResource("OpenXRIPBinding_x1adc"), SubResource("OpenXRIPBinding_j1vtv"), SubResource("OpenXRIPBinding_tud50")]
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_xri1r"]
action = SubResource("OpenXRAction_oi0ij")
binding_path = "/user/hand/left/input/aim/pose"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_etqcv"]
action = SubResource("OpenXRAction_oi0ij")
binding_path = "/user/hand/right/input/aim/pose"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_og5pg"]
action = SubResource("OpenXRAction_m08eo")
binding_path = "/user/hand/left/input/aim/pose"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_nwe40"]
action = SubResource("OpenXRAction_m08eo")
binding_path = "/user/hand/right/input/aim/pose"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_ts2ff"]
action = SubResource("OpenXRAction_c4j1d")
binding_path = "/user/hand/left/input/grip/pose"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_yhsv0"]
action = SubResource("OpenXRAction_c4j1d")
binding_path = "/user/hand/right/input/grip/pose"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_sf2dt"]
action = SubResource("OpenXRAction_sopde")
binding_path = "/user/hand/left/input/grip_surface/pose"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_67dwi"]
action = SubResource("OpenXRAction_sopde")
binding_path = "/user/hand/right/input/grip_surface/pose"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_hswdx"]
action = SubResource("OpenXRAction_iphn4")
binding_path = "/user/hand/left/input/menu/click"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_7gr0f"]
action = SubResource("OpenXRAction_iphn4")
binding_path = "/user/hand/right/input/system/click"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_uvspk"]
action = SubResource("OpenXRAction_wdehm")
binding_path = "/user/hand/left/input/x/click"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_ica2g"]
action = SubResource("OpenXRAction_wdehm")
binding_path = "/user/hand/right/input/a/click"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_5fecu"]
action = SubResource("OpenXRAction_clfly")
binding_path = "/user/hand/left/input/x/touch"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_0nfxl"]
action = SubResource("OpenXRAction_clfly")
binding_path = "/user/hand/right/input/a/touch"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_sbe0d"]
action = SubResource("OpenXRAction_e1frq")
binding_path = "/user/hand/left/input/y/click"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_rf1ko"]
action = SubResource("OpenXRAction_e1frq")
binding_path = "/user/hand/right/input/b/click"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_jx7ge"]
action = SubResource("OpenXRAction_l7aq8")
binding_path = "/user/hand/left/input/y/touch"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_d2w1t"]
action = SubResource("OpenXRAction_l7aq8")
binding_path = "/user/hand/right/input/b/touch"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_v2kct"]
action = SubResource("OpenXRAction_6ivru")
binding_path = "/user/hand/left/input/trigger/value"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_37uq4"]
action = SubResource("OpenXRAction_6ivru")
binding_path = "/user/hand/right/input/trigger/value"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_kooyb"]
action = SubResource("OpenXRAction_vfhwq")
binding_path = "/user/hand/left/input/trigger/value"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_51qre"]
action = SubResource("OpenXRAction_vfhwq")
binding_path = "/user/hand/right/input/trigger/value"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_fncxp"]
action = SubResource("OpenXRAction_5w03k")
binding_path = "/user/hand/left/input/trigger/touch"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_qi50k"]
action = SubResource("OpenXRAction_5w03k")
binding_path = "/user/hand/right/input/trigger/touch"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_h5icu"]
action = SubResource("OpenXRAction_typ1r")
binding_path = "/user/hand/left/input/squeeze/value"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_b1sv6"]
action = SubResource("OpenXRAction_typ1r")
binding_path = "/user/hand/right/input/squeeze/value"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_yu2t6"]
action = SubResource("OpenXRAction_clvbf")
binding_path = "/user/hand/left/input/squeeze/value"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_labib"]
action = SubResource("OpenXRAction_clvbf")
binding_path = "/user/hand/right/input/squeeze/value"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_altuc"]
action = SubResource("OpenXRAction_3k6la")
binding_path = "/user/hand/left/input/thumbstick"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_7p0fp"]
action = SubResource("OpenXRAction_3k6la")
binding_path = "/user/hand/right/input/thumbstick"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_yjnix"]
action = SubResource("OpenXRAction_i8esw")
binding_path = "/user/hand/left/input/thumbstick/click"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_pgh0x"]
action = SubResource("OpenXRAction_i8esw")
binding_path = "/user/hand/right/input/thumbstick/click"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_lplyu"]
action = SubResource("OpenXRAction_um1hv")
binding_path = "/user/hand/left/input/thumbstick/touch"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_ifnya"]
action = SubResource("OpenXRAction_um1hv")
binding_path = "/user/hand/right/input/thumbstick/touch"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_jl4vo"]
action = SubResource("OpenXRAction_sow2k")
binding_path = "/user/hand/left/output/haptic"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_1n6j6"]
action = SubResource("OpenXRAction_sow2k")
binding_path = "/user/hand/right/output/haptic"
[sub_resource type="OpenXRInteractionProfile" id="OpenXRInteractionProfile_o1nfs"]
interaction_profile_path = "/interaction_profiles/oculus/touch_controller"
bindings = [SubResource("OpenXRIPBinding_xri1r"), SubResource("OpenXRIPBinding_etqcv"), SubResource("OpenXRIPBinding_og5pg"), SubResource("OpenXRIPBinding_nwe40"), SubResource("OpenXRIPBinding_ts2ff"), SubResource("OpenXRIPBinding_yhsv0"), SubResource("OpenXRIPBinding_sf2dt"), SubResource("OpenXRIPBinding_67dwi"), SubResource("OpenXRIPBinding_hswdx"), SubResource("OpenXRIPBinding_7gr0f"), SubResource("OpenXRIPBinding_uvspk"), SubResource("OpenXRIPBinding_ica2g"), SubResource("OpenXRIPBinding_5fecu"), SubResource("OpenXRIPBinding_0nfxl"), SubResource("OpenXRIPBinding_sbe0d"), SubResource("OpenXRIPBinding_rf1ko"), SubResource("OpenXRIPBinding_jx7ge"), SubResource("OpenXRIPBinding_d2w1t"), SubResource("OpenXRIPBinding_v2kct"), SubResource("OpenXRIPBinding_37uq4"), SubResource("OpenXRIPBinding_kooyb"), SubResource("OpenXRIPBinding_51qre"), SubResource("OpenXRIPBinding_fncxp"), SubResource("OpenXRIPBinding_qi50k"), SubResource("OpenXRIPBinding_h5icu"), SubResource("OpenXRIPBinding_b1sv6"), SubResource("OpenXRIPBinding_yu2t6"), SubResource("OpenXRIPBinding_labib"), SubResource("OpenXRIPBinding_altuc"), SubResource("OpenXRIPBinding_7p0fp"), SubResource("OpenXRIPBinding_yjnix"), SubResource("OpenXRIPBinding_pgh0x"), SubResource("OpenXRIPBinding_lplyu"), SubResource("OpenXRIPBinding_ifnya"), SubResource("OpenXRIPBinding_jl4vo"), SubResource("OpenXRIPBinding_1n6j6")]
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_unnrh"]
action = SubResource("OpenXRAction_oi0ij")
binding_path = "/user/hand/left/input/aim/pose"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_3wafl"]
action = SubResource("OpenXRAction_oi0ij")
binding_path = "/user/hand/right/input/aim/pose"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_tjb53"]
action = SubResource("OpenXRAction_m08eo")
binding_path = "/user/hand/left/input/aim/pose"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_lcg2b"]
action = SubResource("OpenXRAction_m08eo")
binding_path = "/user/hand/right/input/aim/pose"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_sp6l2"]
action = SubResource("OpenXRAction_c4j1d")
binding_path = "/user/hand/left/input/grip/pose"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_xj6ir"]
action = SubResource("OpenXRAction_c4j1d")
binding_path = "/user/hand/right/input/grip/pose"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_igmf3"]
action = SubResource("OpenXRAction_sopde")
binding_path = "/user/hand/left/input/grip_surface/pose"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_inw5v"]
action = SubResource("OpenXRAction_sopde")
binding_path = "/user/hand/right/input/grip_surface/pose"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_iy2wq"]
action = SubResource("OpenXRAction_3p2as")
binding_path = "/user/hand/left/input/system/click"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_plu03"]
action = SubResource("OpenXRAction_3p2as")
binding_path = "/user/hand/right/input/system/click"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_dad45"]
action = SubResource("OpenXRAction_iphn4")
binding_path = "/user/hand/left/input/menu/click"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_m5e8q"]
action = SubResource("OpenXRAction_wdehm")
binding_path = "/user/hand/left/input/x/click"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_5t7jh"]
action = SubResource("OpenXRAction_wdehm")
binding_path = "/user/hand/right/input/a/click"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_chplt"]
action = SubResource("OpenXRAction_clfly")
binding_path = "/user/hand/left/input/x/touch"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_obxrh"]
action = SubResource("OpenXRAction_clfly")
binding_path = "/user/hand/right/input/a/touch"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_on7oi"]
action = SubResource("OpenXRAction_e1frq")
binding_path = "/user/hand/left/input/y/click"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_ege4h"]
action = SubResource("OpenXRAction_e1frq")
binding_path = "/user/hand/right/input/b/click"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_h7ix0"]
action = SubResource("OpenXRAction_l7aq8")
binding_path = "/user/hand/left/input/y/touch"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_8qanm"]
action = SubResource("OpenXRAction_l7aq8")
binding_path = "/user/hand/right/input/b/touch"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_3senm"]
action = SubResource("OpenXRAction_6ivru")
binding_path = "/user/hand/left/input/trigger/value"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_7ca55"]
action = SubResource("OpenXRAction_6ivru")
binding_path = "/user/hand/right/input/trigger/value"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_ih1l2"]
action = SubResource("OpenXRAction_vfhwq")
binding_path = "/user/hand/left/input/trigger/value"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_ipewn"]
action = SubResource("OpenXRAction_vfhwq")
binding_path = "/user/hand/right/input/trigger/value"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_5ngl7"]
action = SubResource("OpenXRAction_5w03k")
binding_path = "/user/hand/left/input/trigger/touch"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_klygg"]
action = SubResource("OpenXRAction_5w03k")
binding_path = "/user/hand/right/input/trigger/touch"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_4p63k"]
action = SubResource("OpenXRAction_typ1r")
binding_path = "/user/hand/left/input/squeeze/value"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_6vi2m"]
action = SubResource("OpenXRAction_typ1r")
binding_path = "/user/hand/right/input/squeeze/value"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_888d1"]
action = SubResource("OpenXRAction_clvbf")
binding_path = "/user/hand/left/input/squeeze/value"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_246v5"]
action = SubResource("OpenXRAction_clvbf")
binding_path = "/user/hand/right/input/squeeze/value"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_xj73r"]
action = SubResource("OpenXRAction_3k6la")
binding_path = "/user/hand/left/input/thumbstick"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_sugej"]
action = SubResource("OpenXRAction_3k6la")
binding_path = "/user/hand/right/input/thumbstick"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_fp7u7"]
action = SubResource("OpenXRAction_i8esw")
binding_path = "/user/hand/left/input/thumbstick/click"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_gvgeq"]
action = SubResource("OpenXRAction_i8esw")
binding_path = "/user/hand/right/input/thumbstick/click"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_i0s8c"]
action = SubResource("OpenXRAction_um1hv")
binding_path = "/user/hand/left/input/thumbstick/touch"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_ynetq"]
action = SubResource("OpenXRAction_um1hv")
binding_path = "/user/hand/right/input/thumbstick/touch"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_p8bcx"]
action = SubResource("OpenXRAction_sow2k")
binding_path = "/user/hand/left/output/haptic"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_jkemj"]
action = SubResource("OpenXRAction_sow2k")
binding_path = "/user/hand/right/output/haptic"
[sub_resource type="OpenXRInteractionProfile" id="OpenXRInteractionProfile_d3nfp"]
interaction_profile_path = "/interaction_profiles/bytedance/pico4_controller"
bindings = [SubResource("OpenXRIPBinding_unnrh"), SubResource("OpenXRIPBinding_3wafl"), SubResource("OpenXRIPBinding_tjb53"), SubResource("OpenXRIPBinding_lcg2b"), SubResource("OpenXRIPBinding_sp6l2"), SubResource("OpenXRIPBinding_xj6ir"), SubResource("OpenXRIPBinding_igmf3"), SubResource("OpenXRIPBinding_inw5v"), SubResource("OpenXRIPBinding_iy2wq"), SubResource("OpenXRIPBinding_plu03"), SubResource("OpenXRIPBinding_dad45"), SubResource("OpenXRIPBinding_m5e8q"), SubResource("OpenXRIPBinding_5t7jh"), SubResource("OpenXRIPBinding_chplt"), SubResource("OpenXRIPBinding_obxrh"), SubResource("OpenXRIPBinding_on7oi"), SubResource("OpenXRIPBinding_ege4h"), SubResource("OpenXRIPBinding_h7ix0"), SubResource("OpenXRIPBinding_8qanm"), SubResource("OpenXRIPBinding_3senm"), SubResource("OpenXRIPBinding_7ca55"), SubResource("OpenXRIPBinding_ih1l2"), SubResource("OpenXRIPBinding_ipewn"), SubResource("OpenXRIPBinding_5ngl7"), SubResource("OpenXRIPBinding_klygg"), SubResource("OpenXRIPBinding_4p63k"), SubResource("OpenXRIPBinding_6vi2m"), SubResource("OpenXRIPBinding_888d1"), SubResource("OpenXRIPBinding_246v5"), SubResource("OpenXRIPBinding_xj73r"), SubResource("OpenXRIPBinding_sugej"), SubResource("OpenXRIPBinding_fp7u7"), SubResource("OpenXRIPBinding_gvgeq"), SubResource("OpenXRIPBinding_i0s8c"), SubResource("OpenXRIPBinding_ynetq"), SubResource("OpenXRIPBinding_p8bcx"), SubResource("OpenXRIPBinding_jkemj")]
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_jyu76"]
action = SubResource("OpenXRAction_oi0ij")
binding_path = "/user/hand/left/input/aim/pose"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_aecy5"]
action = SubResource("OpenXRAction_oi0ij")
binding_path = "/user/hand/right/input/aim/pose"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_8vb80"]
action = SubResource("OpenXRAction_m08eo")
binding_path = "/user/hand/left/input/aim/pose"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_myee0"]
action = SubResource("OpenXRAction_m08eo")
binding_path = "/user/hand/right/input/aim/pose"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_1vv3a"]
action = SubResource("OpenXRAction_c4j1d")
binding_path = "/user/hand/left/input/grip/pose"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_tjysa"]
action = SubResource("OpenXRAction_c4j1d")
binding_path = "/user/hand/right/input/grip/pose"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_r4yxn"]
action = SubResource("OpenXRAction_sopde")
binding_path = "/user/hand/left/input/grip_surface/pose"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_me87v"]
action = SubResource("OpenXRAction_sopde")
binding_path = "/user/hand/right/input/grip_surface/pose"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_d8myu"]
action = SubResource("OpenXRAction_6ivru")
binding_path = "/user/hand/left/input/pinch_ext/value"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_hsh5n"]
action = SubResource("OpenXRAction_6ivru")
binding_path = "/user/hand/right/input/pinch_ext/value"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_lng5j"]
action = SubResource("OpenXRAction_vfhwq")
binding_path = "/user/hand/left/input/pinch_ext/value"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_aeeoj"]
action = SubResource("OpenXRAction_vfhwq")
binding_path = "/user/hand/right/input/pinch_ext/value"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_gosqu"]
action = SubResource("OpenXRAction_typ1r")
binding_path = "/user/hand/left/input/grasp_ext/value"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_n52fm"]
action = SubResource("OpenXRAction_typ1r")
binding_path = "/user/hand/right/input/grasp_ext/value"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_vushy"]
action = SubResource("OpenXRAction_clvbf")
binding_path = "/user/hand/left/input/grasp_ext/value"
[sub_resource type="OpenXRIPBinding" id="OpenXRIPBinding_lbhgg"]
action = SubResource("OpenXRAction_clvbf")
binding_path = "/user/hand/right/input/grasp_ext/value"
[sub_resource type="OpenXRInteractionProfile" id="OpenXRInteractionProfile_m1cgb"]
interaction_profile_path = "/interaction_profiles/ext/hand_interaction_ext"
bindings = [SubResource("OpenXRIPBinding_jyu76"), SubResource("OpenXRIPBinding_aecy5"), SubResource("OpenXRIPBinding_8vb80"), SubResource("OpenXRIPBinding_myee0"), SubResource("OpenXRIPBinding_1vv3a"), SubResource("OpenXRIPBinding_tjysa"), SubResource("OpenXRIPBinding_r4yxn"), SubResource("OpenXRIPBinding_me87v"), SubResource("OpenXRIPBinding_d8myu"), SubResource("OpenXRIPBinding_hsh5n"), SubResource("OpenXRIPBinding_lng5j"), SubResource("OpenXRIPBinding_aeeoj"), SubResource("OpenXRIPBinding_gosqu"), SubResource("OpenXRIPBinding_n52fm"), SubResource("OpenXRIPBinding_vushy"), SubResource("OpenXRIPBinding_lbhgg")]
[resource]
action_sets = [SubResource("OpenXRActionSet_ngwcy")]
interaction_profiles = [SubResource("OpenXRInteractionProfile_akdt0"), SubResource("OpenXRInteractionProfile_o1nfs"), SubResource("OpenXRInteractionProfile_d3nfp"), SubResource("OpenXRInteractionProfile_m1cgb")]
+41
View File
@@ -0,0 +1,41 @@
; Engine configuration file.
; It's best edited using the editor UI and not directly,
; since the parameters that go here are not all obvious.
;
; Format:
; [section] ; section goes between []
; param=value ; assign values to parameters
config_version=5
[application]
config/name="VR Messs"
run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.7", "C#", "Forward Plus")
config/icon="res://icon.svg"
[display]
window/stretch/mode="canvas_items"
window/stretch/aspect="expand"
[dotnet]
project/assembly_name="VR Messs"
[physics]
common/physics_ticks_per_second=90
common/max_physics_steps_per_frame=14
3d/physics_engine="Box3D Physics"
[rendering]
rendering_device/driver.windows="d3d12"
[xr]
openxr/enabled=true
openxr/startup_alert=false
shaders/enabled=true
+184
View File
@@ -0,0 +1,184 @@
# 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
```
+41
View File
@@ -0,0 +1,41 @@
# Research: arm IK stability, elbow poles, torso yaw, overextension (follow-up)
Second research pass for VR Messs, after v1.1 of the placeholder rig showed: residual shaking
when the arm tugs the body near (but not at) full extension, twitchy elbows driven by wrist
rotation, and pure-HMD chest yaw. Label: [S] = sourced, [I] = inference.
See also `vr-arm-ik.md` (prior art) and `vr-grabbing.md` (grab architecture).
## 1. Full-extension elbow jitter = the classic 2-bone singularity
- [S] At cos(±1) the elbow solution is bistable and tiny target noise flips it (robotics "elbow singularity"; FINAL IK threads confirm the "elbow pops to fully straight" symptom: https://discussions.unity.com/t/final-ik-full-body-ik-aim-look-at-fabrik-ccd-ik-1-0-released/525769).
- [S] The established fix is SOFT IK: clamp the effective target distance to a fraction of chain length (recommended 0.98) and ease over the last percent of reach (MSMotion docs "Soft IK Ratio": https://maharajastudiocustomerservice-cell.github.io/MaharajaStudioWeb/ms-motion-docs.html; Blender soft-IK threads: https://blenderartists.org).
- [S] VRIK docs: elbow bones should be slightly bent in their natural bend direction — production solvers NEVER allow the perfectly-straight configuration (http://www.root-motion.com/finalikdox/html/page16.html).
- [I] Pole smoothing alone does NOT fix a singular solve — the pole is undefined exactly at full extension.
- **Recommendation applied/usable**: clamp shoulder→hand distance to reach×0.98 before the law-of-cosines solve; keep ~5–8° minimum elbow bend; slerp the elbow over 2–3 frames; hysteresis 0.97/0.99 on the straight-arm state so the transition can't oscillate.
## 2. Elbow direction (pole) heuristics
- [S] VRIK does NOT use hand rotation as the primary pole driver — it samples the avatar's start pose for the natural bend direction, then uses twist weights (`shoulderTwistWeight` etc.) and optional bend goals (docs above; Resonite VRIK mirror: https://wiki.resonite.com/Component:VRIK).
- [S] Community practice for aiming: a bend goal at the elbow's down/back/outward position as a SOFT bias (Unreal forums on driving the middle joint from hand orientation: https://forums.unrealengine.com).
- [I] Controller-rotation-driven poles (ours) are common in physics-hands recreations but are the twitchiest variant; Boneworks-style arms derive elbow direction mostly from body-relative geometry.
- **Recommendation**: build the pole in a stable torso frame ("down + back + outward"), PROJECT OUT the shoulder→hand axis so wrist spin can't rotate it, add hand-forward only as a small weight (~0.2–0.3) that ramps in when aiming, and exponentially smooth the final pole; never let a blend weight cross zero (flip risk).
## 3. Chest/torso yaw from HMD + hands
- [S] Academic prior art: Ponton et al. 2022, "Combining Motion Matching and Orientation Prediction to Animate Avatars for Consumer-Grade VR Devices" (https://arxiv.org/abs/2209.11478) — HMD-forward alone has 14.5° mean body-orientation error vs 5.4° for a learned predictor; AvatarPoser (https://arxiv.org/abs/2107.04044), HMD-NeMo (https://arxiv.org/html/2308.11261v1), MirrorPoser are the NN lineage.
- [S] Classic game heuristic: torso yaw from the controller-to-controller line blended with HMD yaw (https://forums.unrealengine.com).
- [S] Ponton et al. smooth predicted orientation with a spring-damper/slerp and DECOUPLE orientation from the head (never hard-blended against head yaw).
- **Recommendation (non-NN)**: yaw = HMD yaw base blended toward a hands-derived yaw with weight proportional to how far forward/raised the hands are (0 idle → ~0.6 aiming), applied through a critically damped spring with ~150–250 ms response.
## 4. Overextension with physics hands
- [S] VRIF-style frameworks render an "arm stretch" but clamp within a limit; soft IK is the standard clamp (VRIF: https://discussions.unity.com; MSMotion above).
- [S] Boneworks is fully physics-driven: at max reach the arm simply straightens and the whole physics body is pulled/leaning rather than the arm stretching (GDC coverage: https://www.gamedeveloper.com/design/how-classical-game-mechanics-and-physics-converge-in-vr-hit-i-boneworks-i/; https://www.reddit.com/r/StressLevelZero/comments/frucx5/).
- [I] No documented game stretches the visual arm beyond bone length — everyone clamps the target to a sphere around the shoulder; the physics hand continuing past reach reads fine because the straight arm still points at it.
- **Recommendation**: clamp the VISUAL hand target to a sphere around the shoulder (~98% reach, soft-eased) while the physics hand body keeps moving freely; the reach-budget torso shift covers the remainder.
## Applied to VR Messs (BodyRig.cs)
Soft IK factor 0.98; pole projected in torso frame with hand weight 0.25 and exponential
smoothing; torso yaw spring (HMD base + hands-derived yaw × forwardness, exp smoothing ~12/s);
reach-budget hysteresis unchanged. Next dial if shaking persists: SoftIKFactor → 0.95.
+33
View File
@@ -0,0 +1,33 @@
# Research: VR arm IK approaches (for physics-hands rigs)
Researched by an agent for VR Messs (Godot 4.7, C#, physics hands = force-clamped rigid palm
bodies chasing controllers). Superseded by nothing; see also `vr-arm-ik-deep.md` for the
follow-up on stability and torso yaw. Label: [S] = sourced, [I] = inference.
## Prior art with physics hands
- [S] Boneworks/Bonelab (GDC 2020 talk: https://www.gamedeveloper.com/design/how-classical-game-mechanics-and-physics-converge-in-vr-hit-i-boneworks-i/, Marrow wiki: https://boneworks.fandom.com/wiki/1Marrow_Interaction_Engine): the whole virtual body is a physics object; arms are 2-bone chains driven by constraints toward controller targets with a clamped force/velocity ("heuristic force limit") — when the force budget is exceeded the BODY moves, not the hand. Internal solver details are not public beyond this.
- [S] HL:Alyx shipped HANDS ONLY, deliberately (Valve dev commentary: https://combineoverwiki.net/wiki/Developer_commentary/Half-Life:_Alyx): IK arms mislead because there is no proprioception for elbow/shoulder. "Alyx-style arm IK" is not a Valve technique.
- [S] Final IK VRIK (docs: http://www.root-motion.com/finalikdox/html/page16.html): analytic 2-bone IK per limb; elbow direction is UNDERDETERMINED from HMD+hands alone — bend direction is sampled from the calibrated start pose + shoulder orientation, plus twist weights and optional explicit bend goals. Rated best-in-class for 3-point body estimation (ISM 2021: https://www.computer.org/csdl/proceedings-article/ism/2021/373400a123/1A3j8qqoU8g).
## Shoulder / torso handling
- [S] Prior art FOR "idle shoulders, reach budget pulls the spine": VRIK's spine solver stage pulls the root toward hands past a reach threshold; VRChat clamped overshooting arms at arm length rather than rotating shoulders (https://docs.vrchat.com/docs/vrchat-202221); Ponton et al. 2023 self-avatar paper covers torso compensation (https://www.cs.upc.edu/~npelechano/VR2023_fittedAvatars.pdf).
- [S] Pitfalls: wrong shoulder width/height → permanent elbow strain (VRChat calibration issues); pull torso TRANSLATIONALLY first, lean second (rotation reads as drunkenness).
- [I] The Go-Go arm-extension alternative (stretch arms instead of leaning) is rejected for physics-hands setups — breaks hand-body consistency.
## Godot 4.7 API facts (verified against docs.godotengine.org/en/4.7)
- [S] `SkeletonIK3D` still exists but is DEPRECATED (https://docs.godotengine.org/en/4.7/classes/class_skeletonik3d.html).
- [S] The modern system: `SkeletonModifier3D` base class — override `_process_modification_with_delta(delta)` (the `_process_modification()` variant is deprecated; `influence` auto-blends, write full-strength poses) (https://docs.godotengine.org/en/4.7/classes/class_skeletonmodifier3d.html).
- [S] Built-in modifiers: `ChainIK3D` → `IterateIK3D` → `CCDIK3D` / `FABRIK3D` / `JacobianIK3D` (https://docs.godotengine.org/en/4.7/classes/class_chainik3d.html), and `LookAtModifier3D` for single-bone look-at with angle limits and damping, stackable neck-under-head (https://docs.godotengine.org/en/4.7/classes/class_lookatmodifier3d.html).
- [S] No built-in analytic 2-bone solver with a pole target (ChainIK3D ignores twisting). [I] Write our own law-of-cosines 2-bone solve in a SkeletonModifier3D subclass.
- [S] Reference implementations: Godot-Dojo XR-Avatar (GDScript, deprecated: https://github.com/Godot-Dojo/Deprecated-Godot-XR-Avatar), godot-xr-tools (rig, no real IK: https://github.com/GodotVR/godot-xr-tools).
## Head/neck pattern
- [S] Point the head bone directly at the HMD; distribute residual rotation into the neck at lower weight (30–50%) with clamped pitch/yaw; `LookAtModifier3D` provides limits + damping natively; stack the neck modifier ABOVE the head modifier. [I] The exact weights are taste.
## Chosen architecture for VR Messs
- Kinematic spine + analytic 2-bone arms in a custom SkeletonModifier3D (when a real skeleton exists; placeholder uses raw transforms) + existing physics hands. Matches Boneworks' observable behavior without simulating the skeleton. Full-physics-body = highest fidelity but highest risk; pure IK-over-animation = where the jank lives.
+69
View File
@@ -0,0 +1,69 @@
# Research: body physics on Godot 4.7 + Box3D — verified capabilities
Companion to `vr-body-physics-hugs.md` (prior art + recommendation). This file grounds that
recommendation in what the LOCAL stack actually supports. Everything [S] was verified by the
orchestrator directly against `.vendor/godot-box3d/` source and the pinned docs at
`/home/foxyz/Projects/Games/.shared/godot-docs-4.7/`. Researched 2026-10-01.
## 1. Joint inventory on Box3D (v0.2.6, verified in source)
- [S] Implemented joint types (`src/joints/`, dispatch in
`src/servers/box3d_physics_server_3d.cpp`): **pin** (`_joint_make_pin`, line 942),
**hinge** (`_joint_make_hinge`, line 1003), **slider** (line 1068), **cone-twist**
(line 1100 — LOCALLY PATCHED to b3WeldJoint, see `godot-box3d-weld.md`).
- [S] **Generic6DOFJoint3D is NOT supported** — `_joint_make_generic_6dof` (line 1126) is a
hard `ERR_FAIL_MSG("Box3D: Generic6DOFJoint3D is not supported; use PinJoint3D, HingeJoint3D,
or SliderJoint3D instead.")`. Any design must not rely on 6DOF drives.
- [S] **Hinge motors WORK** (`src/joints/box3d_hinge_joint_impl_3d.cpp`): `HINGE_JOINT_FLAG_ENABLE_MOTOR`
→ `b3RevoluteJoint_EnableMotor`; params `HINGE_JOINT_MOTOR_TARGET_VELOCITY` +
`HINGE_JOINT_MOTOR_MAX_IMPULSE` → `b3RevoluteJoint_SetMotorSpeed/SetMaxMotorTorque` (lines 22–24,
128–131). It is a VELOCITY motor with a torque clamp — position control = per-tick PD:
`targetVelocity = (targetAngle − angle) × gain`, clamped, with `maxImpulse` as the force budget.
This is the Box3D primitive for active-ragdoll limb drives (phase 2 of the hugs design).
- [S] Body modes exist (`_body_set_mode`, line 448); constant forces/torques work
(`_body_add_constant_torque` line 749) — already proven in production by PhysicsHands'
90 N chase (RigidBody3D, mass 0.4, gravity off, CCD, `ApplyTorque` verified earlier).
## 2. Godot 4.7 skeleton/modifier APIs (pinned docs, `classes/*.rst`)
- [S] Available and current: `SkeletonModifier3D` (base, `_process_modification_with_delta`),
`IKModifier3D`, `LookAtModifier3D`, `CopyTransformModifier3D`, `ConvertTransformModifier3D`,
`ModifierBoneTarget3D`, `LimitAngularVelocityModifier3D`, `RetargetModifier3D`,
`BoneAttachment3D`, `PhysicalBone3D` + `PhysicalBoneSimulator3D`, `SkeletonProfileHumanoid`.
- [S] `SkeletonIK3D` remains deprecated (known trap, HANDOFF §2). `XRBodyModifier3D` and
`XRFaceModifier3D` exist (OpenXR body/face tracking — future spike material).
- [I] For the placeholder rig (no Skeleton3D), none of these apply yet — the plan keeps the
procedural `BodyRig` boxes and adds physics BODIES that the boxes follow (CopyTransform-style
reads in `_Process`), then re-home the same chase bodies onto a real skeleton later via
`BoneAttachment3D`/`SkeletonModifier3D`. Migration path stays intact.
## 3. Drive patterns that work on this stack
- [S] **PD-driven rigid follow is PROVEN on Box3D at 90 Hz** — PhysicsHands is exactly that
(desired velocity `(target−pos)/dt`, force-clamped to 90 N). A heavier torso body is the same
pattern with a larger mass and larger budget; the 0.4 kg hands' stability across grab v5's
saga is the empirical evidence the pattern holds.
- [S] Kinematic drive + physical reaction alternative: `AnimatableBody3D` pushes dynamics in
Godot; but a kinematic chest is INFINITE mass — it would shove NPC bodies without ever being
resisted, breaking the mutual-force requirement from `vr-body-physics-hugs.md` §4. [I] Rejected
for the chest; fine for the WorldPanel.
- [I] No built-in "spring to target pose" body/joint in Godot or Box3D — confirmed absence;
every drive is hand-rolled PD (hands already do it; hinge motor is the solver-native version).
## 4. Collision strategy
- [S] Layers/masks live on `CollisionObject3D` (standing trap). Current use: 1 = world, 2 = UI;
hands layer 1/mask 1. Proposal: 3 = player body (torso + future arm segments), torso mask = 1|3
(world + itself-if-needed), hands mask 1|3 so palms can rest on the own chest; exclude
torso↔own-forearm pairs later via joint `ExcludeNodesFromCollision` equivalents — Box3D has no
pair-exclusion API [I, verify when needed]; simplest is disjoint masks per limb until proven
otherwise.
## 5. Recommendation for VR Messs
Follow `vr-body-physics-hugs.md` §5. Verifications add two facts to it:
- Phase 2 limb drives are buildable TODAY on the patched addon: hinge velocity motors + per-tick
PD toward the analytic IK angles (no Generic6DOF, no 6DOF drives — design accordingly).
- Phase 1 needs NO new addon work: torso = RigidBody3D chase-driven (hands pattern, bigger mass),
arms stay analytic IK, palms + torso give mutual contact. Sickness guard: camera stays
kinematic; torso chases the HMD, never welds to it; cap tug coupling below the chase budget.
+157
View File
@@ -0,0 +1,157 @@
# Research: physics player bodies + hugging/contact in VR
Fourth research pass for VR Messs (Godot 4.7, C#, physics hands = force-clamped rigid palms
chasing controllers at 90 N; placeholder box rig = procedural torso + 2-bone analytic arms,
no Skeleton3D yet; Box3D physics server, joints: pin/hinge/slider/cone-twist→weld-patched).
Goal: player arms+chest react physically — hugging characters, body reacting when grabbed.
Label: [S] = sourced, [I] = inference. See also `vr-arm-ik.md`, `vr-arm-ik-deep.md`,
`vr-grabbing.md`, `vr-two-handed-grab.md`.
## 1. Prior art — player body physics in VR
- [S] **Boneworks/Bonelab** (GDC 2020 "Heuristic Based Approaches to Physics Systems"; coverage:
https://www.gamedeveloper.com/design/how-classical-game-mechanics-and-physics-converge-in-vr-hit-i-boneworks-i/,
Marrow wiki: https://boneworks.fandom.com/wiki/1Marrow_Interaction_Engine): the ENTIRE virtual
body is one physics object, 82 kg, "no infinite forces" — every interaction draws on the body's
force budget; jumping = physically thrusting feet down; exploits die because classical mechanics
is the ruleset. Arms = 2-bone chains driven by constraints toward controller targets with a
heuristic force/velocity limit; over budget → the BODY moves, not the hand. Internal solver
details beyond that are not public. Motion sickness is the accepted cost: players report it as
one of the most nauseating VR games because the physics body can shove the camera off the real
head pose (https://www.resetera.com/threads/highly-anticipated-vr-title-boneworks-is-a-collection-of-vr-sins.158449/page-5).
- [S] **HL:Alyx** shipped HANDS ONLY, deliberately — IK arms mislead because there is no
proprioception for elbow/shoulder (dev commentary:
https://combineoverwiki.net/wiki/Developer_commentary/Half-Life:_Alyx). The strongest evidence
that "collision-enabled limbs" were considered not worth it.
- [S] **Stormland** (Insomniac): fully ANIMATED rendered arms + hip/shoulder holsters that ground
body awareness; body position handled by "invisible logic" for player intent, not a simulated
chain (https://roadtovr.com/stormland-behind-the-scenes-insomniac-games/2/). Kinematic body,
fake arms — feels great, can't hug anyone.
- [S] **Vertigo 2**: solo dev moved to a full physics-driven first-person body + hand physics and
committed to "full physics going forward" (devblog:
https://www.reddit.com/r/ValveIndex/comments/d48pex/vertigo_2_devblog_new_first_person_body_hand);
no postmortem numbers published. Proof one person can ship it in a standard engine.
- [S] **Open-source / community implementations**: Valem's Boneworks-style full-body-physics Unity
tutorial series (https://www.youtube.com/watch?v=D9qQb3EvU8c — rigidbody torso + jointed limbs
chasing trackers); RealMotionVR physics player controller
(https://github.com/pierricklyons/real-motion-vr); community full-body-physics WIPs with the
recurring pattern "rigidbody hands diverge from ghost hands → haptic + visual snap-back"
(https://www.reddit.com/r/Unity3D/comments/9sjsm9/vr_fullbody_physics_because_im_tired_of_reaching/,
https://www.reddit.com/r/ValveIndex/comments/ircdm3/wip_full_body_physics_high_fidelity_hands_in).
[I] No repo named "Hurdy" surfaced in any search — likely misremembered.
- [I] Pattern across all of them: simulate at most ONE powered body (the chest/torso root) driven
toward the HMD, and let limbs be either kinematic (Stormland/Alyx) or weakly-driven physics
chains (Boneworks). Nobody ships a fully self-colliding humanoid player rig — self-collision +
comfort + tuning cost is why.
## 2. Active ragdoll / puppet techniques (how pose-tracking-on-physics is done)
- [S] **Canonical recipe** (Gang Beasts-style; https://www.reddit.com/r/Unity3D/comments/3v6kt8/does_anyone_know_how_gang_beasts_physics_are):
semi-procedural blend of keyframed pose-matching + active ragdoll. Implementation lineage:
- Joint drive to animated "master" pose: duplicate physical body, each ConfigurableJoint's
angular drive target-rotation = the animated counterpart's rotation
(https://github.com/sergioabreu-g/active-ragdolls).
- PD control per joint: proportional-derivative torques toward target pose — the standard
formulation (EggyStudio Unity.Humanoid.ActiveRagdoll, "follows an animation using a simple
PD controller" with configurable joints, weakening drives on collision:
https://github.com/CanTalat-Yakan/Unity.Humanoid.ActiveRagdoll).
- Research tier: DReCon (Bergamin et al. 2019, ACM TOG) = motion matching picks the target
pose, RL-trained policy applies PD torques + root drive for balance
(https://www.researchgate.net/publication/337117918_DReCon_data-driven_responsive_control_of_physics-based_characters);
SIMBICON-style feedback and stepping controllers for balance
(https://arxiv.org/html/2210.14730v1; survey: Geijtenbeek et al. 2012,
https://onlinelibrary.wiley.com/doi/abs/10.1111/j.1467-8659.2012.03189.x).
- Balance practicals: lock hips X/Z rotation so the ragdoll can't fall while still being pushed
(https://sergioabreu-g.medium.com/how-to-make-active-ragdolls-in-unity-35347dcb952d), or apply
an external upright torque/com-height controller — joint motors alone don't balance
(https://medium.com/@jacasch/balancing-of-active-ragdolls-in-games-367f146b25fb).
- Practical tips for making them look alive, not spastic:
https://www.gamedeveloper.com/design/8-tips-for-animating-active-ragdolls.
- [S] **Human Fall Flat** never published a technique talk (closest is design retrospectives);
the joint-motor active-ragdoll recipe above is the community-reconstructed equivalent
(https://www.reddit.com/r/godot/comments/1fnwu2v/how_to_make_an_active_ragdoll_using_joint_motors).
- [I] **Failure modes** (consistent across sources): too-high drive gains inject energy →
jitter/explosions at solver limits; too-low → mush that can't hold a pose; the balance layer is
the whole difficulty — pose tracking is easy, staying upright while pushed is not. Gain tuning
is per-body and iterative (tens of iterations), and (per wirewhiz Alyx-hands notes in
`vr-grabbing.md`) "overly aggressive joint values destabilize the sim" is a physics-server-level
truth, not engine-specific.
## 3. Hugging / close contact with characters in VR
- [S] **In-game examples are almost nonexistent.** The closest known artifact is a Unity dev
clip "Added arm physics so now you can hug stuff in VR!" — physics-driven arms colliding with
bodies produce a passable hug for free (https://www.reddit.com/r/Unity3D/comments/jnfau2/added_arm_physics_so_now_you_can_hug_stuff_in_vr);
"The Hug Game" makes the other player's arms the hug implement
(https://automaton-media.com/en/news/20220225-10037). No shipped AAA VR game has a simulated
embrace — the user's "not even Bonelab had it" observation is correct.
- [S] **VRChat avatar "hugging"** is theater, not physics: PhysBones are spring chains for
secondary motion that can collide but carry no character-to-character force exchange
(https://creators.vrchat.com/common-components/physbones); Contacts are overlap event triggers —
sender touches receiver → animator parameter — auto-placed on hand/finger colliders
(https://creators.vrchat.com/common-components/contacts). So VRChat hugs = arms clip through +
contact event plays an animation. It still sells, which is evidence contact EVENTS + reaction
matter more than force fidelity.
- [S] **HCI/haptics research on hugs** (what makes a hug read as a hug):
Hugvie — a hug-shaped pillow that improves conversation affect, i.e. the SHAPE of contact does
the work (https://eng.irl.sys.es.osaka-u.ac.jp/projects/hugvie; study:
https://www.semanticscholar.org/paper/3a11fff7f3945f1a103ef233aba2d46de1122f35);
HuggieBot — established design tenets of natural robotic hugging, haptic REACTIVITY (responding
during the hug) significantly improves perception (https://arxiv.org/html/2101.07679v1,
https://is.mpg.de/hi/projects/huggiebot-evolution-of-an-interactive-hugging-robot-with-visual-and-haptic-perception);
intra-hug gestures the huggee should respond to: holding, rubbing, patting, squeezing
(https://dl.acm.org/doi/fullHtml/10.1145/3526110);
"Anatomy of a Hug in Virtual Reality" (Deusdado & Antunes, Procedia CS 239, 2024) — hug =
wrap + pressure + duration, haptic presence over fidelity
(https://www.sciencedirect.com/science/article/pii/S187705092401528X).
- [I] Synthesis: a hug reads correctly when (1) your arms VISIBLY wrap (no clipping, no
IK explosion), (2) contact is mutual — you can push and feel resistance, (3) the other party
RESPONDS (pressure → squeeze-back, pat → shift). Force simulation provides 1+2 cheaply; 3 is
gameplay code reading contact events.
## 4. What sells contact — full sim vs pseudo-physics
- [S] Boneworks' GDC claim in essence: physics contact sells because forces are CONSISTENT
(one 82 kg budget, no infinite forces) — not because bones are individually simulated.
- [S] Alyx cut arm physics and shipped the best-feeling hands of its generation; Stormland faked
arms entirely and scored on body presence. Both prove partial approaches can carry immersion.
- [S] But neither can HUG: pseudo-physics limbs clip through characters by design, and clip = no
hug, no lean, no tug. Boneworks CAN be leaned on things and shove bodies with its chest.
- [I] Decision rule: pseudo-physics (kinematic torso collider + IK arms) is enough for
self-presence, NOT enough for contact mechanics. The minimum real-physics set for hugging is
exactly: one physics torso/chest collider + two physical arm segments (we already have palms)
with weak pose drives — i.e. an active-ragdoll-torso, not a full skeleton sim.
## 5. Recommended approach for VR Messs
Constraints: Box3D (pin/hinge/slider/weld-patched-cone-twist), placeholder box rig, no
Skeleton3D, physics hands exist. [I] throughout this section unless noted.
1. **Torso = one RigidBody3D (capsule/box), PD-driven toward an HMD-derived target** — position
from HMD, yaw from the torso-yaw spring already specced in `vr-arm-ik-deep.md`. Force-CLAMPED
chase (same 90 N-class budget pattern as the palms). This body grounds all contact: hug
pressure, tug response, door pushes. DO NOT weld the HMD to it — camera stays kinematic
(Boneworks' sickness lesson, §1); torso chases the camera, never the reverse.
2. **Arms phase 1 = visual-only 2-bone analytic IK (existing) + existing palm bodies.** Chest
contact comes from the torso body; arms don't need physics to hug at chest height. Palms
colliding with the NPC's body already provide "arms resist contact" below the face.
3. **Arms phase 2 (only if phase 1 reads as clipping) = active-ragdoll upper-arm/forearm
segments**: rigidbodies per segment, hinge joints, hinge MOTOR targets = the analytic IK
solution's angles, low gain + velocity cap (§2 recipe, adapted to Box3D's hinge motors).
Joint motors beat per-frame force application — let the solver own it (same lesson as
`vr-grabbing.md`). Skip self-collision entirely (torso↔arm excluded pairs); nobody notices.
4. **Hug detection = contact events, not force analysis**: both palms (or forearm segments) in
contact with NPC torso + player chest collider touching NPC torso + sustained ≥0.5 s →
"embracing" state; read total contact impulse for a pressure value. NPC response = squeeze
force ramp on the player torso body + reaction gestures (HuggieBot reactivity finding, §3).
Optional: fade the visual arms into a scripted wrap pose during the state — VRChat proves
players forgive clipped/faked hugs if the reaction is right.
5. **Grabbed/tugged**: free — any joint from an NPC/prop to the torso body makes the whole body
react, because the torso is now the anchored mass the palms were implicitly missing. Cap the
coupling force below the torso chase budget so tugs can't yank the camera frame (sickness).
6. **Box3D-specific risks**: [I] cone-twist is weld-patched → use hinge+motor for limb drives and
pin for torso coupling; verify hinge motor max-impulse behavior at 90 Hz before building on
it (the `godot-box3d-weld.md` patch is the precedent for upstreaming what's missing).
7. **Explicitly rejected**: full per-bone simulated player skeleton (§1 — nobody ships it;
comfort + tuning cost), VRChat-style event-only hugs (no mutual force = no resistance = hollow),
Go-Go stretch arms (rejected before in `vr-arm-ik.md`).
+38
View File
@@ -0,0 +1,38 @@
# Research: grabbing/picking up physics objects in VR (for physics-hands rigs)
Third research pass for VR Messs, after script-level grab welds (PD force weld, then
rigid-follow-with-yield) proved unstable: launched objects, hand drift, limit cycles.
Label: [S] = sourced, [I] = inference. Companion to `vr-arm-ik.md` / `vr-arm-ik-deep.md`.
## Sources verified (primary where possible)
- [S] **godot-xr-tools** — actual source read (pickable.gd, grab_driver.gd, grab_point_hand.gd, master: https://github.com/GodotVR/godot-xr-tools/blob/master/addons/godot-xr-tools/objects/pickable.gd): pickables are RigidBody3Ds that get `freeze = true` on grab; a GrabDriver node kinematically drives the frozen body to the hand's grab point per physics tick; on release, freeze is restored and `linear_velocity` is set for the throw. Second-hand enum IGNORE/SWAP/SECOND — in SECOND mode both grab points drive, blended by each point's `drive_position`. NO JOINTS anywhere.
- [S] **Unity XR Interaction Toolkit** (https://docs.unity3d.com/Packages/com.unity.xr.interaction.toolkit@3.1/manual/xr-grab-interactable.html): three movement types — VelocityTracking (sets velocity/angular velocity per FixedUpdate; best collision fidelity + weight feel, "can appear to lag"), Kinematic (move-toward), Instantaneous (teleport; clips). Two-handed = multiple grab points + grab transformers. Zero joints.
- [S] **BNG/VRIF** (https://wiki.beardedninjagames.com/en/Core/Grabbable): GrabPhysics = Physics Joint (ConfigurableJoint grabber→grabbable, rigidity dynamically retuned by collisions), Kinematic, Velocity, or None; Break Distance for force-release; secondary grab "DualGrab currently unsupported" — single primary hand only.
- [S] **Blade & Sorcery** (BasSDK wiki: https://kospy.github.io/BasSDK/Components/ThunderRoad/Items/Handle.html): items stay DYNAMIC rigidbodies; Handle defines grab axis/radius; `twoHandedRequireSamePhysicbody` — dual grip = both hands gripping handles on the SAME body; hand colliders disabled while gripping. [I] Joint type per grip (decompiled ThunderRoad).
- [S] **Boneworks/Bonelab** (MarrowSDK: https://github.com/StressLevelZero/MarrowSDK; GDC: https://www.gamedeveloper.com/design/how-classical-game-mechanics-and-physics-converge-in-vr-hit-i-boneworks-i/): hands are rigidbodies force-driven to controllers; grab = JOINT from hand body to object at the grab point → mass "tug of war"; philosophy: "no infinite force", the 82 kg body grounds all force budgets.
- [S] **HL:Alyx** (dev commentary: https://combineoverwiki.net/wiki/Developer_commentary/Half-Life:_Alyx): two-handed weapons were CUT because "the object itself provides a physical connection between the two hands" and VR couldn't fake it. [I] Attachment = Source 2 constraint (undocumented).
- [I] **H3VR**: no public implementation writeup (interview: https://voicesofvr.com/1717-h3vr-10-year-retrospective-with-anton-hand-origins-design-evolution-of-hot-dogs-horseshoes-hand-grenades/); community consensus says spring/joint attach.
- [I] **Auto Hand**: physics-based, two-handed grabs, break events (https://assetstore.unity.com/packages/tools/game-toolkits/auto-hand-vr-interaction-165323); joint details not public.
- [S] **Instability prior art**: ODE manual on joint motors with force limits — letting the SOLVER integrate beats per-step velocity override, which overshoots/oscillates (https://ode.org/wiki/index.php); wirewhiz Alyx-hands writeup: joint-driven hands at 90 Hz, "greatly increased stability" from a hand-root collider, overly aggressive joint values destabilize the sim (https://wirewhiz.com/making-half-life-alyx-physics-hands-in-unity/).
## Taxonomy
| Approach (who uses it) | Stability | Weight feel | Two hands | Cost in Box3D |
|---|---|---|---|---|
| Freeze + kinematic drive (xr-tools, XRIT Kinematic) | High (boring-safe) | None real; faked via clamps | Blend two drivers; source-verified | Low |
| Velocity-tracking hold (XRIT VelocityTracking, VRIF Velocity) | Medium — lag/jitter documented | Good (real mass resists) | Weak; lag reported | Low — **but this was our unstable design** |
| Joint hand→object, force-limited hand (Boneworks, B&S, VRIF Physics Joint, H3VR [I]) | High IF the solver owns the coupling | Best: hand lags under load, the hand's force budget does the work | Second joint to the same body (B&S model), or single-hand-only (VRIF) | Medium |
| Rigid weld/parenting (Alyx-style) | High but fake | Tuned by illusions | Cut by Valve for good reason | High (needs 6DOF) |
## Recommended architecture for VR Messs
1. [S-backed] **Position coupling: PinJoint3D hand→object at the grab point.** Stop setting object velocity — velocity override + reaction force = two controllers fighting one integrator (the limit cycle we hit). Let the engine's joint solver own both bodies (ODE/force-limit literature; matches Boneworks/B&S/VRIF). One authority, no fight.
2. [I] **Rotation: clamped angular spring** on the held object toward the hand's grip orientation (angular-velocity chase toward the basis delta, capped ~20 rad/s), owned by the PRIMARY hand only. With only Pin/Hinge/Slider, the pin gives position for free; the clamped spring supplies rigid-grip feel without 6DOF.
3. [S] **Weight feel = the existing hand force budget.** Don't clamp object mass; heavy objects drag the hand through the joint (Boneworks tug-of-war). Optionally scale the rotational spring by mass (nose-heavy guns).
4. [S] **Grip break**: Godot joints have no break force — monitor the joint anchor separation; sustained error over N ticks = grip ripped away (same effect as VRIF Break Distance, manually).
5. [S] **Two hands: BOTH hands PinJoint to the same object** (B&S `twoHandedRequireSamePhysicbody` model). Primary hand owns the rotation spring; secondary adds position only. Do NOT connect the hands through the object — Valve cut that.
6. [S] **Fallback** if Box3D's pin joint proves too soft/unstable: the xr-tools pattern — freeze the held body, drive it kinematically to the hand each tick, restore velocity on release, blend second-hand drivers. Zero instability, weight feel becomes theater.
Note: our physics hands already chase with forces, so the joint approach slots straight in —
the hand body IS the Boneworks setup; only the grab coupling was wrong.
+401
View File
@@ -0,0 +1,401 @@
# Research: two-handed grab, held-rotation stabilization, hand snap-back vs grip, and what Box3D joints really are
Fourth research pass for VR Messs, after the user playtest of grab v5 (PinJoint3D per hand):
two-handed hold feels "jointed-on" not welded, held-object rotation gets out of control, and
PhysicsHands' stuck-safety teleports the hand out from under a live joint. Label: [S] = sourced
(verified against source/docs this pass), [I] = inference. Companion to `vr-grabbing.md` — that
report's taxonomy still holds; this one covers what it didn't.
## Sources verified this pass
### Box3D / godot-box3d (all read directly — the most load-bearing section)
- [S] **Local addon has no source** — `/home/foxyz/Projects/Games/vr-messs/addons/godot-box3d/`
contains only LICENSE, `.gdextension`, and 3 binaries. Class surface extracted from
`bin/libgodot-box3d.so` via `strings`: registered joint impls are
**Box3DPinJointImpl3D, Box3DHingeJointImpl3D, Box3DSliderJointImpl3D, Box3DFilterJointImpl3D**
(+ base Box3DJointImpl3D). Strings also carry the rejections: "ConeTwistJoint3D is not
supported in this version", "Generic6DOFJoint3D is not supported; use PinJoint3D,
HingeJoint3D, or SliderJoint3D instead", "PinJoint3D's IMPULSE_CLAMP parameter has no Box3D
equivalent and is ignored", HingeJoint BIAS/LIMIT_BIAS/LIMIT_RELAXATION/LIMIT_SOFTNESS ignored.
- [S] **Upstream bearlikelion/godot-box3d** (https://github.com/bearlikelion/godot-box3d, tag
v0.2.6 — `src/joints/box3d_pin_joint_impl_3d.cpp` diffed IDENTICAL to main): PinJoint3D maps
to a Box3D **spherical joint with `enableSpring = true` HARDCODED, `hertz = bias * 30.0f`,
`dampingRatio = damping`**. IMPULSE_CLAMP = warn + no-op. Defaults: bias 0.3 → **9 Hz
rotational spring**, damping 1.0 → critically damped. So Godot's bias/damping DO apply —
but to the joint's ROTATIONAL spring, not to the positional constraint.
- [S] **Box3D = Erin Catto's engine** (https://github.com/erincatto/box3d), Rubikon/Source-2
lineage (bearlikelion README, https://bearlikelion.github.io/godot-box3d/). The wrapper README
admits it is "behind box3d-godot on: joint types, ConeTwist and 6DOF" — PhysicsServer3D has no
entry points for Box3D's other joints, so a drop-in backend *can't* surface them.
- [S] **The underlying spherical joint is a near-cone-twist** (`include/box3d/types.h`,
`src/spherical_joint.c`): b3SphericalJointDef has `enableSpring/hertz/dampingRatio`
(rotational spring toward `targetRotation`, captured at creation), `targetRotation` (b3Quat),
`enableConeLimit`/`coneAngle` [0, π] (cone around frameA z-axis), `enableTwistLimit`/
lower/upperTwistAngle (±0.99π around frameB z-axis), `enableMotor`/`motorVelocity` (relative
angular velocity, world space)/`maxMotorTorque`. **None of this is reachable through the
wrapper** — `_joint_make_pin` passes identity-basis frames and `_pin_joint_set_local_a/b`
rewrite the frame ORIGIN only (src/servers/box3d_physics_server_3d.cpp), so the spring target
is permanently the relative rotation at grab time.
- [S] **The positional (point-to-point) part of the pin joint is tuned separately and the
wrapper never touches it** (`src/joint.c`): `b3DefaultJointDef` sets `constraintHertz = 60`,
`constraintDampingRatio = 2.0`, and `b3PrepareJoint` clamps hertz to **0.25 × tick rate** →
at our 90 Hz physics the positional constraint runs at **22.5 Hz, overdamped (ζ 2.0)**. This
is the sag/drift in the playtest and it is NOT tunable via PinJoint3D.Bias/Damping.
- [S] **b3Joint_SetLocalFrameA/B apply to the live joint without rebuilding** (warm start kept)
— `src/joints/box3d_joint_impl_3d.cpp` — this is the mechanism behind our hand-following pivot.
- [S] **Box3D also has** (public API, all unreachable from the wrapper): weld joint ("connects
two bodies together rigidly", linearHertz 0 = max stiffness, with the note "the approximate
solver in Box3D cannot hold many bodies together rigidly"), **motor joint** (desired relative
linear+angular velocity with max velocity force/torque, plus spring hertz/dampingRatio with
max spring force/torque — b3MotorJointDef, include/box3d/types.h), distance/parallel/wheel
joints. The separate project **box3d-godot** (Stink-O) exposes 8 joint types but as its own
node hierarchy (no PhysicsServer3D replacement) — not usable without abandoning RigidBody3D.
- [S] **HingeJoint3D DOES expose its motor** through the wrapper
(`box3d_hinge_joint_impl_3d.cpp` → b3RevoluteJoint_EnableMotor/SetMotorSpeed/
SetMaxMotorTorque) — usable later for cranks/wheels, irrelevant to grab.
### Prior art — two-handed hold
- [S] **Unity Discussions, "Need help with two Configurable Joints! (Two hands grab in VR)"**
(https://discussions.unity.com/t/need-help-with-two-configurable-joints-two-hands-grab-in-vr/220913,
found via search; scheme corroborated by search-result synthesis): two joints hand→object;
BOTH lock position always; only the FIRST hand's joint locks rotation; the working scheme is
"primary owns rotation, secondary is position-only" — giving both joints rotation locks made
the weapon flip (the two rotational authorities fight).
- [I] **Blade & Sorcery** (decompile-derived, consistent with the wiki we verified in
`vr-grabbing.md`): the second hand does NOT create a second joint — Handle.jointType "Rigid"
is a fully-locked ConfigurableJoint on the primary hand only, and going two-handed adjusts
the PRIMARY joint's limits. Secondary hand contributes pose/limits, not an independent
constraint.
- [S] **Meta ISDK** (https://developers.meta.com/horizon/documentation/unity/unity-isdk-two-handed-grab-interaction/):
kinematic transformers — position = centroid of both grab points, rotation "computed from
relative hand movement" (TwoGrabRotateTransformer, axis-constrained with min/max degrees).
This is the documented kinematic form of "orient from the two hands", no physics.
- [S] **HurricaneVR** (release notes read from a public mirror,
https://code.fbi.h-da.de/sttijahnk/Relax/-/blob/ae2026c6e21d8215cbfd7c05e80a2c4c00bea5f2/Relax/Assets/HurricaneVR/Documentation/ReleaseNotes.txt):
joint-based physics hands; separate JointSettings for one- vs two-handed grabbing;
"Two hand grabs are much more stable with the physics hand updates"; `FinalJointMaxAngle`
15° + `FinalJointTimeout` 0.25 s (snap the last rotation error after a timeout);
`PerpendicularThreshold` breaks the joint on stretch.
- [I] **Rigid-body theory**: two ideal point constraints remove 5 of 6 DOF and leave rotation
about the axis through the two anchors free. It is killed by (a) a third non-collinear point
constraint (over-constrains; soft solvers leak), (b) an angular spring/motor about that axis,
or (c) aim-the-anchor-axis-at-the-hand-hand-vector + roll. **Box3D's pin joints already
contain (b) implicitly** — every pin joint carries a rotational spring toward the grab-time
relative rotation — so the playtest's visible axial rotation/drift is the 9 Hz DEFAULT
spring being too weak, not a missing constraint.
### Prior art — held-object rotation stabilization
- [S] **Box3D manual** (https://github.com/erincatto/box3d, docs/simulation.md): joint motors
control position "by specifying a joint velocity that is proportional to the difference
between the actual and desired position" with a torque cap (servo); friction = zero target
velocity + small max torque; joint spring hertz "should be less than half the frequency of
the time step" (60 Hz step → < 30 Hz). Same doctrine as ODE (already cited in
`vr-grabbing.md`): let the SOLVER integrate the correction, never per-step velocity override.
- [S] **Unity ConfigurableJoint reference** (https://docs.unity3d.com/Manual/class-ConfigurableJoint.html):
rotation control = drive spring + damper + **Maximum Force clamp** — clamping the drive force
is the standard stabilizer.
- [I] Community fix patterns (Unity/Unreal threads found via search): clamp
|angularVelocity| each tick while held as a *safety net*, raise angular damping while held,
apply counter-torque ∝ angular error rather than hard velocity sets. No public writeup gives
tuned gain values for 90 Hz — the Box3D spring hertz limits above are the only hard numbers.
- [S] **The current code's oscillation source is now precisely identifiable**: PropGrabber.cs
sets `body.AngularVelocity` every tick (cap 20 rad/s) while the primary pin joint's own 9 Hz
rotational spring pulls toward the grab rotation — two authorities on the same DOF, the
documented fight.
### Prior art — hand snap-back safety vs a live grip
- [S] **HurricaneVR**: joints carry JointBreakForce/JointBreakTorque (default infinite) and a
stretch break (`PerpendicularThreshold`); the hand is never teleported while jointed — the
GRIP breaks instead.
- [S] **Blade & Sorcery** (BasSDK wiki, verified in `vr-grabbing.md`): hand colliders disabled
while gripping; grips break by distance/timeout.
- [S] **godot-xr-tools** (pickable.gd re-read): kinematic drive (freeze + move-to-hand each
tick) has NO joint, so a lagging hand is impossible by construction — the snap-back conflict
only exists in joint-based grabs.
- [S] **Unreal forum, "Picking up object in VR gets stuck in hand"**
(https://forums.unrealengine.com/t/picking-up-object-in-vr-gets-stuck-in-hand-when-letting-go/658445):
teleport while holding must move the held object with the rig, else yank/stuck — the joint
has to go (or move) when the reference frame jumps.
## Comparison table
| Scheme (who) | Rotation authority | Two-handed behavior | Hand-safety conflict | Usable in Box3D wrapper today |
|---|---|---|---|---|
| Two ball joints, both default (our v5) | Primary script velocity-SET + two weak 9 Hz springs (fight) | Free-ish axial DOF, anchor drift | Stuck-snap yanks joint | Yes — this is the bug list |
| Primary-owns-rotation two-joint scheme (Unity thread) | Primary joint's rotational lock | Secondary = position only | Same conflict, solved by break-first | Yes — stiffen primary spring, weaken secondary |
| Primary joint only, secondary pose-only (B&S [I]) | Fully-locked primary joint | Primary joint limits adjust | Grips break by distance | Partially (no locked joint; stiff spring approximates) |
| Kinematic centroid + relative-hand rotation (Meta ISDK) | Transformer math, no physics | Native | None (no joint) | Yes (freeze fallback) but loses weight feel |
| Box3D spherical joint full surface (cone+twist limits, motor, targetRotation) | Solver servo, torque-capped | Cone/twist limits = true weld feel | Joint break first | **No — needs a small wrapper patch** |
## Recommended architecture for VR Messs
Everything below assumes 90 Hz physics (dt ≈ 11.1 ms), Box3D backend, and no wrapper changes
unless marked Tier B. Constants are starting values for in-headset tuning, not gospel.
### (a) Two-handed constraint scheme (wrapper-level, works today)
1. **Keep two PinJoint3D hand→prop** (position coupling stays solver-owned — correct in v5).
2. **PRIMARY hand's joint: `PinJoint3D.Bias = 0.75`, `Damping = 1.0`** → its implicit
rotational spring runs at 0.75×30 = **22.5 Hz** — exactly Box3D's own 0.25×tick stability
clamp at 90 Hz; critically damped. This single change welds orientation to the primary hand
solver-side and kills the free axial rotation (the axial DOF was never truly free — it was
held by a 9 Hz default spring). Tuning range: 0.6–1.0 (never above 1.5 = 45 Hz = half tick
rate; Box3D clamps only the positional tuning, the spring hertz is unclamped in
spherical_joint.c and CAN go unstable).
3. **SECONDARY hand's joint: `Bias = 0.3` (default), `Damping = 1.0`** → 9 Hz weak spring:
contributes position + a whisper of rotation, and cannot fight the primary's 22.5 Hz spring
(the Unity-thread fight). Do NOT add a third anchor point/joint — redundant constraints in
Box3D's soft solver just leak (weld-joint note), and B&S ships two-handed without any.
4. Anchor drift between hands: leave it to the existing break logic; optionally (cheap spike)
re-capture the SECONDARY joint's anchors (destroy + recreate the joint node at the current
pose, the same configure-before-AddChild ordering as grab) if its anchor separation exceeds
~0.15 m while both hands hold. [I] optional, only if drift still reads wrong after (2).
5. **Tier B (enabling change, ask before doing): fork/PR godot-box3d to expose** on pin joints:
`b3Joint_SetConstraintTuning` (positional stiffness — fixes the 22.5 Hz/ζ2.0 sag),
`b3SphericalJoint_SetTargetRotation` + `EnableMotor`/`SetMotorVelocity`/`SetMaxMotorTorque`
(true solver-side orientation servo), and `EnableConeLimit`/`EnableTwistLimit` (true weld
feel: cone 0 + twist [0,0] on the primary joint). Each is a one-line b3 call behind the
existing joint_impl plumbing; upstream README already concedes these are unreached.
### (b) Per-tick rotation control (replaces the velocity-SET)
1. **DELETE the per-tick `body.AngularVelocity` set in PropGrabber.cs** (the spin = ... clamp +
assign block). Two authorities on one DOF (velocity override vs the joint's rotational
spring) is the documented oscillation source; the spring IS the orientation servo now.
2. **Safety clamp only**: each tick while held, if prop |angular velocity| > **30 rad/s**
(`MaxHeldSpin`), scale it to 30. This is a guard, not a controller — it should almost never
fire once (1) is gone.
3. Optional stabilization while held [I, starting values]: raise the prop's
`AngularDamp` to **2.0** on grab, restore on release. Cheap; test with and without.
4. Tier B servo (after the patch): retarget the primary joint's `targetRotation` from the
controller each tick, or enable the spherical motor with `SetMotorVelocity ≈ 15 × rotation
error (rad/s per rad)` and `SetMaxMotorTorque ≈ 8 N·m` for props ≤ 5 kg (scale ∝ mass);
keep the spring enabled at 10–20 Hz as the position term. Servo-with-torque-cap is the
Box3D manual's own recipe.
### (c) Hand safety policy while gripping
1. **While a hand holds a live grab joint, SUSPEND both safeties for that hand** in
PhysicsHands.cs: the stuck-snap (StuckDistance 0.2 m / StuckTime 1.0 s) and the
SnapDistance 2 m teleport. A joint legitimately holds the hand back — that's weight feel,
not a bug; teleporting a jointed hand out from under itself is the yank. (Prior art: HVR
and B&S break the GRIP instead of moving the hand; kinematic systems have no joint to
yank.)
2. **Grip break always runs first** and is unchanged in spirit: existing BreakDistance
0.25 m / BreakTime 0.25 s, both-hands 0.4 m. Add a hard rip: separation >
**`GripRipDistance` 0.8 m → immediate release, no timer** (analog of HVR's
PerpendicularThreshold stretch break) so a genuinely stuck hand recovers in one tick
instead of one second.
3. After release, the normal safety logic resumes next tick; the one-frame arm jump on snap
stays a known cosmetic issue (already in HANDOFF §5).
4. Do NOT teleport the prop with the hand. Move-object-with-teleport applies to kinematic
holds; here the joint must break first, then everything is ordinary again.
### (d) Box3D joint facts (evidence for the above)
- Wrapper joint classes (local .so strings + upstream `src/joints/`): Pin → b3SphericalJoint
(spring hertz = bias×30, damping = dampingRatio, spring ALWAYS on, targetRotation = grab-time
relative rotation, unreachable), Hinge (limits + motor reachable), Slider, Filter (collision
exception). Cone-twist/6DOF explicitly rejected by design; weld/motor/distance/parallel/wheel
joints exist in Box3D but have no wrapper path.
- PinJoint3D.IMPULSE_CLAMP is a warned no-op; HingeJoint3D's BIAS/LIMIT_* params are ignored.
- Positional constraint tuning: world default 60 Hz/ζ2.0, clamped to 0.25×tick (22.5 Hz at 90
Hz physics), not exposed by the wrapper — the floor under all "softness" complaints.
- Local evidence paths: `/home/foxyz/Projects/Games/vr-messs/addons/godot-box3d/bin/`
(strings of `libgodot-box3d.so`); upstream `src/joints/box3d_pin_joint_impl_3d.{hpp,cpp}`,
`src/joints/box3d_joint_impl_3d.cpp`, `src/servers/box3d_physics_server_3d.cpp`
(bearlikelion/godot-box3d v0.2.6 = main); `include/box3d/types.h`, `src/joint.c`,
`src/spherical_joint.c`, `docs/simulation.md` (erincatto/box3d main).
- Jolt-fallback caveat (existing): on Jolt, PinJoint3D reverts to vanilla origin-pinning and
bias/damping semantics — grab v5.1 is even more Box3D-specific than v5; the freeze+drive
fallback in `vr-grabbing.md` remains the lossless escape hatch.
**Order of attack for the implementing agent**: (b1)+(b2) first (delete velocity-SET, add
clamp — one file, likely fixes "out of control"), then (a2)/(a3) bias split (second file,
likely fixes "jointed-on"), then (c) safety gating (third file), then decide on Tier B only
after a playtest of those three.
---
## CORRECTION + verification (2026-10-01) — the spring's target is IDENTITY, not "grab-time relative rotation"
The original claim above — "the spring target is permanently the relative rotation at grab
time" (and in (d): "targetRotation = grab-time relative rotation") — was **WRONG**. It was an
invalid inference from the wrapper passing identity-basis frames. Re-verification against
actual source (below) shows the target is **IDENTITY**, i.e. the spring drives the two bodies
to the SAME WORLD ORIENTATION (prop springs to the hand's orientation). This exactly explains
playtest observation 1 (~180° forced rotation on grab) and reframes 2–4. Everything else in
the original sections (spring always on, hertz = bias×30, positional tuning unreachable,
wrapper API surface, hand-safety prior art) re-verified correct.
### What was verified, with quotes
**Pinned version note [S]**: godot-box3d v0.2.6 (tag `791ed422`) vendors box3d as a git
submodule pinned to commit `3fc20f5b` (2026-07-30). box3d `main` is 11 commits ahead, 0
behind; `src/spherical_joint.c` differs from main by ONE COMMENT LINE, and the `joint.c` /
`solver.h` diffs are unrelated (contact-destroy refactor, restitution). All quotes below are
identical in the pinned commit and main.
**A1. targetRotation at creation = the def default = identity; nothing re-captures the
current relative rotation.** [S]
- `src/joint.c:86-92` — `b3DefaultSphericalJointDef()`: `b3SphericalJointDef def = { 0 };`
`def.base = b3DefaultJointDef(); def.targetRotation = b3Quat_identity;`
- `src/joint.c:645` — `b3CreateSphericalJoint()` just copies it:
`joint->sphericalJoint.targetRotation = def->targetRotation;` — there is NO code anywhere
that initializes targetRotation from the bodies' current qA/qB. The ONLY recompute from
live relative rotation (`base->sphericalJoint.targetRotation = relQ;`, `joint.c:1472`)
sits inside `#if 0 ... #endif` (disabled helper `b3Joint_SetSpringRotationTarget`).
- The wrapper never calls `b3SphericalJoint_SetTargetRotation`
(`b3SphericalJoint_SetTargetRotation` exists at `src/spherical_joint.c:154-161` but has no
wrapper path), and `b3DefaultSphericalJointDef()` is what the wrapper starts from —
see B below.
- Consequence [I]: with identity local frames (wrapper), the solver's error
`relQ = b3InvMulQuat(quatA, quatB)` (where `quatA/B = body rotation × localFrame.q`,
`spherical_joint.c:452-455, 326-329`) is the pure hand↔prop relative rotation, and the
spring pulls it toward `targetRotation = identity` ⇒ **prop world orientation is driven to
the hand's world orientation**, from the first solver tick after grab. Observation 1
CONFIRMED at source level.
**A2. Spring "torque" formula: soft-constraint PD, inertia-scaled, NO torque cap.** [S]
- `src/spherical_joint.c:457-475` (`b3SolveSphericalJoint`):
```
b3Vec3 deltaRotation = b3DeltaQuatToRotation( relQ, joint->targetRotation );
b3Vec3 c = b3Neg( b3RotateVector( quatA, deltaRotation ) ); // position error (rad)
b3Vec3 bias = b3MulSV( joint->springSoftness.biasRate, c ); // velocity bias ∝ error
impulse = -massScale * rotationMass * ( wB - wA + bias ) - impulseScale * springImpulse;
```
where `rotationMass = b3InvertMatrix( invInertiaSum )` (`spherical_joint.c:362-369`) — the
combined inverse-inertia tensor, so torque auto-scales with the paired rotational inertia;
and `springSoftness = b3MakeSoft( joint->hertz, joint->dampingRatio, context->h )` (line
371). It is a PD in Box3D's soft-constraint form (stiffness via `biasRate`, damping via the
`cdot` term), NOT a literal k·θ torque — hence no explicit cap: **the rotational spring has
no max-torque clamp**; only the motor is capped (`spherical_joint.c:483-489`,
`maxImpulse = joint->maxMotorTorque * context->h`).
- Error convention: `b3DeltaQuatToRotation(q, target) = 2·vec((target − ±q)·conj(q))`
(`src/math_internal.h:221-232`) — shortest-arc (polarity-corrected) error toward `target`.
**A3. hertz = 0 (wrapper bias = 0) kills ONLY the spring; position is untouched.** [S]
- `src/solver.h:265-273` — `b3MakeSoft( hertz, zeta, h )`:
`if ( hertz == 0.0f ) return { biasRate = 0.0f, massScale = 0.0f, impulseScale = 0.0f };`
⇒ every term of the spring impulse is multiplied by 0 ⇒ **spring impulse is exactly zero**
(not merely weak — the solve block at `spherical_joint.c:458` still runs but contributes
nothing, not even angular-velocity coupling).
- The point-to-point block (`spherical_joint.c:605-647`) uses `base->constraintSoftness`
only, which `b3PrepareJoint` sets independently (`src/joint.c:1484-1486`):
`hertz = b3MinFloat( joint->constraintHertz, 0.25f * context->inv_h )` with
`b3DefaultJointDef()` defaults `constraintHertz = 60.0f; constraintDampingRatio = 2.0f;`
(`joint.c:30-31`) — completely independent of the spring's `hertz/dampingRatio`. So
**PinJoint3D.Bias = 0.0 ⇒ pure ball joint (position-only), no rotational behavior at all**.
**A4. The spring torques BOTH bodies, equal and opposite.** [S]
- `spherical_joint.c:473-474`: `wA = b3Sub( wA, b3MulMV( iA, impulse ) );`
`wB = b3Add( wB, b3MulMV( iB, impulse ) );` — the hand (dynamic) receives the reaction, and
the identity target means the hand is ALSO being pulled toward the prop's orientation. This
is the source of observation 3 ("hand can rotate around the grab joint" — visibly dragged
by the spring) and part of observation 4.
**B. Wrapper confirmation.** [S]
- `src/joints/box3d_pin_joint_impl_3d.cpp` `_create_joint_id()`:
`b3SphericalJointDef def = b3DefaultSphericalJointDef(); ... def.enableSpring = true;
def.hertz = (float)bias * 30.0f; def.dampingRatio = (float)damping;` — BIAS feeds ONLY the
spring hertz (nothing positional; the positional constraint is never touched by the
wrapper). `set_param` mirrors it live via `b3SphericalJoint_SetSpringHertz`.
- `src/servers/box3d_physics_server_3d.cpp:941-955` `_joint_make_pin()`: passes
`Transform3D(Basis(), p_local_a)` / `Transform3D(Basis(), p_local_b)` — **identity basis**
⇒ identity frame quats; Box3D then computes world frame rotations as
`transform.q × localFrame.q` at prepare (`spherical_joint.c:326-329`) and recomputes
NOTHING into targetRotation at creation. Target stays identity forever.
**Reframe of playtest observations 2–4 [I]**: a spring pulling toward the WRONG target
(identity = hand's orientation) reads as "weak hold": the 3.5 kg brick is simultaneously
sprung toward the hand's orientation (which changes as the wrist turns) and yanked by
gameplay forces, so it never settles where the player wants it. Two hands = two springs
pulling toward two different orientations (each hand's) at 9 Hz and 22.5 Hz — the lower-rate
secondary spring is simply the loser, and re-creating the primary joint at bias 0.75 cannot
help because the target is still identity and still fights the secondary's 9 Hz identity
target from a different hand pose. The original recommendation to RAISE primary bias to 0.75
would have made observation 1 WORSE (stronger snap-to-hand-orientation), not better. The
"(a2)/(a3) bias split" and "order of attack" in the original sections are retracted; (b1)
(delete velocity-SET) and (c) (safety gating) stand unchanged.
### C. Replacement design evidence — script-side orientation servo
**Godot 4.7 C# torque APIs [S]** (pinned docs `/home/foxyz/Projects/Games/.shared/godot-docs-4.7/classes/`):
- `RigidBody3D`: `ApplyTorque(Vector3)` (class_rigidbody3d.rst:886-890),
`ApplyTorqueImpulse(Vector3)` (:900-904), `AddConstantTorque(Vector3)` (:814-820) +
`ConstantTorque` property (:447-456). (The hypothesis's "RigidBody3D has no ApplyTorque"
was wrong — it has had it since 4.x.)
- `PhysicsDirectBodyState3D`: `ApplyTorque` (:521+), `ApplyTorqueImpulse`,
`InverseInertia` (Vector3, :235-243), `InverseInertiaTensor` (Basis, :251-259) — usable in
`_IntegrateForces`.
- `PhysicsServer3D`: `body_apply_torque` / `body_apply_torque_impulse` /
`body_add_constant_torque` all exist (class_physicsserver3d.rst:107-121).
**Box3D wrapper honors all of them [S]** (`bearlikelion/godot-box3d` main):
- `src/servers/box3d_physics_server_3d.hpp:131-143` overrides
`_body_apply_torque/_body_apply_torque_impulse/_body_add_constant_torque/...`;
`src/objects/box3d_body_impl_3d.cpp:325-329` `apply_torque_impulse →
b3Body_ApplyAngularImpulse(..., true)`; `:346-351` `apply_torque` accumulates
`applied_torque` and wakes the body; `:454-460` `pre_step()` applies the accumulated sum
via `b3Body_ApplyTorque(body_id, ..., false)` each physics tick (then clears it — so call
it every tick from `_IntegrateForces`/`_PhysicsProcess`).
- `src/objects/box3d_physics_direct_body_state_3d.cpp:57-70` implements
`inverse_inertia`/`inverse_inertia_tensor` (real values from b3, not stubs); `:114-127`
forwards state `ApplyTorque*` to the body.
**Prior art for the gains [S]** — David Wu's backward-PD ("Controlling Objects With Forces
and Torques", digitalopus.ca/site/pd-controllers/): gains parameterized by frequency f
("1/f seconds to ~95% convergence at damping 1") and damping d, inertia-tensor-scaled so one
gain set fits any mass:
`kp = (6f)² × 0.25 = 9f²`, `kd = 4.5·f·d`,
`g = 1/(1 + kd·dt + kp·dt²)`, `ksg = kp·g`, `kdg = (kd + kp·dt)·g`,
`torque = ksg·(axis·angle_rad) − kdg·ω`, then transformed through the inertia tensor. The
canonical community form (Unity forums/Reddit, e.g. discussions.unity.com/t/rigidbody-lookat-torque/483110)
is the same shape without inertia scaling: `torque = axis·angle·kp − ω·kd` — works only
per-object, which is why Wu's form is preferred here. Unity ConfigurableJoint practice
(docs.unity3d.com class-ConfigurableJoint) corroborates the hard part: always cap the drive
(Maximum Force), which the Box3D spring lacks and the script servo must add.
**Servo vs raising bias — the verdict [I]**: raising bias cannot be right even in principle
because the target is identity (wrong by construction) and unreachable without a wrapper
patch. The script servo can be re-targeted every tick (target = hand's CURRENT world basis,
optionally offset by the grab-pose delta), is inertia-scaled (one gain set for brick →
rifle), torque-capped, and numerically safe: with Wu's form at 90 Hz (dt = 1/90) and f = 6 Hz
the effective natural frequency 3f = 18 rad/s gives ω_n·dt ≈ 0.2 ≪ 1 — far inside the
stability Box3D's own manual demands of solver springs ("< half the frequency of the time
step"). Holding the 3.5 kg brick (principal inertias ≈ 0.004–0.015 kg·m² for a
0.2×0.1×0.05 m block) level at a 0.5 rad error needs τ ≈ I·9f²·θ ≈ 1–3 N·m — well under any
sane cap. Instability risk lives in the HAND (light body, big reaction), so the cap and the
existing hand-follow logic absorb it; this is the same authority split HVR/Unity-thread
prior art uses (one rotational authority, position stays solver-owned).
### Recommended grab v5.2 architecture (supersedes (a)/(b) rotation parts above)
1. **PinJoint3D per hand stays, but both at `Bias = 0.0`, `Damping` irrelevant** → spring
impulse is EXACTLY zero (A3) ⇒ pure ball joints: position coupling stays solver-owned,
all rotational behavior is now script-owned. This kills observations 1, 3 (spring part),
and the 4 fight in one line each.
2. **Script-side orientation servo on the prop (one authority: the primary hand)** in the
prop's `_IntegrateForces` (or a fixed-tick controller using `RigidBody3D.ApplyTorque`):
`q_err = q_hand_world × Quaternion(q_prop_world).Inverse()`; `q_err.ToAngleAxis(out a, out
axis)` (wrap a to (−π, π]); gains via Wu's backward-PD with `f = 6.0`, `damping = 1.0`,
`dt = 1/90`; torque `τ = ksg·axis·a − kdg·ω_prop` applied through the inertia tensor
(`InverseInertiaTensor`); **hard cap `|τ| ≤ τ_max`** before applying.
3. **Starting constants [I]**: `f = 6.0 Hz`, `damping = 1.0`, `τ_max = 20 N·m` for props
≤ 5 kg (scale `τ_max` ∝ mass for heavier props; the inertia scaling inside the formula
already handles shape). Keep the v5 (b2) safety spin clamp (30 rad/s). Keep prop
`AngularDamp` untouched (the kd term IS the damping; two dampers fight).
4. **Secondary hand**: joint at Bias 0 (position-only). On second grab, record
`q_offset = q_secondary_hand_world⁻¹ × q_target_world` and keep the servo target at
`q_secondary_hand_world × q_offset` while the primary holds — secondary hand drags the
servo target (Blade & Sorcery's "secondary adjusts pose", no second rotational authority).
On primary release, promote the secondary: new servo owner, new `q_offset` captured from
ITS grab pose — orientation continuity without any joint recreation semantics.
5. **(c) hand-safety policy unchanged** (suspend stuck-snap/snap-teleport while a live joint
exists; grip break first; rip threshold). Tier B (wrapper patch to expose
`SetTargetRotation`/motor/cone-twist) is now OPTIONAL polish, not the fix path — the
script servo needs no patch and is retargetable, which the joint spring is not.
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/GrabSpike.cs" id="1_spike"]
[node name="GrabSpike" type="Node3D"]
script = ExtResource("1_spike")
+126
View File
@@ -0,0 +1,126 @@
[gd_scene load_steps=19 format=3]
[ext_resource type="Script" path="res://scripts/Main.cs" id="1_main"]
[ext_resource type="PackedScene" path="res://scenes/teleport_marker.tscn" id="2_marker"]
[ext_resource type="Script" path="res://scripts/Locomotion.cs" id="3_locomotion"]
[ext_resource type="Script" path="res://scripts/WorldPanel.cs" id="4_worldpanel"]
[ext_resource type="Script" path="res://scripts/AnchorMoveMode.cs" id="5_anchormove"]
[ext_resource type="Script" path="res://scripts/PropShop.cs" id="6_propshop"]
[ext_resource type="Script" path="res://scripts/PropGrabber.cs" id="7_propgrabber"]
[ext_resource type="Script" path="res://scripts/PhysicsHands.cs" id="8_physicshands"]
[ext_resource type="Script" path="res://scripts/BodyRig.cs" id="9_bodyrig"]
[sub_resource type="ProceduralSkyMaterial" id="ProceduralSkyMaterial_sky"]
sky_horizon_color = Color(0.66, 0.72, 0.81, 1)
ground_horizon_color = Color(0.66, 0.72, 0.81, 1)
[sub_resource type="Sky" id="Sky_main"]
sky_material = SubResource("ProceduralSkyMaterial_sky")
[sub_resource type="Environment" id="Environment_main"]
background_mode = 2
sky = SubResource("Sky_main")
[sub_resource type="StandardMaterial3D" id="Material_floor"]
albedo_color = Color(0.35, 0.35, 0.38, 1)
[sub_resource type="PlaneMesh" id="PlaneMesh_floor"]
material = SubResource("Material_floor")
size = Vector2(10, 10)
[sub_resource type="BoxShape3D" id="BoxShape_floor"]
size = Vector3(10, 0.1, 10)
[sub_resource type="SphereMesh" id="SphereMesh_hand"]
radius = 0.04
height = 0.08
[sub_resource type="StandardMaterial3D" id="Material_hand_left"]
albedo_color = Color(0.9, 0.3, 0.35, 1)
[sub_resource type="StandardMaterial3D" id="Material_hand_right"]
albedo_color = Color(0.3, 0.5, 0.9, 1)
[node name="Main" type="Node3D"]
script = ExtResource("1_main")
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
environment = SubResource("Environment_main")
[node name="Sun" type="DirectionalLight3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 0.642788, 0.766044, 0, -0.766044, 0.642788, 0, 8, 0)
shadow_enabled = true
directional_shadow_max_distance = 10.0
shadow_bias = 0.04
shadow_normal_bias = 0.5
[node name="Floor" type="StaticBody3D" parent="."]
[node name="FloorShape" type="CollisionShape3D" parent="Floor"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.05, 0)
shape = SubResource("BoxShape_floor")
[node name="FloorMesh" type="MeshInstance3D" parent="Floor"]
mesh = SubResource("PlaneMesh_floor")
[node name="XROrigin3D" type="XROrigin3D" parent="."]
[node name="XRCamera3D" type="XRCamera3D" parent="XROrigin3D"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.7, 0)
[node name="LeftHand" type="XRController3D" parent="XROrigin3D"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -0.5, 1, -0.5)
tracker = &"left_hand"
pose = &"default"
[node name="RightHand" type="XRController3D" parent="XROrigin3D"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0.5, 1, -0.5)
tracker = &"right_hand"
pose = &"default"
[node name="WorldPanel" type="Node3D" parent="XROrigin3D"]
script = ExtResource("4_worldpanel")
[node name="SpawnPoint" type="Marker3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 2.5)
[node name="TeleportMarkers" type="Node3D" parent="."]
[node name="Marker1" parent="TeleportMarkers" instance=ExtResource("2_marker")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -3, 0, -3)
MarkerLabel = "1"
[node name="Marker2" parent="TeleportMarkers" instance=ExtResource("2_marker")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 3, 0, -3)
MarkerLabel = "2"
[node name="Marker3" parent="TeleportMarkers" instance=ExtResource("2_marker")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, -4)
MarkerLabel = "3"
[node name="Locomotion" type="Node" parent="."]
script = ExtResource("3_locomotion")
[node name="AnchorMoveMode" type="Node" parent="."]
script = ExtResource("5_anchormove")
[node name="PropShop" type="Node3D" parent="."]
script = ExtResource("6_propshop")
[node name="PropGrabber" type="Node" parent="."]
script = ExtResource("7_propgrabber")
[node name="PhysicsHands" type="Node" parent="."]
script = ExtResource("8_physicshands")
[node name="BodyRig" type="Node3D" parent="."]
script = ExtResource("9_bodyrig")
[node name="FadeLayer" type="CanvasLayer" parent="."]
[node name="Fade" type="ColorRect" parent="FadeLayer"]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
color = Color(0, 0, 0, 0)
mouse_filter = 2
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/PhysicsSpike.cs" id="1_spike"]
[node name="PhysicsSpike" type="Node3D"]
script = ExtResource("1_spike")
+42
View File
@@ -0,0 +1,42 @@
[gd_scene load_steps=5 format=3]
[ext_resource type="Script" path="res://scripts/TeleportMarker.cs" id="1_marker"]
[sub_resource type="CylinderMesh" id="Mesh_disc"]
top_radius = 0.45
bottom_radius = 0.45
height = 0.02
[sub_resource type="CylinderMesh" id="Mesh_beam"]
top_radius = 0.03
bottom_radius = 0.03
height = 2.5
[sub_resource type="BoxShape3D" id="Shape_pick"]
size = Vector3(0.9, 2.5, 0.9)
[node name="TeleportMarker" type="Node3D"]
script = ExtResource("1_marker")
[node name="Disc" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.01, 0)
mesh = SubResource("Mesh_disc")
[node name="Beam" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.25, 0)
cast_shadow = 0
mesh = SubResource("Mesh_beam")
[node name="Label3D" type="Label3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.9, 0)
pixel_size = 0.004
billboard = 1
text = "1"
font_size = 128
outline_size = 24
[node name="Body" type="StaticBody3D" parent="."]
[node name="Shape" type="CollisionShape3D" parent="Body"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.25, 0)
shape = SubResource("Shape_pick")
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/WeldSpike.cs" id="1_spike"]
[node name="WeldSpike" type="Node3D"]
script = ExtResource("1_spike")
+95
View File
@@ -0,0 +1,95 @@
using Godot;
public partial class AnchorMoveMode : Node
{
private const float GripThreshold = 0.6f;
private XROrigin3D _origin;
private XRCamera3D _camera;
private XRController3D _left;
private XRController3D _right;
private bool _wasTwoHand;
private Vector3 _leftPrev;
private Vector3 _rightPrev;
private Vector3 _twoHandMidPrev;
private float _twoHandYawPrev;
public override void _Ready()
{
_origin = GetNode<XROrigin3D>("../XROrigin3D");
_camera = GetNode<XRCamera3D>("../XROrigin3D/XRCamera3D");
_left = GetNode<XRController3D>("../XROrigin3D/LeftHand");
_right = GetNode<XRController3D>("../XROrigin3D/RightHand");
}
public override void _Process(double delta)
{
bool leftGrip = _left.GetFloat("grip") > GripThreshold && !PropGrabber.IsGrabbing(_left);
bool rightGrip = _right.GetFloat("grip") > GripThreshold && !PropGrabber.IsGrabbing(_right);
if (leftGrip && rightGrip && !WorldPanel.IsBlocking(_left) && !WorldPanel.IsBlocking(_right))
TwoHandGrab();
else
{
_wasTwoHand = false;
if (leftGrip && !WorldPanel.IsBlocking(_left))
GrabMove(_left, ref _leftPrev);
else
_leftPrev = _left.Position;
if (rightGrip && !WorldPanel.IsBlocking(_right))
GrabMove(_right, ref _rightPrev);
else
_rightPrev = _right.Position;
}
}
private void TwoHandGrab()
{
Vector3 mid = Midpoint();
float yaw = HandsYaw();
if (!_wasTwoHand)
{
_wasTwoHand = true;
_twoHandMidPrev = mid;
_twoHandYawPrev = yaw;
return;
}
Vector3 deltaWorld = _origin.GlobalTransform.Basis * (mid - _twoHandMidPrev);
float yawDelta = _twoHandYawPrev - yaw;
if (deltaWorld.LengthSquared() > 0.000001f || Mathf.Abs(yawDelta) > 0.000001f)
{
Vector3 hxz = new Vector3(_camera.GlobalPosition.X, 0f, _camera.GlobalPosition.Z);
Transform3D turn = new Transform3D(Basis.Identity, hxz)
* new Transform3D(Basis.FromEuler(new Vector3(0, yawDelta, 0)), Vector3.Zero)
* new Transform3D(Basis.Identity, -hxz);
_origin.GlobalTransform = new Transform3D(Basis.Identity, -deltaWorld) * turn * _origin.GlobalTransform;
}
_twoHandMidPrev = mid;
_twoHandYawPrev = yaw;
}
private void GrabMove(XRController3D controller, ref Vector3 prev)
{
Vector3 pos = controller.Position;
Vector3 delta = pos - prev;
prev = pos;
if (delta.LengthSquared() == 0f)
return;
Vector3 deltaWorld = _origin.GlobalTransform.Basis * delta;
_origin.GlobalTransform = new Transform3D(Basis.Identity, -deltaWorld) * _origin.GlobalTransform;
}
private Vector3 Midpoint() => (_left.Position + _right.Position) * 0.5f;
private float HandsYaw()
{
Vector3 v = _right.Position - _left.Position;
v.Y = 0f;
if (v.LengthSquared() < 0.0001f)
return _twoHandYawPrev;
return Mathf.Atan2(v.X, v.Z);
}
}
+1
View File
@@ -0,0 +1 @@
uid://00dofwddtngn
+250
View File
@@ -0,0 +1,250 @@
using Godot;
public partial class BodyRig : Node3D
{
private const float HeadBackOffset = 0.06f;
private const float HeadSize = 0.15f;
private const float TorsoDrop = 0.42f;
private const float TorsoBackOffset = 0.06f;
private const float ShoulderRise = 0.25f;
private const float ReachBudgetMax = 0.25f;
private const float ReachVerticalMin = -0.05f;
private const float ReachVerticalMax = 0.12f;
private const float ReachPushSpeed = 0.8f;
private const float ReachDecaySpeed = 0.4f;
private const float ReachDecayFactor = 0.92f;
private const float WristOffset = 0.06f;
private const float SoftIKFactor = 0.98f;
private const float PoleHandWeight = 0.25f;
private const float PoleSmoothRate = 10f;
private const float TorsoYawHandsWeight = 0.6f;
private const float TorsoYawForwardness = 0.3f;
private const float TorsoYawSmoothRate = 12f;
private XRCamera3D _camera;
private XRController3D _left;
private XRController3D _right;
private MeshInstance3D _headMesh;
private MeshInstance3D _torsoMesh;
private readonly MeshInstance3D[] _upperArmMeshes = new MeshInstance3D[2];
private readonly MeshInstance3D[] _forearmMeshes = new MeshInstance3D[2];
private bool _calibrated;
private float _upperLen;
private float _foreLen;
private float _shoulderHalfWidth;
private Vector3 _torsoOffset = Vector3.Zero;
private Basis _yawBasis = Basis.Identity;
private Basis _torsoBasis = Basis.Identity;
private float _torsoYaw;
private readonly Vector3[] _lastPole = new Vector3[2];
public override void _Ready()
{
_camera = GetNode<XRCamera3D>("../XROrigin3D/XRCamera3D");
_left = GetNode<XRController3D>("../XROrigin3D/LeftHand");
_right = GetNode<XRController3D>("../XROrigin3D/RightHand");
_headMesh = MakeMesh(new BoxMesh { Size = new Vector3(HeadSize, HeadSize, HeadSize) }, new Color(0.8f, 0.8f, 0.8f));
_torsoMesh = MakeMesh(new BoxMesh { Size = new Vector3(0.22f, 0.5f, 0.13f) }, new Color(0.4f, 0.45f, 0.55f));
_upperArmMeshes[0] = MakeMesh(new BoxMesh { Size = new Vector3(0.07f, 0.3f, 0.07f) }, new Color(0.3f, 0.3f, 0.33f));
_upperArmMeshes[1] = MakeMesh(new BoxMesh { Size = new Vector3(0.07f, 0.3f, 0.07f) }, new Color(0.3f, 0.3f, 0.33f));
_forearmMeshes[0] = MakeMesh(new BoxMesh { Size = new Vector3(0.06f, 0.3f, 0.06f) }, new Color(0.3f, 0.3f, 0.33f));
_forearmMeshes[1] = MakeMesh(new BoxMesh { Size = new Vector3(0.06f, 0.3f, 0.06f) }, new Color(0.3f, 0.3f, 0.33f));
SetRigVisible(false);
}
private MeshInstance3D MakeMesh(Mesh mesh, Color color)
{
MeshInstance3D node = new() { Mesh = mesh };
node.SetSurfaceOverrideMaterial(0, new StandardMaterial3D { AlbedoColor = color, Roughness = 0.6f });
AddChild(node);
return node;
}
private void SetRigVisible(bool visible)
{
_headMesh.Visible = visible;
_torsoMesh.Visible = visible;
foreach (MeshInstance3D m in _upperArmMeshes)
m.Visible = visible;
foreach (MeshInstance3D m in _forearmMeshes)
m.Visible = visible;
}
public override void _Process(double delta)
{
UpdateYawBasis();
float dt = (float)delta;
if (!_calibrated)
{
float headHeight = _camera.GlobalPosition.Y;
if (headHeight <= 0.5f)
return;
Calibrate(headHeight);
}
Vector3 headPos = _camera.GlobalPosition - _yawBasis * Vector3.Forward * HeadBackOffset;
_headMesh.GlobalTransform = new Transform3D(_yawBasis, headPos);
UpdateTorsoYaw(headPos, dt);
Vector3 torsoBack = _torsoBasis * Vector3.Back;
Vector3 torsoCenter = headPos + Vector3.Down * TorsoDrop + torsoBack * TorsoBackOffset + _torsoOffset;
_torsoMesh.GlobalTransform = new Transform3D(_torsoBasis, torsoCenter);
Vector3 topCenter = torsoCenter + Vector3.Up * ShoulderRise;
Vector3 leftShoulder = topCenter - _torsoBasis.X * _shoulderHalfWidth;
Vector3 rightShoulder = topCenter + _torsoBasis.X * _shoulderHalfWidth;
Vector3 push = SolveArm(0, leftShoulder, -_torsoBasis.X, PhysicsHands.GetBody(_left), _upperArmMeshes[0], _forearmMeshes[0], dt, out bool decayLeft);
push += SolveArm(1, rightShoulder, _torsoBasis.X, PhysicsHands.GetBody(_right), _upperArmMeshes[1], _forearmMeshes[1], dt, out bool decayRight);
if (push != Vector3.Zero)
{
_torsoOffset += push * (dt * ReachPushSpeed);
}
else if (decayLeft || decayRight)
{
_torsoOffset = _torsoOffset.MoveToward(Vector3.Zero, ReachDecaySpeed * dt);
}
Vector3 horizontal = new(_torsoOffset.X, 0f, _torsoOffset.Z);
horizontal = horizontal.LimitLength(ReachBudgetMax);
_torsoOffset = new Vector3(horizontal.X, Mathf.Clamp(_torsoOffset.Y, ReachVerticalMin, ReachVerticalMax), horizontal.Z);
}
private void UpdateYawBasis()
{
Vector3 fwd = -_camera.GlobalTransform.Basis.Z;
fwd.Y = 0f;
if (fwd.LengthSquared() < 0.0001f)
return;
_yawBasis = Basis.LookingAt(fwd.Normalized(), Vector3.Up);
}
private void UpdateTorsoYaw(Vector3 headPos, float delta)
{
float hmdYaw = YawOf(-_yawBasis.Z);
RigidBody3D leftBody = PhysicsHands.GetBody(_left);
RigidBody3D rightBody = PhysicsHands.GetBody(_right);
float targetYaw = hmdYaw;
if (leftBody != null && rightBody != null)
{
Vector3 toHands = (leftBody.GlobalPosition + rightBody.GlobalPosition) * 0.5f - headPos;
Vector3 horizontal = new(toHands.X, 0f, toHands.Z);
if (horizontal.LengthSquared() > 0.0001f)
{
float handsYaw = YawOf(horizontal.Normalized());
float forwardness = Mathf.Clamp(horizontal.Dot(-_yawBasis.Z) / TorsoYawForwardness, 0f, 1f);
targetYaw = Mathf.LerpAngle(hmdYaw, handsYaw, forwardness * TorsoYawHandsWeight);
}
}
_torsoYaw = Mathf.LerpAngle(_torsoYaw, targetYaw, 1f - Mathf.Exp(-delta * TorsoYawSmoothRate));
Vector3 fwd = new(-Mathf.Sin(_torsoYaw), 0f, -Mathf.Cos(_torsoYaw));
_torsoBasis = Basis.LookingAt(fwd, Vector3.Up);
}
private static float YawOf(Vector3 forward)
{
return Mathf.Atan2(-forward.X, -forward.Z);
}
private void Calibrate(float headHeight)
{
_calibrated = true;
_torsoYaw = YawOf(-_yawBasis.Z);
float armTotal = Mathf.Clamp(headHeight * 0.46f, 0.5f, 0.72f);
_upperLen = 0.5f * armTotal;
_foreLen = 0.5f * armTotal;
_shoulderHalfWidth = 0.16f + headHeight * 0.02f;
((BoxMesh)_upperArmMeshes[0].Mesh).Size = new Vector3(0.07f, _upperLen, 0.07f);
((BoxMesh)_upperArmMeshes[1].Mesh).Size = new Vector3(0.07f, _upperLen, 0.07f);
((BoxMesh)_forearmMeshes[0].Mesh).Size = new Vector3(0.06f, _foreLen, 0.06f);
((BoxMesh)_forearmMeshes[1].Mesh).Size = new Vector3(0.06f, _foreLen, 0.06f);
SetRigVisible(true);
GD.Print($"BodyRig: proportions calibrated from head height {headHeight:F2} m " +
$"(arm total {armTotal:F2} m, upper {_upperLen:F2} m, fore {_foreLen:F2} m, shoulder half-width {_shoulderHalfWidth:F2} m)");
}
private Vector3 SolveArm(int index, Vector3 shoulder, Vector3 side, RigidBody3D body, MeshInstance3D upperMesh, MeshInstance3D foreMesh, float delta, out bool decay)
{
Vector3 rawTarget = body != null
? body.GlobalPosition + body.GlobalTransform.Basis * new Vector3(0f, 0f, WristOffset)
: shoulder + _torsoBasis * new Vector3(0f, -0.25f, -0.3f);
float reach = _upperLen + _foreLen;
Vector3 shoulderToTarget = rawTarget - shoulder;
if (shoulderToTarget.LengthSquared() < 0.0001f)
shoulderToTarget = _torsoBasis * new Vector3(0f, -0.25f, -0.3f);
float rawDist = shoulderToTarget.Length();
float d = Mathf.Clamp(rawDist, 0.01f, reach * SoftIKFactor);
Vector3 dir = shoulderToTarget / rawDist;
Vector3 clampedTarget = shoulder + dir * d;
Vector3 push = Vector3.Zero;
decay = false;
if (rawDist > reach)
{
push = (rawTarget - clampedTarget).Normalized();
}
else if (rawDist < reach * ReachDecayFactor)
{
decay = true;
}
float cosA = (_upperLen * _upperLen + d * d - _foreLen * _foreLen) / (2f * _upperLen * d);
float angle = Mathf.Acos(Mathf.Clamp(cosA, -1f, 1f));
Vector3 handForward = body != null ? body.GlobalTransform.Basis * Vector3.Forward : Vector3.Zero;
Vector3 pole = Vector3.Down * 2f + _torsoBasis * Vector3.Back * 0.8f + side * 0.3f;
pole -= dir * pole.Dot(dir);
pole -= handForward * PoleHandWeight;
Vector3 poleDir;
if (pole.LengthSquared() < 0.0001f)
poleDir = _lastPole[index] == Vector3.Zero ? Vector3.Down : _lastPole[index];
else
{
poleDir = _lastPole[index].Lerp(pole, 1f - Mathf.Exp(-delta * PoleSmoothRate));
if (poleDir.LengthSquared() < 0.0001f)
poleDir = _lastPole[index];
poleDir = poleDir.Normalized();
_lastPole[index] = poleDir;
}
Vector3 axis = dir.Cross(poleDir);
if (axis.LengthSquared() < 0.0001f)
axis = dir.Cross(Vector3.Up);
if (axis.LengthSquared() < 0.0001f)
axis = Vector3.Right;
axis = axis.Normalized();
Vector3 elbowPos = shoulder + dir.Rotated(axis, angle) * _upperLen;
Vector3 foreEnd = elbowPos + (clampedTarget - elbowPos).Normalized() * _foreLen;
PlaceSegment(upperMesh, shoulder, elbowPos);
PlaceSegment(foreMesh, elbowPos, foreEnd);
return push;
}
private static void PlaceSegment(MeshInstance3D mesh, Vector3 from, Vector3 to)
{
Vector3 segment = to - from;
if (segment.LengthSquared() < 0.000001f)
return;
Vector3 y = segment.Normalized();
Vector3 x = Vector3.Up.Cross(y);
if (x.LengthSquared() < 0.0001f)
x = Vector3.Right.Cross(y);
x = x.Normalized();
Vector3 z = x.Cross(y);
mesh.GlobalTransform = new Transform3D(new Basis(x, y, z), (from + to) * 0.5f);
}
}
+1
View File
@@ -0,0 +1 @@
uid://dwaonvvnka0b
+487
View File
@@ -0,0 +1,487 @@
using Godot;
public partial class GrabSpike : Node3D
{
private const float HandMass = 0.4f;
private const float HandMaxForce = 90f;
private const float HandMaxSpeed = 2.5f;
private const float ReachSpeed = 1f;
private const float HandMaxSpin = 120f;
private const float HandRadius = 0.05f;
private const float MoveDistance = 2f;
private const float MoveDuration = 2f;
private const float DragRampTime = 1f;
private const float LiftHeight = 0.3f;
private const float MaxSpeed = 20f;
private const float ReachTimeout = 3f;
private const float RigidWeldBias = 0f;
private const float RigidRotLimitDeg = 2f;
private const float RigidSepLimit = 0.01f;
private const float SpinRotLimitDeg = 3.5f;
private const float TakeoverRotLimitDeg = 5f;
private const float SpinLimit = 40f;
private const float PullDistance = 0.3f;
private const float SpinAngleDeg = 360f;
private const float SpinDuration = 2.5f;
private const float SpinHoldDuration = 0.5f;
private const float TugDuration = 1.5f;
private const float SettleDuration = 0.5f;
private const float FollowDuration = 1f;
private const float TugPull = 0.5f;
private const float FollowSway = 0.03f;
private const float TwoHandDuration = TugDuration + FollowDuration;
private const float TakeoverDuration = 1.5f;
private enum Phase { Reach, Drag, Spin, Hand2Reach, TwoHanded, TwoSettle, TwoFollow, Takeover }
private RigidBody3D _prop;
private RigidBody3D _hand;
private RigidBody3D _hand2;
private Node3D _target;
private Node3D _target2;
private ConeTwistJoint3D _joint;
private ConeTwistJoint3D _joint2;
private Vector3 _grabPoint;
private Vector3 _grab2Point;
private Vector3 _propAnchor;
private Vector3 _handAnchor;
private Vector3 _propAnchor2;
private Vector3 _handAnchor2;
private Quaternion _relRotation1;
private Quaternion _relRotation2;
private Quaternion _spinFrom;
private Quaternion _spinTo;
private Quaternion _cmd1 = Quaternion.Identity;
private Vector3 _takeoverBase;
private Phase _phase = Phase.Reach;
private float _phaseStart;
private float _elapsed;
private bool _dragRotOk;
private bool _dragSepOk;
private bool _dragSpeedOk;
private bool _spinRotOk;
private bool _spinSepOk;
private bool _spinSpeedOk;
private bool _twoHandedOk;
private bool _takeoverRotOk;
private bool _takeoverSepOk;
private bool _takeoverSpeedOk;
private float _maxDragRot;
private float _maxDragSep;
private float _maxDragPropSpeed;
private float _maxDragHandSpeed;
private float _maxSpinRot;
private float _maxSpinSep;
private float _maxSpinPropSpeed;
private float _maxSpinHandSpeed;
private float _maxTwoAnchor1;
private float _maxTugAnchor2;
private float _maxFollowAnchor2;
private float _maxTwoPropSpeed;
private float _maxTwoHandSpeed;
private float _maxTwoHand2Speed;
private float _maxTwoSpin;
private float _maxTakeoverRot;
private float _maxTakeoverSep;
private float _maxTakeoverPropSpeed;
private float _maxTakeoverHandSpeed;
private int _lastLogStep;
public override void _Ready()
{
StaticBody3D floor = new() { Position = new Vector3(0, -0.5f, 0) };
floor.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = new Vector3(20, 1, 20) } });
AddChild(floor);
_prop = new RigidBody3D { Position = new Vector3(0, 0.15f, 0), Rotation = new Vector3(0, Mathf.Pi / 2f, 0), Mass = 1f };
_prop.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = Vector3.One * 0.3f } });
AddChild(_prop);
_hand = new RigidBody3D
{
Position = new Vector3(0, 0.9f, 0),
Mass = HandMass,
GravityScale = 0f,
ContinuousCd = true,
ContactMonitor = true,
MaxContactsReported = 4,
};
_hand.AddChild(new CollisionShape3D { Shape = new SphereShape3D { Radius = HandRadius } });
_hand.BodyEntered += OnHand1Touched;
AddChild(_hand);
_hand2 = new RigidBody3D
{
Position = new Vector3(0, 0.15f, -1f),
Mass = HandMass,
GravityScale = 0f,
ContinuousCd = true,
ContactMonitor = true,
MaxContactsReported = 4,
};
_hand2.AddChild(new CollisionShape3D { Shape = new SphereShape3D { Radius = HandRadius } });
_hand2.BodyEntered += OnHand2Touched;
AddChild(_hand2);
_target = new Node3D { Position = _prop.Position };
AddChild(_target);
_target2 = new Node3D { Position = _hand2.Position };
AddChild(_target2);
GD.Print("GRAB: grab spike scene ready");
}
private void OnHand1Touched(Node body)
{
if (body != _prop || _phase != Phase.Reach)
return;
_grabPoint = _hand.GlobalPosition;
_relRotation1 = _hand.GlobalTransform.Basis.GetRotationQuaternion().Inverse()
* _prop.GlobalTransform.Basis.GetRotationQuaternion();
_propAnchor = _prop.GlobalTransform.AffineInverse() * _grabPoint;
_handAnchor = _hand.GlobalTransform.AffineInverse() * _grabPoint;
_joint = MakeWeld(_hand, _prop, _grabPoint, RigidWeldBias);
_maxDragRot = 0f;
_maxDragSep = 0f;
_maxDragPropSpeed = 0f;
_maxDragHandSpeed = 0f;
_phase = Phase.Drag;
_phaseStart = _elapsed;
_lastLogStep = 0;
GD.Print($"GRAB: rigid weld grab at t={_elapsed:F2} s, pivot {_grabPoint}, server joint type = {PhysicsServer3D.JointGetType(_joint.GetRid())} (expect {PhysicsServer3D.JointType.ConeTwist})");
}
private void OnHand2Touched(Node body)
{
if (body != _prop || _phase != Phase.Hand2Reach)
return;
_grab2Point = _hand2.GlobalPosition;
_relRotation2 = _hand2.GlobalTransform.Basis.GetRotationQuaternion().Inverse()
* _prop.GlobalTransform.Basis.GetRotationQuaternion();
_propAnchor2 = _prop.GlobalTransform.AffineInverse() * _grab2Point;
_handAnchor2 = _hand2.GlobalTransform.AffineInverse() * _grab2Point;
_joint2 = MakeWeld(_hand2, _prop, _grab2Point, RigidWeldBias);
_maxTwoAnchor1 = 0f;
_maxTugAnchor2 = 0f;
_maxFollowAnchor2 = 0f;
_maxTwoPropSpeed = 0f;
_maxTwoHandSpeed = 0f;
_maxTwoHand2Speed = 0f;
_maxTwoSpin = 0f;
_phase = Phase.TwoHanded;
_phaseStart = _elapsed;
_lastLogStep = 0;
GD.Print($"GRAB: second hand rigid weld at t={_elapsed:F2} s, pivot {_grab2Point}");
}
public override void _PhysicsProcess(double delta)
{
float dt = (float)delta;
_elapsed += dt;
float phaseT = _elapsed - _phaseStart;
switch (_phase)
{
case Phase.Reach:
_target.GlobalPosition = _prop.GlobalPosition;
_target2.GlobalPosition = _hand2.GlobalPosition;
if (phaseT > ReachTimeout)
Fail("hand never touched the prop");
break;
case Phase.Drag:
{
_target.GlobalPosition = _grabPoint + new Vector3(DragOffset(phaseT), LiftHeight * Mathf.Min(phaseT / DragRampTime, 1f), 0f);
_maxDragPropSpeed = Mathf.Max(_maxDragPropSpeed, _prop.LinearVelocity.Length());
_maxDragHandSpeed = Mathf.Max(_maxDragHandSpeed, _hand.LinearVelocity.Length());
TrackRigidWeld(ref _maxDragRot, ref _maxDragSep);
int step = (int)(phaseT / 0.5f);
if (step > _lastLogStep && phaseT < MoveDuration)
{
_lastLogStep = step;
GD.Print($"GRAB: drag t={phaseT:F2} rotDev={Mathf.RadToDeg(RotDev1()):F2} deg sep={Sep1():F4} m propV={_prop.LinearVelocity.Length():F2}");
}
if (phaseT >= MoveDuration)
FinishDrag();
break;
}
case Phase.Spin:
{
_cmd1 = _spinFrom * new Quaternion(Vector3.Up,
Mathf.DegToRad(SpinAngleDeg) * Mathf.SmoothStep(0f, 1f, Mathf.Min(phaseT / SpinDuration, 1f)));
_target.GlobalPosition = _grabPoint + new Vector3(MoveDistance, LiftHeight, 0f);
_maxSpinPropSpeed = Mathf.Max(_maxSpinPropSpeed, _prop.LinearVelocity.Length());
_maxSpinHandSpeed = Mathf.Max(_maxSpinHandSpeed, _hand.LinearVelocity.Length());
TrackRigidWeld(ref _maxSpinRot, ref _maxSpinSep);
int step = (int)(phaseT / 0.5f);
if (step > _lastLogStep)
{
_lastLogStep = step;
GD.Print($"GRAB: fast spin t={phaseT:F2} rotDev={Mathf.RadToDeg(RotDev1()):F2} deg sep={Sep1():F4} m handW={_hand.AngularVelocity.Length():F1} rad/s");
}
if (phaseT >= SpinDuration + SpinHoldDuration)
FinishSpin();
break;
}
case Phase.Hand2Reach:
_target.GlobalPosition = _grabPoint + new Vector3(MoveDistance, LiftHeight, 0f);
_target2.GlobalPosition = _prop.GlobalPosition;
if (phaseT > ReachTimeout)
Fail("second hand never touched the prop");
break;
case Phase.TwoHanded:
{
float pull = TugPull * Mathf.SmoothStep(0f, 1f, Mathf.Min(phaseT / 1f, 1f));
float zSway = 0.35f * Mathf.Sin(2f * phaseT) * (1f - Mathf.SmoothStep(0f, 1f, Mathf.Min(phaseT / TugDuration, 1f)));
_target.GlobalPosition = _grabPoint + new Vector3(MoveDistance + pull, LiftHeight, zSway);
_target2.GlobalPosition = _grab2Point + new Vector3(-pull, 0f, -zSway);
_maxTwoAnchor1 = Mathf.Max(_maxTwoAnchor1, Sep1());
_maxTugAnchor2 = Mathf.Max(_maxTugAnchor2, Sep2());
_maxTwoPropSpeed = Mathf.Max(_maxTwoPropSpeed, _prop.LinearVelocity.Length());
_maxTwoHandSpeed = Mathf.Max(_maxTwoHandSpeed, _hand.LinearVelocity.Length());
_maxTwoHand2Speed = Mathf.Max(_maxTwoHand2Speed, _hand2.LinearVelocity.Length());
_maxTwoSpin = Mathf.Max(_maxTwoSpin, _prop.AngularVelocity.Length());
int step = (int)(phaseT / 0.5f);
if (step > _lastLogStep)
{
_lastLogStep = step;
GD.Print($"GRAB: two-handed tug t={phaseT:F2} anchor1={Sep1():F3} anchor2={Sep2():F3} spin={_prop.AngularVelocity.Length():F1} propV={_prop.LinearVelocity.Length():F2}");
}
if (phaseT >= TugDuration)
{
_phase = Phase.TwoSettle;
_phaseStart = _elapsed;
_lastLogStep = 0;
GD.Print("GRAB: tug done, settling before the follow assert");
}
break;
}
case Phase.TwoSettle:
{
_target.GlobalPosition = _grabPoint + new Vector3(MoveDistance + TugPull, LiftHeight, 0f);
_target2.GlobalPosition = _prop.GlobalTransform * _propAnchor2;
if (phaseT >= SettleDuration)
{
_phase = Phase.TwoFollow;
_phaseStart = _elapsed;
_lastLogStep = 0;
GD.Print($"GRAB: settled at anchor2={Sep2():F3} m, second hand now follows with a small out-of-phase sway");
}
break;
}
case Phase.TwoFollow:
{
_target.GlobalPosition = _grabPoint + new Vector3(MoveDistance + TugPull, LiftHeight, 0f);
_target2.GlobalPosition = _prop.GlobalTransform * _propAnchor2 + new Vector3(0f, 0f, -FollowSway * Mathf.Sin(2f * Mathf.Pi * 1.5f * phaseT));
_maxTwoAnchor1 = Mathf.Max(_maxTwoAnchor1, Sep1());
_maxFollowAnchor2 = Mathf.Max(_maxFollowAnchor2, Sep2());
_maxTwoPropSpeed = Mathf.Max(_maxTwoPropSpeed, _prop.LinearVelocity.Length());
_maxTwoHandSpeed = Mathf.Max(_maxTwoHandSpeed, _hand.LinearVelocity.Length());
_maxTwoHand2Speed = Mathf.Max(_maxTwoHand2Speed, _hand2.LinearVelocity.Length());
_maxTwoSpin = Mathf.Max(_maxTwoSpin, _prop.AngularVelocity.Length());
int step = (int)(phaseT / 0.5f);
if (step > _lastLogStep)
{
_lastLogStep = step;
GD.Print($"GRAB: two-handed follow t={phaseT:F2} anchor1={Sep1():F3} anchor2={Sep2():F3} propV={_prop.LinearVelocity.Length():F2}");
}
if (phaseT >= FollowDuration)
FinishTwoHanded();
break;
}
case Phase.Takeover:
{
_target.GlobalPosition = _grabPoint + new Vector3(MoveDistance, LiftHeight, 0f);
float move = PullDistance * Mathf.SmoothStep(0f, 1f, Mathf.Min(phaseT / TakeoverDuration, 1f));
_target2.GlobalPosition = _takeoverBase + new Vector3(-move, 0f, 0f);
_maxTakeoverPropSpeed = Mathf.Max(_maxTakeoverPropSpeed, _prop.LinearVelocity.Length());
_maxTakeoverHandSpeed = Mathf.Max(_maxTakeoverHandSpeed, _hand2.LinearVelocity.Length());
TrackTakeoverWeld();
int step = (int)(phaseT / 0.5f);
if (step > _lastLogStep)
{
_lastLogStep = step;
GD.Print($"GRAB: takeover t={phaseT:F2} rotDev={Mathf.RadToDeg(RotDev2()):F2} deg sep={Sep2():F4} m propV={_prop.LinearVelocity.Length():F2}");
}
if (phaseT >= TakeoverDuration)
Finish();
break;
}
}
Chase(_hand, _target, dt, _phase == Phase.Reach || _phase == Phase.Hand2Reach ? ReachSpeed : HandMaxSpeed);
Chase(_hand2, _target2, dt, _phase == Phase.Reach || _phase == Phase.Hand2Reach ? ReachSpeed : HandMaxSpeed);
SteerHand(_hand, _cmd1, dt);
SteerHand(_hand2, Quaternion.Identity, dt);
}
private ConeTwistJoint3D MakeWeld(RigidBody3D hand, RigidBody3D prop, Vector3 pivot, float bias)
{
ConeTwistJoint3D weld = new()
{
ExcludeNodesFromCollision = true,
Position = pivot,
NodeA = hand.GetPath(),
NodeB = prop.GetPath(),
SwingSpan = 0f,
TwistSpan = 0f,
Bias = bias,
};
AddChild(weld);
return weld;
}
private void TrackRigidWeld(ref float maxRot, ref float maxSep)
{
maxRot = Mathf.Max(maxRot, RotDev1());
maxSep = Mathf.Max(maxSep, Sep1());
}
private void TrackTakeoverWeld()
{
Quaternion relNow = _hand2.GlobalTransform.Basis.GetRotationQuaternion().Inverse()
* _prop.GlobalTransform.Basis.GetRotationQuaternion();
_maxTakeoverRot = Mathf.Max(_maxTakeoverRot, DevAngle(relNow, _relRotation2));
_maxTakeoverSep = Mathf.Max(_maxTakeoverSep, Sep2());
}
private float RotDev1() => DevAngle(
_hand.GlobalTransform.Basis.GetRotationQuaternion().Inverse()
* _prop.GlobalTransform.Basis.GetRotationQuaternion(),
_relRotation1);
private float RotDev2() => DevAngle(
_hand2.GlobalTransform.Basis.GetRotationQuaternion().Inverse()
* _prop.GlobalTransform.Basis.GetRotationQuaternion(),
_relRotation2);
private float Sep1() =>
(_prop.GlobalTransform * _propAnchor).DistanceTo(_hand.GlobalTransform * _handAnchor);
private float Sep2() =>
(_prop.GlobalTransform * _propAnchor2).DistanceTo(_hand2.GlobalTransform * _handAnchor2);
private static float DevAngle(Quaternion relNow, Quaternion rel0)
{
Quaternion dev = relNow * rel0.Inverse();
if (dev.W < 0f)
dev = new Quaternion(-dev.X, -dev.Y, -dev.Z, -dev.W);
// GetAngle() NaNs when w exceeds 1 by a float epsilon; clamp like WeldSpike does
if (float.IsNaN(dev.W))
return float.NaN;
return 2f * Mathf.Acos(Mathf.Clamp(Mathf.Abs(dev.W), 0f, 1f));
}
private static float DragOffset(float t)
{
float cruiseSpeed = MoveDistance / (MoveDuration - DragRampTime);
if (t <= DragRampTime)
return 0.5f * cruiseSpeed / DragRampTime * t * t;
if (t <= MoveDuration - DragRampTime)
return 0.5f * cruiseSpeed * DragRampTime + cruiseSpeed * (t - DragRampTime);
float remaining = MoveDuration - t;
return MoveDistance - 0.5f * cruiseSpeed / DragRampTime * remaining * remaining;
}
private static void SteerHand(RigidBody3D hand, Quaternion cmd, float dt)
{
Quaternion q = cmd * hand.GlobalTransform.Basis.GetRotationQuaternion().Inverse();
Vector3 spin = q.GetAxis() * (q.GetAngle() / dt);
if (spin.Length() > HandMaxSpin)
spin = spin.Normalized() * HandMaxSpin;
hand.AngularVelocity = spin;
}
private static void Chase(RigidBody3D body, Node3D target, float dt, float maxSpeed)
{
Vector3 to = target.GlobalPosition - body.GlobalPosition;
Vector3 desired = to / dt;
if (desired.Length() > maxSpeed)
desired = desired.Normalized() * maxSpeed;
Vector3 dv = desired - body.LinearVelocity;
Vector3 force = dv / dt * HandMass;
if (force.Length() > HandMaxForce)
force = force.Normalized() * HandMaxForce;
body.LinearVelocity += force / HandMass * dt;
}
private void FinishDrag()
{
_dragRotOk = _maxDragRot < Mathf.DegToRad(RigidRotLimitDeg) && !float.IsNaN(_maxDragRot);
_dragSepOk = _maxDragSep < RigidSepLimit;
_dragSpeedOk = _maxDragPropSpeed < MaxSpeed && _maxDragHandSpeed < MaxSpeed;
GD.Print($"GRAB: drag rigid — rot dev max {Mathf.RadToDeg(_maxDragRot):F2} deg (limit {RigidRotLimitDeg}), sep max {_maxDragSep:F4} m (limit {RigidSepLimit}) → {(_dragRotOk && _dragSepOk ? "PASS" : "FAIL")}");
GD.Print($"GRAB: drag speeds — prop max {_maxDragPropSpeed:F2} m/s, hand max {_maxDragHandSpeed:F2} m/s (limit {MaxSpeed}) → {(_dragSpeedOk ? "PASS" : "FAIL")}");
_spinFrom = _hand.GlobalTransform.Basis.GetRotationQuaternion();
_spinTo = _spinFrom * new Quaternion(Vector3.Up, Mathf.DegToRad(SpinAngleDeg));
_maxSpinRot = 0f;
_maxSpinSep = 0f;
_maxSpinPropSpeed = 0f;
_maxSpinHandSpeed = 0f;
_phase = Phase.Spin;
_phaseStart = _elapsed;
_lastLogStep = 0;
GD.Print($"GRAB: fast spin starting — {SpinAngleDeg:F0} deg in {SpinDuration:0.#} s around world up"); }
private void FinishSpin()
{
_spinRotOk = _maxSpinRot < Mathf.DegToRad(SpinRotLimitDeg) && !float.IsNaN(_maxSpinRot);
_spinSepOk = _maxSpinSep < RigidSepLimit;
_spinSpeedOk = _maxSpinPropSpeed < MaxSpeed && _maxSpinHandSpeed < MaxSpeed;
GD.Print($"GRAB: fast spin rigid — rot dev max {Mathf.RadToDeg(_maxSpinRot):F2} deg (limit {SpinRotLimitDeg}), sep max {_maxSpinSep:F4} m (limit {RigidSepLimit}) → {(_spinRotOk && _spinSepOk ? "PASS" : "FAIL")}");
GD.Print($"GRAB: fast spin speeds — prop max {_maxSpinPropSpeed:F2} m/s, hand max {_maxSpinHandSpeed:F2} m/s (limit {MaxSpeed}) → {(_spinSpeedOk ? "PASS" : "FAIL")}");
_hand.AngularVelocity = Vector3.Zero;
_hand2.GlobalTransform = new Transform3D(Basis.Identity, _prop.GlobalPosition + new Vector3(0.45f, 0f, 0f));
_hand2.LinearVelocity = Vector3.Zero;
_hand2.AngularVelocity = Vector3.Zero;
_phase = Phase.Hand2Reach;
_phaseStart = _elapsed;
_lastLogStep = 0;
}
private void FinishTwoHanded()
{
bool primaryRigidOk = _maxTwoAnchor1 < RigidSepLimit;
bool tugRigidOk = _maxTugAnchor2 < RigidSepLimit;
bool followOk = _maxFollowAnchor2 < RigidSepLimit;
bool spinOk = _maxTwoSpin < SpinLimit;
bool speedOk = _maxTwoPropSpeed < MaxSpeed && _maxTwoHandSpeed < MaxSpeed && _maxTwoHand2Speed < MaxSpeed;
GD.Print($"GRAB: two-handed primary rigid — anchor1 max {_maxTwoAnchor1:F4} m (limit {RigidSepLimit}) → {(primaryRigidOk ? "PASS" : "FAIL")}");
GD.Print($"GRAB: two-handed tug rigid — anchor2 max {_maxTugAnchor2:F4} m (limit {RigidSepLimit}) → {(tugRigidOk ? "PASS" : "FAIL")}");
GD.Print($"GRAB: two-handed follow — anchor2 max {_maxFollowAnchor2:F4} m (limit {RigidSepLimit}) → {(followOk ? "PASS" : "FAIL")}");
GD.Print($"GRAB: two-handed spin — prop max {_maxTwoSpin:F1} rad/s (limit {SpinLimit}) → {(spinOk ? "PASS" : "FAIL")}");
GD.Print($"GRAB: two-handed speed — prop max {_maxTwoPropSpeed:F2} m/s, hands max {Mathf.Max(_maxTwoHandSpeed, _maxTwoHand2Speed):F2} m/s (limit {MaxSpeed}) → {(speedOk ? "PASS" : "FAIL")}");
_twoHandedOk = primaryRigidOk && tugRigidOk && followOk && spinOk && speedOk;
_joint.QueueFree();
_joint = null;
_takeoverBase = _target2.GlobalPosition;
_maxTakeoverRot = 0f;
_maxTakeoverSep = 0f;
_maxTakeoverPropSpeed = 0f;
_maxTakeoverHandSpeed = 0f;
_phase = Phase.Takeover;
_phaseStart = _elapsed;
_lastLogStep = 0;
GD.Print($"GRAB: primary released, surviving rigid weld untouched (pivot {_grab2Point})");
}
private void Finish()
{
_takeoverRotOk = _maxTakeoverRot < Mathf.DegToRad(TakeoverRotLimitDeg) && !float.IsNaN(_maxTakeoverRot);
_takeoverSepOk = _maxTakeoverSep < RigidSepLimit;
_takeoverSpeedOk = _maxTakeoverPropSpeed < MaxSpeed && _maxTakeoverHandSpeed < MaxSpeed;
GD.Print($"GRAB: takeover rigid — rot dev max {Mathf.RadToDeg(_maxTakeoverRot):F2} deg (limit {TakeoverRotLimitDeg}), sep max {_maxTakeoverSep:F4} m (limit {RigidSepLimit}) → {(_takeoverRotOk && _takeoverSepOk ? "PASS" : "FAIL")}");
GD.Print($"GRAB: takeover speeds — prop max {_maxTakeoverPropSpeed:F2} m/s, hand2 max {_maxTakeoverHandSpeed:F2} m/s (limit {MaxSpeed}) → {(_takeoverSpeedOk ? "PASS" : "FAIL")}");
bool overall = _dragRotOk && _dragSepOk && _dragSpeedOk &&
_spinRotOk && _spinSepOk && _spinSpeedOk &&
_twoHandedOk &&
_takeoverRotOk && _takeoverSepOk && _takeoverSpeedOk;
GD.Print($"GRAB: overall {(overall ? "PASS — solver-owned rigid weld grab (v5.3) functional on Box3D" : "CHECK — behavior differs, inspect above")}");
GetTree().Quit();
}
private void Fail(string reason)
{
GD.Print($"GRAB: overall CHECK — {reason}");
GetTree().Quit();
}
}
+1
View File
@@ -0,0 +1 @@
uid://c7ala2jq058dh
+244
View File
@@ -0,0 +1,244 @@
using Godot;
public partial class Locomotion : Node
{
private const float GripThreshold = 0.6f;
private const float TriggerThreshold = 0.6f;
private const float RayLength = 50f;
private const float LaserMinDistance = 0.35f;
private const float MarkerNearDistance = 1.2f;
private XROrigin3D _origin;
private XRCamera3D _camera;
private XRController3D _left;
private XRController3D _right;
private Marker3D _spawn;
private ColorRect _fade;
private Main _main;
private Node3D _leftLaser;
private Node3D _rightLaser;
private bool _placedAtSpawn;
private bool _teleporting;
private bool _loggedRecentered;
private TeleportMarker _highlighted;
private bool _leftTrigger;
private bool _rightTrigger;
public override void _Ready()
{
_main = GetNode<Main>("..");
_origin = GetNode<XROrigin3D>("../XROrigin3D");
_camera = GetNode<XRCamera3D>("../XROrigin3D/XRCamera3D");
_left = GetNode<XRController3D>("../XROrigin3D/LeftHand");
_right = GetNode<XRController3D>("../XROrigin3D/RightHand");
_spawn = GetNode<Marker3D>("../SpawnPoint");
_fade = GetNode<ColorRect>("../FadeLayer/Fade");
_main.FocusGained += OnFocusGained;
_main.PoseRecentered += OnPoseRecentered;
_leftLaser = MakeLaser(_left);
_rightLaser = MakeLaser(_right);
MakeTip(_left);
MakeTip(_right);
}
public override void _Process(double delta)
{
if (_teleporting)
return;
UpdateHighlight();
UpdateNearMarkers();
UpdatePointer(_left, _leftLaser);
UpdatePointer(_right, _rightLaser);
CheckTeleport(_left, ref _leftTrigger);
CheckTeleport(_right, ref _rightTrigger);
}
private static Node3D MakeLaser(XRController3D controller)
{
StandardMaterial3D material = new()
{
AlbedoColor = new Color(1f, 0.55f, 0.15f),
EmissionEnabled = true,
Emission = new Color(1f, 0.55f, 0.15f),
EmissionEnergyMultiplier = 2.0f,
ShadingMode = BaseMaterial3D.ShadingModeEnum.Unshaded,
};
MeshInstance3D beam = new()
{
Mesh = new CylinderMesh { TopRadius = 0.0035f, BottomRadius = 0.0035f, Height = 1f },
Position = new Vector3(0, 0.5f, 0),
CastShadow = GeometryInstance3D.ShadowCastingSetting.Off,
};
beam.SetSurfaceOverrideMaterial(0, material);
Node3D pivot = new() { Visible = false };
pivot.AddChild(beam);
Basis rot = Basis.FromEuler(new Vector3(-Mathf.Pi / 2f, 0, 0));
pivot.Transform = new Transform3D(rot, new Vector3(0, 0, -0.12f));
controller.AddChild(pivot);
return pivot;
}
private static void MakeTip(XRController3D controller)
{
StandardMaterial3D material = new()
{
AlbedoColor = new Color(1f, 0.9f, 0.6f),
EmissionEnabled = true,
Emission = new Color(1f, 0.9f, 0.6f),
EmissionEnergyMultiplier = 1.5f,
ShadingMode = BaseMaterial3D.ShadingModeEnum.Unshaded,
};
MeshInstance3D tip = new()
{
Mesh = new CylinderMesh { TopRadius = 0f, BottomRadius = 0.009f, Height = 0.09f },
CastShadow = GeometryInstance3D.ShadowCastingSetting.Off,
};
tip.SetSurfaceOverrideMaterial(0, material);
Basis rot = Basis.FromEuler(new Vector3(-Mathf.Pi / 2f, 0, 0));
tip.Transform = new Transform3D(rot, rot * new Vector3(0, 0.045f, 0));
controller.AddChild(tip);
}
private void UpdateNearMarkers()
{
Vector3 head = _camera.GlobalPosition;
foreach (Node node in GetTree().GetNodesInGroup("teleport_marker"))
{
if (node is not TeleportMarker marker)
continue;
Vector3 d = marker.GlobalPosition - head;
d.Y = 0f;
marker.SetNear(d.Length() < MarkerNearDistance);
}
}
private void UpdatePointer(XRController3D controller, Node3D laser)
{
Vector3 from = controller.GlobalPosition;
Vector3 dir = -controller.GlobalTransform.Basis.Z;
var query = PhysicsRayQueryParameters3D.Create(from, from + dir * RayLength);
var hit = GetViewport().GetWorld3D().DirectSpaceState.IntersectRay(query);
bool show = false;
float dist = 0f;
if (hit.TryGetValue("collider", out Variant collider) && collider.As<Node>() is Node node)
{
bool interactive;
if (node.GetParent() is TeleportMarker marker)
interactive = !marker.IsNear;
else
interactive = WorldPanel.IsPanelCollider(node);
if (interactive && hit.TryGetValue("position", out Variant posVariant))
{
dist = from.DistanceTo(posVariant.As<Vector3>());
show = dist > LaserMinDistance;
}
}
laser.Visible = show;
if (show)
laser.Scale = new Vector3(1, dist, 1);
}
private void CheckTeleport(XRController3D controller, ref bool wasPressed)
{
bool pressed = controller.GetFloat("trigger") > TriggerThreshold;
if (pressed && !wasPressed && !WorldPanel.IsBlocking(controller))
{
TeleportMarker marker = AimMarker(controller);
if (marker is { IsNear: false })
StartTeleport(marker);
}
wasPressed = pressed;
}
private void UpdateHighlight()
{
TeleportMarker marker = AimMarker(_left) ?? AimMarker(_right);
if (marker is { IsNear: true })
marker = null;
if (marker == _highlighted)
return;
_highlighted?.SetHighlighted(false);
_highlighted = marker;
_highlighted?.SetHighlighted(true);
}
private TeleportMarker AimMarker(XRController3D controller)
{
Vector3 from = controller.GlobalPosition;
Vector3 to = from - controller.GlobalTransform.Basis.Z * RayLength;
var query = PhysicsRayQueryParameters3D.Create(from, to);
var hit = GetViewport().GetWorld3D().DirectSpaceState.IntersectRay(query);
if (hit.TryGetValue("collider", out Variant collider) && collider.As<Node>()?.GetParent() is TeleportMarker marker)
return marker;
return null;
}
private void StartTeleport(TeleportMarker marker)
{
_teleporting = true;
Vector3 target = marker.GlobalPosition;
float yaw = ForwardYaw(marker.GlobalTransform);
var tween = CreateTween();
tween.TweenProperty(_fade, "color:a", 1.0f, 0.12);
tween.TweenCallback(Callable.From(() =>
{
PlacePlayer(target, yaw);
GD.Print($"Locomotion: teleported to marker '{marker.MarkerLabel}'");
}));
tween.TweenProperty(_fade, "color:a", 0.0f, 0.25);
tween.TweenCallback(Callable.From(() => _teleporting = false));
}
public void ReturnToSpawn()
{
MoveToSpawn();
GD.Print("Locomotion: returned to spawn point");
}
private void OnFocusGained()
{
if (_placedAtSpawn)
return;
_placedAtSpawn = true;
MoveToSpawn();
GD.Print("Locomotion: player placed at spawn point");
}
private void OnPoseRecentered()
{
if (_loggedRecentered)
return;
_loggedRecentered = true;
GD.Print("Locomotion: pose_recentered received — WiVRN fires this continuously (~90 Hz), placement stays unchanged and further occurrences are not logged");
}
private void MoveToSpawn() => PlacePlayer(_spawn.GlobalPosition, ForwardYaw(_spawn.GlobalTransform));
private void PlacePlayer(Vector3 targetXz, float yaw)
{
Transform3D h = _camera.GlobalTransform;
Transform3D hLocal = _origin.GlobalTransform.AffineInverse() * h;
float hmdYaw = h.Basis.GetEuler().Y;
Basis basis = Basis.FromEuler(new Vector3(0, yaw - hmdYaw, 0)) * h.Basis;
Transform3D desired = new Transform3D(basis, new Vector3(targetXz.X, h.Origin.Y, targetXz.Z));
_origin.GlobalTransform = desired * hLocal.AffineInverse();
}
private static float ForwardYaw(Transform3D transform)
{
Vector3 fwd = -transform.Basis.Z;
fwd.Y = 0f;
if (fwd.LengthSquared() < 0.0001f)
return 0f;
return Mathf.Atan2(-fwd.X, -fwd.Z);
}
}
+1
View File
@@ -0,0 +1 @@
uid://be6cxrxsc202g
+104
View File
@@ -0,0 +1,104 @@
using Godot;
public partial class Main : Node3D
{
[Signal]
public delegate void FocusLostEventHandler();
[Signal]
public delegate void FocusGainedEventHandler();
[Signal]
public delegate void PoseRecenteredEventHandler();
[Export]
public int MaximumRefreshRate { get; set; } = 90;
private OpenXRInterface _xrInterface;
private bool _xrIsFocused;
public override void _Ready()
{
_xrInterface = (OpenXRInterface)XRServer.FindInterface("OpenXR");
if (_xrInterface != null && _xrInterface.IsInitialized())
{
GD.Print("OpenXR instantiated successfully.");
Viewport vp = GetViewport();
vp.UseXR = true;
DisplayServer.WindowSetVsyncMode(DisplayServer.VSyncMode.Disabled);
if (RenderingServer.GetRenderingDevice() != null)
vp.VrsMode = Viewport.VrsModeEnum.XR;
_xrInterface.SessionBegun += OnSessionBegun;
_xrInterface.SessionVisible += OnSessionVisible;
_xrInterface.SessionFocussed += OnSessionFocussed;
_xrInterface.SessionStopping += OnSessionStopping;
_xrInterface.PoseRecentered += OnPoseRecentered;
}
else
{
GD.PushWarning("OpenXR not instantiated, running in flat preview mode. Check headset and XR runtime.");
}
}
private void OnSessionBegun()
{
float currentRefreshRate = _xrInterface.DisplayRefreshRate;
GD.Print(currentRefreshRate > 0.0f
? $"OpenXR: refresh rate reported as {currentRefreshRate}"
: "OpenXR: no refresh rate given by XR runtime");
float newRate = currentRefreshRate;
var availableRates = _xrInterface.GetAvailableDisplayRefreshRates();
if (availableRates.Count == 0)
GD.Print("OpenXR: target does not support refresh rate extension");
else if (availableRates.Count == 1)
newRate = (float)availableRates[0];
else
{
foreach (float rate in availableRates)
{
if (rate > newRate && rate <= MaximumRefreshRate)
newRate = rate;
}
}
if (currentRefreshRate != newRate)
{
GD.Print($"OpenXR: setting refresh rate to {newRate}");
_xrInterface.DisplayRefreshRate = newRate;
currentRefreshRate = newRate;
}
Engine.PhysicsTicksPerSecond = (int)currentRefreshRate;
}
private void OnSessionVisible()
{
if (_xrIsFocused)
{
GD.Print("OpenXR lost focus");
_xrIsFocused = false;
GetTree().Paused = true;
EmitSignal(SignalName.FocusLost);
}
}
private void OnSessionFocussed()
{
GD.Print("OpenXR gained focus");
_xrIsFocused = true;
GetTree().Paused = false;
EmitSignal(SignalName.FocusGained);
}
private void OnSessionStopping()
{
GD.Print("OpenXR is stopping");
}
private void OnPoseRecentered()
{
EmitSignal(SignalName.PoseRecentered);
}
}
+1
View File
@@ -0,0 +1 @@
uid://dsy2jm1gdcao0
+138
View File
@@ -0,0 +1,138 @@
using Godot;
public partial class PhysicsHands : Node
{
private const float HandMass = 0.4f;
public const float HandMaxForce = 90f;
private const float HandMaxSpin = 120f;
public const float SnapDistance = 2f;
private const float GhostLagDistance = 0.1f;
private const float StuckDistance = 0.2f;
private const float StuckTime = 1.0f;
private static readonly Vector3 HandSize = new(0.035f, 0.09f, 0.12f);
private static PhysicsHands _instance;
private XRController3D _left;
private XRController3D _right;
private readonly RigidBody3D[] _bodies = new RigidBody3D[2];
private readonly MeshInstance3D[] _ghosts = new MeshInstance3D[2];
private readonly float[] _stuckTimer = new float[2];
public override void _Ready()
{
_instance = this;
_left = GetNode<XRController3D>("../XROrigin3D/LeftHand");
_right = GetNode<XRController3D>("../XROrigin3D/RightHand");
_bodies[0] = MakeHand("LeftHandBody", new Color(0.9f, 0.3f, 0.35f));
_bodies[1] = MakeHand("RightHandBody", new Color(0.3f, 0.5f, 0.9f));
_ghosts[0] = MakeGhost();
_ghosts[1] = MakeGhost();
GD.Print("PhysicsHands: physical hand bodies spawned");
}
private MeshInstance3D MakeGhost()
{
MeshInstance3D ghost = new()
{
Mesh = new BoxMesh { Size = HandSize },
Visible = false,
CastShadow = GeometryInstance3D.ShadowCastingSetting.Off,
};
ghost.SetSurfaceOverrideMaterial(0, new StandardMaterial3D
{
AlbedoColor = new Color(1f, 1f, 1f, 0.3f),
Transparency = BaseMaterial3D.TransparencyEnum.Alpha,
ShadingMode = BaseMaterial3D.ShadingModeEnum.Unshaded,
});
AddChild(ghost);
return ghost;
}
public static RigidBody3D GetBody(XRController3D controller)
{
if (_instance == null)
return null;
return _instance._left == controller ? _instance._bodies[0] : _instance._bodies[1];
}
private RigidBody3D MakeHand(string name, Color color)
{
RigidBody3D body = new()
{
Name = name,
Mass = HandMass,
GravityScale = 0f,
ContinuousCd = true,
ContactMonitor = true,
MaxContactsReported = 8,
CollisionLayer = 1,
CollisionMask = 1,
};
body.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = HandSize } });
MeshInstance3D mesh = new() { Mesh = new BoxMesh { Size = HandSize } };
StandardMaterial3D material = new() { AlbedoColor = color, Roughness = 0.5f };
mesh.SetSurfaceOverrideMaterial(0, material);
body.AddChild(mesh);
AddChild(body);
return body;
}
public override void _PhysicsProcess(double delta)
{
Chase(_left, _bodies[0], (float)delta);
Chase(_right, _bodies[1], (float)delta);
}
private void Chase(XRController3D controller, RigidBody3D body, float delta)
{
if (body == null)
return;
int hand = _left == controller ? 0 : 1;
Vector3 target = controller.GlobalPosition;
bool lagging = body.GlobalPosition.DistanceTo(target) > GhostLagDistance;
if (_ghosts[hand] != null)
{
_ghosts[hand].Visible = lagging;
if (lagging)
_ghosts[hand].GlobalTransform = controller.GlobalTransform;
}
if (!PropGrabber.IsGrabbing(controller))
{
if (body.GlobalPosition.DistanceTo(target) > StuckDistance)
_stuckTimer[hand] += delta;
else
_stuckTimer[hand] = 0f;
if (_stuckTimer[hand] > StuckTime)
{
_stuckTimer[hand] = 0f;
body.GlobalTransform = controller.GlobalTransform;
body.LinearVelocity = Vector3.Zero;
GD.Print("PhysicsHands: hand stuck, snapped back");
return;
}
if (body.GlobalPosition.DistanceTo(target) > SnapDistance)
{
body.GlobalTransform = controller.GlobalTransform;
body.LinearVelocity = Vector3.Zero;
return;
}
}
Vector3 desiredVelocity = (target - body.GlobalPosition) / delta;
Vector3 dv = desiredVelocity - body.LinearVelocity;
Vector3 force = dv / delta * body.Mass;
if (force.Length() > HandMaxForce)
force = force.Normalized() * HandMaxForce;
body.LinearVelocity += force / body.Mass * delta;
Basis deltaBasis = controller.GlobalTransform.Basis * body.GlobalTransform.Basis.Inverse();
Quaternion q = deltaBasis.GetRotationQuaternion();
Vector3 spin = q.GetAxis() * (q.GetAngle() / delta);
if (spin.Length() > HandMaxSpin)
spin = spin.Normalized() * HandMaxSpin;
body.AngularVelocity = spin;
}
}
+1
View File
@@ -0,0 +1 @@
uid://b4mgat3v21u30
+56
View File
@@ -0,0 +1,56 @@
using Godot;
public partial class PhysicsSpike : Node3D
{
private RigidBody3D _ball;
private RigidBody3D _chaser;
private Node3D _chaseTarget;
private float _elapsed;
private bool _ballTouched;
public override void _Ready()
{
StaticBody3D floor = new() { Position = new Vector3(0, -0.5f, 0) };
floor.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = new Vector3(20, 1, 20) } });
AddChild(floor);
_ball = new RigidBody3D { Position = new Vector3(0, 2, 0), ContactMonitor = true, MaxContactsReported = 4 };
_ball.AddChild(new CollisionShape3D { Shape = new SphereShape3D { Radius = 0.25f } });
_ball.BodyEntered += _ => _ballTouched = true;
AddChild(_ball);
StaticBody3D wall = new() { Position = new Vector3(5, 2, 0) };
wall.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = new Vector3(0.5f, 4, 4) } });
AddChild(wall);
_chaser = new RigidBody3D { Position = new Vector3(0, 1, 0), GravityScale = 0f };
_chaser.AddChild(new CollisionShape3D { Shape = new SphereShape3D { Radius = 0.25f } });
AddChild(_chaser);
_chaseTarget = new Node3D();
AddChild(_chaseTarget);
GD.Print("SPIKE: physics spike scene ready");
}
public override void _PhysicsProcess(double delta)
{
_elapsed += (float)delta;
_chaseTarget.GlobalPosition = new Vector3(Mathf.Min(8f, _elapsed * 2f), 1, 0);
Vector3 to = _chaseTarget.GlobalPosition - _chaser.GlobalPosition;
Vector3 desired = to / (float)delta;
if (desired.Length() > 3f)
desired = desired.Normalized() * 3f;
_chaser.LinearVelocity = desired;
if (_elapsed < 2.5f)
return;
bool dropOk = _ballTouched && _ball.GlobalPosition.Y > 0.2f && _ball.GlobalPosition.Y < 0.35f;
bool chaseOk = _chaser.GlobalPosition.X > 4.4f && _chaser.GlobalPosition.X < 5.05f;
GD.Print($"SPIKE: drop test — ball y={_ball.GlobalPosition.Y:F3} (expected ~0.25), contact={_ballTouched} → {(dropOk ? "PASS" : "FAIL")}");
GD.Print($"SPIKE: chase test — chaser x={_chaser.GlobalPosition.X:F3} (wall face 4.75, body radius 0.25), vel={_chaser.LinearVelocity.Length():F2} → {(chaseOk ? "PASS" : "FAIL")}");
GD.Print($"SPIKE: overall {(dropOk && chaseOk ? "PASS — Box3D functional from C#" : "CHECK — behavior differs, inspect above")}");
GetTree().Quit();
}
}
+1
View File
@@ -0,0 +1 @@
uid://pmjecyvvet5c
+225
View File
@@ -0,0 +1,225 @@
using Godot;
public partial class PropGrabber : Node
{
private const float GripThreshold = 0.6f;
private const float PalmGrabOffset = 0.045f;
private const float GrabRadius = 0.16f;
private const float CenterGrabRadius = 0.12f;
private const float RigidWeldBias = 0f;
private const float BreakDistance = 0.25f;
private const float BreakTime = 0.25f;
private const float BreakDistanceBoth = 0.4f;
private const float GripRipDistance = 0.8f;
private static PropGrabber _instance;
private XRController3D _left;
private XRController3D _right;
private readonly RigidBody3D[] _held = new RigidBody3D[2];
private readonly ConeTwistJoint3D[] _joints = new ConeTwistJoint3D[2];
private readonly Vector3[] _localAnchor = new Vector3[2];
private readonly uint[] _grabSeq = new uint[2];
private readonly float[] _breakTimer = new float[2];
private readonly bool[] _gripPrev = new bool[2];
private uint _grabCount;
public override void _Ready()
{
_instance = this;
_left = GetNode<XRController3D>("../XROrigin3D/LeftHand");
_right = GetNode<XRController3D>("../XROrigin3D/RightHand");
}
public static bool IsGrabbing(XRController3D controller) =>
_instance != null &&
((_instance._left == controller && _instance._held[0] != null) ||
(_instance._right == controller && _instance._held[1] != null));
public override void _Process(double delta)
{
UpdateHand(_left, 0);
UpdateHand(_right, 1);
}
public override void _PhysicsProcess(double delta)
{
float dt = (float)delta;
HoldUpdate(_left, 0, dt);
HoldUpdate(_right, 1, dt);
}
private void UpdateHand(XRController3D controller, int hand)
{
bool grip = controller.GetFloat("grip") > GripThreshold;
int other = 1 - hand;
if (grip && !_gripPrev[hand] && _held[hand] == null)
{
RigidBody3D prop = FindProp(controller, hand);
RigidBody3D handBody = PhysicsHands.GetBody(controller);
if (prop != null && handBody != null)
{
_held[hand] = prop;
_grabSeq[hand] = _grabCount++;
Vector3 pivot = PalmPoint(handBody, hand);
_joints[hand] = MakeWeld(handBody, prop, pivot, RigidWeldBias);
_localAnchor[hand] = prop.GlobalTransform.AffineInverse() * pivot;
GD.Print($"PropGrabber: grabbed '{prop.Name}' ({prop.Mass:0.###} kg, {(hand == 0 ? "left" : "right")}{(_held[other] == prop ? ", two-handed" : "")})");
}
}
if (!grip && _held[hand] != null)
{
RigidBody3D prop = _held[hand];
_held[hand] = null;
DestroyJoint(hand);
if (IsInstanceValid(prop))
GD.Print($"PropGrabber: released '{prop.Name}' at {prop.LinearVelocity.Length():F2} m/s");
}
_gripPrev[hand] = grip;
}
private void HoldUpdate(XRController3D controller, int hand, float delta)
{
RigidBody3D body = _held[hand];
if (body == null)
return;
if (!IsInstanceValid(body))
{
_held[hand] = null;
DestroyJoint(hand);
return;
}
RigidBody3D handBody = PhysicsHands.GetBody(controller);
ConeTwistJoint3D joint = _joints[hand];
if (handBody == null || !IsInstanceValid(handBody) || joint == null || !IsInstanceValid(joint))
{
_held[hand] = null;
DestroyJoint(hand);
return;
}
if (handBody.GlobalPosition.DistanceTo(controller.GlobalPosition) > PhysicsHands.SnapDistance)
{
BreakGrip(hand, body, "grip dragged off");
return;
}
float separation = (body.GlobalTransform * _localAnchor[hand]).DistanceTo(PalmPoint(handBody, hand));
int other = 1 - hand;
bool twoHanded = _held[other] == body && IsInstanceValid(_held[other]) &&
_joints[other] != null && IsInstanceValid(_joints[other]);
if (twoHanded)
{
RigidBody3D otherHandBody = PhysicsHands.GetBody(other == 0 ? _left : _right);
float otherSeparation = otherHandBody == null
? 0f
: (body.GlobalTransform * _localAnchor[other]).DistanceTo(PalmPoint(otherHandBody, other));
if (separation > BreakDistanceBoth && otherSeparation > BreakDistanceBoth)
{
BreakGrip(hand, body, $"both hands stretched ({separation:0.###} m)");
BreakGrip(other, body, "both hands stretched");
return;
}
}
if (separation > GripRipDistance)
{
BreakGrip(hand, body, $"ripped ({separation:0.###} m)");
return;
}
if (separation > BreakDistance)
_breakTimer[hand] += delta;
else
_breakTimer[hand] = 0f;
if (_breakTimer[hand] > BreakTime)
{
BreakGrip(hand, body, $"stretched ({separation:0.###} m for {BreakTime:0.#} s)");
return;
}
}
private int PrimaryHand(RigidBody3D body)
{
int primary = -1;
for (int hand = 0; hand < 2; hand++)
{
if (_held[hand] == body && (primary == -1 || _grabSeq[hand] < _grabSeq[primary]))
primary = hand;
}
return primary;
}
private ConeTwistJoint3D MakeWeld(RigidBody3D handBody, RigidBody3D prop, Vector3 pivot, float bias)
{
ConeTwistJoint3D weld = new()
{
ExcludeNodesFromCollision = true,
Position = pivot,
NodeA = handBody.GetPath(),
NodeB = prop.GetPath(),
SwingSpan = 0f,
TwistSpan = 0f,
Bias = bias,
};
AddChild(weld);
return weld;
}
private void BreakGrip(int hand, RigidBody3D body, string reason)
{
if (_held[hand] != body)
return;
_held[hand] = null;
DestroyJoint(hand);
GD.Print($"PropGrabber: grip broke on '{body.Name}' ({reason})");
}
private void DestroyJoint(int hand)
{
if (_joints[hand] != null && IsInstanceValid(_joints[hand]))
_joints[hand].QueueFree();
_joints[hand] = null;
_breakTimer[hand] = 0f;
}
private static Vector3 PalmPoint(RigidBody3D handBody, int hand) =>
handBody.GlobalPosition + handBody.GlobalTransform.Basis * new Vector3(PalmOffset(hand), 0f, 0f);
private static float PalmOffset(int hand) => hand == 0 ? PalmGrabOffset : -PalmGrabOffset;
private RigidBody3D FindProp(XRController3D controller, int hand)
{
RigidBody3D handBody = PhysicsHands.GetBody(controller);
if (handBody == null)
return null;
Vector3 palm = PalmPoint(handBody, hand);
RigidBody3D nearest = NearestProp(palm, GrabRadius);
if (nearest != null)
return nearest;
return NearestProp(handBody.GlobalPosition, CenterGrabRadius);
}
private RigidBody3D NearestProp(Vector3 point, float radius)
{
RigidBody3D nearest = null;
float nearestDist = radius;
foreach (Node node in GetTree().GetNodesInGroup("prop"))
{
if (node is not RigidBody3D prop)
continue;
float dist = prop.GlobalPosition.DistanceTo(point);
if (dist < nearestDist)
{
nearestDist = dist;
nearest = prop;
}
}
return nearest;
}
}
+1
View File
@@ -0,0 +1 @@
uid://70m2rsbntlm4
+141
View File
@@ -0,0 +1,141 @@
using Godot;
public partial class PropShop : Node3D
{
private struct PropSpec
{
public string Name;
public float Radius;
public Vector3 Box;
public float Mass;
public float Bounce;
public float Friction;
public Color Color;
public Vector3 Position;
}
private static readonly PropSpec[] Specs =
{
new() { Name = "Ping pong · 2.7 g", Radius = 0.02f, Mass = 0.0027f, Bounce = 0.9f, Friction = 0.3f, Color = new(1f, 1f, 1f), Position = new(-0.5f, 0.78f, -0.55f) },
new() { Name = "Bouncy ball · 50 g", Radius = 0.03f, Mass = 0.05f, Bounce = 0.85f, Friction = 0.4f, Color = new(1f, 0.4f, 0.7f), Position = new(-0.3f, 0.79f, -0.45f) },
new() { Name = "Apple · 150 g", Radius = 0.045f, Mass = 0.15f, Bounce = 0.3f, Friction = 0.6f, Color = new(0.85f, 0.15f, 0.1f), Position = new(-0.08f, 0.81f, -0.55f) },
new() { Name = "Crate · 2 kg", Box = new(0.16f, 0.16f, 0.16f), Mass = 2f, Bounce = 0.1f, Friction = 0.8f, Color = new(0.7f, 0.5f, 0.25f), Position = new(0.18f, 0.84f, -0.5f) },
new() { Name = "Brick · 3.5 kg", Box = new(0.2f, 0.06f, 0.095f), Mass = 3.5f, Bounce = 0.05f, Friction = 0.9f, Color = new(0.6f, 0.3f, 0.2f), Position = new(-0.32f, 0.79f, -0.25f) },
new() { Name = "Bowling ball · 7.2 kg", Radius = 0.11f, Mass = 7.2f, Bounce = 0.1f, Friction = 0.5f, Color = new(0.1f, 0.2f, 0.5f), Position = new(0.05f, 0.87f, -0.25f) },
new() { Name = "Anvil · 50 kg", Box = new(0.28f, 0.16f, 0.13f), Mass = 50f, Bounce = 0f, Friction = 0.95f, Color = new(0.25f, 0.25f, 0.28f), Position = new(0.38f, 0.84f, -0.35f) },
};
public override void _Ready()
{
BuildTable();
foreach (PropSpec spec in Specs)
SpawnProp(spec);
}
public void Respawn()
{
foreach (Node node in GetTree().GetNodesInGroup("prop"))
node.QueueFree();
foreach (PropSpec spec in Specs)
SpawnProp(spec);
GD.Print("PropShop: table reset");
}
private void BuildTable()
{
Color wood = new(0.45f, 0.3f, 0.18f);
StaticBody3D table = new();
table.AddChild(new CollisionShape3D
{
Position = new Vector3(0, 0.725f, -0.6f),
Shape = new BoxShape3D { Size = new Vector3(1.4f, 0.05f, 0.8f) },
});
MeshInstance3D top = new()
{
Position = new Vector3(0, 0.725f, -0.6f),
Mesh = new BoxMesh { Size = new Vector3(1.4f, 0.05f, 0.8f) },
};
top.SetSurfaceOverrideMaterial(0, MakeMaterial(wood));
table.AddChild(top);
Vector3[] legOffsets =
{
new(-0.62f, 0, 0.32f),
new(0.62f, 0, 0.32f),
new(-0.62f, 0, -0.32f),
new(0.62f, 0, -0.32f),
};
foreach (Vector3 offset in legOffsets)
{
Vector3 pos = new(offset.X, 0.3625f, -0.6f + offset.Z);
table.AddChild(new CollisionShape3D
{
Position = pos,
Shape = new BoxShape3D { Size = new Vector3(0.06f, 0.725f, 0.06f) },
});
MeshInstance3D leg = new()
{
Position = pos,
Mesh = new BoxMesh { Size = new Vector3(0.06f, 0.725f, 0.06f) },
};
leg.SetSurfaceOverrideMaterial(0, MakeMaterial(wood * 0.8f));
table.AddChild(leg);
}
AddChild(table);
}
private void SpawnProp(PropSpec spec)
{
RigidBody3D body = new()
{
Name = spec.Name,
Mass = spec.Mass,
Position = spec.Position,
PhysicsMaterialOverride = new PhysicsMaterial { Bounce = spec.Bounce, Friction = spec.Friction },
ContinuousCd = spec.Mass < 0.1f,
ContactMonitor = true,
MaxContactsReported = 4,
};
body.AddToGroup("prop");
if (spec.Radius > 0f)
{
body.AddChild(new CollisionShape3D { Shape = new SphereShape3D { Radius = spec.Radius } });
MeshInstance3D mesh = new()
{
Mesh = new SphereMesh { Radius = spec.Radius, Height = spec.Radius * 2f },
};
mesh.SetSurfaceOverrideMaterial(0, MakeMaterial(spec.Color));
body.AddChild(mesh);
}
else
{
body.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = spec.Box } });
MeshInstance3D mesh = new()
{
Mesh = new BoxMesh { Size = spec.Box },
};
mesh.SetSurfaceOverrideMaterial(0, MakeMaterial(spec.Color));
body.AddChild(mesh);
}
float halfHeight = spec.Radius > 0f ? spec.Radius : spec.Box.Y * 0.5f;
body.AddChild(new Label3D
{
Text = spec.Name,
FontSize = 64,
PixelSize = 0.0004f,
OutlineSize = 8,
Billboard = BaseMaterial3D.BillboardModeEnum.Enabled,
Position = new Vector3(0, halfHeight + 0.1f, 0),
});
AddChild(body);
}
private static StandardMaterial3D MakeMaterial(Color color) => new()
{
AlbedoColor = color,
Roughness = 0.7f,
};
}
+1
View File
@@ -0,0 +1 @@
uid://dathgkdgedhrj
+59
View File
@@ -0,0 +1,59 @@
using Godot;
public partial class TeleportMarker : Node3D
{
private static readonly Color IdleColor = new(0.2f, 0.55f, 0.9f);
private static readonly Color HotColor = new(0.4f, 0.95f, 1.0f);
[Export]
public string MarkerLabel { get; set; } = "1";
private StandardMaterial3D _discMaterial;
private StandardMaterial3D _beamMaterial;
private MeshInstance3D _beam;
private Label3D _label;
public bool IsNear { get; private set; }
public override void _Ready()
{
AddToGroup("teleport_marker");
_discMaterial = MakeMaterial(IdleColor, false);
_beamMaterial = MakeMaterial(new Color(IdleColor, 0.35f), true);
GetNode<MeshInstance3D>("Disc").SetSurfaceOverrideMaterial(0, _discMaterial);
_beam = GetNode<MeshInstance3D>("Beam");
_beam.SetSurfaceOverrideMaterial(0, _beamMaterial);
_label = GetNode<Label3D>("Label3D");
_label.Text = MarkerLabel;
}
public void SetNear(bool near)
{
if (IsNear == near)
return;
IsNear = near;
GetNode<MeshInstance3D>("Disc").Visible = !near;
_beam.Visible = !near;
_label.Visible = !near;
}
public void SetHighlighted(bool highlighted)
{
Color c = highlighted ? HotColor : IdleColor;
_discMaterial.AlbedoColor = c;
_discMaterial.Emission = c;
_beamMaterial.AlbedoColor = new Color(c, 0.35f);
_beamMaterial.Emission = c;
}
private static StandardMaterial3D MakeMaterial(Color color, bool transparent) => new()
{
AlbedoColor = color,
EmissionEnabled = true,
Emission = color,
EmissionEnergyMultiplier = 2.0f,
Transparency = transparent ? BaseMaterial3D.TransparencyEnum.Alpha : BaseMaterial3D.TransparencyEnum.Disabled,
};
}
+1
View File
@@ -0,0 +1 @@
uid://ccejrjq47ht5h
+227
View File
@@ -0,0 +1,227 @@
using Godot;
// Regression for the patched godot-box3d addon: ConeTwistJoint3D (swing 0, twist 0) maps to
// a solver-owned Box3D weld joint that locks the two bodies at the relative pose captured at
// creation. Phase 1 asserts a near-rigid weld under load; phase 2 asserts a soft weld
// (bias > 0) stretches under sustained extreme force and recovers without exploding.
public partial class WeldSpike : Node3D
{
private const float BodyAMass = 1f;
private const float BodyBMass = 0.05f;
private const float RigidWeldBias = 0f;
private const float SoftWeldBias = 0.5f;
private const float RigidDuration = 3f;
private const float SoftDuration = 4f;
private const float LoadDuration = 1.5f;
private const float PullDuration = 2.5f;
private const float RigidTorque = 0.5f;
private const float RigidForce = 0.5f;
private const float PullForce = 4f;
private const float MaxSpeed = 10f;
private const float RigidRotLimitDeg = 2f;
private const float RigidSepLimit = 0.01f;
private const float SoftRotLimitDeg = 90f;
private const float SoftSepLimit = 0.3f;
private const float SoftSettleRotLimitDeg = 10f;
private const float SoftSettleSepLimit = 0.05f;
private enum Phase { RigidWeld, SoftWeld }
private RigidBody3D _bodyA;
private RigidBody3D _bodyB;
private ConeTwistJoint3D _joint;
private Phase _phase = Phase.RigidWeld;
private float _elapsed;
private float _phaseStart;
private Quaternion _relRotation0;
private Vector3 _localAnchorA;
private Vector3 _localAnchorB;
private float _maxRelRot;
private float _maxSep;
private float _maxSpeed;
private bool _typeOk;
private bool _rigidRotOk;
private bool _rigidSepOk;
private bool _rigidSpeedOk;
private bool _softStretchOk;
private bool _softSpeedOk;
private bool _softSettleOk;
private int _lastLogStep;
public override void _Ready()
{
_bodyA = new RigidBody3D
{
Position = new Vector3(-0.2f, 2f, 0f),
Mass = BodyAMass,
GravityScale = 0f,
ContinuousCd = true,
};
_bodyA.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = Vector3.One * 0.4f } });
AddChild(_bodyA);
_bodyB = new RigidBody3D
{
Position = new Vector3(0.25f, 2f, 0f),
Mass = BodyBMass,
GravityScale = 0f,
ContinuousCd = true,
};
_bodyB.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = Vector3.One * 0.1f } });
AddChild(_bodyB);
Weld(RigidWeldBias);
_phaseStart = _elapsed;
GD.Print($"WELD: rigid weld created at t={_elapsed:F2} s, server joint type = {PhysicsServer3D.JointGetType(_joint.GetRid())} (expect {PhysicsServer3D.JointType.ConeTwist})");
_typeOk = PhysicsServer3D.JointGetType(_joint.GetRid()) == PhysicsServer3D.JointType.ConeTwist;
GD.Print("WELD: weld spike scene ready");
}
private void Weld(float bias)
{
Vector3 pivot = (_bodyA.GlobalPosition + _bodyB.GlobalPosition) * 0.5f;
_relRotation0 = _bodyA.GlobalTransform.Basis.GetRotationQuaternion().Inverse()
* _bodyB.GlobalTransform.Basis.GetRotationQuaternion();
_localAnchorA = _bodyA.GlobalTransform.AffineInverse() * pivot;
_localAnchorB = _bodyB.GlobalTransform.AffineInverse() * pivot;
_maxRelRot = 0f;
_maxSep = 0f;
_maxSpeed = 0f;
_joint = new ConeTwistJoint3D
{
ExcludeNodesFromCollision = true,
Position = pivot,
NodeA = _bodyA.GetPath(),
NodeB = _bodyB.GetPath(),
SwingSpan = 0f,
TwistSpan = 0f,
Bias = bias,
};
AddChild(_joint);
}
public override void _PhysicsProcess(double delta)
{
float phaseT = _elapsed - _phaseStart;
_elapsed += (float)delta;
switch (_phase)
{
case Phase.RigidWeld:
{
if (phaseT < LoadDuration)
{
_bodyA.ApplyTorque(new Vector3(0f, RigidTorque, 0f));
_bodyB.ApplyCentralForce(new Vector3(RigidForce, 0f, 0f));
}
Track();
LogBucket(phaseT);
if (phaseT >= RigidDuration)
FinishRigid();
break;
}
case Phase.SoftWeld:
{
if (phaseT < PullDuration)
{
_bodyA.ApplyCentralForce(new Vector3(-PullForce, 0f, 0f));
_bodyB.ApplyCentralForce(new Vector3(PullForce, 0f, 0f));
}
Track();
LogBucket(phaseT);
if (phaseT >= SoftDuration)
Finish();
break;
}
}
}
private static float DevAngle(Quaternion relNow, Quaternion rel0)
{
Quaternion dev = relNow * rel0.Inverse();
if (dev.W < 0f)
dev = new Quaternion(-dev.X, -dev.Y, -dev.Z, -dev.W);
// GetAngle() NaNs when w exceeds 1 by a float epsilon; clamp like GrabSpike does
if (float.IsNaN(dev.W))
return float.NaN;
return 2f * Mathf.Acos(Mathf.Clamp(Mathf.Abs(dev.W), 0f, 1f));
}
private void Track()
{
Quaternion relNow = _bodyA.GlobalTransform.Basis.GetRotationQuaternion().Inverse()
* _bodyB.GlobalTransform.Basis.GetRotationQuaternion();
float rotDev = DevAngle(relNow, _relRotation0);
if (float.IsNaN(rotDev))
{
GD.Print("WELD: FAIL — NaN rotation error (explosion guard)");
GetTree().Quit();
return;
}
_maxRelRot = Mathf.Max(_maxRelRot, rotDev);
float sep = (_bodyA.GlobalTransform * _localAnchorA).DistanceTo(_bodyB.GlobalTransform * _localAnchorB);
_maxSep = Mathf.Max(_maxSep, sep);
float speed = Mathf.Max(_bodyA.LinearVelocity.Length(), _bodyB.LinearVelocity.Length());
if (float.IsNaN(speed) || speed > MaxSpeed)
{
GD.Print($"WELD: FAIL — body speed {speed} m/s exceeds {MaxSpeed} (explosion guard)");
GetTree().Quit();
return;
}
_maxSpeed = Mathf.Max(_maxSpeed, speed);
}
private void LogBucket(float phaseT)
{
int step = (int)(phaseT / 0.5f);
if (step == _lastLogStep)
return;
_lastLogStep = step;
Quaternion relNow = _bodyA.GlobalTransform.Basis.GetRotationQuaternion().Inverse()
* _bodyB.GlobalTransform.Basis.GetRotationQuaternion();
float rotDev = DevAngle(relNow, _relRotation0);
float sep = (_bodyA.GlobalTransform * _localAnchorA).DistanceTo(_bodyB.GlobalTransform * _localAnchorB);
GD.Print($"WELD: t={_elapsed:F2} phase={_phase} rotDev={Mathf.RadToDeg(rotDev):F2} deg sep={sep:F4} m speedA={_bodyA.LinearVelocity.Length():F2} speedB={_bodyB.LinearVelocity.Length():F2}");
}
private void FinishRigid()
{
_rigidRotOk = _maxRelRot < Mathf.DegToRad(RigidRotLimitDeg);
_rigidSepOk = _maxSep < RigidSepLimit;
_rigidSpeedOk = _maxSpeed < MaxSpeed;
GD.Print($"WELD: rigid hold — max rot dev {Mathf.RadToDeg(_maxRelRot):F2} deg (limit {RigidRotLimitDeg}) → {(_rigidRotOk ? "PASS" : "FAIL")}");
GD.Print($"WELD: rigid anchors — max sep {_maxSep:F4} m (limit {RigidSepLimit}) → {(_rigidSepOk ? "PASS" : "FAIL")}");
GD.Print($"WELD: rigid speeds — max {_maxSpeed:F2} m/s (limit {MaxSpeed}) → {(_rigidSpeedOk ? "PASS" : "FAIL")}");
// fresh-grab conditions: stop the coasting bodies before re-welding
_bodyA.LinearVelocity = Vector3.Zero;
_bodyA.AngularVelocity = Vector3.Zero;
_bodyB.LinearVelocity = Vector3.Zero;
_bodyB.AngularVelocity = Vector3.Zero;
_joint.QueueFree();
_joint = null;
_phase = Phase.SoftWeld;
_phaseStart = _elapsed;
_lastLogStep = 0;
Weld(SoftWeldBias);
GD.Print($"WELD: soft weld re-created at current pose, bias {SoftWeldBias} (weld hertz {SoftWeldBias * 30f:F0}), sustained ±{PullForce} N pull starting");
}
private void Finish()
{
_softStretchOk = _maxRelRot < Mathf.DegToRad(SoftRotLimitDeg) && _maxSep < SoftSepLimit;
_softSpeedOk = _maxSpeed < MaxSpeed;
// settle: assert the stretch has recovered by the end
Quaternion relNow = _bodyA.GlobalTransform.Basis.GetRotationQuaternion().Inverse()
* _bodyB.GlobalTransform.Basis.GetRotationQuaternion();
float settleRot = DevAngle(relNow, _relRotation0);
float settleSep = (_bodyA.GlobalTransform * _localAnchorA).DistanceTo(_bodyB.GlobalTransform * _localAnchorB);
_softSettleOk = settleRot < Mathf.DegToRad(SoftSettleRotLimitDeg) && settleSep < SoftSettleSepLimit;
GD.Print($"WELD: soft excursion bounded — max rot dev {Mathf.RadToDeg(_maxRelRot):F2} deg (limit {SoftRotLimitDeg}), max sep {_maxSep:F4} m (limit {SoftSepLimit}) → {(_softStretchOk ? "PASS" : "FAIL")}");
GD.Print($"WELD: soft speeds — max {_maxSpeed:F2} m/s (limit {MaxSpeed}) → {(_softSpeedOk ? "PASS" : "FAIL")}");
GD.Print($"WELD: soft settle — rot dev {Mathf.RadToDeg(settleRot):F2} deg (limit {SoftSettleRotLimitDeg}), sep {settleSep:F4} m (limit {SoftSettleSepLimit}) → {(_softSettleOk ? "PASS" : "FAIL")}");
bool overall = _typeOk && _rigidRotOk && _rigidSepOk && _rigidSpeedOk && _softStretchOk && _softSpeedOk && _softSettleOk;
GD.Print($"WELD: overall {(overall ? "PASS — solver-owned rigid weld via ConeTwistJoint3D functional on patched Box3D" : "CHECK — behavior differs, inspect above")}");
GetTree().Quit();
}
}
+1
View File
@@ -0,0 +1 @@
uid://ck1ceetotj4pk
+396
View File
@@ -0,0 +1,396 @@
using Godot;
using System.Collections.Generic;
public partial class WorldPanel : Node3D
{
private const float GripThreshold = 0.6f;
private const float TriggerThreshold = 0.6f;
private const float DirectGrabRadius = 0.45f;
private const float TipHoverRadius = 0.07f;
private const float PressContactDistance = 0.045f;
private const ulong PressCooldownMs = 400;
private const float TipLength = 0.12f;
private const float RayLength = 50f;
private static WorldPanel _instance;
private XRController3D _left;
private XRController3D _right;
private XRCamera3D _camera;
private Locomotion _locomotion;
private PropShop _propShop;
private AudioStreamPlayer3D _click;
private StaticBody3D _panelBody;
private struct Knob
{
public string Name;
public Node3D Root;
public MeshInstance3D Mesh;
public StandardMaterial3D Material;
public Callable Action;
public ulong LastPressMs;
}
private readonly List<Knob> _knobs = new();
private readonly bool[] _dragging = new bool[2];
private readonly bool[] _directDrag = new bool[2];
private readonly float[] _laserDist = new float[2];
private readonly Vector3[] _directOffset = new Vector3[2];
private readonly bool[] _triggerPrev = new bool[2];
private readonly bool[] _summonPrev = new bool[2];
private readonly int[] _hover = new int[2] { -1, -1 };
private readonly bool[] _hoverPanel = new bool[2];
public override void _Ready()
{
_instance = this;
_left = GetNode<XRController3D>("../LeftHand");
_right = GetNode<XRController3D>("../RightHand");
_camera = GetNode<XRCamera3D>("../XRCamera3D");
_locomotion = GetNode<Locomotion>("../../Locomotion");
_propShop = GetNode<PropShop>("../../PropShop");
Visible = false;
BuildPanel();
BuildClickSound();
SetPanelActive(false);
}
public static bool IsBlocking(XRController3D controller) =>
_instance != null && _instance.Visible &&
_instance.GlobalPosition.DistanceTo(controller.GlobalPosition) < DirectGrabRadius;
public static bool IsPanelCollider(Node node) =>
_instance != null && _instance.Visible && node != null &&
(node.HasMeta("knob_index") || node == _instance._panelBody || node.GetParent() == _instance._panelBody);
public override void _Process(double delta)
{
PollSummon(_left, 0);
PollSummon(_right, 1);
if (!Visible)
return;
UpdateHand(_left, 0);
UpdateHand(_right, 1);
}
private void PollSummon(XRController3D controller, int hand)
{
bool summon = controller.IsButtonPressed("ax_button");
if (summon && !_summonPrev[hand])
{
if (!Visible)
PlaceInFrontOf(controller);
SetPanelActive(!Visible);
GD.Print($"WorldPanel: {(Visible ? "summoned" : "dismissed")} via ax_button ({(hand == 0 ? "left" : "right")})");
}
_summonPrev[hand] = summon;
}
private void SetPanelActive(bool active)
{
if (!active)
ResetInteractionState();
Visible = active;
SetShapesEnabled(this, active);
}
private static void SetShapesEnabled(Node root, bool enabled)
{
foreach (Node child in root.GetChildren())
{
if (child is CollisionShape3D shape)
shape.SetDeferred(CollisionShape3D.PropertyName.Disabled, !enabled);
SetShapesEnabled(child, enabled);
}
}
private void PlaceInFrontOf(XRController3D controller) =>
OrientTo(controller.GlobalPosition - controller.GlobalTransform.Basis.Z * 0.5f);
private void PlaceInFrontOfHead() =>
OrientTo(_camera.GlobalPosition - _camera.GlobalTransform.Basis.Z * 0.5f);
private void OrientTo(Vector3 pos)
{
Vector3 look = _camera.GlobalPosition - pos;
if (look.LengthSquared() < 0.0001f)
return;
Vector3 zAxis = look.Normalized();
Vector3 xAxis = Vector3.Up.Cross(zAxis);
if (xAxis.LengthSquared() < 0.0001f)
return;
xAxis = xAxis.Normalized();
Vector3 yAxis = zAxis.Cross(xAxis).Normalized();
GlobalTransform = new Transform3D(new Basis(xAxis, yAxis, zAxis), pos);
}
private void OrientTowardCamera()
{
Vector3 look = _camera.GlobalPosition - GlobalPosition;
if (look.LengthSquared() < 0.0001f)
return;
Vector3 zAxis = look.Normalized();
Vector3 xAxis = Vector3.Up.Cross(zAxis);
if (xAxis.LengthSquared() < 0.0001f)
return;
xAxis = xAxis.Normalized();
Vector3 yAxis = zAxis.Cross(xAxis).Normalized();
GlobalBasis = GlobalBasis.Slerp(new Basis(xAxis, yAxis, zAxis), 0.35f).Orthonormalized();
}
private void BuildPanel()
{
Node3D plate = new() { Name = "Plate" };
AddChild(plate);
StandardMaterial3D plateMaterial = new()
{
AlbedoColor = new Color(0.08f, 0.09f, 0.12f),
EmissionEnabled = true,
Emission = new Color(0.1f, 0.2f, 0.35f),
EmissionEnergyMultiplier = 0.4f,
};
MeshInstance3D plateMesh = new() { Mesh = new BoxMesh { Size = new Vector3(0.6f, 0.34f, 0.02f) } };
plateMesh.SetSurfaceOverrideMaterial(0, plateMaterial);
plate.AddChild(plateMesh);
_panelBody = new StaticBody3D
{
Name = "PanelBody",
CollisionLayer = 2,
CollisionMask = 0,
};
_panelBody.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = new Vector3(0.6f, 0.34f, 0.06f) } });
plate.AddChild(_panelBody);
Vector3[] positions =
{
new(-0.21f, 0.06f, 0.03f),
new(-0.07f, 0.06f, 0.03f),
new(0.07f, 0.06f, 0.03f),
new(0.21f, 0.06f, 0.03f),
};
string[] names = { "Spawn", "Ping", "Reset", "Close" };
Color[] colors =
{
new(0.25f, 0.6f, 1f),
new(0.95f, 0.75f, 0.25f),
new(0.2f, 0.8f, 0.35f),
new(0.9f, 0.3f, 0.3f),
};
Callable[] actions =
{
Callable.From(() =>
{
_locomotion.ReturnToSpawn();
ResetInteractionState();
PlaceInFrontOfHead();
}),
Callable.From(Ping),
Callable.From(() => _propShop.Respawn()),
Callable.From(Close),
};
for (int i = 0; i < names.Length; i++)
{
StandardMaterial3D material = new()
{
AlbedoColor = colors[i],
EmissionEnabled = true,
Emission = colors[i],
EmissionEnergyMultiplier = 1.2f,
};
MeshInstance3D mesh = new()
{
Mesh = new CylinderMesh { TopRadius = 0.032f, BottomRadius = 0.032f, Height = 0.025f },
RotationDegrees = new Vector3(90, 0, 0),
};
mesh.SetSurfaceOverrideMaterial(0, material);
StaticBody3D body = new()
{
CollisionLayer = 2,
CollisionMask = 0,
};
body.SetMeta("knob_index", i);
body.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = new Vector3(0.09f, 0.09f, 0.06f) } });
Node3D root = new() { Position = positions[i] };
root.AddChild(mesh);
root.AddChild(body);
Label3D label = new()
{
Text = names[i],
FontSize = 96,
PixelSize = 0.00025f,
OutlineSize = 6,
Position = new Vector3(0, -0.08f, 0.015f),
};
root.AddChild(label);
plate.AddChild(root);
_knobs.Add(new Knob
{
Name = names[i],
Root = root,
Mesh = mesh,
Material = material,
Action = actions[i],
});
}
}
private void BuildClickSound()
{
const int rate = 44100;
const int samples = rate / 25;
byte[] data = new byte[samples * 2];
for (int i = 0; i < samples; i++)
{
float envelope = 1f - (float)i / samples;
float value = Mathf.Sin(Mathf.Tau * 900f * i / rate) * envelope * 0.35f;
short sample = (short)(value * short.MaxValue);
data[i * 2] = (byte)sample;
data[i * 2 + 1] = (byte)(sample >> 8);
}
_click = new AudioStreamPlayer3D
{
Stream = new AudioStreamWav
{
Format = AudioStreamWav.FormatEnum.Format16Bits,
MixRate = rate,
Stereo = false,
Data = data,
},
};
AddChild(_click);
}
private void UpdateHand(XRController3D controller, int hand)
{
Vector3 from = controller.GlobalPosition;
Vector3 dir = -controller.GlobalTransform.Basis.Z;
Vector3 tip = from + dir * TipLength;
UpdateHover(controller, hand, from, dir, tip);
bool grip = controller.GetFloat("grip") > GripThreshold
&& !PropGrabber.IsGrabbing(controller);
if (grip && !_dragging[hand] && (_hoverPanel[hand] || GlobalPosition.DistanceTo(from) < DirectGrabRadius))
{
_dragging[hand] = true;
_directDrag[hand] = !_hoverPanel[hand];
if (_directDrag[hand])
_directOffset[hand] = GlobalPosition - from;
else
_laserDist[hand] = Mathf.Max(0.2f, (GlobalPosition - from).Dot(dir));
GD.Print($"WorldPanel: grab started ({(_directDrag[hand] ? "direct" : "laser")}, {(hand == 0 ? "left" : "right")})");
}
if (!grip && _dragging[hand])
{
_dragging[hand] = false;
GD.Print("WorldPanel: grab released");
}
if (_dragging[hand])
{
GlobalPosition = _directDrag[hand]
? from + _directOffset[hand]
: from + dir * _laserDist[hand];
OrientTowardCamera();
}
bool trigger = controller.GetFloat("trigger") > TriggerThreshold;
if (trigger && !_triggerPrev[hand] && _hover[hand] >= 0)
Press(_hover[hand]);
_triggerPrev[hand] = trigger;
if (_hover[hand] >= 0)
{
int hovered = _hover[hand];
Knob knob = _knobs[hovered];
if (knob.Root.GlobalPosition.DistanceTo(tip) < PressContactDistance)
{
ulong now = Time.GetTicksMsec();
if (now - knob.LastPressMs > PressCooldownMs)
{
knob.LastPressMs = now;
_knobs[hovered] = knob;
Press(hovered);
}
}
}
}
private void UpdateHover(XRController3D controller, int hand, Vector3 from, Vector3 dir, Vector3 tip)
{
int knob = -1;
bool panel = false;
var query = PhysicsRayQueryParameters3D.Create(from, from + dir * RayLength);
var hit = GetViewport().GetWorld3D().DirectSpaceState.IntersectRay(query);
if (hit.TryGetValue("collider", out Variant collider) && collider.As<Node>() is Node node)
{
if (node.HasMeta("knob_index"))
knob = (int)node.GetMeta("knob_index");
else
panel = IsPanelCollider(node) || node.GetParent() == _panelBody;
}
if (knob < 0)
{
for (int i = 0; i < _knobs.Count; i++)
{
if (_knobs[i].Root.GlobalPosition.DistanceTo(tip) < TipHoverRadius)
{
knob = i;
break;
}
}
}
if (knob != _hover[hand])
{
if (_hover[hand] >= 0)
_knobs[_hover[hand]].Material.EmissionEnergyMultiplier = 1.2f;
_hover[hand] = knob;
if (knob >= 0)
_knobs[knob].Material.EmissionEnergyMultiplier = 2.8f;
}
_hoverPanel[hand] = panel;
}
private void Press(int index)
{
Knob knob = _knobs[index];
GD.Print($"WorldPanel: pressed '{knob.Name}'");
_click.Play();
Tween tween = CreateTween();
tween.TweenProperty(knob.Mesh, "position:z", -0.014f, 0.05);
tween.Parallel().TweenProperty(knob.Material, "emission_energy_multiplier", 4.0f, 0.05);
tween.TweenProperty(knob.Mesh, "position:z", 0f, 0.12);
tween.Parallel().TweenProperty(knob.Material, "emission_energy_multiplier", 1.2f, 0.3);
knob.Action.Call();
}
private void Ping() => GD.Print("WorldPanel: ping");
private void Close() => SetPanelActive(false);
private void ResetInteractionState()
{
for (int hand = 0; hand < 2; hand++)
{
_dragging[hand] = false;
_hoverPanel[hand] = false;
_triggerPrev[hand] = false;
if (_hover[hand] >= 0)
_knobs[_hover[hand]].Material.EmissionEnergyMultiplier = 1.2f;
_hover[hand] = -1;
}
}
}
+1
View File
@@ -0,0 +1 @@
uid://babrdgj2luqwv