feat(physics): draw bound nodes between fixed steps - #585
Merged
Conversation
Physics runs at a fixed rate and the display does not, so at 60 Hz physics on a 144 Hz screen most frames showed the same fixed state twice and then jumped. `PhysicsBinding` wrote the newest fixed transform verbatim, and bodies kept no earlier state, so an application could not interpolate for itself either. A body now brackets its most recent fixed step: `previousX`, `previousY` and `previousAngle` hold the transform that step started from, `x`/`y`/`angle` the one it produced. The pair is captured in `_finalizePosition` before the step's delta is applied and before its no-motion early return, so a body that did not move reports previous === current instead of a stale pair from the last step it did move in, and several fixed steps inside one `step()` call leave the pair describing the last of them. `setTransform` collapses the pair: a teleport is a discontinuity, not motion, and must not be swept across. `PhysicsWorld.interpolation` (default off) switches bindings from snapping to blending. The factor comes from `frameAlphaSource`, defaulting to the world's own accumulator - correct for a world driven with `step()`, which advances it. A world registered as a System goes through `fixedUpdate()` and bypasses that accumulator, so it has to be pointed at the host's own fraction. Physics deliberately does not run a second clock to avoid that: it would drift from the host's the moment a step is clamped or dropped. Interpolated presentation therefore moves to the world's new variable-rate `update()` phase, since the factor is only final once the frame's last fixed step has run. Angles blend with a plain lerp - body angles are continuous and unbounded, never wrapped into a range, so there is no shortest-arc case. A host-supplied factor is clamped to [0, 1] (NaN to 0) so a stale or custom accumulator extrapolates the node past the simulated state instead of blending between two known ones. Interpolation is presentation only; the simulation is bit-identical either way. Claude-Session: https://claude.ai/code/session_01R4RHfm7nhMPXAuxFrpgrqj
Exoridus
enabled auto-merge (squash)
August 21, 2026 21:47
Bundle ReportChanges will increase total bundle size by 5.64kB (0.03%) ⬆️. This is within the configured threshold ✅ Detailed changes
Affected Assets, Files, and Routes:view changes for bundle: exojs-physics-esmAssets Changed:
Files in
Files in
Files in
Files in
view changes for bundle: exo-full-iife-Exo-iifeAssets Changed:
Files in
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Physics runs at a fixed rate and the display does not, so at 60 Hz physics on a 144 Hz screen most frames showed the same fixed state twice and then jumped.
PhysicsBindingwrote the newest fixed transform verbatim, and bodies kept no earlier state — so an application could not interpolate for itself either.The bracket
A body now brackets its most recent fixed step:
previousX/previousY/previousAnglex/y/angleThe pair is captured in
_finalizePosition, before the step's delta is applied and before its no-motion early return. Two consequences that are the point of doing it there:previous === current, not a stale pair from the last step it did move in;step()call leave the pair describing the last of them.setTransform()collapses the pair — a teleport is a discontinuity, not motion, and must not be swept across.Turning it on
PhysicsWorld.interpolation(defaultfalse) switches bindings from snapping to blending:The blend factor comes from
frameAlphaSource, which defaults to the world's own accumulator — correct for a world driven withstep(), since that is what advances it.A world registered as a System goes through
fixedUpdate()and bypasses that accumulator, so it has to be pointed at the host's own fraction:Physics deliberately does not run a second clock to avoid that wiring: a private accumulator would drift from the host's the moment a step is clamped or dropped. Interpolated presentation therefore moves to the world's new variable-rate
update()phase — the factor is only final once the frame's last fixed step has run, sofixedUpdate()(which can run several times per frame) is the wrong slot for it.Details
[0, 1], andNaNmaps to0— a stale or custom accumulator would otherwise extrapolate the node past the simulated state instead of blending between two known ones.body.x/body.yremain the authoritative fixed-step values. The trade is latency: a bound node renders up to one fixed step behind the newest state, which is what makes the motion continuous.Validation
pnpm verify:quick16/16 ·pnpm test10 593 passed · API docs regenerated · guide section added to Physics basics (typechecked, no newno-checkblocks).New
interpolation.test.ts(13 cases) covers the bracket over one and several steps, an unmoved body, the teleport collapse, the constructed initial state, snapping by default, blending by the leftover fraction, unbounded-angle rotation past a full turn, a teleport not being swept, factor clamping includingNaN, switching interpolation off at runtime, and the system-driven split betweenfixedUpdate()andupdate().https://claude.ai/code/session_01R4RHfm7nhMPXAuxFrpgrqj