fix: Add per-tick callback, fix Rapier rollback index, and physics server state access - #640
fix: Add per-tick callback, fix Rapier rollback index, and physics server state access#640realpoke wants to merge 6 commits into
Conversation
This would break sub stepping. If the game tick rate is running at 30htz and you need physics to run at 60htz this is no longer possible. See Physics Factor in https://foxssake.github.io/netfox/latest/netfox.extras/guides/physics/
I believe the root cause here was recently fixed in #633 and we now deregister when the node is gone.
This looks ok, some unit tests proving it with scenarios would be awesome.
I think this is dead code, we don't actually use it ... |
`_clock_stretch_factor` is calculated by lerping linearly between `1.0 / clock_stretch_max` and `clock_stretch_max`. Stretch factors are speed multipliers, so they compose geometrically - the neutral point is the geometric mean, not the arithmetic one. With the default `clock_stretch_max` of 1.25, a `clock_diff` of zero yields `lerpf(0.8, 1.25, 0.5)` = 1.025, so the simulation clock runs 2.5% fast even when perfectly in sync. The controller is still stable, but it settles at a nonzero error: the clock parks permanently ahead of the reference clock by roughly `-0.111 * ticktime`. That scales with tick time, so it is negligible at high tick rates and grows noticeable at low ones: | Tickrate | Steady-state clock offset | | --- | --- | | 60 Hz | ~1.9 ms | | 30 Hz | ~3.7 ms | | 10 Hz | ~11.1 ms | At low tick rates that constant offset is large enough to intermittently tip `_get_ticks_in_loop()` over into running an extra tick or skipping one, which shows up as jitter. Interpolate in log space instead: `pow(clock_stretch_max, t)` where `t` is the clock difference in ticks, clamped to `[-1, 1]`. - `t = 0` gives exactly `1.0` - `t = 1` gives `clock_stretch_max` - `t = -1` gives `1.0 / clock_stretch_max` Bounds and the "reach max at one tick of difference" behaviour are unchanged. Speeding up and slowing down by the same amount are now exact inverses, which was the property the old code violated. The calculation is extracted into a pure static method so it can be tested without a running clock. Added a Vest suite covering the neutral point, both bounds, clamping, symmetry, and direction.
Two bugs in `_rollback_space()` compound.
1. `_stored_states` is never incremented. It starts at 0 and is only
incremented after the guard that reads it, which always returns first (`offset`
is always >= 1 during resimulation). `load_cached_state()` is therefore never
called - with the Rapier drivers, `rollback_physics_space` silently does nothing,
while rollback still re-steps the space forward.
2. The index assumes the cache is newest-first. It isn't. StateManager's
`push_state_to_cache` appends and evicts from the front, so index 0 is the
oldest cached state. Verified against godot-rapier 0.8.40 on Godot 4.7.1:
ordered_cache_tags() -> [100, 101, 102, 103, 104]
load_cached_state(space, 0) -> restores tick 100 (oldest)
Fixing (1) alone would be worse than the old behaviour: it would activate
`load_cached_state()` with an index derived from the wrong ordering, restoring an
unrelated tick on every rollback. Measured with a cache of 8 and ticks 0-19
snapshotted, every lookup lands on the wrong state:
want tick 19 -> index 1 -> got 13
want tick 17 -> index 3 -> got 15
want tick 14 -> index 6 -> got 18
Drop `_stored_states` and look the tick up by its cache tag. netfox already
passes the tick as the tag to `cache_state()`, and `ordered_cache_tags()` exposes
them in cache order.
`rfind()` rather than `find()`, because a tick is cached more than once while
history is rewritten during rollback and the most recent entry is the one we
want. A negative result means the tick has aged out, and rollback is correctly
skipped.
`_physics_rollback_tick()` is called once per physics sub-step but handed a network tick number. With `physics_factor > 1` it runs multiple times for the same tick, which is correct for continuous forces (cleared after each step) and wrong for one-off impulses/input consumption (applied multiple times). Measured with godot-rapier 0.8.40 on Godot 4.7.1, `physics_factor = 2`, 30 Hz tick, 1 kg body, no gravity: | | applied once per tick | applied per sub-step | | --- | --- | --- | | `apply_central_force(10 N)` | vx = 0.16611 | vx = 0.33250 | | `apply_central_impulse(10)` | vx = 9.96675 | vx = 19.95011 | Add `_before_physics_rollback_tick(delta, tick)`, called once per network tick with the full tick delta, before any sub-step. `_physics_rollback_tick()` keeps its per-sub-step behaviour, preserving backward compatibility.
…erver `get_state()` / `set_state()` go through `body_get_state()` / `body_set_state()` on the RID rather than a handle cached at `_ready`. These are the same server calls the direct state object forwards to, and match the pattern `godot_driver_3d.gd` uses for snapshotting. Benefits: - No cached handle to go stale after a body is freed - On a freed RID, `body_get_state()` returns nil rather than erroring - Verified working with Rapier 0.8.40 `direct_state` is kept as a computed property rather than removed, so existing user code still works - it is fetched on access now instead of cached. Also makes sleeping state restore explicitly last, since setting transform and velocities wakes the body, and fixes the 2D placeholder array which was seeded with 3D types.
`NetworkHistoryServer._get_earliest_for()` has no callers, and contains a typo
that makes it always return -1 regardless of input:
if earliest < 0:
earliest = earliest # never leaves -1
Since nothing uses it, removing it is cleaner than maintaining dead code.
The `NetworkSchemas` suite fails on Godot 4.4 and up due to three issues in `network-schemas.test.gd`: 1. `vec2f16` case called `NetworkSchemas.vec2f32()`. Below 4.4 the expected size matched f32 due to the f32 fallback; on 4.4+ it expected 4 bytes while f32 wrote 8. 2. `normal2f16`, `quat16f` and `transform2f16` carry ~11 bits of mantissa and don't round-trip bit-identically. Exact `expect_equal` fails when real half-floats are active. 3. `Transform3D.rotated()` was called with an unnormalized axis `Vector3.ONE`, which caused Godot to print an error and return identity (testing identity components 0/1 instead of real rotation). Fixes: - Point `vec2f16` case at `NetworkSchemas.vec2f16()`. - Normalize the rotation axis in `transform3f*` cases. - Add optional per-case float component tolerance and apply `HALF_EPSILON` to half-precision cases. - Fix format string placeholder count in size mismatch assertion.
|
Thanks for the feedback! You were spot on regarding continuous forces vs impulses, continuous forces |
Hey! I ran into a few edge cases while testing my project with Rapier at low network tick rates and a 1-2 physics factor. I tracked the jitter, broken physics and crashes down to a few issues.
This PR refactors
NetworkRigidBodyrollback stepping, fixes state retrieval bugs in the Rapier physics drivers, and improves NetworkTime clock stretch precision.1. Per-tick callback on
NetworkRigidBody(feat)_physics_rollback_tick()runs once per sub-step (physics_factortimes per network tick), which is required for continuous forces (apply_central_force) since Godot resets forces every step._before_physics_rollback_tick(delta, tick)which runs once per network tick with the full tickdelta. Use this for one-shot logic (impulses, state transitions, input consumption) to prevent impulses from runningphysics_factortimes.2. Rapier driver rollback state fix (
fix)StateManager::push_state_to_cachestores states oldest-first (index 0 is oldest). Derived offset math previously retrieved the wrong cached tick during rollback._state.ordered_cache_tags().rfind(tick).3. Physics server state access (
refactor)NetworkRigidBody2D/3Dget_state()andset_state()to queryPhysicsServer2D/PhysicsServer3Ddirectly via RID.direct_statefrom a cached@onreadyvariable to a computed property (get: return PhysicsServer...) so it cannot go stale if a node is freed.BODY_STATE_SLEEPINGlast duringset_state()so transform/velocity updates don't wake the body.4. Log-space clock stretch interpolation (
fix)_clock_stretch_factorin log space (pow(clock_stretch_max, t)). Linear interpolation resulted in1.025stretch factor at zero clock difference, causing the clock to park ahead of reference and introduce jitter at lower tickrates.NetworkTime.5. NetworkSchemas test suite fixes (
fix)vec2f16test invokingvec2f32().Transform3D.rotated()in 3D transform tests.6. Clean up dead code (
chore)_get_earliest_for()inNetworkHistoryServer.Tests
sh/test.sh: 325 passed, 0 failed.Let me know if anything needs adjustments. Thanks!