Skip to content

feat!: step physics in the r3f v10 scheduler's fixed-step phase - #788

Open
DennisSmolek wants to merge 16 commits into
nextfrom
feat/v10-scheduler-stepping
Open

DennisSmolek wants to merge 16 commits into
nextfrom
feat/v10-scheduler-stepping

Conversation

@DennisSmolek

@DennisSmolek DennisSmolek commented Oct 10, 2026 •

Copy link
Copy Markdown
Member

Summary

This is the core stepping work for 3.0. It moves the physics loop onto the react-three-fiber v10 scheduler (@pmndrs/scheduler 0.3) and its fixed-timestep phases.

Depends on the 3.0 foundation PR (#787), which bumps to @react-three/fiber 10 canary, React 19.2 and three 0.186. This branch is rebased on feat/3.0-foundation, so until #787 merges the diff also shows its commits. Review from feat!: step physics… onward.

What changed

  • Split stepping. Physics stepping is now two functions:
    • stepWorld(dt) runs once per fixed substep. It calls before-step callbacks with (world, dt), then snapshots the previous pose for interpolation (after the callbacks, so teleports made there are not lerped). Then it runs world.step, calls after-step callbacks with (world, dt), and drains collision, contact-force and soft-body tear events.
    • syncMeshes(alpha) runs once per rendered frame, visiting bodies parent-first. It handles sleep/wake events, writes interpolated poses into Object3Ds and instances, and calls invalidate() once if any non-fixed body is awake.
    • lateSync() runs in a rapier:late phase right before render. If any parent read by the sync has moved since (an ancestor animated in a regular useFrame, drei <Float>), it re-expresses the already synced world poses in the new parent spaces. When nothing moved, it costs one updateWorldMatrix plus a matrix compare per distinct parent.
  • Scheduler fixed phase (mode A).
    • Each world owns one fixed phase, rapier:fixed:<n>, inserted after r3f's physics phase with addPhase(name, { before: "rapier", timestep, maxSubsteps }). A timeStep or maxSubSteps change retimes that phase in place with setPhaseTimestep(name, timeStep, { maxSubsteps }). On unmount the phase goes back to a pool that lives on the scheduler singleton, and the next world reuses it.
    • A sync job runs in a new per-frame rapier phase, which sits before update. It uses scheduler.getOverstep(phase, rootId) as the interpolation alpha.
  • Fallback (mode B). If getScheduler() lacks getOverstep/getPhaseTimestep (scheduler < 0.3, e.g. fiber 10.0.0-alpha.5), one per-frame job in rapier runs our own accumulator. It uses the same banking and capping rules as the scheduler, then syncs.
  • timeStep="vary" runs one per-frame job: one step with the frame delta clamped to 0.5s, then sync with alpha 1.
  • updateLoop="independent" keeps its own rAF loop. That loop uses the same accumulator, and its first frame now reports a delta of 0 instead of performance.now().
  • maxSubSteps prop (default 5, matching react-three-jolt) caps catch-up. Surplus time is dropped and only the fractional step is kept (Performance issue with a custom collider #697).
  • Presentation Controls doesn't work the same #36 parent space. Every frame, poses are written in the body's current parent space.
    • The parent's world matrix is re-read once per sync pass and shared by its children, using updateWorldMatrix(true, false).
    • Sleeping bodies are re-synced when their parent moves. A re-parented body picks up its new parent, because the parent identity is compared, not only its version.
    • States are visited in scene-depth order, so a nested <RigidBody> is written after the body that carries it. The order is rebuilt when bodies register or unregister, not every frame.
  • Interpolation resets. Previous poses are forgotten after a manual step(), after setWorld(), and on paused/interpolate/timeStep changes. Until the next step, the current pose renders without lerping back.
    • Interpolation happens in world space, so each body needs only one compose/premultiply/decompose.
  • Demo is broken in various ways ("Debug" button, Cradle demo and Plinko demo - possibly more) #666 Debug. Debug now keeps one persistent BufferGeometry.
    • Attributes are rewritten in place. They grow by powers of two, disposing the outgrown GPU buffers.
    • Only the used range is uploaded, drawRange is set to the vertex count, and the geometry is disposed on unmount.
    • The job runs in update, after this frame's steps. The lines show the latest step, so with interpolation they lead the meshes by up to one step.
    • Rapier's RGBA colors stay itemSize: 4. WebGLRenderer reads them through vertexAlphas. In three 0.186 WebGPU, VertexColorNode is vec4 and NodeMaterial multiplies it in, so no conversion is needed.
  • World teardown (world and EventQueue) is deferred to a microtask, the same approach as react-three-jolt.
    • Child cleanups now run against a live world. Before, the parent freed the world first and the lazy proxy then created and leaked a new one.
    • StrictMode remounts are safe.
  • Soft-body seam. softBodyTearCallbacks and the per-substep drainSoftBodyTearEvents are included verbatim from feat: soft bodies (SoftBody, useSoftBody) #786 so that merge is trivial. WorldStepCallback is (world: World, dt: number) => void and exported, which feat: kinematic character controller (useCharacterController + <CharacterController>) #783 relies on.

Scheduler design decision: one fixed phase per world, retimed in place, not setPhaseTimestep('physics')

Phase timesteps are global to the scheduler singleton, while clocks are per root per phase. Retiming the built-in physics phase would cause three problems:

  1. It would silently change the timestep for every other user of that phase (other physics libraries, user useFrame({ phase: 'physics' }) code).
  2. It would make two <Physics> worlds with different timeSteps impossible.
  3. Every change would reset that phase's clock on every root.

So each world gets its own rapier:fixed:<n> phase:

  • setPhaseTimestep on that phase works for non-built-in phases in scheduler 0.3. I checked PhaseGraph.setPhaseTimestep, which updates maxSubsteps too. It resets only that phase's clock, so a timeStep change affects only this world.
  • The scheduler has no removePhase. Released phases therefore go to a pool keyed on the scheduler singleton, and the phase count is bounded by the most worlds mounted at once. An idle phase costs one clock advance per root per frame.
  • The phase is acquired in a layout effect, so StrictMode's double mount releases and re-acquires the same one.

state.overstep only reports the first fixed phase (physics), so the sync job calls getOverstep(phase, rootId) explicitly.

Prop semantics

Prop 3.0 behaviour
timeStep={n} (default 1/60) Fixed steps of n. Mode A: phase rapier:fixed:n:maxSubSteps. Mode B / independent: internal accumulator.
timeStep="vary" One step per frame with min(delta, 0.5), no interpolation.
maxSubSteps (new, default 5) The most fixed steps per frame. Surplus time is dropped. Ignored for "vary".
interpolate (default true) Alpha = overstep (or accumulator / timeStep). false means alpha 1.
paused The step job is disabled. No time is banked, so there is no burst on resume. Sync still runs (alpha 1), and step() still works.
updatePriority Breaking. It is only { priority } within the step/sync phases (higher first). It no longer takes over rendering.
updateLoop="follow" Scheduler jobs, as above. Works with frameloop="demand" because sync invalidates while bodies are awake.
updateLoop="independent" Own rAF loop. It uses the same accumulator and maxSubSteps and invalidates the canvas.

Test results

  • New tests/stepping.test.tsx, 27 tests. The first five run under both mode A and a forced mode B:
    • fixed-step determinism for varying deltas
    • the maxSubSteps cap and that dropped time stays dropped
    • paused: no steps and no burst on resume
    • interpolation equals the overstep, and interpolate={false}
    • step hooks receive dt, and single-argument hooks still work
    • vary mode
    • manual step while paused syncs meshes
    • two worlds with 1/60 and 1/30 in one canvas, with physics still at 1/60
    • demand frameloop keeps ticking via invalidation, and fixed-only scenes don't invalidate
    • independent loop
    • moving and rotating an ancestor, including while the body sleeps (Presentation Controls doesn't work the same #36). I checked that this test fails when the parent inverse is not refreshed.
    • Debug geometry and attributes are reused and disposed once per toggle (Demo is broken in various ways ("Debug" button, Cradle demo and Plinko demo - possibly more) #666)
    • Regressions ported from the review repros, each failing before the fix:
      • nested body has no frame of lag (R1)
      • a teleport in useBeforePhysicsStep is not lerped (R4)
      • re-parenting (R5)
      • timeStep changes don't grow scheduler.phases and the world steps at the new rate (R6)
      • remounts reuse phases
      • no back-lerp after a manual step and resume (R9)
      • a teleport while paused
      • an ancestor moved and rotated in a default-phase useFrame, awake and asleep (R10)
  • physics.test.tsx: the independent-loop test now waits 150 ms per window, because the rAF loop no longer starts with a 0.5s catch-up burst.
  • Snapshot: this PR restores physics.test.tsx.snap to the exact values in next. chore: 3.0 foundation (yarn 4, r3f v10 stack, rollup build, CI, alpha prerelease) #787 re-baselined it to tiny float noise (…523 → …554). That noise came from the old code lerping with an accumulator alpha just below 1. The new stepper renders the exact simulated pose at that frame.
  • Locally on the foundation base: yarn typecheck passes, vitest run passes 65/65 (stable across 3 runs), and prettier is clean on the touched files. The remaining yarn lint errors are in files this PR does not touch.

Migration: Stepping & the r3f v10 scheduler

For docs/advanced/migration.mdx

Where physics runs. With the default updateLoop="follow", <Physics> registers two scheduler jobs per world:

  • A step job in the world's own fixed-timestep phase, named rapier:fixed:<n>. It runs right after r3f's physics phase, once per whole timeStep of banked frame time, so zero, one or several times per frame.
  • A sync job in a per-frame rapier phase, just before update. It writes interpolated transforms to your meshes once per frame.
  • A late check in a rapier:late phase, just before render. It re-applies those transforms if an ancestor of a body moved during update.

The phase order is start → input → physics → rapier:fixed:… → rapier → update → rapier:late → render → finish. A useFrame in the default update phase therefore always sees meshes that were already synced this frame.

The built-in physics phase timestep is never changed, so other libraries and your own useFrame(cb, { phase: "physics" }) code keep their 1/60. To run code in lockstep with Rapier, use useBeforePhysicsStep / useAfterPhysicsStep, not the physics phase.

updatePriority changed meaning (breaking). It used to be passed to useFrame as a numeric priority, and a value above 0 took over r3f's render loop. It is now only the order of this world's jobs within its phases. Higher runs first, matching r3f v10's { priority }. r3f keeps rendering in all cases. If you relied on updatePriority to render manually, register your own useFrame(cb, { phase: "render" }).

New maxSubSteps (default 5). This is the most fixed steps one frame may run. Any time beyond that is dropped, so a stalled tab no longer makes physics fast-forward. Raise it if you use a small timeStep (e.g. 1/240) and must not lose simulation time at low frame rates.

Step hooks receive dt.

useBeforePhysicsStep((world, dt) => {
  // dt === timeStep for fixed steps, or the clamped frame delta for "vary"
});

Existing (world) => … callbacks keep working.

Events are per substep. Collision, intersection and contact-force events are now dispatched after every physics step instead of once per frame. Handlers may run several times in one frame. They also run before the frame's mesh sync. In a payload, the rigidBodyObject / colliderObject transforms are therefore the previous frame's rendered (interpolated) pose. For the simulated pose at that step, read payload.rigidBody.translation() / .rotation().

Pausing. paused banks no time, so resuming does not jump ahead. useRapier().step(dt) still steps manually while paused, and it updates meshes immediately. Interpolation restarts from the current pose after a manual step, a pause/resume, or setWorld(), so visuals never lerp backwards.

Teleports. Teleport with setTranslation inside useBeforePhysicsStep to avoid any interpolation smear. A teleport made elsewhere (an event handler, a regular useFrame) is interpolated from the previous step's pose for at most one step.

Demand frameloop. <Canvas frameloop="demand"> works with the default "follow" loop. Physics invalidates the canvas every frame while any dynamic or kinematic body is awake, and stops when everything sleeps. updateLoop="independent" is no longer needed for this. Physics only notices bodies on a frame. So when you wake or move a body outside a frame while the canvas is idle (e.g. applyImpulse in a DOM click handler), call invalidate() once, as in 2.x.

Moving parents. A <RigidBody>'s visual is written into its parent's current space every frame. Moving, rotating or re-parenting an ancestor after mount (PresentationControls, drei <Float>, a group animated in useFrame) no longer desyncs visuals from bodies. This holds even when the ancestor moves during update, after the sync, because the late check catches it. The bodies themselves stay where the simulation put them, because ancestors do not drag bodies along. Nested <RigidBody>s are synced parent-first.

Changing timeStep at runtime. Changing it works and retimes only that world's phase. Each change drops the fraction of a step already banked, so avoid animating timeStep every frame (e.g. for slow motion). Prefer scaling forces or gravity, or use paused plus manual step(dt).

Debug lines. <Physics debug> draws the latest simulated step. With interpolation, the lines can lead the rendered meshes by up to one step.

Older fiber builds. If your @react-three/fiber ships a scheduler without fixed phases (@pmndrs/scheduler < 0.3, e.g. 10.0.0-alpha.5), <Physics> falls back to its own accumulator in a per-frame job. The behaviour is the same, including maxSubSteps, paused and interpolation.

clock is gone from frame state. This affects user code only: use state.elapsed and state.delta.

🤖 Generated with Claude Code

DennisSmolek and others added 13 commits October 10, 2026 12:09
- packageManager yarn@4.18.1, nodeLinker node-modules (.yarnrc.yml)
- move root tooling from dependencies to devDependencies
- drop @manypkg/cli (only used by the `ci` script)
- demo/addons depend on workspace packages via workspace:*
- engines.node >=22, ignore .yarn state but keep releases/plugins/patches

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- @react-three/fiber + test-renderer 10.0.0-canary.a056612 (scheduler 0.3, fixed step),
  three/@types/three 0.186, react/react-dom 19.2.8, drei 11.0.0-alpha.7 (demo)
- peerDependencies: fiber >=10.0.0-0, react >=19.0 <19.3, three >=0.185.0;
  rapier-addons peers on @react-three/rapier ^3.0.0-0
- three-stdlib ^2.36.1 (2.35 imports LuminanceFormat, removed from three)
- vitest 5 + jsdom 30, typescript 5.9; vitest.config.mts replaces vite.config.mts and
  resolves workspace packages from src (no build needed); root tsconfig uses
  moduleResolution Bundler with matching `paths`
- utils.ts: type-only narrowing for fiber v10's readonly-tuple math types
- tests: await `create()` instead of racing it against mount callbacks (overlapping act
  scopes), expect the extra zero-delta priming frame from advanceFrames, re-baseline the
  physics snapshot (float noise from scheduler ms timing, see PR)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mirrors react-three-jolt's build: `tsc -p tsconfig.build.json` emits per-file declarations,
rollup bundles each entry to dist/<name>.{mjs,cjs} with esbuild and rolls declarations up
into dist/<name>.{d.ts,d.mts,d.cts}.

- shared config in scripts/rollup-library.mjs; entry points are derived from each
  package's `exports` map, so a subpath entry (e.g. `./soft-bodies` -> src/soft-bodies/index.ts)
  only needs an `exports` + `files` entry. Relative imports of another entry are rewritten to
  the published specifier so core is never inlined twice.
- package.json: `exports` map (import/require with types), `type`, `sideEffects: false`,
  `engines`, narrowed `files`, repository/bugs/homepage/license metadata
- deps, peers and their subpaths are external
- `check:package` (publint --strict + attw --pack) per package, `yarn check:packages` at root
- drop babel config and the preconstruct dev postinstall

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Minimal fixes only; the demo migration proper (WebGPU, routing, perf overlay) is separate work.

- resolve @react-three/rapier(-addons) from workspace sources via vite aliases + tsconfig
  paths (keeps the old `preconstruct dev` behaviour: no build needed, HMR on library code)
- moduleResolution Bundler (exports maps of fiber v10 / drei 11 / three)
- `state.clock` is gone in v10: use `state.elapsed`
- `<Canvas shadows>` -> `gl={{ shadows: true }}` (keeps the default WebGL renderer)
- drei 11: `Text` comes from `@react-three/drei/legacy`; drop unused `useCamera`
- OrbitControls type from three/addons instead of three-stdlib
- SpringExample: stop deep-importing library internals (blocked by the exports map)
- replace r3f-perf (pins drei 9 / an old three-stdlib that breaks on three 0.186) with drei
  <Stats>

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- eslint.config.mjs mirrors react-three-jolt (js + typescript-eslint recommended, react,
  react-hooks rules-of-hooks/exhaustive-deps, jsx-a11y, eslint-config-prettier last);
  automatic JSX runtime everywhere
- prettier 3 with the repo's existing style options moved to .prettierrc, so the deferred
  reformat stays small (6 files differ today)
- `lint`, `format`, `format:check` scripts. The codebase is NOT reformatted or lint-fixed
  here (baseline: 29 errors / 126 warnings); that lands at the end of the 3.0 integration.
- pre-commit hook runs `yarn typecheck` instead of the full CI

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- ci.yml (replaces test.yml): typecheck, test, build + publint/attw, demo build, and a
  non-blocking lint/format job; runs on PRs and pushes to main and next, with concurrency
  cancellation and read-only permissions
- shared .github/actions/setup composite: corepack (yarn 4) + setup-node with yarn cache +
  `yarn install --immutable`
- current action majors: checkout v7, setup-node v7, configure-pages v6,
  upload-pages-artifact v5, deploy-pages v5 (supersedes #733)
- release.yml mirrors react-three-jolt: runs on main and next, owner guard, permissions
  (contents/pull-requests write, id-token for provenance), yarn 4 publish env;
  `changeset:version` / `changeset:publish` scripts (publish builds and tests first)
- @changesets/cli 3; `yarn changeset pre enter alpha` (.changeset/pre.json), so `next`
  releases 3.0.0-alpha.n under the `alpha` dist-tag; `prerelease:enter`/`prerelease:exit`
  scripts replace the canary ones
- changeset for the r3f v10 stack / package format (core + addons, major)
- dependabot: root workspace + github-actions ecosystem, ignore majors of the r3f stack
- .nvmrc v22.22.2 (jsdom 30 needs >=22.22.2), demo `typecheck` script

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Split Physics stepping into stepWorld(dt), run once per fixed substep
(interpolation snapshot, before-step callbacks, world.step, after-step
callbacks, collision/contact-force/soft-body tear event draining), and
syncMeshes(alpha), run once per rendered frame (sleep/wake events,
interpolated transforms, a single invalidate() while bodies are awake).

With updateLoop="follow" and a scheduler that supports fixed phases
(@pmndrs/scheduler >= 0.3), each world steps in a dedicated fixed phase
`rapier:fixed:<timeStep>:<maxSubSteps>` inserted after `physics`, and
meshes sync in a per-frame `rapier` phase before `update`, with the
phase's overstep as the interpolation alpha. The shared `physics` phase
is never retimed. Without fixed-phase support the same contract runs on
our own accumulator inside a per-frame job. timeStep="vary" steps once
per frame with the clamped delta; updateLoop="independent" keeps its own
rAF loop (whose first frame no longer reports a huge delta).

- New maxSubSteps prop (default 5) bounds catch-up; surplus time is
  dropped (#697).
- paused disables the step job; no time is banked, resume does not burst.
- Manual step() works while paused and syncs meshes immediately.
- updatePriority is now ordering within the phase only (BREAKING).
- useBeforePhysicsStep / useAfterPhysicsStep callbacks receive
  (world, dt); single-argument callbacks keep working.
- Bodies are synced into their CURRENT parent space: the parent inverse
  world matrix is re-read every frame, so moving or rotating an ancestor
  after mount keeps visuals on their bodies, sleeping ones included (#36).
- The world is freed in a microtask after unmount so child cleanups run
  against a live world and StrictMode remounts are safe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Debug kept replacing its LineSegments geometry with a new BufferGeometry
every frame, and the last one leaked on every toggle. Keep one geometry,
rewrite its attributes in place (grow only when needed, upload only the
used range, draw range for the vertex count) and dispose it on unmount.
Rapier's RGBA colors stay a 4-component attribute, which both the WebGL
(vertexAlphas) and WebGPU (vec4 vertexColor node) renderers read. The
job runs in `update`, after meshes were synced in the `rapier` phase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixed-step determinism, maxSubSteps cap, pause without burst, and
interpolation by overstep, each under both the scheduler fixed phase and
the accumulator fallback; dt in step hooks; vary mode; manual step while
paused; multiple worlds with different timeSteps; demand frameloop
invalidation; independent loop; moving/rotating ancestors (#36); debug
geometry reuse and disposal across toggles (#666).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The foundation re-baseline captured float noise from lerping the old
accumulator alpha; with the scheduler stepping the synced positions are
again bit-identical to the values in next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eMemo

Also apply prettier 3 formatting from the foundation tooling and make
importRapier's binding const.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: dce14bb

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@react-three/rapier Major
@react-three/rapier-addons Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

DennisSmolek and others added 3 commits October 10, 2026 12:47
…rpolation resets, phase reuse)

- Sync bodies parent-first: the state map keeps a scene-depth order,
  rebuilt only when bodies register/unregister, so a nested RigidBody no
  longer lags the body carrying it by a frame.
- Late pass in a `rapier:late` phase right before render: if any parent
  read by the sync moved afterwards (e.g. an ancestor animated in a
  regular useFrame, drei <Float>), re-express the already synced world
  poses in the new parent space. One matrix compare per distinct parent
  when nothing moved (#36).
- Compare the parent object too, not just its space version, so a
  re-parented body picks up its new parent space.
- Take the interpolation snapshot after before-step callbacks (teleports
  there no longer lerp) and forget previous poses after a manual step(),
  setWorld(), and paused/interpolate/timeStep changes, so resuming or
  stepping manually never renders behind the latest pose.
- One fixed phase per world (`rapier:fixed:<n>`), retimed in place with
  setPhaseTimestep and returned to a pool on unmount, instead of a phase
  per (timeStep, maxSubSteps) pair: changing timeStep no longer grows the
  scheduler's phase list, and remounts reuse phases.
- Free the EventQueue together with the world.
- Debug: the lines show the latest step, ahead of interpolated meshes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…reparenting and phase reuse

Ported from the PR review repros (R1, R4, R5, R6, R9, R10); each failed
before the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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