Initial commit: VR Messs workspace (grab v5.3.1, body-physics research)
This commit is contained in:
commit
5d2c5bf265
57 files changed
+4990
No files matched your search
@@ -0,0 +1,4 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
@@ -0,0 +1,2 @@
|
||||
# Normalize EOL for all files that Git considers text files.
|
||||
* text=auto eol=lf
|
||||
@@ -0,0 +1,5 @@
|
||||
.godot/
|
||||
.vendor/godot-box3d/
|
||||
.vendor/venv/
|
||||
.export/
|
||||
*.translation
|
||||
Whitespace-only changes.
@@ -0,0 +1,154 @@
|
||||
diff --git a/src/joints/box3d_weld_joint_impl_3d.cpp b/src/joints/box3d_weld_joint_impl_3d.cpp
|
||||
new file mode 100644
|
||||
index 0000000..ed4c687
|
||||
--- /dev/null
|
||||
+++ b/src/joints/box3d_weld_joint_impl_3d.cpp
|
||||
@@ -0,0 +1,64 @@
|
||||
+#include "box3d_weld_joint_impl_3d.hpp"
|
||||
+
|
||||
+#include "../misc/type_conversions.hpp"
|
||||
+
|
||||
+#include <box3d/box3d.h>
|
||||
+
|
||||
+b3JointId Box3DWeldJointImpl3D::_create_joint_id(b3WorldId p_world_id, b3BodyId p_body_a, b3BodyId p_body_b, b3Transform p_local_frame_a, b3Transform p_local_frame_b) {
|
||||
+ b3WeldJointDef def = b3DefaultWeldJointDef();
|
||||
+ def.base.bodyIdA = p_body_a;
|
||||
+ def.base.bodyIdB = p_body_b;
|
||||
+ def.base.localFrameA = p_local_frame_a;
|
||||
+
|
||||
+ // The weld enforces relQ == identity against the two local frames, so frame B's rotation
|
||||
+ // is pre-corrected with the bodies' CURRENT relative rotation: the joint then locks the
|
||||
+ // relative pose at creation time, no matter how the joint node was oriented.
|
||||
+ const b3Quat quat_a = b3Body_GetRotation(p_body_a);
|
||||
+ const b3Quat quat_b = b3Body_GetRotation(p_body_b);
|
||||
+ const b3Quat world_frame_a = b3MulQuat(quat_a, p_local_frame_a.q);
|
||||
+ def.base.localFrameB.q = b3InvMulQuat(quat_b, world_frame_a);
|
||||
+ def.base.localFrameB.p = p_local_frame_b.p;
|
||||
+
|
||||
+ // hertz 0 = maximum stiffness (Box3D falls back to its engine-clamped world constraint
|
||||
+ // softness); bias > 0 softens the weld: hertz = bias * 30, mirroring the pin joint.
|
||||
+ def.linearHertz = (float)bias * 30.0f;
|
||||
+ def.angularHertz = (float)bias * 30.0f;
|
||||
+ def.linearDampingRatio = 1.0f;
|
||||
+ def.angularDampingRatio = 1.0f;
|
||||
+ return b3CreateWeldJoint(p_world_id, &def);
|
||||
+}
|
||||
+
|
||||
+real_t Box3DWeldJointImpl3D::get_param(PhysicsServer3D::ConeTwistJointParam p_param) const {
|
||||
+ switch (p_param) {
|
||||
+ case PhysicsServer3D::CONE_TWIST_JOINT_BIAS:
|
||||
+ return bias;
|
||||
+ default:
|
||||
+ return 0.0;
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+void Box3DWeldJointImpl3D::set_param(PhysicsServer3D::ConeTwistJointParam p_param, real_t p_value) {
|
||||
+ switch (p_param) {
|
||||
+ case PhysicsServer3D::CONE_TWIST_JOINT_BIAS:
|
||||
+ bias = p_value;
|
||||
+ if (has_joint_id()) {
|
||||
+ b3WeldJoint_SetLinearHertz(get_joint_id(), (float)bias * 30.0f);
|
||||
+ b3WeldJoint_SetAngularHertz(get_joint_id(), (float)bias * 30.0f);
|
||||
+ }
|
||||
+ break;
|
||||
+ case PhysicsServer3D::CONE_TWIST_JOINT_SWING_SPAN:
|
||||
+ case PhysicsServer3D::CONE_TWIST_JOINT_TWIST_SPAN:
|
||||
+ if (p_value > 0.0) {
|
||||
+ WARN_PRINT_ONCE("Box3D: ConeTwistJoint3D's swing/twist spans have no Box3D equivalent (the weld joint has no limits) and are treated as fully rigid.");
|
||||
+ }
|
||||
+ break;
|
||||
+ case PhysicsServer3D::CONE_TWIST_JOINT_SOFTNESS:
|
||||
+ WARN_PRINT_ONCE("Box3D: ConeTwistJoint3D's SOFTNESS parameter has no Box3D equivalent and is ignored; use BIAS to soften the weld.");
|
||||
+ break;
|
||||
+ case PhysicsServer3D::CONE_TWIST_JOINT_RELAXATION:
|
||||
+ WARN_PRINT_ONCE("Box3D: ConeTwistJoint3D's RELAXATION parameter has no Box3D equivalent and is ignored.");
|
||||
+ break;
|
||||
+ default:
|
||||
+ break;
|
||||
+ }
|
||||
+}
|
||||
diff --git a/src/joints/box3d_weld_joint_impl_3d.hpp b/src/joints/box3d_weld_joint_impl_3d.hpp
|
||||
new file mode 100644
|
||||
index 0000000..408fd3b
|
||||
--- /dev/null
|
||||
+++ b/src/joints/box3d_weld_joint_impl_3d.hpp
|
||||
@@ -0,0 +1,30 @@
|
||||
+#pragma once
|
||||
+
|
||||
+#include "box3d_joint_impl_3d.hpp"
|
||||
+
|
||||
+// CONE_TWIST_JOINT mapped to Box3D's weld joint: a solver-owned rigid weld that locks the
|
||||
+// two bodies at the relative pose captured when the joint is created. Godot's
|
||||
+// ConeTwistJoint3D with swing_span 0 + twist_span 0 is therefore a rigid grab weld; nonzero
|
||||
+// spans have no Box3D equivalent (the weld has no limits) and are warned + treated as rigid.
|
||||
+class Box3DWeldJointImpl3D final : public Box3DJointImpl3D {
|
||||
+public:
|
||||
+ Box3DWeldJointImpl3D(
|
||||
+ Box3DBodyImpl3D* p_body_a,
|
||||
+ Box3DBodyImpl3D* p_body_b,
|
||||
+ const Transform3D& p_local_frame_a,
|
||||
+ const Transform3D& p_local_frame_b) :
|
||||
+ Box3DJointImpl3D(p_body_a, p_body_b, p_local_frame_a, p_local_frame_b) {
|
||||
+ }
|
||||
+
|
||||
+ PhysicsServer3D::JointType get_type() const override { return PhysicsServer3D::JOINT_TYPE_CONE_TWIST; }
|
||||
+
|
||||
+ real_t get_param(PhysicsServer3D::ConeTwistJointParam p_param) const;
|
||||
+
|
||||
+ void set_param(PhysicsServer3D::ConeTwistJointParam p_param, real_t p_value);
|
||||
+
|
||||
+protected:
|
||||
+ b3JointId _create_joint_id(b3WorldId p_world_id, b3BodyId p_body_a, b3BodyId p_body_b, b3Transform p_local_frame_a, b3Transform p_local_frame_b) override;
|
||||
+
|
||||
+private:
|
||||
+ real_t bias = 0.0;
|
||||
+};
|
||||
diff --git a/src/servers/box3d_physics_server_3d.cpp b/src/servers/box3d_physics_server_3d.cpp
|
||||
index d7de27e..01565a2 100644
|
||||
--- a/src/servers/box3d_physics_server_3d.cpp
|
||||
+++ b/src/servers/box3d_physics_server_3d.cpp
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "../joints/box3d_filter_joint_impl_3d.hpp"
|
||||
#include "../joints/box3d_pin_joint_impl_3d.hpp"
|
||||
#include "../joints/box3d_slider_joint_impl_3d.hpp"
|
||||
+#include "../joints/box3d_weld_joint_impl_3d.hpp"
|
||||
#include "../misc/type_conversions.hpp"
|
||||
#include "../objects/box3d_area_impl_3d.hpp"
|
||||
#include "../objects/box3d_body_impl_3d.hpp"
|
||||
@@ -1093,15 +1094,33 @@ double Box3DPhysicsServer3D::_slider_joint_get_param(const RID& p_joint, Physics
|
||||
return joint->get_param(p_param);
|
||||
}
|
||||
|
||||
+// Box3D's weld joint has no cone/twist limits: a ConeTwistJoint3D with swing_span 0 +
|
||||
+// twist_span 0 is exactly a rigid weld at the captured relative pose, and nonzero spans are
|
||||
+// warned + treated as fully rigid (see Box3DWeldJointImpl3D).
|
||||
void Box3DPhysicsServer3D::_joint_make_cone_twist(const RID& p_joint, const RID& p_body_a, const Transform3D& p_local_ref_a, const RID& p_body_b, const Transform3D& p_local_ref_b) {
|
||||
- ERR_FAIL_MSG("Box3D: ConeTwistJoint3D is not supported in this version of the Box3D extension.");
|
||||
+ _joint_clear(p_joint);
|
||||
+
|
||||
+ Box3DBodyImpl3D* body_a = body_owner.get_or_null(p_body_a);
|
||||
+ Box3DBodyImpl3D* body_b = body_owner.get_or_null(p_body_b);
|
||||
+ ERR_FAIL_NULL(body_a);
|
||||
+ ERR_FAIL_NULL(body_b);
|
||||
+
|
||||
+ auto* joint = memnew(Box3DWeldJointImpl3D(body_a, body_b, p_local_ref_a, p_local_ref_b));
|
||||
+ joint->set_rid(p_joint);
|
||||
+ joint_owner.replace(p_joint, joint);
|
||||
+ joint->rebuild();
|
||||
}
|
||||
|
||||
void Box3DPhysicsServer3D::_cone_twist_joint_set_param(const RID& p_joint, PhysicsServer3D::ConeTwistJointParam p_param, double p_value) {
|
||||
+ auto* joint = dynamic_cast<Box3DWeldJointImpl3D*>(joint_owner.get_or_null(p_joint));
|
||||
+ ERR_FAIL_NULL(joint);
|
||||
+ joint->set_param(p_param, p_value);
|
||||
}
|
||||
|
||||
double Box3DPhysicsServer3D::_cone_twist_joint_get_param(const RID& p_joint, PhysicsServer3D::ConeTwistJointParam p_param) const {
|
||||
- return 0.0;
|
||||
+ auto* joint = dynamic_cast<Box3DWeldJointImpl3D*>(joint_owner.get_or_null(p_joint));
|
||||
+ ERR_FAIL_NULL_V(joint, 0.0);
|
||||
+ return joint->get_param(p_param);
|
||||
}
|
||||
|
||||
void Box3DPhysicsServer3D::_joint_make_generic_6dof(const RID& p_joint, const RID& p_body_a, const Transform3D& p_local_ref_a, const RID& p_body_b, const Transform3D& p_local_ref_b) {
|
||||
@@ -0,0 +1,236 @@
|
||||
# VR Messs
|
||||
|
||||
VR anime-character "scene viewer" (Koikatsu-VR-class, but with real physics hands).
|
||||
Not a game: no gameplay loops, quests, or progression. Gameplay features are the
|
||||
owner's domain; explore, interact, touch, pose — that's the product.
|
||||
|
||||
TERMINOLOGY (owner-corrected, use precisely): **"getting a body"** = the physical rig
|
||||
(torso/arms/hands — the interaction body). **"embodying"** = literally viewing from the
|
||||
perspective of a character in an animation (the view-follow + avatar system). They are
|
||||
separate systems; never call the rig milestone "embodying".
|
||||
|
||||
This repo is the experimentation lab (hence the name). The designs below are working hypotheses
|
||||
to be validated with cheap, throwaway spikes — a failed experiment is progress, not rework. The
|
||||
embodiment/view system in particular is theory until proven.
|
||||
|
||||
## Toolchain pins (do not drift)
|
||||
|
||||
- **Godot 4.7.2** (current stable, Forward Plus renderer), **C# / .NET 8**. Desktop-only (PCVR).
|
||||
- **Physics: Box3D** via the `godot-box3d` GDExtension (server-replacement package by
|
||||
bearlikelion) — **installed, v0.2.6**, setting value `physics/3d/physics_engine="Box3D
|
||||
Physics"`. Validation spike (`scenes/physics_spike.tscn`, run headless) PASSES: drop+contact
|
||||
and velocity-chase-into-wall both work from C#. WARNING: exactly ONE `.gdextension` file may
|
||||
exist — a stray duplicate (e.g. at `addons/` root) double-loads the library and breaks the
|
||||
server with "required virtual method" errors. Jolt (built-in) is the fallback: reverting is
|
||||
the one project-setting line, and all gameplay code must stay standard-Godot-physics-node
|
||||
based so that flip is lossless.
|
||||
- Character import (Koikatsu etc.) is a separate pipeline owned by someone else. We consume a
|
||||
character scene that provides: `Skeleton3D` with humanoid bone mapping + `AnimationPlayer`.
|
||||
Don't touch import code; if the interface is insufficient, say so instead of hacking around it.
|
||||
- Dev runtime stack: **WiVRN + WayVR** on Linux. Headset and runtime both support hand tracking —
|
||||
treat hand tracking as available. View-follow milestone verified working (OpenXR + WiVRN).
|
||||
- Graphics are deliberately **stylized** — fits the anime look and buys performance headroom.
|
||||
Renderer choice (currently Forward+; docs recommend Mobile for desktop VR) is deferred until
|
||||
profiling justifies a change.
|
||||
|
||||
## Documentation rules (hard rules — this project's #1 failure mode)
|
||||
|
||||
- Local pinned docs: `/home/foxyz/Projects/Games/.shared/godot-docs-4.7` — full manual +
|
||||
class reference in `.rst` (source of docs.godotengine.org, branch `4.7`). Grep it first.
|
||||
- For ANY engine API detail, class name, property, or signal: **trust only these 4.7 docs or a
|
||||
live spike.** Training-memory APIs and older-version knowledge are banned — verify or don't write it.
|
||||
- Web fallback: `https://docs.godotengine.org/en/4.7/...` only — the version goes IN the URL.
|
||||
Never `/latest/`, never another version path.
|
||||
- Code examples: rst sources contain `code-tab::` blocks with BOTH gdscript and csharp — grep for
|
||||
`code-tab:: csharp`. Class reference lives in `classes/class_*.rst`.
|
||||
- XR tutorials: `tutorials/xr/` (setting_up_xr, xr_action_map, xr_room_scale,
|
||||
openxr_body_tracking, basic_xr_locomotion). Physics: `tutorials/physics/` (rigid_body,
|
||||
ragdoll_system, using_jolt_physics, physics_introduction).
|
||||
|
||||
## Godot/C# traps
|
||||
|
||||
- Addons that are GDScript-only can't be referenced from C# — **Godot XR Tools is GDScript-only**:
|
||||
treat it as a reference implementation to port ideas from, never a dependency.
|
||||
- Default OpenXR action map names (verified from engine source, 4.7): `trigger`/`grip` (Float),
|
||||
`ax_button` (A/X), `by_button` (B/Y), `menu_button` (menu/system — often eaten by the runtime,
|
||||
e.g. WiVRN screenshot+anchor-hold), `primary` (thumbstick). `select_button` is Pico-only.
|
||||
Poses: `default_pose`, `aim_pose`, `grip_pose`, `palm_pose`.
|
||||
- C# in Godot covers desktop + mobile only (fine — PCVR target).
|
||||
- Signals: C# `+=`/`-=` events; check the class ref for exact C# signatures.
|
||||
- The C# binding can diverge from engine class names: `AudioStreamWAV` is `AudioStreamWav`
|
||||
in C#. When docs and compiler disagree, grep the shipped `GodotSharp.dll` (strings) —
|
||||
it's at `/usr/lib/godot-mono/GodotSharp/Api/Debug/GodotSharp.dll` on this machine.
|
||||
|
||||
## Architecture (working hypotheses — test cheap before committing)
|
||||
|
||||
Four layers, bottom-up, applied per frame in this order:
|
||||
|
||||
1. **Animation** — `AnimationPlayer` drives the skeleton (any pose, any animation).
|
||||
2. **Tracked override / view embodiment** — several input modes, all blended over whatever
|
||||
animation is playing:
|
||||
- **View-follow (core feature, Koikatsu-VR-style)**: the view follows the character's head
|
||||
frame including tilt, so weird animations stay correct. The player's HMD look is remapped
|
||||
through a one-time calibration transform captured relative to the character's chest
|
||||
("chest calibration", e.g. once while lying flat) and drives a look override on the
|
||||
head+neck (possibly slight chest) bones. Skeleton-space only — the physics layer stays
|
||||
separate from this. Pivot behavior (rotation-only snap vs positional head offset) and the
|
||||
calibration UX are open experiments.
|
||||
- **Free-view**: ordinary playspace (`XROrigin3D`); "lean back mode" = origin offset +
|
||||
recline calibration, zero physics involved.
|
||||
- **Single-controller mode**: one controller as master tracker — hand override, or strapped
|
||||
to the hip driving the animation root/hip (hip-tracker mode).
|
||||
- Full-body trackers are a far-future extension of this same layer (more override targets),
|
||||
not a separate system.
|
||||
3. **Physics embodiment** — a small set of rigid bodies (hand L/R, head, torso/hip capsule) that
|
||||
CHASE layer-1/2 targets: velocity-clamped steering (`v = clamp((target - pos) / dt)`), CCD on,
|
||||
no joints between them. Unobstructed → effectively 1:1 tracking; obstructed → pushes with
|
||||
tunable strength. The chase deviation is the feedback signal: it clamps the visual hand offset,
|
||||
pushes the character pose via spring-damped offsets ("pushable from animation"), and fires
|
||||
logical touch-reaction events. Boneworks calls the general technique "heuristic force limit".
|
||||
4. **Secondary motion (hair/skirt/cloth) — custom spring-bone solver.** Koikatsu DynamicBones and
|
||||
VRM springBone are poorly interactive and/or expensive; VRChat PhysBones are the gold standard
|
||||
but proprietary. So: original PhysBones-*style* math (spring-damper chains on the skeleton,
|
||||
animation-space) with analytic sphere/capsule colliders (body, hands, floor). The physics
|
||||
engine contributes only its QUERY API (throttled ray/shape casts) so bones respect scene
|
||||
geometry — **never rigid-body chains for jiggle**. Performance is a first-class goal of this
|
||||
project: chain budgets, screen-size/distance LOD, simplified colliders. If the per-bone C#
|
||||
loop profiles hot, the hot loop moves to a GDExtension — profile first, don't pre-optimize.
|
||||
|
||||
Do NOT build anything that needs 6DOF/cone-twist joints between tracked bodies (the wrapper lacks
|
||||
them; velocity steering replaces them). Scene objects stay standard nodes (`RigidBody3D`,
|
||||
`StaticBody3D`, `CharacterBody3D`) so arbitrary imported scene content works and the Jolt fallback
|
||||
stays real.
|
||||
|
||||
## XR
|
||||
|
||||
OpenXR is built into Godot 4.7 — no plugin. Node set: `XROrigin3D` / `XRCamera3D` /
|
||||
`XRController3D` (+ `XRBodyTracker`/`XRHandTracker` for body/hand tracking data). OpenXR runs on
|
||||
device only; the editor has an XR simulator for dev. Trackers/action map: see `xr_action_map.rst`.
|
||||
|
||||
## Interaction & UI (design directions — experiments welcome)
|
||||
|
||||
- **Touch-first hybrid cursor**: interactive surfaces prefer *touch*. A pointer tip (controller
|
||||
tip or index fingertip) acts as a cursor when in proximity of a surface — push, hold, slide,
|
||||
physical press feel. Out of proximity, fall back to a laser ray for distant precision. Lasers
|
||||
ruin immersion and are hard to aim at distance; they are the fallback, not the default.
|
||||
- **Hand tracking**: cheap spike early, full support later. Interaction logic must not assume
|
||||
controllers — controller tip and fingertip feed the same cursor concept.
|
||||
- **Locomotion** (implemented, play-feel tuning ongoing): spawn re-anchoring
|
||||
happens ONCE on first focus gained (HMD xz at `SpawnPoint` + spawn yaw; real head height and
|
||||
pitch kept) — physical room position never matters. `pose_recentered` is deliberately NOT a
|
||||
placement trigger AND its log is throttled to one line: measured on WiVRN it fires
|
||||
continuously at refresh rate (~90 Hz, 22k lines/session) — reacting to it was the original
|
||||
view-locked-forward bug. `Locomotion.cs` is the manager (spawn placement,
|
||||
teleport markers, lasers, fade); **grab movement lives in `AnchorMoveMode.cs` as its own
|
||||
movement mode** — scene-viewer-first, other modes (walk, tp-only variants) are future nodes
|
||||
beside it. Teleport markers (`scenes/teleport_marker.tscn`):
|
||||
aim + trigger, with fade; near markers (within `MarkerNearDistance` 1.2 m) fully hide
|
||||
(disc, beam, label) AND are not teleport-targetable. Grab semantics are
|
||||
ANCHOR-based: hands are anchors to push/pull from — one-hand grip
|
||||
moves the player OPPOSITE to hand motion in full 3D, both grips = anchor translation +
|
||||
IN-PLACE yaw turn around the head. All grab deltas and turn pivots are computed in
|
||||
WORLD space (tracking-space math made turns orbit the map center — do not regress).
|
||||
Spawn/teleport placement pins arrival height (head height preserved). Locomotion drives
|
||||
`XROrigin3D` directly; physics reconciliation comes with the embodiment layer. Grip/trigger
|
||||
are polled Floats with thresholds per docs
|
||||
(bool actions unreliable across runtimes). Engine logs land in
|
||||
`~/.local/share/godot/app_userdata/VR Messs/logs/godot.log` — agents: read this after user
|
||||
playtests instead of guessing.
|
||||
- **UI direction — physical prop controls first**: buttons, knobs, sliders as real 3D objects
|
||||
(touch interaction, later Box3D for feel). Godot has NO native world-space widget system —
|
||||
`Control`s are screen-space; world UI is always Control-rendered-into-3D via `SubViewport`
|
||||
(optionally `SubViewportCompositionLayer` for XR-native sharpness/perf). Use at most one
|
||||
SubViewport/composition-layer screen for dense content (file browser, settings) if needed;
|
||||
everything else is props. World-space text is native and cheap: `Label3D` (SDF, crisp at
|
||||
distance) and `TextMesh` (extruded) — no SubViewport required.
|
||||
- **`WorldPanel.cs` is the first prop-UI prototype**: summoned/dismissed via `ax_button` (A/X —
|
||||
menu_button belongs to the runtime). It is PARENTED UNDER `XROrigin3D` — Koikatsu-VR-style
|
||||
"fixed in the player's room": IRL movement doesn't move it relative to you, game locomotion
|
||||
carries it. If that feel is wrong, reverting = reparenting to `Main` and fixing the `_Ready`
|
||||
node paths. Floats in front of the summoning hand facing the player (full 3D facing — works
|
||||
from lying poses), draggable directly or via laser at fixed ray distance while gripped
|
||||
(continuously re-orients toward the head). The Spawn
|
||||
knob also recalls the panel in front of the head. The pointer
|
||||
tip is a protruding stylus cone (apex ~12 cm ahead of the controller) and is THE cursor:
|
||||
knobs hover-highlight and PRESS ON CONTACT (tip within 4.5 cm, with cooldown) — physical
|
||||
buttons, no trigger needed; trigger still presses laser-hovered knobs (knobs are
|
||||
laser-targetable). Presses play a synthesized click (code-generated `AudioStreamWav` PCM
|
||||
— no asset). Spawn/Ping/Close knobs. Locomotion defers
|
||||
grab/teleport to the panel when a hand is near it (`WorldPanel.IsBlocking`). Laser lines are
|
||||
owned by `Locomotion.UpdatePointer`: shown only when aiming at something interactive (marker
|
||||
or panel) AND beyond `LaserMinDistance` (0.35 m) — near targets are touched via the pointer
|
||||
tip instead, which is the touch-first hybrid cursor in miniature. Grab deltas and turn
|
||||
pivots are computed in WORLD space (tracking-space math made two-hand turns orbit the map
|
||||
center — do not regress this).
|
||||
- **Control scheme is deliberate**: decide what lives on controllers vs. what lives in menus
|
||||
before stacking bindings — controller button budget is scarce, menus carry the rest.
|
||||
|
||||
## Order of work
|
||||
|
||||
1. ~~Get VR working at all~~ — **done**: OpenXR + WiVRN verified in-headset, flat preview
|
||||
fallback works without a headset.
|
||||
2. ~~Input foundation (partial)~~ — grip/trigger Float polling + `ax_button` summon verified
|
||||
from engine source; the deliberate full action-map pass happens with the UI milestone.
|
||||
**Hand tracking spike still TODO** (`XRHandTracker` through WiVRN, see
|
||||
`openxr_hand_tracking.rst`).
|
||||
3. ~~Locomotion: anchor-grab mode~~ — **done** (`AnchorMoveMode.cs` + teleport markers +
|
||||
spawn anchoring); other movement modes are future nodes beside it.
|
||||
4. ~~UI: first prop panel~~ — **done** (`WorldPanel.cs`); SubViewport/composition-layer screen
|
||||
deferred until dense content (file browser) is actually needed.
|
||||
5. ~~Box3D validation spike~~ — **done, PASS** (drop+contact, velocity-chase vs wall, headless
|
||||
via `scenes/physics_spike.tscn`).
|
||||
6. **Physics hands & props sandbox — grab v5.3 = SOLVER-OWNED RIGID WELD (patched addon).**
|
||||
Research first (see `research/vr-grabbing.md`, `vr-two-handed-grab.md` + its dated
|
||||
CORRECTION, `godot-box3d-weld.md` — sourced taxonomy, Box3D internals, the whole v5.0→5.2
|
||||
history). The owner rejected every point-joint variant as "hanging on a winch" (ball
|
||||
orbits the palm, anvil surface drifts, palms clip) — a grab must lock the object's POSE
|
||||
relative to the hand, not one point + a rotator. **The addon is now locally patched**
|
||||
(source + recipe in `.vendor/`, originals in `addons/godot-box3d/bin-stock/`, Linux binary
|
||||
only): Godot's ConeTwistJoint3D maps to Box3D's `b3WeldJoint`, pre-corrected so the weld
|
||||
locks the relative pose AT CREATION (no identity-target snap; bias semantics INVERTED vs
|
||||
pin — 0 = max stiffness). `PropGrabber.cs`: grab creates a ConeTwistJoint3D weld at the
|
||||
palm point (`SwingSpan/TwistSpan 0`, `ExcludeNodesFromCollision`, configured BEFORE
|
||||
AddChild — mandatory ordering). NO script forces/torques/damp anywhere and NO per-tick
|
||||
joint re-positioning (the weld is not a hand-following pivot) — the solver owns everything
|
||||
and weight flows into the hand's 90 N budget honestly. BOTH hands weld RIGID (bias 0 —
|
||||
v5.3.1: the owner rejected the soft secondary; hand₁+prop+hand₂ welded = one composite
|
||||
body, conflicting controller demands resolve through hand lag, not object stretch);
|
||||
takeover is automatic — the survivor's weld persists, and the brief ~3° ring when one
|
||||
hand releases is stored weld stress discharging (honest physics). Break: stretch-based (rip 0.8 m / 0.25 m for 0.25 s / both-hands
|
||||
0.4 m) + "grip dragged off" when the hand body lags its controller > `PhysicsHands.SnapDistance`.
|
||||
Headless regressions: `scenes/weld_spike.tscn` (addon patch), `scenes/physics_spike.tscn`,
|
||||
`scenes/grab_spike.tscn` (rigid pose under drag + fast spin — the winch regression; don't
|
||||
tighten rotation limits below ~2°, that's the gravity floor for off-center grabs).
|
||||
`PhysicsHands.cs`: physical palm bodies (0.4 kg, 90 N chase, rotation lock, stuck-safety
|
||||
snap — SUSPENDED for a hand while it grips, HAND ghosts > 10 cm lag). Collision layers:
|
||||
1 = world, 2 = UI (panel mask 0 — props pass through even when shown); layers live on
|
||||
CollisionObject3D, NOT CollisionShape3D. Panel = prop-UI prototype (ax_button summon,
|
||||
contact-press knobs, Reset). Research reports live in `research/` (arm IK, IK
|
||||
stability/deep, grabbing, two-handed grab/Box3D internals, addon weld patch) — READ before
|
||||
re-researching or changing grab/IK. Stink-O's box3d-godot (custom components) = documented
|
||||
plan-B. Laser/distant grab REMOVED (user never knew it existed); grab = palm-proximity only,
|
||||
per-hand palm-face offset (left +X, right −X — taste). Box3D PinJoint quirks: anchors are
|
||||
CAPTURED AT CREATION; the joint node's position is READ EVERY TICK (keep it on the palm =
|
||||
hand-following pivot); break must measure the prop-local anchor vs the palm point. Headless
|
||||
regression tests: `scenes/physics_spike.tscn` AND `scenes/grab_spike.tscn` — both must print
|
||||
overall PASS. `PhysicsHands.cs`: physical palm bodies (0.4 kg, 90 N chase, rotation lock,
|
||||
stuck-safety snap, HAND ghosts > 10 cm lag). Collision layers: 1 = world, 2 = UI (panel mask
|
||||
0 — props pass through even when shown); layers live on CollisionObject3D, NOT
|
||||
CollisionShape3D. Panel = prop-UI prototype (ax_button summon, contact-press knobs, Reset).
|
||||
Research reports live in `research/` (arm IK, IK stability/deep, grabbing,
|
||||
two-handed grab/Box3D joint internals) — READ before re-researching or changing grab/IK.
|
||||
7. **Getting a body (rig) — placeholder v1.2 (cube character, agent-built).** `BodyRig.cs`:
|
||||
all-BOX primitives (owner preference) — head 0.15³ behind the eyes, slim torso retracted,
|
||||
wrist-anchored 2-bone arms. **Soft IK applied**: shoulder→hand distance clamped to
|
||||
reach×0.98 before the solve (the straight-arm singularity was the overextension shake; if it
|
||||
persists, lower SoftIKFactor toward 0.95). Elbow pole: torso frame, shoulder→hand axis
|
||||
PROJECTED OUT, hand weight 0.25, exp-smoothed 10/s (twitchy wrists fixed). **Torso yaw
|
||||
spring**: HMD yaw base blended toward hands-derived yaw (weight 0.6 × forwardness over
|
||||
0.3 m), exp-smoothed 12/s, decoupled from the head — the "chest = headset + hands" ask.
|
||||
Reach budget hysteresis unchanged. Full sourced findings in `research/vr-arm-ik.md` and
|
||||
`research/vr-arm-ik-deep.md` (HL:Alyx went hands-only on purpose; SkeletonIK3D deprecated →
|
||||
custom SkeletonModifier3D when a real skeleton arrives; LookAtModifier3D for head→neck).
|
||||
Physical torso/head capsules later. EMBODYING (view-from-character-perspective) comes after
|
||||
the head/neck look override exists. **HANDOFF.md at project root is the takeover document**
|
||||
(constant table verified against code — code wins on drift); keep it updated when systems
|
||||
change.
|
||||
+296
@@ -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.
|
||||
@@ -0,0 +1,8 @@
|
||||
<Project Sdk="Godot.NET.Sdk/4.7.2">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<EnableDynamicLoading>true</EnableDynamicLoading>
|
||||
<AssemblyName>VR Messs</AssemblyName>
|
||||
<RootNamespace>VRMesss</RootNamespace>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Mark Arneman
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,14 @@
|
||||
[configuration]
|
||||
|
||||
entry_symbol = "godot_box3d_main"
|
||||
compatibility_minimum = "4.3"
|
||||
reloadable = true
|
||||
|
||||
[libraries]
|
||||
|
||||
linux.debug.x86_64 = "res://addons/godot-box3d/bin/libgodot-box3d.so"
|
||||
linux.release.x86_64 = "res://addons/godot-box3d/bin/libgodot-box3d.so"
|
||||
windows.debug.x86_64 = "res://addons/godot-box3d/bin/godot-box3d.dll"
|
||||
windows.release.x86_64 = "res://addons/godot-box3d/bin/godot-box3d.dll"
|
||||
macos.debug = "res://addons/godot-box3d/bin/libgodot-box3d.dylib"
|
||||
macos.release = "res://addons/godot-box3d/bin/libgodot-box3d.dylib"
|
||||
@@ -0,0 +1 @@
|
||||
uid://cwrhma7nfgvfm
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"><rect width="124" height="124" x="2" y="2" fill="#363d52" stroke="#212532" stroke-width="4" rx="14"/><g fill="#fff" transform="translate(12.322 12.322)scale(.101)"><path d="M105 673v33q407 354 814 0v-33z"/><path fill="#478cbf" d="m105 673 152 14q12 1 15 14l4 67 132 10 8-61q2-11 15-15h162q13 4 15 15l8 61 132-10 4-67q3-13 15-14l152-14V427q30-39 56-81-35-59-83-108-43 20-82 47-40-37-88-64 7-51 8-102-59-28-123-42-26 43-46 89-49-7-98 0-20-46-46-89-64 14-123 42 1 51 8 102-48 27-88 64-39-27-82-47-48 49-83 108 26 42 56 81zm0 33v39c0 276 813 276 814 0v-39l-134 12-5 69q-2 10-14 13l-162 11q-12 0-16-11l-10-65H446l-10 65q-4 11-16 11l-162-11q-12-3-14-13l-5-69z"/><path d="M483 600c0 34 58 34 58 0v-86c0-34-58-34-58 0z"/><circle cx="725" cy="526" r="90"/><circle cx="299" cy="526" r="90"/></g><g fill="#414042" transform="translate(12.322 12.322)scale(.101)"><circle cx="307" cy="532" r="60"/><circle cx="717" cy="532" r="60"/></g></svg>
|
||||
|
After Width: | Height: | Size: 995 B |
@@ -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
|
||||
@@ -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")]
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -0,0 +1,95 @@
|
||||
using Godot;
|
||||
|
||||
public partial class AnchorMoveMode : Node
|
||||
{
|
||||
private const float GripThreshold = 0.6f;
|
||||
|
||||
private XROrigin3D _origin;
|
||||
private XRCamera3D _camera;
|
||||
private XRController3D _left;
|
||||
private XRController3D _right;
|
||||
|
||||
private bool _wasTwoHand;
|
||||
private Vector3 _leftPrev;
|
||||
private Vector3 _rightPrev;
|
||||
private Vector3 _twoHandMidPrev;
|
||||
private float _twoHandYawPrev;
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
_origin = GetNode<XROrigin3D>("../XROrigin3D");
|
||||
_camera = GetNode<XRCamera3D>("../XROrigin3D/XRCamera3D");
|
||||
_left = GetNode<XRController3D>("../XROrigin3D/LeftHand");
|
||||
_right = GetNode<XRController3D>("../XROrigin3D/RightHand");
|
||||
}
|
||||
|
||||
public override void _Process(double delta)
|
||||
{
|
||||
bool leftGrip = _left.GetFloat("grip") > GripThreshold && !PropGrabber.IsGrabbing(_left);
|
||||
bool rightGrip = _right.GetFloat("grip") > GripThreshold && !PropGrabber.IsGrabbing(_right);
|
||||
|
||||
if (leftGrip && rightGrip && !WorldPanel.IsBlocking(_left) && !WorldPanel.IsBlocking(_right))
|
||||
TwoHandGrab();
|
||||
else
|
||||
{
|
||||
_wasTwoHand = false;
|
||||
if (leftGrip && !WorldPanel.IsBlocking(_left))
|
||||
GrabMove(_left, ref _leftPrev);
|
||||
else
|
||||
_leftPrev = _left.Position;
|
||||
if (rightGrip && !WorldPanel.IsBlocking(_right))
|
||||
GrabMove(_right, ref _rightPrev);
|
||||
else
|
||||
_rightPrev = _right.Position;
|
||||
}
|
||||
}
|
||||
|
||||
private void TwoHandGrab()
|
||||
{
|
||||
Vector3 mid = Midpoint();
|
||||
float yaw = HandsYaw();
|
||||
|
||||
if (!_wasTwoHand)
|
||||
{
|
||||
_wasTwoHand = true;
|
||||
_twoHandMidPrev = mid;
|
||||
_twoHandYawPrev = yaw;
|
||||
return;
|
||||
}
|
||||
|
||||
Vector3 deltaWorld = _origin.GlobalTransform.Basis * (mid - _twoHandMidPrev);
|
||||
float yawDelta = _twoHandYawPrev - yaw;
|
||||
if (deltaWorld.LengthSquared() > 0.000001f || Mathf.Abs(yawDelta) > 0.000001f)
|
||||
{
|
||||
Vector3 hxz = new Vector3(_camera.GlobalPosition.X, 0f, _camera.GlobalPosition.Z);
|
||||
Transform3D turn = new Transform3D(Basis.Identity, hxz)
|
||||
* new Transform3D(Basis.FromEuler(new Vector3(0, yawDelta, 0)), Vector3.Zero)
|
||||
* new Transform3D(Basis.Identity, -hxz);
|
||||
_origin.GlobalTransform = new Transform3D(Basis.Identity, -deltaWorld) * turn * _origin.GlobalTransform;
|
||||
}
|
||||
_twoHandMidPrev = mid;
|
||||
_twoHandYawPrev = yaw;
|
||||
}
|
||||
|
||||
private void GrabMove(XRController3D controller, ref Vector3 prev)
|
||||
{
|
||||
Vector3 pos = controller.Position;
|
||||
Vector3 delta = pos - prev;
|
||||
prev = pos;
|
||||
if (delta.LengthSquared() == 0f)
|
||||
return;
|
||||
Vector3 deltaWorld = _origin.GlobalTransform.Basis * delta;
|
||||
_origin.GlobalTransform = new Transform3D(Basis.Identity, -deltaWorld) * _origin.GlobalTransform;
|
||||
}
|
||||
|
||||
private Vector3 Midpoint() => (_left.Position + _right.Position) * 0.5f;
|
||||
|
||||
private float HandsYaw()
|
||||
{
|
||||
Vector3 v = _right.Position - _left.Position;
|
||||
v.Y = 0f;
|
||||
if (v.LengthSquared() < 0.0001f)
|
||||
return _twoHandYawPrev;
|
||||
return Mathf.Atan2(v.X, v.Z);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://00dofwddtngn
|
||||
@@ -0,0 +1,250 @@
|
||||
using Godot;
|
||||
|
||||
public partial class BodyRig : Node3D
|
||||
{
|
||||
private const float HeadBackOffset = 0.06f;
|
||||
private const float HeadSize = 0.15f;
|
||||
private const float TorsoDrop = 0.42f;
|
||||
private const float TorsoBackOffset = 0.06f;
|
||||
private const float ShoulderRise = 0.25f;
|
||||
private const float ReachBudgetMax = 0.25f;
|
||||
private const float ReachVerticalMin = -0.05f;
|
||||
private const float ReachVerticalMax = 0.12f;
|
||||
private const float ReachPushSpeed = 0.8f;
|
||||
private const float ReachDecaySpeed = 0.4f;
|
||||
private const float ReachDecayFactor = 0.92f;
|
||||
private const float WristOffset = 0.06f;
|
||||
private const float SoftIKFactor = 0.98f;
|
||||
private const float PoleHandWeight = 0.25f;
|
||||
private const float PoleSmoothRate = 10f;
|
||||
private const float TorsoYawHandsWeight = 0.6f;
|
||||
private const float TorsoYawForwardness = 0.3f;
|
||||
private const float TorsoYawSmoothRate = 12f;
|
||||
|
||||
private XRCamera3D _camera;
|
||||
private XRController3D _left;
|
||||
private XRController3D _right;
|
||||
|
||||
private MeshInstance3D _headMesh;
|
||||
private MeshInstance3D _torsoMesh;
|
||||
private readonly MeshInstance3D[] _upperArmMeshes = new MeshInstance3D[2];
|
||||
private readonly MeshInstance3D[] _forearmMeshes = new MeshInstance3D[2];
|
||||
|
||||
private bool _calibrated;
|
||||
private float _upperLen;
|
||||
private float _foreLen;
|
||||
private float _shoulderHalfWidth;
|
||||
private Vector3 _torsoOffset = Vector3.Zero;
|
||||
private Basis _yawBasis = Basis.Identity;
|
||||
private Basis _torsoBasis = Basis.Identity;
|
||||
private float _torsoYaw;
|
||||
private readonly Vector3[] _lastPole = new Vector3[2];
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
_camera = GetNode<XRCamera3D>("../XROrigin3D/XRCamera3D");
|
||||
_left = GetNode<XRController3D>("../XROrigin3D/LeftHand");
|
||||
_right = GetNode<XRController3D>("../XROrigin3D/RightHand");
|
||||
|
||||
_headMesh = MakeMesh(new BoxMesh { Size = new Vector3(HeadSize, HeadSize, HeadSize) }, new Color(0.8f, 0.8f, 0.8f));
|
||||
_torsoMesh = MakeMesh(new BoxMesh { Size = new Vector3(0.22f, 0.5f, 0.13f) }, new Color(0.4f, 0.45f, 0.55f));
|
||||
_upperArmMeshes[0] = MakeMesh(new BoxMesh { Size = new Vector3(0.07f, 0.3f, 0.07f) }, new Color(0.3f, 0.3f, 0.33f));
|
||||
_upperArmMeshes[1] = MakeMesh(new BoxMesh { Size = new Vector3(0.07f, 0.3f, 0.07f) }, new Color(0.3f, 0.3f, 0.33f));
|
||||
_forearmMeshes[0] = MakeMesh(new BoxMesh { Size = new Vector3(0.06f, 0.3f, 0.06f) }, new Color(0.3f, 0.3f, 0.33f));
|
||||
_forearmMeshes[1] = MakeMesh(new BoxMesh { Size = new Vector3(0.06f, 0.3f, 0.06f) }, new Color(0.3f, 0.3f, 0.33f));
|
||||
|
||||
SetRigVisible(false);
|
||||
}
|
||||
|
||||
private MeshInstance3D MakeMesh(Mesh mesh, Color color)
|
||||
{
|
||||
MeshInstance3D node = new() { Mesh = mesh };
|
||||
node.SetSurfaceOverrideMaterial(0, new StandardMaterial3D { AlbedoColor = color, Roughness = 0.6f });
|
||||
AddChild(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
private void SetRigVisible(bool visible)
|
||||
{
|
||||
_headMesh.Visible = visible;
|
||||
_torsoMesh.Visible = visible;
|
||||
foreach (MeshInstance3D m in _upperArmMeshes)
|
||||
m.Visible = visible;
|
||||
foreach (MeshInstance3D m in _forearmMeshes)
|
||||
m.Visible = visible;
|
||||
}
|
||||
|
||||
public override void _Process(double delta)
|
||||
{
|
||||
UpdateYawBasis();
|
||||
float dt = (float)delta;
|
||||
|
||||
if (!_calibrated)
|
||||
{
|
||||
float headHeight = _camera.GlobalPosition.Y;
|
||||
if (headHeight <= 0.5f)
|
||||
return;
|
||||
Calibrate(headHeight);
|
||||
}
|
||||
|
||||
Vector3 headPos = _camera.GlobalPosition - _yawBasis * Vector3.Forward * HeadBackOffset;
|
||||
_headMesh.GlobalTransform = new Transform3D(_yawBasis, headPos);
|
||||
|
||||
UpdateTorsoYaw(headPos, dt);
|
||||
|
||||
Vector3 torsoBack = _torsoBasis * Vector3.Back;
|
||||
Vector3 torsoCenter = headPos + Vector3.Down * TorsoDrop + torsoBack * TorsoBackOffset + _torsoOffset;
|
||||
_torsoMesh.GlobalTransform = new Transform3D(_torsoBasis, torsoCenter);
|
||||
|
||||
Vector3 topCenter = torsoCenter + Vector3.Up * ShoulderRise;
|
||||
Vector3 leftShoulder = topCenter - _torsoBasis.X * _shoulderHalfWidth;
|
||||
Vector3 rightShoulder = topCenter + _torsoBasis.X * _shoulderHalfWidth;
|
||||
|
||||
Vector3 push = SolveArm(0, leftShoulder, -_torsoBasis.X, PhysicsHands.GetBody(_left), _upperArmMeshes[0], _forearmMeshes[0], dt, out bool decayLeft);
|
||||
push += SolveArm(1, rightShoulder, _torsoBasis.X, PhysicsHands.GetBody(_right), _upperArmMeshes[1], _forearmMeshes[1], dt, out bool decayRight);
|
||||
|
||||
if (push != Vector3.Zero)
|
||||
{
|
||||
_torsoOffset += push * (dt * ReachPushSpeed);
|
||||
}
|
||||
else if (decayLeft || decayRight)
|
||||
{
|
||||
_torsoOffset = _torsoOffset.MoveToward(Vector3.Zero, ReachDecaySpeed * dt);
|
||||
}
|
||||
|
||||
Vector3 horizontal = new(_torsoOffset.X, 0f, _torsoOffset.Z);
|
||||
horizontal = horizontal.LimitLength(ReachBudgetMax);
|
||||
_torsoOffset = new Vector3(horizontal.X, Mathf.Clamp(_torsoOffset.Y, ReachVerticalMin, ReachVerticalMax), horizontal.Z);
|
||||
}
|
||||
|
||||
private void UpdateYawBasis()
|
||||
{
|
||||
Vector3 fwd = -_camera.GlobalTransform.Basis.Z;
|
||||
fwd.Y = 0f;
|
||||
if (fwd.LengthSquared() < 0.0001f)
|
||||
return;
|
||||
_yawBasis = Basis.LookingAt(fwd.Normalized(), Vector3.Up);
|
||||
}
|
||||
|
||||
private void UpdateTorsoYaw(Vector3 headPos, float delta)
|
||||
{
|
||||
float hmdYaw = YawOf(-_yawBasis.Z);
|
||||
RigidBody3D leftBody = PhysicsHands.GetBody(_left);
|
||||
RigidBody3D rightBody = PhysicsHands.GetBody(_right);
|
||||
float targetYaw = hmdYaw;
|
||||
if (leftBody != null && rightBody != null)
|
||||
{
|
||||
Vector3 toHands = (leftBody.GlobalPosition + rightBody.GlobalPosition) * 0.5f - headPos;
|
||||
Vector3 horizontal = new(toHands.X, 0f, toHands.Z);
|
||||
if (horizontal.LengthSquared() > 0.0001f)
|
||||
{
|
||||
float handsYaw = YawOf(horizontal.Normalized());
|
||||
float forwardness = Mathf.Clamp(horizontal.Dot(-_yawBasis.Z) / TorsoYawForwardness, 0f, 1f);
|
||||
targetYaw = Mathf.LerpAngle(hmdYaw, handsYaw, forwardness * TorsoYawHandsWeight);
|
||||
}
|
||||
}
|
||||
_torsoYaw = Mathf.LerpAngle(_torsoYaw, targetYaw, 1f - Mathf.Exp(-delta * TorsoYawSmoothRate));
|
||||
Vector3 fwd = new(-Mathf.Sin(_torsoYaw), 0f, -Mathf.Cos(_torsoYaw));
|
||||
_torsoBasis = Basis.LookingAt(fwd, Vector3.Up);
|
||||
}
|
||||
|
||||
private static float YawOf(Vector3 forward)
|
||||
{
|
||||
return Mathf.Atan2(-forward.X, -forward.Z);
|
||||
}
|
||||
|
||||
private void Calibrate(float headHeight)
|
||||
{
|
||||
_calibrated = true;
|
||||
_torsoYaw = YawOf(-_yawBasis.Z);
|
||||
float armTotal = Mathf.Clamp(headHeight * 0.46f, 0.5f, 0.72f);
|
||||
_upperLen = 0.5f * armTotal;
|
||||
_foreLen = 0.5f * armTotal;
|
||||
_shoulderHalfWidth = 0.16f + headHeight * 0.02f;
|
||||
|
||||
((BoxMesh)_upperArmMeshes[0].Mesh).Size = new Vector3(0.07f, _upperLen, 0.07f);
|
||||
((BoxMesh)_upperArmMeshes[1].Mesh).Size = new Vector3(0.07f, _upperLen, 0.07f);
|
||||
((BoxMesh)_forearmMeshes[0].Mesh).Size = new Vector3(0.06f, _foreLen, 0.06f);
|
||||
((BoxMesh)_forearmMeshes[1].Mesh).Size = new Vector3(0.06f, _foreLen, 0.06f);
|
||||
|
||||
SetRigVisible(true);
|
||||
GD.Print($"BodyRig: proportions calibrated from head height {headHeight:F2} m " +
|
||||
$"(arm total {armTotal:F2} m, upper {_upperLen:F2} m, fore {_foreLen:F2} m, shoulder half-width {_shoulderHalfWidth:F2} m)");
|
||||
}
|
||||
|
||||
private Vector3 SolveArm(int index, Vector3 shoulder, Vector3 side, RigidBody3D body, MeshInstance3D upperMesh, MeshInstance3D foreMesh, float delta, out bool decay)
|
||||
{
|
||||
Vector3 rawTarget = body != null
|
||||
? body.GlobalPosition + body.GlobalTransform.Basis * new Vector3(0f, 0f, WristOffset)
|
||||
: shoulder + _torsoBasis * new Vector3(0f, -0.25f, -0.3f);
|
||||
|
||||
float reach = _upperLen + _foreLen;
|
||||
Vector3 shoulderToTarget = rawTarget - shoulder;
|
||||
if (shoulderToTarget.LengthSquared() < 0.0001f)
|
||||
shoulderToTarget = _torsoBasis * new Vector3(0f, -0.25f, -0.3f);
|
||||
|
||||
float rawDist = shoulderToTarget.Length();
|
||||
float d = Mathf.Clamp(rawDist, 0.01f, reach * SoftIKFactor);
|
||||
Vector3 dir = shoulderToTarget / rawDist;
|
||||
Vector3 clampedTarget = shoulder + dir * d;
|
||||
|
||||
Vector3 push = Vector3.Zero;
|
||||
decay = false;
|
||||
if (rawDist > reach)
|
||||
{
|
||||
push = (rawTarget - clampedTarget).Normalized();
|
||||
}
|
||||
else if (rawDist < reach * ReachDecayFactor)
|
||||
{
|
||||
decay = true;
|
||||
}
|
||||
|
||||
float cosA = (_upperLen * _upperLen + d * d - _foreLen * _foreLen) / (2f * _upperLen * d);
|
||||
float angle = Mathf.Acos(Mathf.Clamp(cosA, -1f, 1f));
|
||||
|
||||
Vector3 handForward = body != null ? body.GlobalTransform.Basis * Vector3.Forward : Vector3.Zero;
|
||||
Vector3 pole = Vector3.Down * 2f + _torsoBasis * Vector3.Back * 0.8f + side * 0.3f;
|
||||
pole -= dir * pole.Dot(dir);
|
||||
pole -= handForward * PoleHandWeight;
|
||||
Vector3 poleDir;
|
||||
if (pole.LengthSquared() < 0.0001f)
|
||||
poleDir = _lastPole[index] == Vector3.Zero ? Vector3.Down : _lastPole[index];
|
||||
else
|
||||
{
|
||||
poleDir = _lastPole[index].Lerp(pole, 1f - Mathf.Exp(-delta * PoleSmoothRate));
|
||||
if (poleDir.LengthSquared() < 0.0001f)
|
||||
poleDir = _lastPole[index];
|
||||
poleDir = poleDir.Normalized();
|
||||
_lastPole[index] = poleDir;
|
||||
}
|
||||
|
||||
Vector3 axis = dir.Cross(poleDir);
|
||||
if (axis.LengthSquared() < 0.0001f)
|
||||
axis = dir.Cross(Vector3.Up);
|
||||
if (axis.LengthSquared() < 0.0001f)
|
||||
axis = Vector3.Right;
|
||||
axis = axis.Normalized();
|
||||
|
||||
Vector3 elbowPos = shoulder + dir.Rotated(axis, angle) * _upperLen;
|
||||
Vector3 foreEnd = elbowPos + (clampedTarget - elbowPos).Normalized() * _foreLen;
|
||||
|
||||
PlaceSegment(upperMesh, shoulder, elbowPos);
|
||||
PlaceSegment(foreMesh, elbowPos, foreEnd);
|
||||
|
||||
return push;
|
||||
}
|
||||
|
||||
private static void PlaceSegment(MeshInstance3D mesh, Vector3 from, Vector3 to)
|
||||
{
|
||||
Vector3 segment = to - from;
|
||||
if (segment.LengthSquared() < 0.000001f)
|
||||
return;
|
||||
Vector3 y = segment.Normalized();
|
||||
Vector3 x = Vector3.Up.Cross(y);
|
||||
if (x.LengthSquared() < 0.0001f)
|
||||
x = Vector3.Right.Cross(y);
|
||||
x = x.Normalized();
|
||||
Vector3 z = x.Cross(y);
|
||||
mesh.GlobalTransform = new Transform3D(new Basis(x, y, z), (from + to) * 0.5f);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dwaonvvnka0b
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://c7ala2jq058dh
|
||||
@@ -0,0 +1,244 @@
|
||||
using Godot;
|
||||
|
||||
public partial class Locomotion : Node
|
||||
{
|
||||
private const float GripThreshold = 0.6f;
|
||||
private const float TriggerThreshold = 0.6f;
|
||||
private const float RayLength = 50f;
|
||||
private const float LaserMinDistance = 0.35f;
|
||||
private const float MarkerNearDistance = 1.2f;
|
||||
|
||||
private XROrigin3D _origin;
|
||||
private XRCamera3D _camera;
|
||||
private XRController3D _left;
|
||||
private XRController3D _right;
|
||||
private Marker3D _spawn;
|
||||
private ColorRect _fade;
|
||||
private Main _main;
|
||||
|
||||
private Node3D _leftLaser;
|
||||
private Node3D _rightLaser;
|
||||
|
||||
private bool _placedAtSpawn;
|
||||
private bool _teleporting;
|
||||
private bool _loggedRecentered;
|
||||
private TeleportMarker _highlighted;
|
||||
|
||||
private bool _leftTrigger;
|
||||
private bool _rightTrigger;
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
_main = GetNode<Main>("..");
|
||||
_origin = GetNode<XROrigin3D>("../XROrigin3D");
|
||||
_camera = GetNode<XRCamera3D>("../XROrigin3D/XRCamera3D");
|
||||
_left = GetNode<XRController3D>("../XROrigin3D/LeftHand");
|
||||
_right = GetNode<XRController3D>("../XROrigin3D/RightHand");
|
||||
_spawn = GetNode<Marker3D>("../SpawnPoint");
|
||||
_fade = GetNode<ColorRect>("../FadeLayer/Fade");
|
||||
_main.FocusGained += OnFocusGained;
|
||||
_main.PoseRecentered += OnPoseRecentered;
|
||||
_leftLaser = MakeLaser(_left);
|
||||
_rightLaser = MakeLaser(_right);
|
||||
MakeTip(_left);
|
||||
MakeTip(_right);
|
||||
}
|
||||
|
||||
public override void _Process(double delta)
|
||||
{
|
||||
if (_teleporting)
|
||||
return;
|
||||
|
||||
UpdateHighlight();
|
||||
UpdateNearMarkers();
|
||||
UpdatePointer(_left, _leftLaser);
|
||||
UpdatePointer(_right, _rightLaser);
|
||||
|
||||
CheckTeleport(_left, ref _leftTrigger);
|
||||
CheckTeleport(_right, ref _rightTrigger);
|
||||
}
|
||||
|
||||
private static Node3D MakeLaser(XRController3D controller)
|
||||
{
|
||||
StandardMaterial3D material = new()
|
||||
{
|
||||
AlbedoColor = new Color(1f, 0.55f, 0.15f),
|
||||
EmissionEnabled = true,
|
||||
Emission = new Color(1f, 0.55f, 0.15f),
|
||||
EmissionEnergyMultiplier = 2.0f,
|
||||
ShadingMode = BaseMaterial3D.ShadingModeEnum.Unshaded,
|
||||
};
|
||||
MeshInstance3D beam = new()
|
||||
{
|
||||
Mesh = new CylinderMesh { TopRadius = 0.0035f, BottomRadius = 0.0035f, Height = 1f },
|
||||
Position = new Vector3(0, 0.5f, 0),
|
||||
CastShadow = GeometryInstance3D.ShadowCastingSetting.Off,
|
||||
};
|
||||
beam.SetSurfaceOverrideMaterial(0, material);
|
||||
Node3D pivot = new() { Visible = false };
|
||||
pivot.AddChild(beam);
|
||||
Basis rot = Basis.FromEuler(new Vector3(-Mathf.Pi / 2f, 0, 0));
|
||||
pivot.Transform = new Transform3D(rot, new Vector3(0, 0, -0.12f));
|
||||
controller.AddChild(pivot);
|
||||
return pivot;
|
||||
}
|
||||
|
||||
private static void MakeTip(XRController3D controller)
|
||||
{
|
||||
StandardMaterial3D material = new()
|
||||
{
|
||||
AlbedoColor = new Color(1f, 0.9f, 0.6f),
|
||||
EmissionEnabled = true,
|
||||
Emission = new Color(1f, 0.9f, 0.6f),
|
||||
EmissionEnergyMultiplier = 1.5f,
|
||||
ShadingMode = BaseMaterial3D.ShadingModeEnum.Unshaded,
|
||||
};
|
||||
MeshInstance3D tip = new()
|
||||
{
|
||||
Mesh = new CylinderMesh { TopRadius = 0f, BottomRadius = 0.009f, Height = 0.09f },
|
||||
CastShadow = GeometryInstance3D.ShadowCastingSetting.Off,
|
||||
};
|
||||
tip.SetSurfaceOverrideMaterial(0, material);
|
||||
Basis rot = Basis.FromEuler(new Vector3(-Mathf.Pi / 2f, 0, 0));
|
||||
tip.Transform = new Transform3D(rot, rot * new Vector3(0, 0.045f, 0));
|
||||
controller.AddChild(tip);
|
||||
}
|
||||
|
||||
private void UpdateNearMarkers()
|
||||
{
|
||||
Vector3 head = _camera.GlobalPosition;
|
||||
foreach (Node node in GetTree().GetNodesInGroup("teleport_marker"))
|
||||
{
|
||||
if (node is not TeleportMarker marker)
|
||||
continue;
|
||||
Vector3 d = marker.GlobalPosition - head;
|
||||
d.Y = 0f;
|
||||
marker.SetNear(d.Length() < MarkerNearDistance);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdatePointer(XRController3D controller, Node3D laser)
|
||||
{
|
||||
Vector3 from = controller.GlobalPosition;
|
||||
Vector3 dir = -controller.GlobalTransform.Basis.Z;
|
||||
|
||||
var query = PhysicsRayQueryParameters3D.Create(from, from + dir * RayLength);
|
||||
var hit = GetViewport().GetWorld3D().DirectSpaceState.IntersectRay(query);
|
||||
|
||||
bool show = false;
|
||||
float dist = 0f;
|
||||
if (hit.TryGetValue("collider", out Variant collider) && collider.As<Node>() is Node node)
|
||||
{
|
||||
bool interactive;
|
||||
if (node.GetParent() is TeleportMarker marker)
|
||||
interactive = !marker.IsNear;
|
||||
else
|
||||
interactive = WorldPanel.IsPanelCollider(node);
|
||||
if (interactive && hit.TryGetValue("position", out Variant posVariant))
|
||||
{
|
||||
dist = from.DistanceTo(posVariant.As<Vector3>());
|
||||
show = dist > LaserMinDistance;
|
||||
}
|
||||
}
|
||||
|
||||
laser.Visible = show;
|
||||
if (show)
|
||||
laser.Scale = new Vector3(1, dist, 1);
|
||||
}
|
||||
|
||||
private void CheckTeleport(XRController3D controller, ref bool wasPressed)
|
||||
{
|
||||
bool pressed = controller.GetFloat("trigger") > TriggerThreshold;
|
||||
if (pressed && !wasPressed && !WorldPanel.IsBlocking(controller))
|
||||
{
|
||||
TeleportMarker marker = AimMarker(controller);
|
||||
if (marker is { IsNear: false })
|
||||
StartTeleport(marker);
|
||||
}
|
||||
wasPressed = pressed;
|
||||
}
|
||||
|
||||
private void UpdateHighlight()
|
||||
{
|
||||
TeleportMarker marker = AimMarker(_left) ?? AimMarker(_right);
|
||||
if (marker is { IsNear: true })
|
||||
marker = null;
|
||||
if (marker == _highlighted)
|
||||
return;
|
||||
_highlighted?.SetHighlighted(false);
|
||||
_highlighted = marker;
|
||||
_highlighted?.SetHighlighted(true);
|
||||
}
|
||||
|
||||
private TeleportMarker AimMarker(XRController3D controller)
|
||||
{
|
||||
Vector3 from = controller.GlobalPosition;
|
||||
Vector3 to = from - controller.GlobalTransform.Basis.Z * RayLength;
|
||||
var query = PhysicsRayQueryParameters3D.Create(from, to);
|
||||
var hit = GetViewport().GetWorld3D().DirectSpaceState.IntersectRay(query);
|
||||
if (hit.TryGetValue("collider", out Variant collider) && collider.As<Node>()?.GetParent() is TeleportMarker marker)
|
||||
return marker;
|
||||
return null;
|
||||
}
|
||||
|
||||
private void StartTeleport(TeleportMarker marker)
|
||||
{
|
||||
_teleporting = true;
|
||||
Vector3 target = marker.GlobalPosition;
|
||||
float yaw = ForwardYaw(marker.GlobalTransform);
|
||||
|
||||
var tween = CreateTween();
|
||||
tween.TweenProperty(_fade, "color:a", 1.0f, 0.12);
|
||||
tween.TweenCallback(Callable.From(() =>
|
||||
{
|
||||
PlacePlayer(target, yaw);
|
||||
GD.Print($"Locomotion: teleported to marker '{marker.MarkerLabel}'");
|
||||
}));
|
||||
tween.TweenProperty(_fade, "color:a", 0.0f, 0.25);
|
||||
tween.TweenCallback(Callable.From(() => _teleporting = false));
|
||||
}
|
||||
|
||||
public void ReturnToSpawn()
|
||||
{
|
||||
MoveToSpawn();
|
||||
GD.Print("Locomotion: returned to spawn point");
|
||||
}
|
||||
|
||||
private void OnFocusGained()
|
||||
{
|
||||
if (_placedAtSpawn)
|
||||
return;
|
||||
_placedAtSpawn = true;
|
||||
MoveToSpawn();
|
||||
GD.Print("Locomotion: player placed at spawn point");
|
||||
}
|
||||
|
||||
private void OnPoseRecentered()
|
||||
{
|
||||
if (_loggedRecentered)
|
||||
return;
|
||||
_loggedRecentered = true;
|
||||
GD.Print("Locomotion: pose_recentered received — WiVRN fires this continuously (~90 Hz), placement stays unchanged and further occurrences are not logged");
|
||||
}
|
||||
|
||||
private void MoveToSpawn() => PlacePlayer(_spawn.GlobalPosition, ForwardYaw(_spawn.GlobalTransform));
|
||||
|
||||
private void PlacePlayer(Vector3 targetXz, float yaw)
|
||||
{
|
||||
Transform3D h = _camera.GlobalTransform;
|
||||
Transform3D hLocal = _origin.GlobalTransform.AffineInverse() * h;
|
||||
float hmdYaw = h.Basis.GetEuler().Y;
|
||||
Basis basis = Basis.FromEuler(new Vector3(0, yaw - hmdYaw, 0)) * h.Basis;
|
||||
Transform3D desired = new Transform3D(basis, new Vector3(targetXz.X, h.Origin.Y, targetXz.Z));
|
||||
_origin.GlobalTransform = desired * hLocal.AffineInverse();
|
||||
}
|
||||
|
||||
private static float ForwardYaw(Transform3D transform)
|
||||
{
|
||||
Vector3 fwd = -transform.Basis.Z;
|
||||
fwd.Y = 0f;
|
||||
if (fwd.LengthSquared() < 0.0001f)
|
||||
return 0f;
|
||||
return Mathf.Atan2(-fwd.X, -fwd.Z);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://be6cxrxsc202g
|
||||
+104
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dsy2jm1gdcao0
|
||||
@@ -0,0 +1,138 @@
|
||||
using Godot;
|
||||
|
||||
public partial class PhysicsHands : Node
|
||||
{
|
||||
private const float HandMass = 0.4f;
|
||||
public const float HandMaxForce = 90f;
|
||||
private const float HandMaxSpin = 120f;
|
||||
public const float SnapDistance = 2f;
|
||||
private const float GhostLagDistance = 0.1f;
|
||||
private const float StuckDistance = 0.2f;
|
||||
private const float StuckTime = 1.0f;
|
||||
private static readonly Vector3 HandSize = new(0.035f, 0.09f, 0.12f);
|
||||
|
||||
private static PhysicsHands _instance;
|
||||
|
||||
private XRController3D _left;
|
||||
private XRController3D _right;
|
||||
private readonly RigidBody3D[] _bodies = new RigidBody3D[2];
|
||||
private readonly MeshInstance3D[] _ghosts = new MeshInstance3D[2];
|
||||
private readonly float[] _stuckTimer = new float[2];
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
_instance = this;
|
||||
_left = GetNode<XRController3D>("../XROrigin3D/LeftHand");
|
||||
_right = GetNode<XRController3D>("../XROrigin3D/RightHand");
|
||||
_bodies[0] = MakeHand("LeftHandBody", new Color(0.9f, 0.3f, 0.35f));
|
||||
_bodies[1] = MakeHand("RightHandBody", new Color(0.3f, 0.5f, 0.9f));
|
||||
_ghosts[0] = MakeGhost();
|
||||
_ghosts[1] = MakeGhost();
|
||||
GD.Print("PhysicsHands: physical hand bodies spawned");
|
||||
}
|
||||
|
||||
private MeshInstance3D MakeGhost()
|
||||
{
|
||||
MeshInstance3D ghost = new()
|
||||
{
|
||||
Mesh = new BoxMesh { Size = HandSize },
|
||||
Visible = false,
|
||||
CastShadow = GeometryInstance3D.ShadowCastingSetting.Off,
|
||||
};
|
||||
ghost.SetSurfaceOverrideMaterial(0, new StandardMaterial3D
|
||||
{
|
||||
AlbedoColor = new Color(1f, 1f, 1f, 0.3f),
|
||||
Transparency = BaseMaterial3D.TransparencyEnum.Alpha,
|
||||
ShadingMode = BaseMaterial3D.ShadingModeEnum.Unshaded,
|
||||
});
|
||||
AddChild(ghost);
|
||||
return ghost;
|
||||
}
|
||||
|
||||
public static RigidBody3D GetBody(XRController3D controller)
|
||||
{
|
||||
if (_instance == null)
|
||||
return null;
|
||||
return _instance._left == controller ? _instance._bodies[0] : _instance._bodies[1];
|
||||
}
|
||||
|
||||
private RigidBody3D MakeHand(string name, Color color)
|
||||
{
|
||||
RigidBody3D body = new()
|
||||
{
|
||||
Name = name,
|
||||
Mass = HandMass,
|
||||
GravityScale = 0f,
|
||||
ContinuousCd = true,
|
||||
ContactMonitor = true,
|
||||
MaxContactsReported = 8,
|
||||
CollisionLayer = 1,
|
||||
CollisionMask = 1,
|
||||
};
|
||||
body.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = HandSize } });
|
||||
MeshInstance3D mesh = new() { Mesh = new BoxMesh { Size = HandSize } };
|
||||
StandardMaterial3D material = new() { AlbedoColor = color, Roughness = 0.5f };
|
||||
mesh.SetSurfaceOverrideMaterial(0, material);
|
||||
body.AddChild(mesh);
|
||||
AddChild(body);
|
||||
return body;
|
||||
}
|
||||
|
||||
public override void _PhysicsProcess(double delta)
|
||||
{
|
||||
Chase(_left, _bodies[0], (float)delta);
|
||||
Chase(_right, _bodies[1], (float)delta);
|
||||
}
|
||||
|
||||
private void Chase(XRController3D controller, RigidBody3D body, float delta)
|
||||
{
|
||||
if (body == null)
|
||||
return;
|
||||
int hand = _left == controller ? 0 : 1;
|
||||
Vector3 target = controller.GlobalPosition;
|
||||
|
||||
bool lagging = body.GlobalPosition.DistanceTo(target) > GhostLagDistance;
|
||||
if (_ghosts[hand] != null)
|
||||
{
|
||||
_ghosts[hand].Visible = lagging;
|
||||
if (lagging)
|
||||
_ghosts[hand].GlobalTransform = controller.GlobalTransform;
|
||||
}
|
||||
|
||||
if (!PropGrabber.IsGrabbing(controller))
|
||||
{
|
||||
if (body.GlobalPosition.DistanceTo(target) > StuckDistance)
|
||||
_stuckTimer[hand] += delta;
|
||||
else
|
||||
_stuckTimer[hand] = 0f;
|
||||
if (_stuckTimer[hand] > StuckTime)
|
||||
{
|
||||
_stuckTimer[hand] = 0f;
|
||||
body.GlobalTransform = controller.GlobalTransform;
|
||||
body.LinearVelocity = Vector3.Zero;
|
||||
GD.Print("PhysicsHands: hand stuck, snapped back");
|
||||
return;
|
||||
}
|
||||
|
||||
if (body.GlobalPosition.DistanceTo(target) > SnapDistance)
|
||||
{
|
||||
body.GlobalTransform = controller.GlobalTransform;
|
||||
body.LinearVelocity = Vector3.Zero;
|
||||
return;
|
||||
}
|
||||
}
|
||||
Vector3 desiredVelocity = (target - body.GlobalPosition) / delta;
|
||||
Vector3 dv = desiredVelocity - body.LinearVelocity;
|
||||
Vector3 force = dv / delta * body.Mass;
|
||||
if (force.Length() > HandMaxForce)
|
||||
force = force.Normalized() * HandMaxForce;
|
||||
body.LinearVelocity += force / body.Mass * delta;
|
||||
|
||||
Basis deltaBasis = controller.GlobalTransform.Basis * body.GlobalTransform.Basis.Inverse();
|
||||
Quaternion q = deltaBasis.GetRotationQuaternion();
|
||||
Vector3 spin = q.GetAxis() * (q.GetAngle() / delta);
|
||||
if (spin.Length() > HandMaxSpin)
|
||||
spin = spin.Normalized() * HandMaxSpin;
|
||||
body.AngularVelocity = spin;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://b4mgat3v21u30
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://pmjecyvvet5c
|
||||
@@ -0,0 +1,225 @@
|
||||
using Godot;
|
||||
|
||||
public partial class PropGrabber : Node
|
||||
{
|
||||
private const float GripThreshold = 0.6f;
|
||||
private const float PalmGrabOffset = 0.045f;
|
||||
private const float GrabRadius = 0.16f;
|
||||
private const float CenterGrabRadius = 0.12f;
|
||||
private const float RigidWeldBias = 0f;
|
||||
private const float BreakDistance = 0.25f;
|
||||
private const float BreakTime = 0.25f;
|
||||
private const float BreakDistanceBoth = 0.4f;
|
||||
private const float GripRipDistance = 0.8f;
|
||||
|
||||
private static PropGrabber _instance;
|
||||
|
||||
private XRController3D _left;
|
||||
private XRController3D _right;
|
||||
|
||||
private readonly RigidBody3D[] _held = new RigidBody3D[2];
|
||||
private readonly ConeTwistJoint3D[] _joints = new ConeTwistJoint3D[2];
|
||||
private readonly Vector3[] _localAnchor = new Vector3[2];
|
||||
private readonly uint[] _grabSeq = new uint[2];
|
||||
private readonly float[] _breakTimer = new float[2];
|
||||
private readonly bool[] _gripPrev = new bool[2];
|
||||
private uint _grabCount;
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
_instance = this;
|
||||
_left = GetNode<XRController3D>("../XROrigin3D/LeftHand");
|
||||
_right = GetNode<XRController3D>("../XROrigin3D/RightHand");
|
||||
}
|
||||
|
||||
public static bool IsGrabbing(XRController3D controller) =>
|
||||
_instance != null &&
|
||||
((_instance._left == controller && _instance._held[0] != null) ||
|
||||
(_instance._right == controller && _instance._held[1] != null));
|
||||
|
||||
public override void _Process(double delta)
|
||||
{
|
||||
UpdateHand(_left, 0);
|
||||
UpdateHand(_right, 1);
|
||||
}
|
||||
|
||||
public override void _PhysicsProcess(double delta)
|
||||
{
|
||||
float dt = (float)delta;
|
||||
HoldUpdate(_left, 0, dt);
|
||||
HoldUpdate(_right, 1, dt);
|
||||
}
|
||||
|
||||
private void UpdateHand(XRController3D controller, int hand)
|
||||
{
|
||||
bool grip = controller.GetFloat("grip") > GripThreshold;
|
||||
int other = 1 - hand;
|
||||
|
||||
if (grip && !_gripPrev[hand] && _held[hand] == null)
|
||||
{
|
||||
RigidBody3D prop = FindProp(controller, hand);
|
||||
RigidBody3D handBody = PhysicsHands.GetBody(controller);
|
||||
if (prop != null && handBody != null)
|
||||
{
|
||||
_held[hand] = prop;
|
||||
_grabSeq[hand] = _grabCount++;
|
||||
Vector3 pivot = PalmPoint(handBody, hand);
|
||||
_joints[hand] = MakeWeld(handBody, prop, pivot, RigidWeldBias);
|
||||
_localAnchor[hand] = prop.GlobalTransform.AffineInverse() * pivot;
|
||||
GD.Print($"PropGrabber: grabbed '{prop.Name}' ({prop.Mass:0.###} kg, {(hand == 0 ? "left" : "right")}{(_held[other] == prop ? ", two-handed" : "")})");
|
||||
}
|
||||
}
|
||||
|
||||
if (!grip && _held[hand] != null)
|
||||
{
|
||||
RigidBody3D prop = _held[hand];
|
||||
_held[hand] = null;
|
||||
DestroyJoint(hand);
|
||||
if (IsInstanceValid(prop))
|
||||
GD.Print($"PropGrabber: released '{prop.Name}' at {prop.LinearVelocity.Length():F2} m/s");
|
||||
}
|
||||
_gripPrev[hand] = grip;
|
||||
}
|
||||
|
||||
private void HoldUpdate(XRController3D controller, int hand, float delta)
|
||||
{
|
||||
RigidBody3D body = _held[hand];
|
||||
if (body == null)
|
||||
return;
|
||||
if (!IsInstanceValid(body))
|
||||
{
|
||||
_held[hand] = null;
|
||||
DestroyJoint(hand);
|
||||
return;
|
||||
}
|
||||
|
||||
RigidBody3D handBody = PhysicsHands.GetBody(controller);
|
||||
ConeTwistJoint3D joint = _joints[hand];
|
||||
if (handBody == null || !IsInstanceValid(handBody) || joint == null || !IsInstanceValid(joint))
|
||||
{
|
||||
_held[hand] = null;
|
||||
DestroyJoint(hand);
|
||||
return;
|
||||
}
|
||||
|
||||
if (handBody.GlobalPosition.DistanceTo(controller.GlobalPosition) > PhysicsHands.SnapDistance)
|
||||
{
|
||||
BreakGrip(hand, body, "grip dragged off");
|
||||
return;
|
||||
}
|
||||
|
||||
float separation = (body.GlobalTransform * _localAnchor[hand]).DistanceTo(PalmPoint(handBody, hand));
|
||||
|
||||
int other = 1 - hand;
|
||||
bool twoHanded = _held[other] == body && IsInstanceValid(_held[other]) &&
|
||||
_joints[other] != null && IsInstanceValid(_joints[other]);
|
||||
if (twoHanded)
|
||||
{
|
||||
RigidBody3D otherHandBody = PhysicsHands.GetBody(other == 0 ? _left : _right);
|
||||
float otherSeparation = otherHandBody == null
|
||||
? 0f
|
||||
: (body.GlobalTransform * _localAnchor[other]).DistanceTo(PalmPoint(otherHandBody, other));
|
||||
if (separation > BreakDistanceBoth && otherSeparation > BreakDistanceBoth)
|
||||
{
|
||||
BreakGrip(hand, body, $"both hands stretched ({separation:0.###} m)");
|
||||
BreakGrip(other, body, "both hands stretched");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (separation > GripRipDistance)
|
||||
{
|
||||
BreakGrip(hand, body, $"ripped ({separation:0.###} m)");
|
||||
return;
|
||||
}
|
||||
|
||||
if (separation > BreakDistance)
|
||||
_breakTimer[hand] += delta;
|
||||
else
|
||||
_breakTimer[hand] = 0f;
|
||||
if (_breakTimer[hand] > BreakTime)
|
||||
{
|
||||
BreakGrip(hand, body, $"stretched ({separation:0.###} m for {BreakTime:0.#} s)");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private int PrimaryHand(RigidBody3D body)
|
||||
{
|
||||
int primary = -1;
|
||||
for (int hand = 0; hand < 2; hand++)
|
||||
{
|
||||
if (_held[hand] == body && (primary == -1 || _grabSeq[hand] < _grabSeq[primary]))
|
||||
primary = hand;
|
||||
}
|
||||
return primary;
|
||||
}
|
||||
|
||||
private ConeTwistJoint3D MakeWeld(RigidBody3D handBody, RigidBody3D prop, Vector3 pivot, float bias)
|
||||
{
|
||||
ConeTwistJoint3D weld = new()
|
||||
{
|
||||
ExcludeNodesFromCollision = true,
|
||||
Position = pivot,
|
||||
NodeA = handBody.GetPath(),
|
||||
NodeB = prop.GetPath(),
|
||||
SwingSpan = 0f,
|
||||
TwistSpan = 0f,
|
||||
Bias = bias,
|
||||
};
|
||||
AddChild(weld);
|
||||
return weld;
|
||||
}
|
||||
|
||||
private void BreakGrip(int hand, RigidBody3D body, string reason)
|
||||
{
|
||||
if (_held[hand] != body)
|
||||
return;
|
||||
_held[hand] = null;
|
||||
DestroyJoint(hand);
|
||||
GD.Print($"PropGrabber: grip broke on '{body.Name}' ({reason})");
|
||||
}
|
||||
|
||||
private void DestroyJoint(int hand)
|
||||
{
|
||||
if (_joints[hand] != null && IsInstanceValid(_joints[hand]))
|
||||
_joints[hand].QueueFree();
|
||||
_joints[hand] = null;
|
||||
_breakTimer[hand] = 0f;
|
||||
}
|
||||
|
||||
private static Vector3 PalmPoint(RigidBody3D handBody, int hand) =>
|
||||
handBody.GlobalPosition + handBody.GlobalTransform.Basis * new Vector3(PalmOffset(hand), 0f, 0f);
|
||||
|
||||
private static float PalmOffset(int hand) => hand == 0 ? PalmGrabOffset : -PalmGrabOffset;
|
||||
|
||||
private RigidBody3D FindProp(XRController3D controller, int hand)
|
||||
{
|
||||
RigidBody3D handBody = PhysicsHands.GetBody(controller);
|
||||
if (handBody == null)
|
||||
return null;
|
||||
Vector3 palm = PalmPoint(handBody, hand);
|
||||
RigidBody3D nearest = NearestProp(palm, GrabRadius);
|
||||
if (nearest != null)
|
||||
return nearest;
|
||||
return NearestProp(handBody.GlobalPosition, CenterGrabRadius);
|
||||
}
|
||||
|
||||
private RigidBody3D NearestProp(Vector3 point, float radius)
|
||||
{
|
||||
RigidBody3D nearest = null;
|
||||
float nearestDist = radius;
|
||||
foreach (Node node in GetTree().GetNodesInGroup("prop"))
|
||||
{
|
||||
if (node is not RigidBody3D prop)
|
||||
continue;
|
||||
float dist = prop.GlobalPosition.DistanceTo(point);
|
||||
if (dist < nearestDist)
|
||||
{
|
||||
nearestDist = dist;
|
||||
nearest = prop;
|
||||
}
|
||||
}
|
||||
return nearest;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://70m2rsbntlm4
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dathgkdgedhrj
|
||||
@@ -0,0 +1,59 @@
|
||||
using Godot;
|
||||
|
||||
public partial class TeleportMarker : Node3D
|
||||
{
|
||||
private static readonly Color IdleColor = new(0.2f, 0.55f, 0.9f);
|
||||
private static readonly Color HotColor = new(0.4f, 0.95f, 1.0f);
|
||||
|
||||
[Export]
|
||||
public string MarkerLabel { get; set; } = "1";
|
||||
|
||||
private StandardMaterial3D _discMaterial;
|
||||
private StandardMaterial3D _beamMaterial;
|
||||
private MeshInstance3D _beam;
|
||||
private Label3D _label;
|
||||
|
||||
public bool IsNear { get; private set; }
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
AddToGroup("teleport_marker");
|
||||
|
||||
_discMaterial = MakeMaterial(IdleColor, false);
|
||||
_beamMaterial = MakeMaterial(new Color(IdleColor, 0.35f), true);
|
||||
|
||||
GetNode<MeshInstance3D>("Disc").SetSurfaceOverrideMaterial(0, _discMaterial);
|
||||
_beam = GetNode<MeshInstance3D>("Beam");
|
||||
_beam.SetSurfaceOverrideMaterial(0, _beamMaterial);
|
||||
_label = GetNode<Label3D>("Label3D");
|
||||
_label.Text = MarkerLabel;
|
||||
}
|
||||
|
||||
public void SetNear(bool near)
|
||||
{
|
||||
if (IsNear == near)
|
||||
return;
|
||||
IsNear = near;
|
||||
GetNode<MeshInstance3D>("Disc").Visible = !near;
|
||||
_beam.Visible = !near;
|
||||
_label.Visible = !near;
|
||||
}
|
||||
|
||||
public void SetHighlighted(bool highlighted)
|
||||
{
|
||||
Color c = highlighted ? HotColor : IdleColor;
|
||||
_discMaterial.AlbedoColor = c;
|
||||
_discMaterial.Emission = c;
|
||||
_beamMaterial.AlbedoColor = new Color(c, 0.35f);
|
||||
_beamMaterial.Emission = c;
|
||||
}
|
||||
|
||||
private static StandardMaterial3D MakeMaterial(Color color, bool transparent) => new()
|
||||
{
|
||||
AlbedoColor = color,
|
||||
EmissionEnabled = true,
|
||||
Emission = color,
|
||||
EmissionEnergyMultiplier = 2.0f,
|
||||
Transparency = transparent ? BaseMaterial3D.TransparencyEnum.Alpha : BaseMaterial3D.TransparencyEnum.Disabled,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://ccejrjq47ht5h
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://ck1ceetotj4pk
|
||||
@@ -0,0 +1,396 @@
|
||||
using Godot;
|
||||
using System.Collections.Generic;
|
||||
|
||||
public partial class WorldPanel : Node3D
|
||||
{
|
||||
private const float GripThreshold = 0.6f;
|
||||
private const float TriggerThreshold = 0.6f;
|
||||
private const float DirectGrabRadius = 0.45f;
|
||||
private const float TipHoverRadius = 0.07f;
|
||||
private const float PressContactDistance = 0.045f;
|
||||
private const ulong PressCooldownMs = 400;
|
||||
private const float TipLength = 0.12f;
|
||||
private const float RayLength = 50f;
|
||||
|
||||
private static WorldPanel _instance;
|
||||
|
||||
private XRController3D _left;
|
||||
private XRController3D _right;
|
||||
private XRCamera3D _camera;
|
||||
private Locomotion _locomotion;
|
||||
private PropShop _propShop;
|
||||
private AudioStreamPlayer3D _click;
|
||||
|
||||
private StaticBody3D _panelBody;
|
||||
|
||||
private struct Knob
|
||||
{
|
||||
public string Name;
|
||||
public Node3D Root;
|
||||
public MeshInstance3D Mesh;
|
||||
public StandardMaterial3D Material;
|
||||
public Callable Action;
|
||||
public ulong LastPressMs;
|
||||
}
|
||||
|
||||
private readonly List<Knob> _knobs = new();
|
||||
|
||||
private readonly bool[] _dragging = new bool[2];
|
||||
private readonly bool[] _directDrag = new bool[2];
|
||||
private readonly float[] _laserDist = new float[2];
|
||||
private readonly Vector3[] _directOffset = new Vector3[2];
|
||||
private readonly bool[] _triggerPrev = new bool[2];
|
||||
private readonly bool[] _summonPrev = new bool[2];
|
||||
private readonly int[] _hover = new int[2] { -1, -1 };
|
||||
private readonly bool[] _hoverPanel = new bool[2];
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
_instance = this;
|
||||
_left = GetNode<XRController3D>("../LeftHand");
|
||||
_right = GetNode<XRController3D>("../RightHand");
|
||||
_camera = GetNode<XRCamera3D>("../XRCamera3D");
|
||||
_locomotion = GetNode<Locomotion>("../../Locomotion");
|
||||
_propShop = GetNode<PropShop>("../../PropShop");
|
||||
Visible = false;
|
||||
BuildPanel();
|
||||
BuildClickSound();
|
||||
SetPanelActive(false);
|
||||
}
|
||||
|
||||
public static bool IsBlocking(XRController3D controller) =>
|
||||
_instance != null && _instance.Visible &&
|
||||
_instance.GlobalPosition.DistanceTo(controller.GlobalPosition) < DirectGrabRadius;
|
||||
|
||||
public static bool IsPanelCollider(Node node) =>
|
||||
_instance != null && _instance.Visible && node != null &&
|
||||
(node.HasMeta("knob_index") || node == _instance._panelBody || node.GetParent() == _instance._panelBody);
|
||||
|
||||
public override void _Process(double delta)
|
||||
{
|
||||
PollSummon(_left, 0);
|
||||
PollSummon(_right, 1);
|
||||
if (!Visible)
|
||||
return;
|
||||
UpdateHand(_left, 0);
|
||||
UpdateHand(_right, 1);
|
||||
}
|
||||
|
||||
private void PollSummon(XRController3D controller, int hand)
|
||||
{
|
||||
bool summon = controller.IsButtonPressed("ax_button");
|
||||
if (summon && !_summonPrev[hand])
|
||||
{
|
||||
if (!Visible)
|
||||
PlaceInFrontOf(controller);
|
||||
SetPanelActive(!Visible);
|
||||
GD.Print($"WorldPanel: {(Visible ? "summoned" : "dismissed")} via ax_button ({(hand == 0 ? "left" : "right")})");
|
||||
}
|
||||
_summonPrev[hand] = summon;
|
||||
}
|
||||
|
||||
private void SetPanelActive(bool active)
|
||||
{
|
||||
if (!active)
|
||||
ResetInteractionState();
|
||||
Visible = active;
|
||||
SetShapesEnabled(this, active);
|
||||
}
|
||||
|
||||
private static void SetShapesEnabled(Node root, bool enabled)
|
||||
{
|
||||
foreach (Node child in root.GetChildren())
|
||||
{
|
||||
if (child is CollisionShape3D shape)
|
||||
shape.SetDeferred(CollisionShape3D.PropertyName.Disabled, !enabled);
|
||||
SetShapesEnabled(child, enabled);
|
||||
}
|
||||
}
|
||||
|
||||
private void PlaceInFrontOf(XRController3D controller) =>
|
||||
OrientTo(controller.GlobalPosition - controller.GlobalTransform.Basis.Z * 0.5f);
|
||||
|
||||
private void PlaceInFrontOfHead() =>
|
||||
OrientTo(_camera.GlobalPosition - _camera.GlobalTransform.Basis.Z * 0.5f);
|
||||
|
||||
private void OrientTo(Vector3 pos)
|
||||
{
|
||||
Vector3 look = _camera.GlobalPosition - pos;
|
||||
if (look.LengthSquared() < 0.0001f)
|
||||
return;
|
||||
Vector3 zAxis = look.Normalized();
|
||||
Vector3 xAxis = Vector3.Up.Cross(zAxis);
|
||||
if (xAxis.LengthSquared() < 0.0001f)
|
||||
return;
|
||||
xAxis = xAxis.Normalized();
|
||||
Vector3 yAxis = zAxis.Cross(xAxis).Normalized();
|
||||
GlobalTransform = new Transform3D(new Basis(xAxis, yAxis, zAxis), pos);
|
||||
}
|
||||
|
||||
private void OrientTowardCamera()
|
||||
{
|
||||
Vector3 look = _camera.GlobalPosition - GlobalPosition;
|
||||
if (look.LengthSquared() < 0.0001f)
|
||||
return;
|
||||
Vector3 zAxis = look.Normalized();
|
||||
Vector3 xAxis = Vector3.Up.Cross(zAxis);
|
||||
if (xAxis.LengthSquared() < 0.0001f)
|
||||
return;
|
||||
xAxis = xAxis.Normalized();
|
||||
Vector3 yAxis = zAxis.Cross(xAxis).Normalized();
|
||||
GlobalBasis = GlobalBasis.Slerp(new Basis(xAxis, yAxis, zAxis), 0.35f).Orthonormalized();
|
||||
}
|
||||
|
||||
private void BuildPanel()
|
||||
{
|
||||
Node3D plate = new() { Name = "Plate" };
|
||||
AddChild(plate);
|
||||
|
||||
StandardMaterial3D plateMaterial = new()
|
||||
{
|
||||
AlbedoColor = new Color(0.08f, 0.09f, 0.12f),
|
||||
EmissionEnabled = true,
|
||||
Emission = new Color(0.1f, 0.2f, 0.35f),
|
||||
EmissionEnergyMultiplier = 0.4f,
|
||||
};
|
||||
MeshInstance3D plateMesh = new() { Mesh = new BoxMesh { Size = new Vector3(0.6f, 0.34f, 0.02f) } };
|
||||
plateMesh.SetSurfaceOverrideMaterial(0, plateMaterial);
|
||||
plate.AddChild(plateMesh);
|
||||
|
||||
_panelBody = new StaticBody3D
|
||||
{
|
||||
Name = "PanelBody",
|
||||
CollisionLayer = 2,
|
||||
CollisionMask = 0,
|
||||
};
|
||||
_panelBody.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = new Vector3(0.6f, 0.34f, 0.06f) } });
|
||||
plate.AddChild(_panelBody);
|
||||
|
||||
Vector3[] positions =
|
||||
{
|
||||
new(-0.21f, 0.06f, 0.03f),
|
||||
new(-0.07f, 0.06f, 0.03f),
|
||||
new(0.07f, 0.06f, 0.03f),
|
||||
new(0.21f, 0.06f, 0.03f),
|
||||
};
|
||||
string[] names = { "Spawn", "Ping", "Reset", "Close" };
|
||||
Color[] colors =
|
||||
{
|
||||
new(0.25f, 0.6f, 1f),
|
||||
new(0.95f, 0.75f, 0.25f),
|
||||
new(0.2f, 0.8f, 0.35f),
|
||||
new(0.9f, 0.3f, 0.3f),
|
||||
};
|
||||
Callable[] actions =
|
||||
{
|
||||
Callable.From(() =>
|
||||
{
|
||||
_locomotion.ReturnToSpawn();
|
||||
ResetInteractionState();
|
||||
PlaceInFrontOfHead();
|
||||
}),
|
||||
Callable.From(Ping),
|
||||
Callable.From(() => _propShop.Respawn()),
|
||||
Callable.From(Close),
|
||||
};
|
||||
|
||||
for (int i = 0; i < names.Length; i++)
|
||||
{
|
||||
StandardMaterial3D material = new()
|
||||
{
|
||||
AlbedoColor = colors[i],
|
||||
EmissionEnabled = true,
|
||||
Emission = colors[i],
|
||||
EmissionEnergyMultiplier = 1.2f,
|
||||
};
|
||||
MeshInstance3D mesh = new()
|
||||
{
|
||||
Mesh = new CylinderMesh { TopRadius = 0.032f, BottomRadius = 0.032f, Height = 0.025f },
|
||||
RotationDegrees = new Vector3(90, 0, 0),
|
||||
};
|
||||
mesh.SetSurfaceOverrideMaterial(0, material);
|
||||
|
||||
StaticBody3D body = new()
|
||||
{
|
||||
CollisionLayer = 2,
|
||||
CollisionMask = 0,
|
||||
};
|
||||
body.SetMeta("knob_index", i);
|
||||
body.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = new Vector3(0.09f, 0.09f, 0.06f) } });
|
||||
|
||||
Node3D root = new() { Position = positions[i] };
|
||||
root.AddChild(mesh);
|
||||
root.AddChild(body);
|
||||
|
||||
Label3D label = new()
|
||||
{
|
||||
Text = names[i],
|
||||
FontSize = 96,
|
||||
PixelSize = 0.00025f,
|
||||
OutlineSize = 6,
|
||||
Position = new Vector3(0, -0.08f, 0.015f),
|
||||
};
|
||||
root.AddChild(label);
|
||||
|
||||
plate.AddChild(root);
|
||||
_knobs.Add(new Knob
|
||||
{
|
||||
Name = names[i],
|
||||
Root = root,
|
||||
Mesh = mesh,
|
||||
Material = material,
|
||||
Action = actions[i],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void BuildClickSound()
|
||||
{
|
||||
const int rate = 44100;
|
||||
const int samples = rate / 25;
|
||||
byte[] data = new byte[samples * 2];
|
||||
for (int i = 0; i < samples; i++)
|
||||
{
|
||||
float envelope = 1f - (float)i / samples;
|
||||
float value = Mathf.Sin(Mathf.Tau * 900f * i / rate) * envelope * 0.35f;
|
||||
short sample = (short)(value * short.MaxValue);
|
||||
data[i * 2] = (byte)sample;
|
||||
data[i * 2 + 1] = (byte)(sample >> 8);
|
||||
}
|
||||
_click = new AudioStreamPlayer3D
|
||||
{
|
||||
Stream = new AudioStreamWav
|
||||
{
|
||||
Format = AudioStreamWav.FormatEnum.Format16Bits,
|
||||
MixRate = rate,
|
||||
Stereo = false,
|
||||
Data = data,
|
||||
},
|
||||
};
|
||||
AddChild(_click);
|
||||
}
|
||||
|
||||
private void UpdateHand(XRController3D controller, int hand)
|
||||
{
|
||||
Vector3 from = controller.GlobalPosition;
|
||||
Vector3 dir = -controller.GlobalTransform.Basis.Z;
|
||||
Vector3 tip = from + dir * TipLength;
|
||||
|
||||
UpdateHover(controller, hand, from, dir, tip);
|
||||
|
||||
bool grip = controller.GetFloat("grip") > GripThreshold
|
||||
&& !PropGrabber.IsGrabbing(controller);
|
||||
if (grip && !_dragging[hand] && (_hoverPanel[hand] || GlobalPosition.DistanceTo(from) < DirectGrabRadius))
|
||||
{
|
||||
_dragging[hand] = true;
|
||||
_directDrag[hand] = !_hoverPanel[hand];
|
||||
if (_directDrag[hand])
|
||||
_directOffset[hand] = GlobalPosition - from;
|
||||
else
|
||||
_laserDist[hand] = Mathf.Max(0.2f, (GlobalPosition - from).Dot(dir));
|
||||
GD.Print($"WorldPanel: grab started ({(_directDrag[hand] ? "direct" : "laser")}, {(hand == 0 ? "left" : "right")})");
|
||||
}
|
||||
if (!grip && _dragging[hand])
|
||||
{
|
||||
_dragging[hand] = false;
|
||||
GD.Print("WorldPanel: grab released");
|
||||
}
|
||||
|
||||
if (_dragging[hand])
|
||||
{
|
||||
GlobalPosition = _directDrag[hand]
|
||||
? from + _directOffset[hand]
|
||||
: from + dir * _laserDist[hand];
|
||||
OrientTowardCamera();
|
||||
}
|
||||
|
||||
bool trigger = controller.GetFloat("trigger") > TriggerThreshold;
|
||||
if (trigger && !_triggerPrev[hand] && _hover[hand] >= 0)
|
||||
Press(_hover[hand]);
|
||||
_triggerPrev[hand] = trigger;
|
||||
|
||||
if (_hover[hand] >= 0)
|
||||
{
|
||||
int hovered = _hover[hand];
|
||||
Knob knob = _knobs[hovered];
|
||||
if (knob.Root.GlobalPosition.DistanceTo(tip) < PressContactDistance)
|
||||
{
|
||||
ulong now = Time.GetTicksMsec();
|
||||
if (now - knob.LastPressMs > PressCooldownMs)
|
||||
{
|
||||
knob.LastPressMs = now;
|
||||
_knobs[hovered] = knob;
|
||||
Press(hovered);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateHover(XRController3D controller, int hand, Vector3 from, Vector3 dir, Vector3 tip)
|
||||
{
|
||||
int knob = -1;
|
||||
bool panel = false;
|
||||
|
||||
var query = PhysicsRayQueryParameters3D.Create(from, from + dir * RayLength);
|
||||
var hit = GetViewport().GetWorld3D().DirectSpaceState.IntersectRay(query);
|
||||
if (hit.TryGetValue("collider", out Variant collider) && collider.As<Node>() is Node node)
|
||||
{
|
||||
if (node.HasMeta("knob_index"))
|
||||
knob = (int)node.GetMeta("knob_index");
|
||||
else
|
||||
panel = IsPanelCollider(node) || node.GetParent() == _panelBody;
|
||||
}
|
||||
|
||||
if (knob < 0)
|
||||
{
|
||||
for (int i = 0; i < _knobs.Count; i++)
|
||||
{
|
||||
if (_knobs[i].Root.GlobalPosition.DistanceTo(tip) < TipHoverRadius)
|
||||
{
|
||||
knob = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (knob != _hover[hand])
|
||||
{
|
||||
if (_hover[hand] >= 0)
|
||||
_knobs[_hover[hand]].Material.EmissionEnergyMultiplier = 1.2f;
|
||||
_hover[hand] = knob;
|
||||
if (knob >= 0)
|
||||
_knobs[knob].Material.EmissionEnergyMultiplier = 2.8f;
|
||||
}
|
||||
_hoverPanel[hand] = panel;
|
||||
}
|
||||
|
||||
private void Press(int index)
|
||||
{
|
||||
Knob knob = _knobs[index];
|
||||
GD.Print($"WorldPanel: pressed '{knob.Name}'");
|
||||
_click.Play();
|
||||
Tween tween = CreateTween();
|
||||
tween.TweenProperty(knob.Mesh, "position:z", -0.014f, 0.05);
|
||||
tween.Parallel().TweenProperty(knob.Material, "emission_energy_multiplier", 4.0f, 0.05);
|
||||
tween.TweenProperty(knob.Mesh, "position:z", 0f, 0.12);
|
||||
tween.Parallel().TweenProperty(knob.Material, "emission_energy_multiplier", 1.2f, 0.3);
|
||||
knob.Action.Call();
|
||||
}
|
||||
|
||||
private void Ping() => GD.Print("WorldPanel: ping");
|
||||
|
||||
private void Close() => SetPanelActive(false);
|
||||
|
||||
private void ResetInteractionState()
|
||||
{
|
||||
for (int hand = 0; hand < 2; hand++)
|
||||
{
|
||||
_dragging[hand] = false;
|
||||
_hoverPanel[hand] = false;
|
||||
_triggerPrev[hand] = false;
|
||||
if (_hover[hand] >= 0)
|
||||
_knobs[_hover[hand]].Material.EmissionEnergyMultiplier = 1.2f;
|
||||
_hover[hand] = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://babrdgj2luqwv
|
||||
Reference in new issue
Block a user