Skip to content

feat(physics): draw bound nodes between fixed steps - #585

Merged
Exoridus merged 1 commit into
mainfrom
physics/fixed-state-interpolation
Aug 21, 2026
Merged

feat(physics): draw bound nodes between fixed steps#585
Exoridus merged 1 commit into
mainfrom
physics/fixed-state-interpolation

Conversation

@Exoridus

Copy link
Copy Markdown
Owner

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.

The bracket

A body now brackets its most recent fixed step:

previousX / previousY / previousAngle the transform that step started from
x / y / angle the transform it produced

The 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:

  • a body that did not move reports previous === current, not a stale pair from the last step it did move in;
  • 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.

Turning it on

PhysicsWorld.interpolation (default false) switches bindings from snapping to blending:

const world = new PhysicsWorld({ gravity: { x: 0, y: 1000 }, interpolation: true });

The blend factor comes from frameAlphaSource, which defaults to the world's own accumulator — correct for a world driven with step(), 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:

world.frameAlphaSource = () => app.frameAlpha;

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, so fixedUpdate() (which can run several times per frame) is the wrong slot for it.

Details

  • Angles blend with a plain lerp: body angles are continuous and unbounded, never wrapped into a range, so there is no shortest-arc case to resolve.
  • A host-supplied factor is clamped to [0, 1], and NaN maps to 0 — a stale or custom accumulator would otherwise extrapolate the node past the simulated state instead of blending between two known ones.
  • Interpolation is presentation only. The simulation is bit-identical either way, and body.x/body.y remain 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:quick 16/16 · pnpm test 10 593 passed · API docs regenerated · guide section added to Physics basics (typechecked, no new no-check blocks).

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 including NaN, switching interpolation off at runtime, and the system-driven split between fixedUpdate() and update().

https://claude.ai/code/session_01R4RHfm7nhMPXAuxFrpgrqj

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
Exoridus enabled auto-merge (squash) August 21, 2026 21:47
@codecov

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

Bundle Report

Changes will increase total bundle size by 5.64kB (0.03%) ⬆️. This is within the configured threshold ✅

Detailed changes
Bundle name Size Change
exo-full-iife-Exo-iife 1.65MB 1.22kB (0.07%) ⬆️
exojs-physics-esm 195.72kB 4.42kB (2.31%) ⬆️

Affected Assets, Files, and Routes:

view changes for bundle: exojs-physics-esm

Assets Changed:

Asset Name Size Change Total Size Change (%)
PhysicsWorld.js 1.71kB 33.77kB 5.34% ⚠️
PhysicsBody.js 1.12kB 17.51kB 6.86% ⚠️
binding/BindingRegistry.js 414 bytes 2.55kB 19.35% ⚠️
binding/PhysicsBinding.js 1.17kB 2.03kB 136.25% ⚠️

Files in PhysicsWorld.js:

  • ./src/PhysicsWorld.ts → Total Size: 33.16kB

Files in PhysicsBody.js:

  • ./src/PhysicsBody.ts → Total Size: 17.28kB

Files in binding/BindingRegistry.js:

  • ./src/binding/BindingRegistry.ts → Total Size: 2.4kB

Files in binding/PhysicsBinding.js:

  • ./src/binding/PhysicsBinding.ts → Total Size: 1.91kB
view changes for bundle: exo-full-iife-Exo-iife

Assets Changed:

Asset Name Size Change Total Size Change (%)
exo.full.iife.min.js 1.22kB 1.65MB 0.07%

Files in exo.full.iife.min.js:

  • ./packages/exojs-physics/src/PhysicsWorld.ts → Total Size: 33.96kB

  • ./packages/exojs-physics/src/binding/BindingRegistry.ts → Total Size: 2.48kB

  • ./packages/exojs-physics/src/binding/PhysicsBinding.ts → Total Size: 1.98kB

  • ./packages/exojs-physics/src/PhysicsBody.ts → Total Size: 17.74kB

@codecov

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.73684% with 2 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
...kages/exojs-physics/src/binding/BindingRegistry.ts 60.00% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@Exoridus
Exoridus merged commit 24afcb4 into main Aug 21, 2026
20 checks passed
@Exoridus
Exoridus deleted the physics/fixed-state-interpolation branch August 21, 2026 21:55
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