diff --git a/AGENTS.md b/AGENTS.md index 593bc04..e31e93a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -321,7 +321,7 @@ device only; the editor has an XR simulator for dev. Trackers/action map: see `x `PhysicsMaterialOverride` before AddChild.** **Step 2.6:** landing DIP is physics (legs touch down only within reach → leg strength stops the body below its stance; big falls fold the legs, Boneworks-style; rise eased so gravity alone can stop it at the stance); - JUMP on right A (countermovement dip + leg push over the stroke — crouching first jumps higher); STICK CROUCH on + JUMP on right A (2.7: hold to charge, release to jump; crouching charges too); STICK CROUCH on right stick Y (`RagdollDriver.StickCrouch` = virtual floor offset); body facing from `BodyMath.HeadFacing` (no flip looking down past vertical); the XR origin is DRAWN interpolated like the bodies (`PlayerRig` `_physOrigin`, restored before physics) — the camera/body "two places at once" desync. Rule learned: play-space @@ -330,6 +330,13 @@ device only; the editor has an XR simulator for dev. Trackers/action map: see `x **The crag** (`Crag.cs`): a 26 m box-rock cliff north of the playground with one graded route (boulders → ladder → 8 m ledge → jug wall → traverse → overhang → 16 m ledge → chimney → summit with props); the way down is the "Crag" teleport marker at the base. Regression `scenes/crag_spike.tscn` "CRAG: overall PASS" (layout only). + **Step 2.7 (owner 2026-10-02: "Nimso's project is the main source of inspiration/mechanics — don't reinvent + the wheel"; check `research/nimso-project-tx.md` + `research/sources/nimso-transcripts/` FIRST for any body + mechanic):** ground under the BODY (centre ray + stance-wide sphere footprint), clambering by PRESSURE (higher + ground is stood on once the hips are over it and the body presses onto it — no special mantle mechanic), + capsule hips (Nimso ~27 cm), heavy physics objects brace like world, arms always collide with held things, + SOFT GRAB (`SoftGrab.cs`: eased into the hand before the weld), hold-A-to-charge jump, render-clock-timed + tracking (`PoseFilter` sampleTime; menu toggle "Tracking: steady/low lag"), physics step cap 6. Owner scope: avatar scaling and NPCs later. 10. **Body physics Stage 2 — PHYSICAL ARMS, implemented 2026-10-02, awaiting owner playtest.** `PhysicsArms.cs`: per side an upper-arm + forearm RigidBody3D (2.0 / 1.2 kg, layer 5) that diff --git a/project.godot b/project.godot index 9ddd8a5..7a9f617 100644 --- a/project.godot +++ b/project.godot @@ -27,9 +27,9 @@ project/assembly_name="VR Messs" [physics] common/physics_ticks_per_second=144 -common/physics_interpolation=true -common/max_physics_steps_per_frame=14 +common/max_physics_steps_per_frame=6 3d/physics_engine="Box3D Physics" +common/physics_interpolation=true [rendering] diff --git a/research/ragdoll-player-step2.md b/research/ragdoll-player-step2.md index 880b3f6..1de15fb 100644 --- a/research/ragdoll-player-step2.md +++ b/research/ragdoll-player-step2.md @@ -221,3 +221,23 @@ fall); held poses drift 1 cm with the eye within 7 mm; crawl, mantle, climb, pul | Menu summon on left X only | Right A is jump | **Shoulder range (2026-10-02):** the fork's DRIVE now honours angular limits (Box3D motor joint + swing ellipse/twist, `research/godot-box3d-6dof.md`); `PlayerRagdoll.ShoulderRange` (Y −90..+100° mirrored, Z −85..+155°, twist ±80°, frame X = humerus at build). Ragdoll spike unchanged with it on — the limits only engage when something forces the arm. + +## Step 2.7 — climbing family, soft grab, charge jump, render-timed tracking (2026-10-02) + +Owner: Nimso's Project TX is the main source of mechanics. His clambering has "no actual mechanics — the physics +engine does all the work"; higher ground "doesn't move my body up … until I push on it with enough pressure". + +| Change | Why / measured | +|---|---| +| **Ground = centre ray + sphere footprint** (r 0.12, face normal from a ray 2 cm inside the contact). Same-level ground from either; higher ground from the centre ray, or from the footprint while clambering | One ray dropped support as the hips swayed off a plank. Spike: 15 cm plank, ±9 cm hip sway → never loses the plank | +| **Pressure rule** replaces "hips well above + whole hips over": stand on higher ground when the hips are over it and pressing = airborne, hands braced, lifted, a press within 0.6 s, or legs tucked | The old rule needed a full press-up. Two hands mantle 0.9 m and chest-high 1.2 m; one hand can't do 1.2 m | +| **Bracing on physics objects** (≥ 15 kg or frozen; not what the hand carries) | Mantling the 25 kg playground box never counted as bracing (owner screenshot). Now mantles | +| **Knee up**: braced (or just pressed) + hips held back tucks the legs collider | Thigh-high box: straight arms can't press while standing | +| **Capsule hips** (Nimso) | The box pelvis's bottom edge caught ledge lips 1 cm below it | +| **Support goal ≤ straight legs** | A head above the calibrated height (tiptoes) floated the body 35 cm | +| **Arms never get collision exceptions** with held objects | Gripping one hold of a crag rock switched the arm off against the whole rock — arms "randomly" phased through the world | +| **Soft grab** (`SoftGrab.cs`): grip point = palm-normal cast (else toward the centre); light props (≤ 6 kg) are moved to the palm, otherwise the hand goes to the hold; ~0.1 s, position only, weld when touching or stalled | Welds froze hover gaps. 1 kg cube from 6 cm → 1.5 mm in 0.10 s; bar from 5 cm → 1.2 cm (contact slop) | +| **Jump: hold A to charge, release** (support goal sinks at 1.2 m/s, max stroke 0.5 m; JumpForce 2.2 g) | Owner: Boneworks-style. Tap 0.16 m, full charge 0.68 m, crouch + tap 0.66 m | +| **Render-clock tracking timestamps** (`PoseFilter.Sample(..., sampleTime)`, `PlayerRig.TrackingAge`, bible v2 recipe) | At 1 m/s the per-tick speed spread is 1.25 m/s by arrival vs 0.013 m/s render-timed — every feed-forward beat at 18 Hz. Constant ~2-tick lag instead. Toggle in the menu to compare | +| **max_physics_steps_per_frame 14 → 6** | One hitch could queue 14 ragdoll ticks in a frame (bible v2) | +| Tried and reverted: arm aim from the physical shoulder vertically while climbing/tilted | Free-hand error 6.4 → 6.2 cm only, and it weakened pulls. The "tracking off when tilted" screenshots need a recording to diagnose | diff --git a/scripts/MainMenu.cs b/scripts/MainMenu.cs index ef8260b..a69d252 100644 --- a/scripts/MainMenu.cs +++ b/scripts/MainMenu.cs @@ -20,6 +20,7 @@ public partial class MainMenu : PanelContainer public event Action Clicked; public event Action SpectatorToggled; public event Action SmoothTurnToggled; + public event Action LowLagTrackingToggled; public event Action StrengthChanged; public TabContainer Tabs { get; private set; } @@ -155,6 +156,10 @@ public partial class MainMenu : PanelContainer SpectatorButton = MakeToggle("Spectator: off", "Spectator: on (grab-fly)", on => SpectatorToggled?.Invoke(on)); modeRow.AddChild(SpectatorButton); player.AddChild(modeRow); + // Compare in the headset: tracking timed by the render clock (even motion) or by arrival (less lag). + HBoxContainer trackingRow = new(); + trackingRow.AddChild(MakeToggle("Tracking: steady", "Tracking: low lag", on => LowLagTrackingToggled?.Invoke(on))); + player.AddChild(trackingRow); Tabs.AddChild(player); // World diff --git a/scripts/PlayerRig.cs b/scripts/PlayerRig.cs index d6292e2..2e0db30 100644 --- a/scripts/PlayerRig.cs +++ b/scripts/PlayerRig.cs @@ -15,6 +15,9 @@ public partial class PlayerRig : Node public const float MoveAcceleration = 8f; // m/s² public const float StickDeadzone = 0.2f; public static bool SmoothTurn = false; + // Tracking timed by the render clock (bible v2: uniform motion, constant ~2-tick lag) vs by + // arrival (less lag, 18 Hz speed beat). Menu toggle so both can be compared in the headset. + public static bool SteadyTracking = true; public const float SnapTurnDegrees = 30f; public const float SmoothTurnSpeed = 2.5f; // rad/s public const float SnapTurnThreshold = 0.7f; @@ -144,7 +147,8 @@ public partial class PlayerRig : Node _driver.RebuildRequested |= _rebuild; _rebuild = false; Transform3D o = _origin.GlobalTransform; - _driver.Tick(profile, ref o, _camera.Transform, _left.Transform, _right.Transform, _moveVelocity, dt); + _driver.Tick(profile, ref o, _camera.Transform, _left.Transform, _right.Transform, _moveVelocity, dt, + SteadyTracking ? TrackingAge() : null); _origin.GlobalTransform = o; _lastOrigin = _origin.GlobalTransform; _haveLastOrigin = true; @@ -153,6 +157,24 @@ public partial class PlayerRig : Node _interpolateOrigin = true; } + // The tracking poses read in this tick were delivered with the last frame, predicted for its render + // time. In physics ticks: render time of that frame ≈ (physics frames + fraction) at its _process, + // plus one frame delta for the frame these ticks belong to (bible v2 recipe). + private double TrackingAge() + { + float rate = Engine.PhysicsTicksPerSecond; + ulong frame = Engine.GetProcessFrames(); + if (frame != _sampleFrame) + { + _sampleFrame = frame; + _sampleTicks = _renderTicks + _renderDelta * rate; + } + return (Engine.GetPhysicsFrames() - _sampleTicks) / rate; + } + + private double _renderTicks, _renderDelta, _sampleTicks; + private ulong _sampleFrame = ulong.MaxValue; + // The origin on screen is interpolated; physics must run on the real one. Anyone else who moved // the origin since (teleport marker, menu) is respected as the new real one. private void RestorePhysicsOrigin() @@ -168,6 +190,8 @@ public partial class PlayerRig : Node public override void _Process(double delta) { + _renderTicks = Engine.GetPhysicsFrames() + Engine.GetPhysicsInterpolationFraction(); + _renderDelta = delta; if (_interpolateOrigin && !Spectator) { Transform3D current = _origin.GlobalTransform; diff --git a/scripts/RagdollDriver.cs b/scripts/RagdollDriver.cs index f80eb6c..0c344a8 100644 --- a/scripts/RagdollDriver.cs +++ b/scripts/RagdollDriver.cs @@ -21,19 +21,25 @@ public sealed class PoseFilter public void Reset() => _has = false; - public Transform3D Sample(Transform3D raw, double now) + // sampleTime: when the pose was really measured (the render time of the frame that delivered it), + // in the same clock as `now`. Without it a sample is timestamped by the first tick that saw it, + // which at 144 Hz under 90 Hz frames alternates 1 and 2 ticks late: the extrapolated motion + // then beats at 18 Hz (±30% speed per frame — bible v2, measured). With it the age is exact + // and motion uniform, at a constant ~2-tick lag. May be negative (a tick before the sample). + public Transform3D Sample(Transform3D raw, double now, double? sampleTime = null) { + double stamp = sampleTime ?? now; if (!_has) { _has = true; _last = raw; - _tLast = now; + _tLast = stamp; Velocity = AngularVelocity = Vector3.Zero; return raw; } if (raw != _last) { - float interval = (float)(now - _tLast); + float interval = (float)(stamp - _tLast); if (interval > 1e-4f) { if (raw.Origin.DistanceTo(_last.Origin) > JumpDistance) @@ -49,9 +55,9 @@ public sealed class PoseFilter } } _last = raw; - _tLast = now; + _tLast = stamp; } - float age = Mathf.Min((float)(now - _tLast), MaxExtrapolation); + float age = Mathf.Clamp((float)(now - _tLast), -MaxExtrapolation, MaxExtrapolation); Basis spin = AngularVelocity.LengthSquared() > 1e-10f ? new Basis(AngularVelocity.Normalized(), AngularVelocity.Length() * age) : Basis.Identity; return new Transform3D(spin * _last.Basis.Orthonormalized(), _last.Origin + Velocity * age); @@ -78,11 +84,14 @@ public sealed class RagdollDriver // origin = play space (modified by the correction); raw* = origin-local tracked poses as last // reported (repeat between tracking updates); moveVelocity = intended origin velocity (stick), // the only origin motion that is real hand motion for the guides' feed-forward. + // trackingAge: how long ago (s) the current tracking poses were measured, if known (PlayerRig + // derives it from the render clock); null = timestamp by arrival (the spike's simulated input). public void Tick(BodyProfile profile, ref Transform3D origin, Transform3D rawHead, Transform3D rawLeft, Transform3D rawRight, - Vector3 moveVelocity, float dt) + Vector3 moveVelocity, float dt, double? trackingAge = null) { _now += dt; - Transform3D head = Head.Sample(rawHead, _now), left = Left.Sample(rawLeft, _now), right = Right.Sample(rawRight, _now); + double? stamp = trackingAge.HasValue ? _now - trackingAge.Value : null; + Transform3D head = Head.Sample(rawHead, _now, stamp), left = Left.Sample(rawLeft, _now, stamp), right = Right.Sample(rawRight, _now, stamp); if (Ragdoll.Built && !RebuildRequested) origin.Origin += Ragdoll.OriginCorrection((origin * head).Origin, origin.Origin.Y + StickCrouch, dt); float floorY = origin.Origin.Y + StickCrouch; diff --git a/scripts/RagdollSpike.cs b/scripts/RagdollSpike.cs index 1e26c17..17321e3 100644 --- a/scripts/RagdollSpike.cs +++ b/scripts/RagdollSpike.cs @@ -83,6 +83,7 @@ public partial class RagdollSpike : Node3D AddChild(_ragdoll); _phases.Add(("hinge sign", SetupHinge, TickHinge, 0.3f, FinishHinge)); + _phases.Add(("pose filter: render-timed tracking moves evenly (no 18 Hz beat)", Rest, _ => { }, 0.05f, FinishPoseFilter)); _phases.Add(("stands still, head on the camera, hands on the controllers", Rest, _ => { }, 4f, FinishStand)); _phases.Add(("reaches forward, out and overhead", Rest, TickReach, 4.5f, FinishReach)); _phases.Add(("fast arm swings stay attached", Rest, TickSwing, 3f, FinishSwing)); @@ -233,6 +234,38 @@ public partial class RagdollSpike : Node3D private int NewSnaps => _ragdoll.Snaps - _snapsAtStart; + // A controller moving at 1 m/s, sampled once per 90 Hz frame (predicted for that frame's render + // time), read by 144 Hz ticks. Per-tick output steps should all be 1/144 m: timestamped by + // arrival they alternate (1 or 2 ticks late), timestamped by render time they don't. + private static bool FinishPoseFilter() + { + float Spread(bool stamped) + { + PoseFilter f = new(); + float min = float.MaxValue, max = 0f; + Vector3 prev = Vector3.Zero; + for (int tick = 0; tick < 300; tick++) + { + double now = tick / 144.0; + long frame = (long)Math.Floor(now * 90.0); // the last frame delivered before this tick + double render = (frame + 1) / 90.0; // its predicted display time + Transform3D raw = new(Basis.Identity, new Vector3((float)render, 0f, 0f)); + Vector3 p = f.Sample(raw, now, stamped ? render : null).Origin; + if (tick > 20) + { + float step = p.X - prev.X; + min = Mathf.Min(min, step); + max = Mathf.Max(max, step); + } + prev = p; + } + return (max - min) * 144f; // spread of the per-tick speed, m/s + } + float arrival = Spread(false), stamped = Spread(true); + GD.Print($"RAGDOLL: pose filter at 1 m/s: per-tick speed spread {arrival:F3} m/s by arrival, {stamped:F3} m/s render-timed"); + return stamped < 0.05f && stamped < arrival; + } + private static StaticBody3D MakeStatic(Vector3 size, Vector3 pos, uint mask = 0) { StaticBody3D body = new() { Position = pos, CollisionMask = mask }; diff --git a/scripts/SoftGrab.cs.uid b/scripts/SoftGrab.cs.uid new file mode 100644 index 0000000..8f2a316 --- /dev/null +++ b/scripts/SoftGrab.cs.uid @@ -0,0 +1 @@ +uid://c4rg1kcdtsxni diff --git a/scripts/WorldPanel.cs b/scripts/WorldPanel.cs index d925a11..182ed06 100644 --- a/scripts/WorldPanel.cs +++ b/scripts/WorldPanel.cs @@ -79,6 +79,7 @@ public partial class WorldPanel : Node3D _menu.ClickVolumeSlider.ValueChanged += v => _click.VolumeDb = Mathf.LinearToDb((float)v / 70f); _menu.StrengthChanged += v => PlayerRagdoll.Strength = v; _menu.SmoothTurnToggled += on => PlayerRig.SmoothTurn = on; + _menu.LowLagTrackingToggled += on => PlayerRig.SteadyTracking = !on; _menu.SpectatorToggled += PlayerRig.SetSpectator; SetPanelActive(false);