commit 5d2c5bf2654e69dcdb677c17f6ba6a5ba1fa00c1 Author: Foxyz1302 Date: Thu Oct 1 21:32:13 2026 +0300 Initial commit: VR Messs workspace (grab v5.3.1, body-physics research) diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f28239b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,4 @@ +root = true + +[*] +charset = utf-8 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8ad74f7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Normalize EOL for all files that Git considers text files. +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0b6d2a4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.godot/ +.vendor/godot-box3d/ +.vendor/venv/ +.export/ +*.translation diff --git a/.vendor/.gdignore b/.vendor/.gdignore new file mode 100644 index 0000000..e69de29 diff --git a/.vendor/box3d-weld.patch b/.vendor/box3d-weld.patch new file mode 100644 index 0000000..ba22537 --- /dev/null +++ b/.vendor/box3d-weld.patch @@ -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 ++ ++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(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(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) { diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..83cbbc7 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..a9e48a2 --- /dev/null +++ b/HANDOFF.md @@ -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. diff --git a/VR Messs.csproj b/VR Messs.csproj new file mode 100644 index 0000000..77c223d --- /dev/null +++ b/VR Messs.csproj @@ -0,0 +1,8 @@ + + + net8.0 + true + VR Messs + VRMesss + + diff --git a/addons/godot-box3d/LICENSE b/addons/godot-box3d/LICENSE new file mode 100644 index 0000000..0410717 --- /dev/null +++ b/addons/godot-box3d/LICENSE @@ -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. diff --git a/addons/godot-box3d/bin-stock/godot-box3d.dll b/addons/godot-box3d/bin-stock/godot-box3d.dll new file mode 100644 index 0000000..6d31110 Binary files /dev/null and b/addons/godot-box3d/bin-stock/godot-box3d.dll differ diff --git a/addons/godot-box3d/bin-stock/libgodot-box3d.dylib b/addons/godot-box3d/bin-stock/libgodot-box3d.dylib new file mode 100644 index 0000000..79d90f8 Binary files /dev/null and b/addons/godot-box3d/bin-stock/libgodot-box3d.dylib differ diff --git a/addons/godot-box3d/bin-stock/libgodot-box3d.so b/addons/godot-box3d/bin-stock/libgodot-box3d.so new file mode 100644 index 0000000..8b32ccd Binary files /dev/null and b/addons/godot-box3d/bin-stock/libgodot-box3d.so differ diff --git a/addons/godot-box3d/bin/godot-box3d.dll b/addons/godot-box3d/bin/godot-box3d.dll new file mode 100644 index 0000000..6d31110 Binary files /dev/null and b/addons/godot-box3d/bin/godot-box3d.dll differ diff --git a/addons/godot-box3d/bin/libgodot-box3d.dylib b/addons/godot-box3d/bin/libgodot-box3d.dylib new file mode 100644 index 0000000..79d90f8 Binary files /dev/null and b/addons/godot-box3d/bin/libgodot-box3d.dylib differ diff --git a/addons/godot-box3d/bin/libgodot-box3d.so b/addons/godot-box3d/bin/libgodot-box3d.so new file mode 100644 index 0000000..dacef9c Binary files /dev/null and b/addons/godot-box3d/bin/libgodot-box3d.so differ diff --git a/addons/godot-box3d/godot-box3d.gdextension b/addons/godot-box3d/godot-box3d.gdextension new file mode 100644 index 0000000..d10c897 --- /dev/null +++ b/addons/godot-box3d/godot-box3d.gdextension @@ -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" diff --git a/addons/godot-box3d/godot-box3d.gdextension.uid b/addons/godot-box3d/godot-box3d.gdextension.uid new file mode 100644 index 0000000..6b6fbad --- /dev/null +++ b/addons/godot-box3d/godot-box3d.gdextension.uid @@ -0,0 +1 @@ +uid://cwrhma7nfgvfm diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..c6bbb7d --- /dev/null +++ b/icon.svg @@ -0,0 +1 @@ + diff --git a/icon.svg.import b/icon.svg.import new file mode 100644 index 0000000..6f2bf01 --- /dev/null +++ b/icon.svg.import @@ -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 diff --git a/openxr_action_map.tres b/openxr_action_map.tres new file mode 100644 index 0000000..647a663 --- /dev/null +++ b/openxr_action_map.tres @@ -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")] diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..45238fc --- /dev/null +++ b/project.godot @@ -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 diff --git a/research/godot-box3d-weld.md b/research/godot-box3d-weld.md new file mode 100644 index 0000000..7e5419c --- /dev/null +++ b/research/godot-box3d-weld.md @@ -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 +``` diff --git a/research/vr-arm-ik-deep.md b/research/vr-arm-ik-deep.md new file mode 100644 index 0000000..0a2d768 --- /dev/null +++ b/research/vr-arm-ik-deep.md @@ -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. diff --git a/research/vr-arm-ik.md b/research/vr-arm-ik.md new file mode 100644 index 0000000..006e990 --- /dev/null +++ b/research/vr-arm-ik.md @@ -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. diff --git a/research/vr-body-physics-godot.md b/research/vr-body-physics-godot.md new file mode 100644 index 0000000..6284330 --- /dev/null +++ b/research/vr-body-physics-godot.md @@ -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. diff --git a/research/vr-body-physics-hugs.md b/research/vr-body-physics-hugs.md new file mode 100644 index 0000000..36cc205 --- /dev/null +++ b/research/vr-body-physics-hugs.md @@ -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`). diff --git a/research/vr-grabbing.md b/research/vr-grabbing.md new file mode 100644 index 0000000..a057615 --- /dev/null +++ b/research/vr-grabbing.md @@ -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. diff --git a/research/vr-two-handed-grab.md b/research/vr-two-handed-grab.md new file mode 100644 index 0000000..c8fb153 --- /dev/null +++ b/research/vr-two-handed-grab.md @@ -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. diff --git a/scenes/grab_spike.tscn b/scenes/grab_spike.tscn new file mode 100644 index 0000000..6d7b31f --- /dev/null +++ b/scenes/grab_spike.tscn @@ -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") diff --git a/scenes/main.tscn b/scenes/main.tscn new file mode 100644 index 0000000..4553e59 --- /dev/null +++ b/scenes/main.tscn @@ -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 diff --git a/scenes/physics_spike.tscn b/scenes/physics_spike.tscn new file mode 100644 index 0000000..54466df --- /dev/null +++ b/scenes/physics_spike.tscn @@ -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") diff --git a/scenes/teleport_marker.tscn b/scenes/teleport_marker.tscn new file mode 100644 index 0000000..3f81a9f --- /dev/null +++ b/scenes/teleport_marker.tscn @@ -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") diff --git a/scenes/weld_spike.tscn b/scenes/weld_spike.tscn new file mode 100644 index 0000000..adf4da9 --- /dev/null +++ b/scenes/weld_spike.tscn @@ -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") diff --git a/scripts/AnchorMoveMode.cs b/scripts/AnchorMoveMode.cs new file mode 100644 index 0000000..1bc4d05 --- /dev/null +++ b/scripts/AnchorMoveMode.cs @@ -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"); + _camera = GetNode("../XROrigin3D/XRCamera3D"); + _left = GetNode("../XROrigin3D/LeftHand"); + _right = GetNode("../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); + } +} diff --git a/scripts/AnchorMoveMode.cs.uid b/scripts/AnchorMoveMode.cs.uid new file mode 100644 index 0000000..399c701 --- /dev/null +++ b/scripts/AnchorMoveMode.cs.uid @@ -0,0 +1 @@ +uid://00dofwddtngn diff --git a/scripts/BodyRig.cs b/scripts/BodyRig.cs new file mode 100644 index 0000000..cd3d78c --- /dev/null +++ b/scripts/BodyRig.cs @@ -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("../XROrigin3D/XRCamera3D"); + _left = GetNode("../XROrigin3D/LeftHand"); + _right = GetNode("../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); + } +} diff --git a/scripts/BodyRig.cs.uid b/scripts/BodyRig.cs.uid new file mode 100644 index 0000000..c39e690 --- /dev/null +++ b/scripts/BodyRig.cs.uid @@ -0,0 +1 @@ +uid://dwaonvvnka0b diff --git a/scripts/GrabSpike.cs b/scripts/GrabSpike.cs new file mode 100644 index 0000000..ea27750 --- /dev/null +++ b/scripts/GrabSpike.cs @@ -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(); + } +} diff --git a/scripts/GrabSpike.cs.uid b/scripts/GrabSpike.cs.uid new file mode 100644 index 0000000..85399da --- /dev/null +++ b/scripts/GrabSpike.cs.uid @@ -0,0 +1 @@ +uid://c7ala2jq058dh diff --git a/scripts/Locomotion.cs b/scripts/Locomotion.cs new file mode 100644 index 0000000..3cab08c --- /dev/null +++ b/scripts/Locomotion.cs @@ -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
(".."); + _origin = GetNode("../XROrigin3D"); + _camera = GetNode("../XROrigin3D/XRCamera3D"); + _left = GetNode("../XROrigin3D/LeftHand"); + _right = GetNode("../XROrigin3D/RightHand"); + _spawn = GetNode("../SpawnPoint"); + _fade = GetNode("../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() 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()); + 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()?.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); + } +} diff --git a/scripts/Locomotion.cs.uid b/scripts/Locomotion.cs.uid new file mode 100644 index 0000000..81236a9 --- /dev/null +++ b/scripts/Locomotion.cs.uid @@ -0,0 +1 @@ +uid://be6cxrxsc202g diff --git a/scripts/Main.cs b/scripts/Main.cs new file mode 100644 index 0000000..542e925 --- /dev/null +++ b/scripts/Main.cs @@ -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); + } +} diff --git a/scripts/Main.cs.uid b/scripts/Main.cs.uid new file mode 100644 index 0000000..46ef3e7 --- /dev/null +++ b/scripts/Main.cs.uid @@ -0,0 +1 @@ +uid://dsy2jm1gdcao0 diff --git a/scripts/PhysicsHands.cs b/scripts/PhysicsHands.cs new file mode 100644 index 0000000..44ecf92 --- /dev/null +++ b/scripts/PhysicsHands.cs @@ -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("../XROrigin3D/LeftHand"); + _right = GetNode("../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; + } +} diff --git a/scripts/PhysicsHands.cs.uid b/scripts/PhysicsHands.cs.uid new file mode 100644 index 0000000..ad72c1c --- /dev/null +++ b/scripts/PhysicsHands.cs.uid @@ -0,0 +1 @@ +uid://b4mgat3v21u30 diff --git a/scripts/PhysicsSpike.cs b/scripts/PhysicsSpike.cs new file mode 100644 index 0000000..ac4413c --- /dev/null +++ b/scripts/PhysicsSpike.cs @@ -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(); + } +} diff --git a/scripts/PhysicsSpike.cs.uid b/scripts/PhysicsSpike.cs.uid new file mode 100644 index 0000000..cf7f461 --- /dev/null +++ b/scripts/PhysicsSpike.cs.uid @@ -0,0 +1 @@ +uid://pmjecyvvet5c diff --git a/scripts/PropGrabber.cs b/scripts/PropGrabber.cs new file mode 100644 index 0000000..6b41503 --- /dev/null +++ b/scripts/PropGrabber.cs @@ -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("../XROrigin3D/LeftHand"); + _right = GetNode("../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; + } +} diff --git a/scripts/PropGrabber.cs.uid b/scripts/PropGrabber.cs.uid new file mode 100644 index 0000000..3e98662 --- /dev/null +++ b/scripts/PropGrabber.cs.uid @@ -0,0 +1 @@ +uid://70m2rsbntlm4 diff --git a/scripts/PropShop.cs b/scripts/PropShop.cs new file mode 100644 index 0000000..cf95d9a --- /dev/null +++ b/scripts/PropShop.cs @@ -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, + }; +} diff --git a/scripts/PropShop.cs.uid b/scripts/PropShop.cs.uid new file mode 100644 index 0000000..bc7dda4 --- /dev/null +++ b/scripts/PropShop.cs.uid @@ -0,0 +1 @@ +uid://dathgkdgedhrj diff --git a/scripts/TeleportMarker.cs b/scripts/TeleportMarker.cs new file mode 100644 index 0000000..d105674 --- /dev/null +++ b/scripts/TeleportMarker.cs @@ -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("Disc").SetSurfaceOverrideMaterial(0, _discMaterial); + _beam = GetNode("Beam"); + _beam.SetSurfaceOverrideMaterial(0, _beamMaterial); + _label = GetNode("Label3D"); + _label.Text = MarkerLabel; + } + + public void SetNear(bool near) + { + if (IsNear == near) + return; + IsNear = near; + GetNode("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, + }; +} diff --git a/scripts/TeleportMarker.cs.uid b/scripts/TeleportMarker.cs.uid new file mode 100644 index 0000000..92cbfce --- /dev/null +++ b/scripts/TeleportMarker.cs.uid @@ -0,0 +1 @@ +uid://ccejrjq47ht5h diff --git a/scripts/WeldSpike.cs b/scripts/WeldSpike.cs new file mode 100644 index 0000000..9c18592 --- /dev/null +++ b/scripts/WeldSpike.cs @@ -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(); + } +} diff --git a/scripts/WeldSpike.cs.uid b/scripts/WeldSpike.cs.uid new file mode 100644 index 0000000..cf011d9 --- /dev/null +++ b/scripts/WeldSpike.cs.uid @@ -0,0 +1 @@ +uid://ck1ceetotj4pk diff --git a/scripts/WorldPanel.cs b/scripts/WorldPanel.cs new file mode 100644 index 0000000..5af8dcb --- /dev/null +++ b/scripts/WorldPanel.cs @@ -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 _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("../LeftHand"); + _right = GetNode("../RightHand"); + _camera = GetNode("../XRCamera3D"); + _locomotion = GetNode("../../Locomotion"); + _propShop = GetNode("../../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() 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; + } + } +} diff --git a/scripts/WorldPanel.cs.uid b/scripts/WorldPanel.cs.uid new file mode 100644 index 0000000..8df2e12 --- /dev/null +++ b/scripts/WorldPanel.cs.uid @@ -0,0 +1 @@ +uid://babrdgj2luqwv