Repository navigation
feat!: step physics in the r3f v10 scheduler's fixed-step phase - #788
Open
DennisSmolek wants to merge 16 commits into
Open
DennisSmolek wants to merge 16 commits into
DennisSmolek wants to merge 16 commits into
Conversation
- 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 detectedLatest commit: dce14bb The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
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 |
6 of 19 tasks
…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
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.
Summary
This is the core stepping work for 3.0. It moves the physics loop onto the react-three-fiber v10 scheduler (
@pmndrs/scheduler0.3) and its fixed-timestep phases.Depends on the 3.0 foundation PR (#787), which bumps to
@react-three/fiber10 canary, React 19.2 and three 0.186. This branch is rebased onfeat/3.0-foundation, so until #787 merges the diff also shows its commits. Review fromfeat!: step physics…onward.What changed
Physicsstepping 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 runsworld.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 callsinvalidate()once if any non-fixed body is awake.lateSync()runs in arapier:latephase right beforerender. If any parent read by the sync has moved since (an ancestor animated in a regularuseFrame, drei<Float>), it re-expresses the already synced world poses in the new parent spaces. When nothing moved, it costs oneupdateWorldMatrixplus a matrix compare per distinct parent.rapier:fixed:<n>, inserted after r3f'sphysicsphase withaddPhase(name, { before: "rapier", timestep, maxSubsteps }). AtimeStepormaxSubStepschange retimes that phase in place withsetPhaseTimestep(name, timeStep, { maxSubsteps }). On unmount the phase goes back to a pool that lives on the scheduler singleton, and the next world reuses it.rapierphase, which sits beforeupdate. It usesscheduler.getOverstep(phase, rootId)as the interpolation alpha.getScheduler()lacksgetOverstep/getPhaseTimestep(scheduler < 0.3, e.g. fiber10.0.0-alpha.5), one per-frame job inrapierruns 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 ofperformance.now().maxSubStepsprop (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).updateWorldMatrix(true, false).<RigidBody>is written after the body that carries it. The order is rebuilt when bodies register or unregister, not every frame.step(), aftersetWorld(), and onpaused/interpolate/timeStepchanges. Until the next step, the current pose renders without lerping back.BufferGeometry.drawRangeis set to the vertex count, and the geometry is disposed on unmount.update, after this frame's steps. The lines show the latest step, so with interpolation they lead the meshes by up to one step.itemSize: 4. WebGLRenderer reads them throughvertexAlphas. In three 0.186 WebGPU,VertexColorNodeisvec4andNodeMaterialmultiplies it in, so no conversion is needed.EventQueue) is deferred to a microtask, the same approach as react-three-jolt.softBodyTearCallbacksand the per-substepdrainSoftBodyTearEventsare included verbatim from feat: soft bodies (SoftBody, useSoftBody) #786 so that merge is trivial.WorldStepCallbackis(world: World, dt: number) => voidand 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
physicsphase would cause three problems:useFrame({ phase: 'physics' })code).<Physics>worlds with differenttimeSteps impossible.So each world gets its own
rapier:fixed:<n>phase:setPhaseTimestepon that phase works for non-built-in phases in scheduler 0.3. I checkedPhaseGraph.setPhaseTimestep, which updatesmaxSubstepstoo. It resets only that phase's clock, so atimeStepchange affects only this world.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.state.oversteponly reports the first fixed phase (physics), so the sync job callsgetOverstep(phase, rootId)explicitly.Prop semantics
timeStep={n}(default 1/60)n. Mode A: phaserapier:fixed:n:maxSubSteps. Mode B / independent: internal accumulator.timeStep="vary"min(delta, 0.5), no interpolation.maxSubSteps(new, default 5)"vary".interpolate(default true)falsemeans alpha 1.pausedstep()still works.updatePriority{ priority }within the step/sync phases (higher first). It no longer takes over rendering.updateLoop="follow"frameloop="demand"because sync invalidates while bodies are awake.updateLoop="independent"maxSubStepsand invalidates the canvas.Test results
tests/stepping.test.tsx, 27 tests. The first five run under both mode A and a forced mode B:maxSubStepscap and that dropped time stays droppedinterpolate={false}dt, and single-argument hooks still workphysicsstill at 1/60useBeforePhysicsStepis not lerped (R4)timeStepchanges don't growscheduler.phasesand the world steps at the new rate (R6)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.physics.test.tsx.snapto the exact values innext. 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.yarn typecheckpasses,vitest runpasses 65/65 (stable across 3 runs), and prettier is clean on the touched files. The remainingyarn linterrors 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:rapier:fixed:<n>. It runs right after r3f'sphysicsphase, once per wholetimeStepof banked frame time, so zero, one or several times per frame.rapierphase, just beforeupdate. It writes interpolated transforms to your meshes once per frame.rapier:latephase, just beforerender. It re-applies those transforms if an ancestor of a body moved duringupdate.The phase order is
start → input → physics → rapier:fixed:… → rapier → update → rapier:late → render → finish. AuseFramein the defaultupdatephase therefore always sees meshes that were already synced this frame.The built-in
physicsphase timestep is never changed, so other libraries and your ownuseFrame(cb, { phase: "physics" })code keep their 1/60. To run code in lockstep with Rapier, useuseBeforePhysicsStep/useAfterPhysicsStep, not thephysicsphase.updatePrioritychanged meaning (breaking). It used to be passed touseFrameas 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 onupdatePriorityto render manually, register your ownuseFrame(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 smalltimeStep(e.g. 1/240) and must not lose simulation time at low frame rates.Step hooks receive
dt.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/colliderObjecttransforms are therefore the previous frame's rendered (interpolated) pose. For the simulated pose at that step, readpayload.rigidBody.translation()/.rotation().Pausing.
pausedbanks 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, orsetWorld(), so visuals never lerp backwards.Teleports. Teleport with
setTranslationinsideuseBeforePhysicsStepto avoid any interpolation smear. A teleport made elsewhere (an event handler, a regularuseFrame) 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.applyImpulsein a DOM click handler), callinvalidate()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 inuseFrame) no longer desyncs visuals from bodies. This holds even when the ancestor moves duringupdate, 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
timeStepat runtime. Changing it works and retimes only that world's phase. Each change drops the fraction of a step already banked, so avoid animatingtimeStepevery frame (e.g. for slow motion). Prefer scaling forces or gravity, or usepausedplus manualstep(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/fiberships 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, includingmaxSubSteps,pausedand interpolation.clockis gone from frame state. This affects user code only: usestate.elapsedandstate.delta.🤖 Generated with Claude Code