Arms playtest-1 fixes: open-loop torso lean (chest hunting), hand-driven elbow pole + height cap, held-prop arm collision exceptions

- Lean computed from PhysicsTorso.NominalTarget/TargetBasis, not the body's own shoulders:
  the closed loop made the chest hunt (spike: |v| 1.1 -> 0.019 m/s, lean range 2.7 cm -> 0.9 mm)
- Elbow on its IK circle, 75% hand-axis (forearm continues behind the wrist), bounded-rate
  rotation smoothing (no flips), capped at max(shoulder, wrist) + 5 cm
- PhysicsArms.SetHeld: held prop ignores that side's arm segments (PropGrabber calls it)
- ArmSpike: stretch settle, hug elbows out (0.17 m vs 0.02 m old pole), held-anvil stability;
  all six spikes PASS x3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
FoxyzandClaude Opus 5.5 committed 2026-10-02 00:25:50 +03:00
1 parent 7589b13aba
commit 464cda2f71
6 files changed
+288 -65

No files matched your search

+25 -9
View File
@@ -145,16 +145,25 @@ TERMINOLOGY (owner-corrected — use precisely):
NO joints (Box3D has no usable 3-DOF shoulder/wrist joint — PinJoint's identity spring, no NO joints (Box3D has no usable 3-DOF shoulder/wrist joint — PinJoint's identity spring, no
Generic6DOF — and AGENTS.md forbids joints between tracked bodies): each segment PD-chases Generic6DOF — and AGENTS.md forbids joints between tracked bodies): each segment PD-chases
its analytic 2-bone IK pose, solved EVERY PHYSICS TICK from the PHYSICAL chest shoulders to its analytic 2-bone IK pose, solved EVERY PHYSICS TICK from the PHYSICAL chest shoulders to
the PHYSICAL hand bodies (IK moved here from BodyRig: soft IK 0.98, torso-frame pole with the PHYSICAL hand bodies (IK moved here from BodyRig: soft IK 0.98; elbow picked on its circle, 75% HAND-DRIVEN —
the shoulder→hand axis projected out, hand-forward push, exp-smoothed). **Hierarchical the forearm continues behind the wrist along the aim pose's forward axis, which gives
outward elbows for wraps/hugs — 25% torso-frame down/back/out; smoothed by bounded-rate
ROTATION around the arm axis (can't pass through zero and flip); capped at
max(shoulder, wrist) + 5 cm height). **Hierarchical
budgets** (Nimso muscle model) from `BodyMassStat` 60 kg: hand 90 N ≺ forearm 180 N ≺ upper budgets** (Nimso muscle model) from `BodyMassStat` 60 kg: hand 90 N ≺ forearm 180 N ≺ upper
arm 360 N ≺ chest 2400 N. Orientation = slewed angular-velocity servo via arm 360 N ≺ chest 2400 N. Orientation = slewed angular-velocity servo via
`BodyMath.RotationError`; segment roll follows the body's current X axis (no flips). `BodyMath.RotationError`; segment roll follows the body's current X axis (no flips).
Safety: 1 m snap (teleports), `ResetIfNonFinite`. Also owns calibration (arm lengths + Safety: 1 m snap (teleports), `ResetIfNonFinite`. Also owns calibration (arm lengths +
shoulder width from first head height > 0.5 m; segments spawn then) and the REACH BUDGET shoulder width from first head height > 0.5 m; segments spawn then) and the torso LEAN
(torso lean toward overreach → `PhysicsTorso.ReachOffset`, hysteresis unchanged). Exposes toward overreaching hands → `PhysicsTorso.ReachOffset`, computed OPEN-LOOP from
`PhysicsTorso.NominalTarget/TargetBasis` (the head-derived pose without the lean). The old
closed loop (lean from the body's own shoulders) made the chest hunt back and forth —
owner-reported, spike-measured 1.1 m/s → 0.019 m/s after the fix. Props held by a hand get
a collision exception with that side's arm segments (`SetHeld`, called by PropGrabber) —
a 180 N forearm shoving a prop welded to a 90 N hand was a fight. Exposes
`Shoulder/Elbow/Wrist/ElbowTarget/WristTarget(side)`; `Elbow` = where the two physical `Shoulder/Elbow/Wrist/ElbowTarget/WristTarget(side)`; `Elbow` = where the two physical
segments meet. Spike: `scenes/arm_spike.tscn` (track gap, fast swing, arm pushes a prop segments meet. Spike: `scenes/arm_spike.tscn` (stretch-beyond-reach chest settle, hug elbows out
0.17 m (old pole: 0.02), held 50 kg anvil stable + forearm out of it, track gap, fast swing, arm pushes a prop
with the hand clear, forearm blocked by a bar without tunneling + quiet while pinned, with the hand clear, forearm blocked by a bar without tunneling + quiet while pinned,
teleport snap, NaN-injection reset, chest undisturbed by own arms). teleport snap, NaN-injection reset, chest undisturbed by own arms).
- **BodyRig.cs** — placeholder rig VISUALS only: head cube (HMD-driven) + arm boxes drawn - **BodyRig.cs** — placeholder rig VISUALS only: head cube (HMD-driven) + arm boxes drawn
@@ -262,7 +271,14 @@ Floor StaticBody mask 1|3|4|5 (collision_mask 29).
static table + two-hand-welded 50 kg anvil) was tested and is stable. Full record: static table + two-hand-welded 50 kg anvil) was tested and is stable. Full record:
`research/body-stage1-failure-brief.md` (RESOLUTION section). **RULE: never use `research/body-stage1-failure-brief.md` (RESOLUTION section). **RULE: never use
`Quaternion.GetAngle()/GetAxis()` for servo errors — use `BodyMath.RotationError`.** `Quaternion.GetAngle()/GetAxis()` for servo errors — use `BodyMath.RotationError`.**
- **Body Stage 2 (physical arms) — unvalidated in-headset.** Things to watch in the first - **Body Stage 2 arms — owner playtest 1 (2026-10-02)**: "works fine for the most part";
reported forearm clipping through a held anvil, chest jitter when stretching one arm,
elbows mostly pointing down / unnatural. Fixed: open-loop lean (jitter), hand-driven
elbow + height cap, held-prop exceptions. The exact screenshot clip (forearm through the
held anvil) was NOT reproduced headless across 3 anvil heights — the drawn forearm is a
straight line from the PHYSICAL elbow to the hand wrist, so any elbow below a held object's
near edge can still draw through it; re-check in-headset.
- **Body Stage 2 (physical arms) — remaining watch-list.** Things to watch in the first
playtest: elbow drawn displaced when a segment is blocked (that's the honest physics view), playtest: elbow drawn displaced when a segment is blocked (that's the honest physics view),
arms sweeping props when seated at the table, any stutter at fast swings (spike: ≤ 6 cm arms sweeping props when seated at the table, any stutter at fast swings (spike: ≤ 6 cm
joint lag at 2 Hz × 0.4 m). Known gaps by design (Stage 2b, feel-driven): a blocked forearm joint lag at 2 Hz × 0.4 m). Known gaps by design (Stage 2b, feel-driven): a blocked forearm
@@ -337,14 +353,14 @@ Floor StaticBody mask 1|3|4|5 (collision_mask 29).
| `PalmGrabOffset` | PropGrabber.cs | ±0.045 m along hand +X (left +, right −) | grab point on the palm (taste choice) | | `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 | | Grip/trigger thresholds | several files | 0.6 | polled Float edges |
| `SoftIKFactor` | PhysicsArms.cs | 0.98 | shoulder→hand distance clamp before IK solve (lower toward 0.95 if shake persists) | | `SoftIKFactor` | PhysicsArms.cs | 0.98 | shoulder→hand distance clamp before IK solve (lower toward 0.95 if shake persists) |
| `PoleHandWeight` / `PoleSmoothRate` | PhysicsArms.cs | 0.25 / 10 | elbow pole: hand-forward push / exp smoothing | | `PoleHandWeight` / `PoleSmoothRate` | PhysicsArms.cs | 0.75 / 12 | elbow pole: hand-axis share (forearm continues behind the wrist) vs torso-frame down/back/out; bounded-rate rotation smoothing |
| `ElbowRiseAllowance` | PhysicsArms.cs | 0.05 m | elbow capped at max(shoulder, wrist) height + this (no chicken wings) |
| `TorsoYawHandsWeight` / `TorsoYawForwardness` / `TorsoYawSmoothRate` | PhysicsTorso.cs | 0.6 / 0.3 m / 12 | torso yaw spring: hands blend / forwardness normalizer / exp smoothing (≈200 ms) | | `TorsoYawHandsWeight` / `TorsoYawForwardness` / `TorsoYawSmoothRate` | PhysicsTorso.cs | 0.6 / 0.3 m / 12 | torso yaw spring: hands blend / forwardness normalizer / exp smoothing (≈200 ms) |
| `TorsoMass` / `MaxForce` / `MaxSpeed` | PhysicsTorso.cs | 30 kg / 2400 N / 6 m/s | chest chase budget (force-clamped PD) | | `TorsoMass` / `MaxForce` / `MaxSpeed` | PhysicsTorso.cs | 30 kg / 2400 N / 6 m/s | chest chase budget (force-clamped PD) |
| `OrientKp` / `OrientMaxSpin` / `OrientMaxAlpha` | PhysicsTorso.cs | 20 / 10 rad/s / 150 rad/s² | chest orientation servo (yaw hold + self-right) | | `OrientKp` / `OrientMaxSpin` / `OrientMaxAlpha` | PhysicsTorso.cs | 20 / 10 rad/s / 150 rad/s² | chest orientation servo (yaw hold + self-right) |
| `SnapDistance` | PhysicsTorso.cs | 2 m | chest teleport snap safety | | `SnapDistance` | PhysicsTorso.cs | 2 m | chest teleport snap safety |
| `armTotal` | PhysicsArms.cs | clamp(0.46×headHeight, 0.5, 0.72) | total arm length, 50/50 split | | `armTotal` | PhysicsArms.cs | clamp(0.46×headHeight, 0.5, 0.72) | total arm length, 50/50 split |
| `ReachPushSpeed` / `ReachDecaySpeed` | PhysicsArms.cs | 0.8 / 0.4 m/s | torso lean toward / back from overreach | | `LeanReachFactor` / `LeanSmoothRate` | PhysicsArms.cs | 0.95 / 6 per s | torso lean starts past 95% reach (open-loop from the nominal chest pose), exp-smoothed ≈170 ms |
| `ReachDecayFactor` | PhysicsArms.cs | 0.92 | hysteresis band (decay only below 92% of reach) |
| `ReachBudgetMax` | PhysicsArms.cs | 0.25 m | horizontal torso offset clamp | | `ReachBudgetMax` | PhysicsArms.cs | 0.25 m | horizontal torso offset clamp |
| `ReachVerticalMin/Max` | PhysicsArms.cs | −0.05 / +0.12 m | vertical torso offset clamp (stops upward oscillation) | | `ReachVerticalMin/Max` | PhysicsArms.cs | −0.05 / +0.12 m | vertical torso offset clamp (stops upward oscillation) |
| `HeadBackOffset` | BodyRig.cs / PhysicsTorso.cs | 0.06 m | keeps the head cube out of the view | | `HeadBackOffset` | BodyRig.cs / PhysicsTorso.cs | 0.06 m | keeps the head cube out of the view |
+126 -2
View File
@@ -5,9 +5,16 @@ using Godot;
// Run: godot-mono --headless --fixed-fps 90 --path . scenes/arm_spike.tscn // Run: godot-mono --headless --fixed-fps 90 --path . scenes/arm_spike.tscn
public partial class ArmSpike : Node3D public partial class ArmSpike : Node3D
{ {
private enum Phase { Settle, Track, Swing, Push, Pin, Teleport, NanGuard, Done } private enum Phase { Settle, Stretch, Hug, Hold, Track, Swing, Push, Pin, Teleport, NanGuard, Done }
private const float GapLimit = 0.04f; private const float GapLimit = 0.04f;
private const float StretchChestSpeedLimit = 0.05f;
private const float StretchLeanRangeLimit = 0.01f;
private const float HugElbowOutMin = 0.05f;
private const float HoldPenetrationLimit = 0.01f;
private const float HoldAnvilY = 1.25f; // anvil top ≈ shoulder height, like a seated player at the table
private const float HoldPitchDeg = 50f;
private static readonly Vector3 AnvilSize = new(0.28f, 0.16f, 0.13f);
private const float SwingGapLimit = 0.12f; private const float SwingGapLimit = 0.12f;
private const float ChestDisturbLimit = 0.01f; private const float ChestDisturbLimit = 0.01f;
private const float PushMinDisplacement = 0.05f; private const float PushMinDisplacement = 0.05f;
@@ -34,6 +41,15 @@ public partial class ArmSpike : Node3D
private float _maxChestErr; private float _maxChestErr;
private float _minHandBoxDist; private float _minHandBoxDist;
private float _maxPinSpeed; private float _maxPinSpeed;
private StaticBody3D _pedestal;
private RigidBody3D _anvil;
private ConeTwistJoint3D _holdWeld;
private float _maxHoldPenetration;
private bool _holdFinite = true;
private float _maxStretchChestSpeed;
private Vector3 _stretchOffsetMin = Vector3.One * 9f;
private Vector3 _stretchOffsetMax = -Vector3.One * 9f;
private readonly RandomNumberGenerator _rng = new() { Seed = 42 };
private float _maxPinObstruction; private float _maxPinObstruction;
private readonly float[] _pinPrevY = { float.MinValue, float.MinValue }; private readonly float[] _pinPrevY = { float.MinValue, float.MinValue };
private bool _pinTunneled; private bool _pinTunneled;
@@ -89,9 +105,102 @@ public partial class ArmSpike : Node3D
{ {
case Phase.Settle: case Phase.Settle:
if (_phaseT > 1f) if (_phaseT > 1f)
Start(Phase.Track, "track — hands circle, segments must stay connected, chest undisturbed"); Start(Phase.Stretch, "stretch — right hand held beyond reach with 1 mm tracker noise, chest must settle (no lean hunting)");
break; break;
case Phase.Stretch:
{
_handTarget[1].Origin = new Vector3(0.2f, 1.38f, -0.72f)
+ new Vector3(_rng.Randfn(), _rng.Randfn(), _rng.Randfn()) * 0.001f;
if (_phaseT > 1.5f)
{
float v = _torso.Body.LinearVelocity.Length();
_maxStretchChestSpeed = Mathf.Max(_maxStretchChestSpeed, v);
_stretchOffsetMin = _stretchOffsetMin.Min(_torso.ReachOffset);
_stretchOffsetMax = _stretchOffsetMax.Max(_torso.ReachOffset);
}
if (_phaseT > 3f)
{
float range = (_stretchOffsetMax - _stretchOffsetMin).Length();
Record($"stretch: chest settles (|v| max {_maxStretchChestSpeed:F3} m/s, limit {StretchChestSpeedLimit}; lean range {range:F4} m, limit {StretchLeanRangeLimit}; lean {_torso.ReachOffset})",
_maxStretchChestSpeed < StretchChestSpeedLimit && range < StretchLeanRangeLimit);
_handTarget[1].Origin = _handRest[1];
Start(Phase.Hug, "hug — hands wrapped inward in front of the chest, elbows must go out to the sides");
}
break;
}
case Phase.Hug:
{
// Hands pointing across each other (left points +X, right points -X), as when
// wrapping arms around someone.
_handTarget[0] = new Transform3D(Basis.LookingAt(Vector3.Right, Vector3.Up), new Vector3(-0.12f, 1.25f, -0.45f));
_handTarget[1] = new Transform3D(Basis.LookingAt(Vector3.Left, Vector3.Up), new Vector3(0.12f, 1.25f, -0.45f));
if (_phaseT > 1.5f)
{
float outL = -_arms.Elbow(0).X - (-_arms.Shoulder(0).X);
float outR = _arms.Elbow(1).X - _arms.Shoulder(1).X;
float outMin = Mathf.Min(outL, outR);
Record($"hug: elbows out beyond the shoulders (L {outL:F3} m, R {outR:F3} m, limit > {HugElbowOutMin}; elbows L {_arms.Elbow(0)} R {_arms.Elbow(1)})", outMin > HugElbowOutMin);
for (int side = 0; side < 2; side++)
_handTarget[side] = new Transform3D(Basis.Identity, _handRest[side]);
_maxHoldPenetration = 0f;
Start(Phase.Hold, "hold — right hand grips a 50 kg anvil from the top, forearm must stay out of it");
}
break;
}
case Phase.Hold:
{
// Far half of the top, fingers pointing forward and steeply down.
Basis down = new Basis(Vector3.Right, Mathf.DegToRad(-HoldPitchDeg));
_handTarget[1] = new Transform3D(down, new Vector3(0.22f, HoldAnvilY + AnvilSize.Y * 0.5f + 0.08f, -0.44f));
if (_anvil == null && _phaseT > 0.5f)
{
// 50 kg anvil on a pedestal in front-right; the right hand grips its top, rear
// half, pointing forward and 35 deg down (the playtest screenshot pose).
_pedestal = new StaticBody3D { CollisionMask = 0b11101, Position = new Vector3(0.22f, HoldAnvilY - 0.13f, -0.40f) };
_pedestal.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = new Vector3(0.4f, 0.1f, 0.4f) } });
AddChild(_pedestal);
_anvil = new RigidBody3D { Mass = 50f, Position = new Vector3(0.22f, HoldAnvilY, -0.40f), CollisionMask = 0b11101 };
_anvil.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = AnvilSize } });
AddChild(_anvil);
}
if (_holdWeld == null && _phaseT > 0.9f)
{
_holdWeld = new ConeTwistJoint3D
{
ExcludeNodesFromCollision = true,
Position = _hands[1].GlobalPosition,
NodeA = _hands[1].GetPath(),
NodeB = _anvil.GetPath(),
SwingSpan = 0f,
TwistSpan = 0f,
Bias = 0f,
};
AddChild(_holdWeld);
_arms.SetHeld(1, _anvil);
}
if (_phaseT > 1.3f)
{
_holdFinite &= BodyMath.IsFinite(_arms.Forearm(1)) && BodyMath.IsFinite(_hands[1]) && BodyMath.IsFinite(_anvil);
Vector3 wrist = _hands[1].GlobalPosition + _hands[1].GlobalTransform.Basis * new Vector3(0f, 0f, 0.06f);
_maxHoldPenetration = Mathf.Max(_maxHoldPenetration, LinePenetration(_arms.Elbow(1), wrist, _anvil));
}
if (_phaseT > 2.8f)
{
Record($"hold: held 50 kg anvil stable, drawn forearm stays out of it (penetration max {_maxHoldPenetration:F3} m, limit {HoldPenetrationLimit}; elbow {_arms.Elbow(1)}; finite {_holdFinite})",
_maxHoldPenetration < HoldPenetrationLimit && _holdFinite);
_arms.SetHeld(1, null);
_holdWeld.QueueFree();
_anvil.QueueFree();
_pedestal.QueueFree();
_handTarget[1] = new Transform3D(Basis.Identity, _handRest[1]);
Start(Phase.Track, "track — hands circle, segments must stay connected, chest undisturbed");
}
break;
}
case Phase.Track: case Phase.Track:
for (int side = 0; side < 2; side++) for (int side = 0; side < 2; side++)
{ {
@@ -233,6 +342,21 @@ public partial class ArmSpike : Node3D
} }
} }
// Deepest point of the segment a→b inside the box body (0 if it never enters).
private static float LinePenetration(Vector3 a, Vector3 b, RigidBody3D box)
{
Vector3 half = AnvilSize * 0.5f;
Transform3D inv = box.GlobalTransform.AffineInverse();
float deepest = 0f;
for (int i = 0; i <= 40; i++)
{
Vector3 local = inv * a.Lerp(b, i / 40f);
float depth = Mathf.Min(half.X - Mathf.Abs(local.X), Mathf.Min(half.Y - Mathf.Abs(local.Y), half.Z - Mathf.Abs(local.Z)));
deepest = Mathf.Max(deepest, depth);
}
return deepest;
}
private float MaxGap() private float MaxGap()
{ {
float gap = 0f; float gap = 0f;
+1
View File
@@ -0,0 +1 @@
uid://jcnflxkhghjy
+126 -53
View File
@@ -23,13 +23,13 @@ public partial class PhysicsArms : Node
private const float ReachBudgetMax = 0.25f; private const float ReachBudgetMax = 0.25f;
private const float ReachVerticalMin = -0.05f; private const float ReachVerticalMin = -0.05f;
private const float ReachVerticalMax = 0.12f; private const float ReachVerticalMax = 0.12f;
private const float ReachPushSpeed = 0.8f; private const float LeanReachFactor = 0.95f;
private const float ReachDecaySpeed = 0.4f; private const float LeanSmoothRate = 6f;
private const float ReachDecayFactor = 0.92f;
private const float WristOffset = 0.06f; private const float WristOffset = 0.06f;
private const float SoftIKFactor = 0.98f; private const float SoftIKFactor = 0.98f;
private const float PoleHandWeight = 0.25f; private const float PoleHandWeight = 0.75f;
private const float PoleSmoothRate = 10f; private const float PoleSmoothRate = 12f;
private const float ElbowRiseAllowance = 0.05f;
public static float ForearmMaxForce => BodyMassStat * ForearmForcePerKg; public static float ForearmMaxForce => BodyMassStat * ForearmForcePerKg;
public static float UpperArmMaxForce => BodyMassStat * UpperArmForcePerKg; public static float UpperArmMaxForce => BodyMassStat * UpperArmForcePerKg;
@@ -51,6 +51,7 @@ public partial class PhysicsArms : Node
private readonly Vector3[] _elbowTarget = new Vector3[2]; private readonly Vector3[] _elbowTarget = new Vector3[2];
private readonly Vector3[] _wristTarget = new Vector3[2]; private readonly Vector3[] _wristTarget = new Vector3[2];
private readonly Vector3[] _lastPole = new Vector3[2]; private readonly Vector3[] _lastPole = new Vector3[2];
private readonly RigidBody3D[] _held = new RigidBody3D[2];
private Vector3 _torsoOffset; private Vector3 _torsoOffset;
private Basis _torsoBasis = Basis.Identity; private Basis _torsoBasis = Basis.Identity;
@@ -93,9 +94,9 @@ public partial class PhysicsArms : Node
_shoulder[0] = topCenter - _torsoBasis.X * ShoulderHalfWidth; _shoulder[0] = topCenter - _torsoBasis.X * ShoulderHalfWidth;
_shoulder[1] = topCenter + _torsoBasis.X * ShoulderHalfWidth; _shoulder[1] = topCenter + _torsoBasis.X * ShoulderHalfWidth;
Vector3 push = SolveArm(0, -_torsoBasis.X, HandLeft, dt, out bool decayLeft); SolveArm(0, -_torsoBasis.X, HandLeft, dt);
push += SolveArm(1, _torsoBasis.X, HandRight, dt, out bool decayRight); SolveArm(1, _torsoBasis.X, HandRight, dt);
UpdateReachBudget(push, decayLeft || decayRight, dt); UpdateLean(dt);
for (int side = 0; side < 2; side++) for (int side = 0; side < 2; side++)
{ {
@@ -117,6 +118,7 @@ public partial class PhysicsArms : Node
string name = side == 0 ? "Left" : "Right"; string name = side == 0 ? "Left" : "Right";
_upper[side] = MakeSegment($"{name}UpperArmBody", UpperArmMass, new Vector3(0.07f, UpperLen - SegmentInset, 0.07f)); _upper[side] = MakeSegment($"{name}UpperArmBody", UpperArmMass, new Vector3(0.07f, UpperLen - SegmentInset, 0.07f));
_fore[side] = MakeSegment($"{name}ForearmBody", ForearmMass, new Vector3(0.06f, ForeLen - SegmentInset, 0.06f)); _fore[side] = MakeSegment($"{name}ForearmBody", ForearmMass, new Vector3(0.06f, ForeLen - SegmentInset, 0.06f));
SetHeldExceptions(side, _held[side], true);
} }
GD.Print($"PhysicsArms: proportions calibrated from head height {headHeight:F2} m " + GD.Print($"PhysicsArms: 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); " + $"(arm total {armTotal:F2} m, upper {UpperLen:F2} m, fore {ForeLen:F2} m, shoulder half-width {ShoulderHalfWidth:F2} m); " +
@@ -186,26 +188,77 @@ public partial class PhysicsArms : Node
private static Vector3 SegmentEnd(RigidBody3D body, float length, float sign) => private static Vector3 SegmentEnd(RigidBody3D body, float length, float sign) =>
body.GlobalPosition + body.GlobalTransform.Basis.Y * (sign * length * 0.5f); body.GlobalPosition + body.GlobalTransform.Basis.Y * (sign * length * 0.5f);
private void UpdateReachBudget(Vector3 push, bool decay, float dt) // A prop held by this side's hand must not collide with this side's arm: the forearm
// naturally lies along a held object, and 180 N of forearm shoving a prop welded to a 90 N
// hand is a fight the hand loses. Called by PropGrabber on grab/release.
public void SetHeld(int side, RigidBody3D prop)
{ {
if (push != Vector3.Zero) if (_held[side] == prop)
_torsoOffset += push * (dt * ReachPushSpeed); return;
else if (decay) SetHeldExceptions(side, _held[side], false);
_torsoOffset = _torsoOffset.MoveToward(Vector3.Zero, ReachDecaySpeed * dt); _held[side] = prop;
SetHeldExceptions(side, prop, true);
}
Vector3 horizontal = new Vector3(_torsoOffset.X, 0f, _torsoOffset.Z).LimitLength(ReachBudgetMax); private void SetHeldExceptions(int side, RigidBody3D prop, bool add)
_torsoOffset = new Vector3(horizontal.X, Mathf.Clamp(_torsoOffset.Y, ReachVerticalMin, ReachVerticalMax), horizontal.Z); {
if (prop == null || !IsInstanceValid(prop) || _upper[side] == null)
return;
foreach (RigidBody3D segment in new[] { _upper[side], _fore[side] })
{
if (add)
segment.AddCollisionExceptionWith(prop);
else
segment.RemoveCollisionExceptionWith(prop);
}
}
// Torso lean toward overreaching hands, computed OPEN-LOOP from the head-derived nominal
// chest pose — never from the body's own shoulders (that loop made the chest hunt:
// lean moves shoulders → overreach changes → lean changes). Exact two-arm combination:
// the bigger excess first, then whatever the other arm still lacks after that shift.
private void UpdateLean(float dt)
{
Basis basis = Torso.TargetBasis;
Vector3 top = Torso.NominalTarget + Vector3.Up * ShoulderRise;
Vector3 shoulderL = top - basis.X * ShoulderHalfWidth;
Vector3 shoulderR = top + basis.X * ShoulderHalfWidth;
Vector3 excessL = Excess(shoulderL, HandLeft);
Vector3 excessR = Excess(shoulderR, HandRight);
Vector3 lean = excessL.LengthSquared() >= excessR.LengthSquared()
? excessL + Excess(shoulderR + excessL, HandRight)
: excessR + Excess(shoulderL + excessR, HandLeft);
Vector3 horizontal = new Vector3(lean.X, 0f, lean.Z).LimitLength(ReachBudgetMax);
lean = new Vector3(horizontal.X, Mathf.Clamp(lean.Y, ReachVerticalMin, ReachVerticalMax), horizontal.Z);
_torsoOffset = _torsoOffset.Lerp(lean, 1f - Mathf.Exp(-dt * LeanSmoothRate));
Torso.ReachOffset = _torsoOffset; Torso.ReachOffset = _torsoOffset;
} }
// Analytic 2-bone IK (moved from BodyRig): soft-IK clamp, torso-frame elbow pole with the private Vector3 Excess(Vector3 shoulder, RigidBody3D hand)
// shoulder→hand axis projected out, hand-forward push, exp-smoothed pole. {
private Vector3 SolveArm(int index, Vector3 side, RigidBody3D hand, float delta, out bool decay) if (hand == null)
return Vector3.Zero;
Vector3 toWrist = WristPoint(hand) - shoulder;
float dist = toWrist.Length();
float limit = (UpperLen + ForeLen) * LeanReachFactor;
return dist > limit ? toWrist * ((dist - limit) / dist) : Vector3.Zero;
}
private static Vector3 WristPoint(RigidBody3D hand) =>
hand.GlobalPosition + hand.GlobalTransform.Basis * new Vector3(0f, 0f, WristOffset);
// Analytic 2-bone IK: soft-IK clamp, then pick the elbow on its circle. The elbow
// direction is mostly HAND-DRIVEN — the forearm continues behind the wrist along the
// hand's (aim pose) forward axis, which is where a real forearm is; that gives outward
// elbows for wraps/hugs and keeps the forearm out of held objects. A torso-frame
// "down/back/out" pole fills in where the hand axis says nothing (arm pointing straight
// along the hand). The pole is smoothed by ROTATING it at a bounded rate around the arm
// axis, so it can never pass through zero and flip.
private void SolveArm(int index, Vector3 side, RigidBody3D hand, float delta)
{ {
Vector3 shoulder = _shoulder[index]; Vector3 shoulder = _shoulder[index];
Vector3 rawTarget = hand != null Vector3 rawTarget = hand != null ? WristPoint(hand) : shoulder + _torsoBasis * new Vector3(0f, -0.25f, -0.3f);
? hand.GlobalPosition + hand.GlobalTransform.Basis * new Vector3(0f, 0f, WristOffset)
: shoulder + _torsoBasis * new Vector3(0f, -0.25f, -0.3f);
float reach = UpperLen + ForeLen; float reach = UpperLen + ForeLen;
Vector3 shoulderToTarget = rawTarget - shoulder; Vector3 shoulderToTarget = rawTarget - shoulder;
@@ -215,44 +268,64 @@ public partial class PhysicsArms : Node
float rawDist = shoulderToTarget.Length(); float rawDist = shoulderToTarget.Length();
float d = Mathf.Clamp(rawDist, 0.01f, reach * SoftIKFactor); float d = Mathf.Clamp(rawDist, 0.01f, reach * SoftIKFactor);
Vector3 dir = shoulderToTarget / rawDist; Vector3 dir = shoulderToTarget / rawDist;
Vector3 clampedTarget = shoulder + dir * d; Vector3 wrist = shoulder + dir * d;
Vector3 push = Vector3.Zero; // Elbow circle: center on the arm axis, radius from the law of cosines.
decay = false; float along = (UpperLen * UpperLen - ForeLen * ForeLen + d * d) / (2f * d);
if (rawDist > reach) float radius = Mathf.Sqrt(Mathf.Max(UpperLen * UpperLen - along * along, 0f));
push = (rawTarget - clampedTarget).Normalized(); Vector3 center = shoulder + dir * along;
else if (rawDist < reach * ReachDecayFactor)
decay = true;
float cosA = (UpperLen * UpperLen + d * d - ForeLen * ForeLen) / (2f * UpperLen * d); Vector3 natural = Vector3.Down * 2f + _torsoBasis * Vector3.Back * 0.8f + side * 0.3f;
float angle = Mathf.Acos(Mathf.Clamp(cosA, -1f, 1f)); natural -= dir * natural.Dot(dir);
Vector3 pole = natural.LengthSquared() > 1e-8f ? natural.Normalized() * (1f - PoleHandWeight) : Vector3.Zero;
Vector3 handForward = hand != null ? hand.GlobalTransform.Basis * Vector3.Forward : Vector3.Zero; if (hand != null)
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)); Vector3 handForward = (hand.GlobalTransform.Basis * Vector3.Forward).Normalized();
if (poleDir.LengthSquared() < 0.0001f) Vector3 handElbow = wrist - handForward * ForeLen - center;
poleDir = _lastPole[index]; handElbow -= dir * handElbow.Dot(dir);
poleDir = poleDir.Normalized(); float confidence = Mathf.Clamp(handElbow.Length() / (0.5f * radius + 1e-4f), 0f, 1f);
_lastPole[index] = poleDir; if (handElbow.LengthSquared() > 1e-8f)
pole += handElbow.Normalized() * (PoleHandWeight * confidence);
} }
Vector3 axis = dir.Cross(poleDir); Vector3 last = _lastPole[index];
if (axis.LengthSquared() < 0.0001f) last -= dir * last.Dot(dir);
axis = dir.Cross(Vector3.Up); if (last.LengthSquared() < 1e-8f)
if (axis.LengthSquared() < 0.0001f) last = pole.LengthSquared() > 1e-8f ? pole : natural;
axis = Vector3.Right; last = last.LengthSquared() > 1e-8f ? last.Normalized() : Vector3.Down;
axis = axis.Normalized(); if (pole.LengthSquared() > 1e-8f)
{
pole = pole.Normalized();
float angle = last.AngleTo(pole);
if (angle > 1e-5f)
{
Vector3 axis = last.Cross(pole);
axis = axis.LengthSquared() > 1e-10f ? axis.Normalized() : dir;
last = last.Rotated(axis, angle * (1f - Mathf.Exp(-delta * PoleSmoothRate)));
}
}
_lastPole[index] = last;
Vector3 elbow = shoulder + dir.Rotated(axis, angle) * UpperLen; Vector3 elbow = center + CapElbowHeight(last, dir, center, radius, Mathf.Max(shoulder.Y, wrist.Y) + ElbowRiseAllowance) * radius;
_elbowTarget[index] = elbow; _elbowTarget[index] = elbow;
_wristTarget[index] = elbow + (clampedTarget - elbow).Normalized() * ForeLen; _wristTarget[index] = elbow + (wrist - elbow).Normalized() * ForeLen;
return push; }
// A real wrist flexes, so following the hand axis literally can throw the elbow above
// the shoulder ("chicken wing"). Keep the elbow no higher than yMax by sliding it down
// its circle, preserving its sideways heading.
private static Vector3 CapElbowHeight(Vector3 pole, Vector3 dir, Vector3 center, float radius, float yMax)
{
if (center.Y + pole.Y * radius <= yMax)
return pole;
Vector3 vertical = Vector3.Up - dir * dir.Y;
if (vertical.LengthSquared() < 1e-6f)
return pole; // arm vertical: the circle is horizontal, height can't change
vertical = vertical.Normalized();
Vector3 lateral = dir.Cross(vertical);
float cosMax = Mathf.Clamp((yMax - center.Y) / (radius * vertical.Y), -1f, 1f);
float sin = Mathf.Sqrt(1f - cosMax * cosMax);
float sideSign = pole.Dot(lateral) >= 0f ? 1f : -1f;
return (vertical * cosMax + lateral * (sin * sideSign)).Normalized();
} }
} }
+6 -1
View File
@@ -42,6 +42,10 @@ public partial class PhysicsTorso : Node
public RigidBody3D Body => _body; public RigidBody3D Body => _body;
public Basis TorsoBasis => _body != null ? _body.GlobalTransform.Basis : _torsoBasis; public Basis TorsoBasis => _body != null ? _body.GlobalTransform.Basis : _torsoBasis;
public Vector3 TargetPosition { get; private set; } public Vector3 TargetPosition { get; private set; }
// Head-derived chest target WITHOUT ReachOffset, and its yaw basis — the open-loop frame
// PhysicsArms computes the lean from (reading the body pose instead made the lean hunt).
public Vector3 NominalTarget { get; private set; }
public Basis TargetBasis => _torsoBasis;
public override void _Ready() public override void _Ready()
{ {
@@ -83,7 +87,8 @@ public partial class PhysicsTorso : Node
_yawBasis = FlatYawBasis(head.GlobalTransform.Basis); _yawBasis = FlatYawBasis(head.GlobalTransform.Basis);
Vector3 headPos = head.GlobalPosition - _yawBasis * Vector3.Forward * HeadBackOffset; Vector3 headPos = head.GlobalPosition - _yawBasis * Vector3.Forward * HeadBackOffset;
UpdateTorsoYaw(headPos, dt); UpdateTorsoYaw(headPos, dt);
Vector3 target = headPos + _torsoBasis * new Vector3(0f, -TorsoDrop, TorsoBackOffset) + ReachOffset; NominalTarget = headPos + _torsoBasis * new Vector3(0f, -TorsoDrop, TorsoBackOffset);
Vector3 target = NominalTarget + ReachOffset;
TargetPosition = target; TargetPosition = target;
if (!_arrived && _body.GlobalPosition.DistanceTo(target) < 0.3f) if (!_arrived && _body.GlobalPosition.DistanceTo(target) < 0.3f)
+4
View File
@@ -16,6 +16,7 @@ public partial class PropGrabber : Node
private XRController3D _left; private XRController3D _left;
private XRController3D _right; private XRController3D _right;
private PhysicsArms _arms;
private readonly RigidBody3D[] _held = new RigidBody3D[2]; private readonly RigidBody3D[] _held = new RigidBody3D[2];
private readonly ConeTwistJoint3D[] _joints = new ConeTwistJoint3D[2]; private readonly ConeTwistJoint3D[] _joints = new ConeTwistJoint3D[2];
@@ -30,6 +31,7 @@ public partial class PropGrabber : Node
_instance = this; _instance = this;
_left = GetNode<XRController3D>("../XROrigin3D/LeftHand"); _left = GetNode<XRController3D>("../XROrigin3D/LeftHand");
_right = GetNode<XRController3D>("../XROrigin3D/RightHand"); _right = GetNode<XRController3D>("../XROrigin3D/RightHand");
_arms = GetNodeOrNull<PhysicsArms>("../PhysicsArms");
} }
public static bool IsGrabbing(XRController3D controller) => public static bool IsGrabbing(XRController3D controller) =>
@@ -66,6 +68,7 @@ public partial class PropGrabber : Node
Vector3 pivot = PalmPoint(handBody, hand); Vector3 pivot = PalmPoint(handBody, hand);
_joints[hand] = MakeWeld(handBody, prop, pivot, RigidWeldBias); _joints[hand] = MakeWeld(handBody, prop, pivot, RigidWeldBias);
_localAnchor[hand] = prop.GlobalTransform.AffineInverse() * pivot; _localAnchor[hand] = prop.GlobalTransform.AffineInverse() * pivot;
_arms?.SetHeld(hand, prop);
GD.Print($"PropGrabber: grabbed '{prop.Name}' ({prop.Mass:0.###} kg, {(hand == 0 ? "left" : "right")}{(_held[other] == prop ? ", two-handed" : "")})"); GD.Print($"PropGrabber: grabbed '{prop.Name}' ({prop.Mass:0.###} kg, {(hand == 0 ? "left" : "right")}{(_held[other] == prop ? ", two-handed" : "")})");
} }
} }
@@ -182,6 +185,7 @@ public partial class PropGrabber : Node
private void DestroyJoint(int hand) private void DestroyJoint(int hand)
{ {
_arms?.SetHeld(hand, null);
if (_joints[hand] != null && IsInstanceValid(_joints[hand])) if (_joints[hand] != null && IsInstanceValid(_joints[hand]))
_joints[hand].QueueFree(); _joints[hand].QueueFree();
_joints[hand] = null; _joints[hand] = null;