Render-clock tracking timestamps (menu toggle), physics step cap 6, docs for step 2.7

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
FoxyzandClaude Opus 5.5 committed 2026-10-02 20:43:49 +03:00
1 parent 22159aa902
commit ca60df3d75
9 files changed
+111 -11

No files matched your search

+8 -1
View File
@@ -321,7 +321,7 @@ device only; the editor has an XR simulator for dev. Trackers/action map: see `x
`PhysicsMaterialOverride` before AddChild.** `PhysicsMaterialOverride` before AddChild.**
**Step 2.6:** landing DIP is physics (legs touch down only within reach → leg strength stops the body below **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); 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` 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` (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 `_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 → **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 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). 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. Owner scope: avatar scaling and NPCs later.
10. **Body physics Stage 2 — PHYSICAL ARMS, implemented 2026-10-02, awaiting owner playtest.** 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 `PhysicsArms.cs`: per side an upper-arm + forearm RigidBody3D (2.0 / 1.2 kg, layer 5) that
+2 -2
View File
@@ -27,9 +27,9 @@ project/assembly_name="VR Messs"
[physics] [physics]
common/physics_ticks_per_second=144 common/physics_ticks_per_second=144
common/physics_interpolation=true common/max_physics_steps_per_frame=6
common/max_physics_steps_per_frame=14
3d/physics_engine="Box3D Physics" 3d/physics_engine="Box3D Physics"
common/physics_interpolation=true
[rendering] [rendering]
+20
View File
@@ -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 | | 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. **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 |
+5
View File
@@ -20,6 +20,7 @@ public partial class MainMenu : PanelContainer
public event Action Clicked; public event Action Clicked;
public event Action<bool> SpectatorToggled; public event Action<bool> SpectatorToggled;
public event Action<bool> SmoothTurnToggled; public event Action<bool> SmoothTurnToggled;
public event Action<bool> LowLagTrackingToggled;
public event Action<float> StrengthChanged; public event Action<float> StrengthChanged;
public TabContainer Tabs { get; private set; } 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)); SpectatorButton = MakeToggle("Spectator: off", "Spectator: on (grab-fly)", on => SpectatorToggled?.Invoke(on));
modeRow.AddChild(SpectatorButton); modeRow.AddChild(SpectatorButton);
player.AddChild(modeRow); 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); Tabs.AddChild(player);
// World // World
+25 -1
View File
@@ -15,6 +15,9 @@ public partial class PlayerRig : Node
public const float MoveAcceleration = 8f; // m/s² public const float MoveAcceleration = 8f; // m/s²
public const float StickDeadzone = 0.2f; public const float StickDeadzone = 0.2f;
public static bool SmoothTurn = false; 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 SnapTurnDegrees = 30f;
public const float SmoothTurnSpeed = 2.5f; // rad/s public const float SmoothTurnSpeed = 2.5f; // rad/s
public const float SnapTurnThreshold = 0.7f; public const float SnapTurnThreshold = 0.7f;
@@ -144,7 +147,8 @@ public partial class PlayerRig : Node
_driver.RebuildRequested |= _rebuild; _driver.RebuildRequested |= _rebuild;
_rebuild = false; _rebuild = false;
Transform3D o = _origin.GlobalTransform; 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; _origin.GlobalTransform = o;
_lastOrigin = _origin.GlobalTransform; _lastOrigin = _origin.GlobalTransform;
_haveLastOrigin = true; _haveLastOrigin = true;
@@ -153,6 +157,24 @@ public partial class PlayerRig : Node
_interpolateOrigin = true; _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 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. // the origin since (teleport marker, menu) is respected as the new real one.
private void RestorePhysicsOrigin() private void RestorePhysicsOrigin()
@@ -168,6 +190,8 @@ public partial class PlayerRig : Node
public override void _Process(double delta) public override void _Process(double delta)
{ {
_renderTicks = Engine.GetPhysicsFrames() + Engine.GetPhysicsInterpolationFraction();
_renderDelta = delta;
if (_interpolateOrigin && !Spectator) if (_interpolateOrigin && !Spectator)
{ {
Transform3D current = _origin.GlobalTransform; Transform3D current = _origin.GlobalTransform;
+16 -7
View File
@@ -21,19 +21,25 @@ public sealed class PoseFilter
public void Reset() => _has = false; 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) if (!_has)
{ {
_has = true; _has = true;
_last = raw; _last = raw;
_tLast = now; _tLast = stamp;
Velocity = AngularVelocity = Vector3.Zero; Velocity = AngularVelocity = Vector3.Zero;
return raw; return raw;
} }
if (raw != _last) if (raw != _last)
{ {
float interval = (float)(now - _tLast); float interval = (float)(stamp - _tLast);
if (interval > 1e-4f) if (interval > 1e-4f)
{ {
if (raw.Origin.DistanceTo(_last.Origin) > JumpDistance) if (raw.Origin.DistanceTo(_last.Origin) > JumpDistance)
@@ -49,9 +55,9 @@ public sealed class PoseFilter
} }
} }
_last = raw; _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 Basis spin = AngularVelocity.LengthSquared() > 1e-10f
? new Basis(AngularVelocity.Normalized(), AngularVelocity.Length() * age) : Basis.Identity; ? new Basis(AngularVelocity.Normalized(), AngularVelocity.Length() * age) : Basis.Identity;
return new Transform3D(spin * _last.Basis.Orthonormalized(), _last.Origin + Velocity * age); 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 // origin = play space (modified by the correction); raw* = origin-local tracked poses as last
// reported (repeat between tracking updates); moveVelocity = intended origin velocity (stick), // reported (repeat between tracking updates); moveVelocity = intended origin velocity (stick),
// the only origin motion that is real hand motion for the guides' feed-forward. // 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, 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; _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) if (Ragdoll.Built && !RebuildRequested)
origin.Origin += Ragdoll.OriginCorrection((origin * head).Origin, origin.Origin.Y + StickCrouch, dt); origin.Origin += Ragdoll.OriginCorrection((origin * head).Origin, origin.Origin.Y + StickCrouch, dt);
float floorY = origin.Origin.Y + StickCrouch; float floorY = origin.Origin.Y + StickCrouch;
+33
View File
@@ -83,6 +83,7 @@ public partial class RagdollSpike : Node3D
AddChild(_ragdoll); AddChild(_ragdoll);
_phases.Add(("hinge sign", SetupHinge, TickHinge, 0.3f, FinishHinge)); _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(("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(("reaches forward, out and overhead", Rest, TickReach, 4.5f, FinishReach));
_phases.Add(("fast arm swings stay attached", Rest, TickSwing, 3f, FinishSwing)); _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; 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) private static StaticBody3D MakeStatic(Vector3 size, Vector3 pos, uint mask = 0)
{ {
StaticBody3D body = new() { Position = pos, CollisionMask = mask }; StaticBody3D body = new() { Position = pos, CollisionMask = mask };
+1
View File
@@ -0,0 +1 @@
uid://c4rg1kcdtsxni
+1
View File
@@ -79,6 +79,7 @@ public partial class WorldPanel : Node3D
_menu.ClickVolumeSlider.ValueChanged += v => _click.VolumeDb = Mathf.LinearToDb((float)v / 70f); _menu.ClickVolumeSlider.ValueChanged += v => _click.VolumeDb = Mathf.LinearToDb((float)v / 70f);
_menu.StrengthChanged += v => PlayerRagdoll.Strength = v; _menu.StrengthChanged += v => PlayerRagdoll.Strength = v;
_menu.SmoothTurnToggled += on => PlayerRig.SmoothTurn = on; _menu.SmoothTurnToggled += on => PlayerRig.SmoothTurn = on;
_menu.LowLagTrackingToggled += on => PlayerRig.SteadyTracking = !on;
_menu.SpectatorToggled += PlayerRig.SetSpectator; _menu.SpectatorToggled += PlayerRig.SetSpectator;
SetPanelActive(false); SetPanelActive(false);