Skip to content

fix: frame-rate-independent velocity spring (60 Hz-anchored)#76

Merged
blugart-dev merged 1 commit into
mainfrom
fix/spring-framerate-independence
Jun 19, 2026
Merged

fix: frame-rate-independent velocity spring (60 Hz-anchored)#76
blugart-dev merged 1 commit into
mainfrom
fix/spring-framerate-independence

Conversation

@blugart-dev

Copy link
Copy Markdown
Owner

The headline correctness item from the audit. ⚠️ Touches the physics-feel path — please give it an in-editor visual pass before merge.

Problem

SpringResolver divided corrections by delta and blended velocity by a constant per-tick weight. Over a fixed wall-clock time you apply that weight tick_rate times, so the effective stiffness — and the residual velocity that then feeds the strength-scaled damping and the max_angular/linear_velocity clamp — scaled with the physics tick rate: stiffer/twitchier at 120 Hz, mushier at 30 Hz, with all tuning implicitly calibrated to 60 Hz. The plugin advertises "Godot 4.7+" with no tick-rate requirement.

Fix (stays velocity-based — not a PD rewrite)

Normalize both the velocity target and the blend weight to a 60 Hz reference:

static func _fr_weight(weight, delta):
    return 1.0 - pow(1.0 - clampf(weight, 0.0, 1.0), delta * _REFERENCE_HZ)  # _REFERENCE_HZ = 60

At 60 Hz, delta * 60 == 1, so _fr_weight is the identity and the velocity target error * 60 == error / delta — i.e. bit-identical to the old behaviour at the default tick rate (existing tuning unchanged). At other rates the convergence per unit wall-clock time is constant. I deliberately did not rewrite the spring into a force/torque PD controller — that would contradict the documented velocity-based architecture.

Validation

  • 112/112 GUT, stable across 4 runs. Crucially, the 109 pre-existing physics tests pass unchanged (springs-hold-against-gravity, ragdoll, recovery, foot-IK) — direct evidence the 60 Hz feel is preserved.
  • test_spring_math.gd (new, +3) proves the math: identity at 60 Hz, frame-rate-independent decay at 30/60/120, and out-of-range weights clamp (no NaN).
  • Clean scene-smoke (0 errors, 150 frames) on demo, euphoria_showcase, shooting_range.
  • Resolves the last Still open item in ROADMAP.md.

🔬 Visual review checklist (please)

  1. 60 Hz (default): reactions should look exactly as before — same stagger wobble, ragdoll, recovery feel. (The math says identical; confirm no perceptible change.)
  2. 120 Hz (Project Settings → Physics → Common → Physics Ticks/Second = 120): characters should no longer feel stiffer/twitchier than 60 Hz.
  3. 30 Hz: should no longer feel noticeably mushier/laggier than 60 Hz.

euphoria_showcase.tscn is the best scene to feel it (stagger sway + active resistance).

🤖 Generated with Claude Code

SpringResolver divided corrections by delta and applied a constant per-tick lerp
weight, so the fraction of error closed per second — and the residual velocity
that feeds damping and the max-velocity clamp — scaled with the physics tick
rate: stiffer/twitchier at 120 Hz, mushier at 30 Hz, with all tuning implicitly
calibrated to 60 Hz. The plugin advertises "Godot 4.7+" with no tick-rate caveat.

Fix, staying within the documented velocity-based architecture (NOT a PD rewrite):
normalize both the velocity target and the blend weight to a 60 Hz reference via
_fr_weight(w, delta) = 1 - pow(1 - clamp(w,0,1), delta*60). At 60 Hz this is the
identity (delta*60 == 1), so existing tuning is preserved bit-for-bit; at other
rates the convergence per unit wall-clock time is constant.

- spring_resolver.gd: angular + pin lerps use _REFERENCE_HZ targets and _fr_weight.
- test_spring_math.gd (new, +3): identity at 60 Hz; frame-rate-independent decay
  at 30/60/120; out-of-range weight clamps (no NaN).
- ROADMAP/CHANGELOG: resolves the last "Still open" hardening item.

Validation: 112/112 GUT (the 109 pre-existing physics tests pass unchanged,
proving 60 Hz behaviour is preserved), stable x4; clean scene-smoke on the
active-ragdoll demos. Feel at 30/60/120 Hz pending in-editor visual review.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@blugart-dev blugart-dev merged commit dcc2b1b into main Jun 19, 2026
1 check passed
@blugart-dev blugart-dev deleted the fix/spring-framerate-independence branch June 19, 2026 17:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant