diff --git a/.changeset/calm-bugs-fixed.md b/.changeset/calm-bugs-fixed.md
new file mode 100644
index 00000000..e73c9ea0
--- /dev/null
+++ b/.changeset/calm-bugs-fixed.md
@@ -0,0 +1,17 @@
+---
+"@react-three/rapier": patch
+"@react-three/rapier-addons": patch
+---
+
+Bug fixes for 3.0:
+
+- Forwarded refs on `RigidBody`, colliders and `InstancedRigidBodies` instances are now cleared when the underlying rapier object is destroyed, instead of pointing at freed objects (#780)
+- Trimesh and convex hull colliders now read geometry positions correctly for interleaved, normalized and quantized attributes, and indices are converted to `Uint32Array`; failing to build a collider now throws a descriptive error (#781, #627)
+- Heightfield collider args are scaled per axis and no longer mutated in place, which compounded scale on remount (#564)
+- `mass`, `density` and `massProperties` collider props now update after mount (#711)
+- Re-export `ActiveCollisionTypes`, `ActiveEvents`, `ActiveHooks` and `QueryFilterFlags` from the package index (#703)
+- Add a regression test for changing the `RigidBody` `type` after mount (#314)
+- `InstancedRigidBodies` no longer spreads `key` into JSX, which warned on React 19 (#691)
+- `InstancedRigidBodies` disables frustum culling on its instanced mesh by default, since cached bounds go stale as bodies move; pass `frustumCulled` to re-enable it (#683)
+- Replace `three-stdlib` with `three/examples/jsm` imports and remove the dependency (#698)
+- Document `HeightfieldArgs` (`heights` must have `(rows + 1) * (cols + 1)` values) and warn in development when the length mismatches (#730)
diff --git a/.changeset/character-controller.md b/.changeset/character-controller.md
new file mode 100644
index 00000000..c02a3a4e
--- /dev/null
+++ b/.changeset/character-controller.md
@@ -0,0 +1,10 @@
+---
+"@react-three/rapier": minor
+---
+
+Add a kinematic character controller built on Rapier's `KinematicCharacterController`.
+
+- `useCharacterController(options)` creates and frees the controller (StrictMode safe), applies `offset`, `up`, `slide`, `maxSlopeClimbAngle`, `minSlopeSlideAngle`, `autostep`, `snapToGround`, `applyImpulsesToDynamicBodies`, `characterMass` and `normalNudgeFactor` reactively, and returns a stable `{ controller, move(rigidBody, desiredTranslation, opts) }` api. `move` computes the collision-resolved movement, calls `setNextKinematicTranslation` and returns a pooled `{ movement, grounded, collisions }`.
+- `` wraps a `kinematicPosition` `RigidBody`. Its ref exposes `{ controller, rigidBody, grounded, setVelocity(v), translate(delta) }`; the desired motion is applied once per fixed physics step with the fixed `dt`, so it is framerate independent. It also accepts `onGrounded`, `onAirborne` (edge triggered) and `onCharacterCollision`.
+
+Options reverting to `undefined` restore Rapier's defaults. `` defaults to `colliders={false}`. Gravity and jumping are intentionally left to userland. See the `character-controller` demo.
diff --git a/.changeset/pre.json b/.changeset/pre.json
new file mode 100644
index 00000000..9a89b8a3
--- /dev/null
+++ b/.changeset/pre.json
@@ -0,0 +1,4 @@
+{
+ "mode": "pre",
+ "tag": "alpha"
+}
diff --git a/.changeset/r3f-v10-foundation.md b/.changeset/r3f-v10-foundation.md
new file mode 100644
index 00000000..c478358a
--- /dev/null
+++ b/.changeset/r3f-v10-foundation.md
@@ -0,0 +1,27 @@
+---
+"@react-three/rapier": major
+"@react-three/rapier-addons": major
+---
+
+Move to the React Three Fiber v10 stack and modernize the package format.
+
+### Requirements
+
+- `@react-three/fiber` `>=10.0.0-0` (developed against `10.0.0-canary.a056612`; scheduler fixed-step support needs `@pmndrs/scheduler` 0.3+)
+- `react` `>=19.0 <19.3` (the range r3f v10 supports)
+- `three` `>=0.185.0`
+- Node.js 22+ for tooling (`engines`)
+- `@react-three/rapier-addons` now peers on `@react-three/rapier` `^3.0.0-0`
+
+### Package format
+
+- Built with rollup + esbuild instead of preconstruct. Each package ships an `exports` map with
+ ESM (`dist/index.mjs`) and CommonJS (`dist/index.cjs`) builds and matching type declarations
+ (`.d.mts` / `.d.cts`), and is marked `sideEffects: false`.
+- Deep imports into package internals (e.g. `@react-three/rapier/src/...` or `dist/...` files)
+ are no longer possible; import from the package root.
+
+### Behaviour notes
+
+- Under r3f v10 `useFrame`'s state no longer has `clock`; use `state.elapsed` / `state.delta`
+ in your own frame callbacks.
diff --git a/.changeset/scheduler-fixed-step.md b/.changeset/scheduler-fixed-step.md
new file mode 100644
index 00000000..a59c5b1b
--- /dev/null
+++ b/.changeset/scheduler-fixed-step.md
@@ -0,0 +1,17 @@
+---
+"@react-three/rapier": major
+---
+
+Step physics on the react-three-fiber v10 scheduler.
+
+- With `updateLoop="follow"` (default) and a numeric `timeStep`, each world steps in its own fixed-timestep scheduler phase (`rapier:fixed:`), which runs after r3f's `physics` phase. Changing `timeStep` or `maxSubSteps` retimes that phase in place, and phases are reused when worlds remount. Rendered transforms are synced once per frame in a new `rapier` phase, before `update`, and interpolated by the phase's overstep. A `rapier:late` pass right before `render` re-applies them if an ancestor moved during `update`. The shared `physics` phase timestep is never changed, and multiple `` worlds with different `timeStep`s work side by side. Without scheduler fixed-phase support (`@pmndrs/scheduler` < 0.3), the same behaviour runs on an internal accumulator.
+- New `maxSubSteps` prop (default `5`) caps the fixed steps a single frame may run. Surplus time is dropped instead of queued, so slow frames can no longer spiral (#697).
+- **Breaking:** `updatePriority` now only orders this world's jobs within their scheduler phase (higher runs first, the r3f v10 convention). It no longer takes over r3f's render loop. Physics always steps before `update`, and r3f keeps rendering.
+- `paused` banks no time, so resuming does not burst. `step()` from `useRapier()` works while paused and syncs meshes immediately.
+- `useBeforePhysicsStep` and `useAfterPhysicsStep` callbacks receive `(world, dt)`, where `dt` is the step length. Existing single-argument callbacks keep working.
+- Collision, contact-force and soft-body tear events are drained after every substep instead of once per frame.
+- Bodies are synced parent-first into their parent's current space every frame, so moving, rotating or re-parenting an ancestor of a `` after mount keeps its visual on the simulated body (#36), and nested bodies no longer lag the body that carries them.
+- Interpolation no longer lerps across teleports made in `useBeforePhysicsStep`, manual `step()` calls, `setWorld()`, or `paused`/`interpolate` changes.
+- `` reuses one geometry and disposes it on unmount, instead of allocating one per frame and leaking on toggle (#666).
+- With `frameloop="demand"`, the canvas is invalidated once per frame while any non-fixed body is awake.
+- The world is freed after the unmount commit, so child cleanups run against a live world and StrictMode remounts are safe.
diff --git a/.changeset/soft-bodies.md b/.changeset/soft-bodies.md
new file mode 100644
index 00000000..eaf6d70d
--- /dev/null
+++ b/.changeset/soft-bodies.md
@@ -0,0 +1,19 @@
+---
+"@react-three/rapier": minor
+---
+
+Add soft bodies: `` and `useSoftBody`, built on Rapier 0.21's deformable bodies.
+
+Build cloth, rope and volumetric (jelly) bodies from the geometry of a `` or ``. Vertices are welded on position only, and the simulated positions are written into the geometry in the parent's local space once per rendered frame, interpolated with the same alpha as the rigid bodies (so cloth attached to an interpolated body stays on it), with normals and bounds recomputed. Tears are handled after every physics substep. Supports pinned particles, attachments to rigid bodies, stiffness/damping/mass, collisions with rigid bodies, and an `onTear` event.
+
+```tsx
+import { SoftBody } from "@react-three/rapier/soft-bodies";
+
+ p.y > 0.49} stiffness={12}>
+
+
+
+ ;
+```
+
+The API lives in the `@react-three/rapier/soft-bodies` entry point (a separate bundle that imports core from `@react-three/rapier`). `RapierContext` gains two internal sets: `softBodyTearCallbacks`, which `` fills from Rapier's soft body tear events after each step, and `frameSyncCallbacks`, which `` calls once per frame with the interpolation alpha after writing the rigid bodies.
diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs
new file mode 100644
index 00000000..a91a6fe7
--- /dev/null
+++ b/.git-blame-ignore-revs
@@ -0,0 +1,5 @@
+# Bulk formatting commits, skipped by `git blame` (GitHub reads this file automatically;
+# locally: git config blame.ignoreRevsFile .git-blame-ignore-revs).
+
+# style: format with prettier 3
+8d7b56b1a0ce4d23fcb275dc72a108aa14c934bf
diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml
new file mode 100644
index 00000000..e12f4370
--- /dev/null
+++ b/.github/actions/setup/action.yml
@@ -0,0 +1,26 @@
+name: Setup
+description: Enable Corepack (Yarn 4), set up Node.js with the Yarn cache and install dependencies
+
+inputs:
+ node-version:
+ description: Node.js version
+ default: 22.x
+
+runs:
+ using: composite
+ steps:
+ # Corepack must be enabled before setup-node so its Yarn cache step finds Yarn 4
+ # (the `packageManager` field), not the runner's global Yarn 1.
+ - name: Enable Corepack
+ shell: bash
+ run: corepack enable
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v7
+ with:
+ node-version: ${{ inputs.node-version }}
+ cache: yarn
+
+ - name: Install dependencies
+ shell: bash
+ run: yarn install --immutable
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 5d6f4795..baee81c8 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -6,6 +6,39 @@
version: 2
updates:
- package-ecosystem: "npm"
- directory: "/packages/react-three-rapier"
+ directory: "/" # yarn workspaces are discovered from the root
schedule:
interval: "weekly"
+ groups:
+ minor-and-patch:
+ applies-to: version-updates
+ update-types:
+ - "minor"
+ - "patch"
+ ignore:
+ # three, react/react-dom and @react-three/* track r3f v10 / drei v11 prereleases;
+ # major bumps are deliberate, hand-verified upgrades.
+ - dependency-name: "three"
+ update-types: ["version-update:semver-major"]
+ - dependency-name: "@types/three"
+ update-types: ["version-update:semver-major"]
+ - dependency-name: "react"
+ update-types: ["version-update:semver-major"]
+ - dependency-name: "react-dom"
+ update-types: ["version-update:semver-major"]
+ - dependency-name: "@types/react"
+ update-types: ["version-update:semver-major"]
+ - dependency-name: "@types/react-dom"
+ update-types: ["version-update:semver-major"]
+ - dependency-name: "@react-three/*"
+ update-types: ["version-update:semver-major"]
+
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ groups:
+ github-actions:
+ update-types:
+ - "minor"
+ - "patch"
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 12861609..36c62c61 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,7 +1,9 @@
## Description
+
### Type of change
+
- ๐ Bug fix
@@ -9,6 +11,7 @@
- ๐ฆ Other (tests, refactoring, docs, etc.)
### Checklist:
+
- [ ] ๐ I have performed a self-review of my code
@@ -16,4 +19,4 @@
- [ ] ๐ I have made corresponding changes to the documentation
- [ ] โญ๏ธ My changes generate no new warnings
- [ ] ๐งช I have added tests
-- [ ] ๐ข All new and existing unit tests pass
\ No newline at end of file
+- [ ] ๐ข All new and existing unit tests pass
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 00000000..fd6681a5
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,68 @@
+name: CI
+
+on:
+ pull_request:
+ branches: [main, next]
+ push:
+ branches: [main, next]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ typecheck:
+ name: Typecheck
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: ./.github/actions/setup
+ - name: Typecheck packages and tests
+ run: yarn typecheck
+ - name: Typecheck demo
+ run: yarn workspace demo typecheck
+
+ test:
+ name: Test
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: ./.github/actions/setup
+ # vitest resolves the workspace packages from src, so no build is needed first
+ - name: Test
+ run: yarn vitest run
+
+ build:
+ name: Build & check packages
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: ./.github/actions/setup
+ - name: Build
+ run: yarn build
+ - name: publint + arethetypeswrong
+ run: yarn check:packages
+
+ demo:
+ name: Build demo
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: ./.github/actions/setup
+ - name: Build demo
+ run: yarn demo:build
+
+ lint:
+ name: Lint & format
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: ./.github/actions/setup
+ - name: Lint
+ run: yarn lint
+ - name: Format
+ if: ${{ !cancelled() }}
+ run: yarn format:check
diff --git a/.github/workflows/r3-rapier-docs.yml b/.github/workflows/r3-rapier-docs.yml
index 2f7ee258..6053fe3f 100644
--- a/.github/workflows/r3-rapier-docs.yml
+++ b/.github/workflows/r3-rapier-docs.yml
@@ -29,31 +29,22 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v3
+ uses: actions/checkout@v7
- - name: Setup Node.js environment
- uses: actions/setup-node@v3.6.0
- with:
- node-version: 22.x
-
- - name: Enable corepack
- run: corepack enable
-
- - name: Install Node Dependencies
- run: yarn --immutable
+ - uses: ./.github/actions/setup
- name: Run typedoc
run: yarn docs:build
- name: Setup Pages
- uses: actions/configure-pages@v2
+ uses: actions/configure-pages@v6
- name: Upload artifact
- uses: actions/upload-pages-artifact@v3
+ uses: actions/upload-pages-artifact@v5
with:
# Upload docs folder
path: "./packages/react-three-rapier/docs"
- name: Deploy to GitHub Pages
id: deployment
- uses: actions/deploy-pages@v4
+ uses: actions/deploy-pages@v5
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index dbce94ad..e7a2ed1b 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,34 +1,44 @@
name: Release
+# `main` publishes stable releases. `next` is the 3.0 integration branch and is in changesets
+# pre mode (.changeset/pre.json, `yarn prerelease:enter`), so the same flow there opens a
+# "Version Packages (alpha)" PR into `next` (the action appends the pre tag) and publishes `x.y.z-alpha.n` under the `alpha`
+# dist-tag, leaving `latest` untouched. Run `yarn prerelease:exit` before merging `next` into
+# `main`.
on:
push:
branches:
- main
+ - next
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
release:
name: Release
+ if: github.repository_owner == 'pmndrs'
runs-on: ubuntu-latest
+ permissions:
+ contents: write # changesets pushes the version branch
+ pull-requests: write # ...and opens the "Version Packages" PR
+ id-token: write # npm provenance / trusted publishing (OIDC)
steps:
- name: Checkout Repo
- uses: actions/checkout@v3
+ uses: actions/checkout@v7
- - name: Setup Node.js 22.x
- uses: actions/setup-node@v3
- with:
- node-version: 22
-
- - name: Install Dependencies
- run: yarn --immutable
+ - uses: ./.github/actions/setup
- name: Create Release Pull Request or Publish to npm
id: changesets
uses: changesets/action@v1
with:
- publish: yarn release
+ version: yarn changeset:version
+ publish: yarn changeset:publish
commit: "chore(changeset): release packages"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ # This is a Yarn 4 workspace, so changesets publishes with `yarn npm publish`, which
+ # reads YARN_NPM_AUTH_TOKEN rather than NPM_TOKEN. NPM_TOKEN is kept because
+ # changesets/action uses it to write the .npmrc it checks registry state with.
+ YARN_NPM_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
deleted file mode 100644
index b951d700..00000000
--- a/.github/workflows/test.yml
+++ /dev/null
@@ -1,31 +0,0 @@
-name: Runs Vitest
-
-on:
- push:
- branches: [main]
- pull_request:
- branches: [main]
-
-jobs:
- test:
- runs-on: ubuntu-latest
-
- steps:
- - uses: actions/checkout@v3
- - name: Setup Node.js 22.x
- uses: actions/setup-node@v3
- with:
- node-version: 22
-
- - name: Enable corepack
- run: corepack enable
-
- - name: Install deps
- run: yarn --immutable
-
- # See if the build runs
- - name: Run build
- run: yarn build
-
- - name: Run tests
- run: npm run test
diff --git a/.gitignore b/.gitignore
index f246032b..e241d678 100644
--- a/.gitignore
+++ b/.gitignore
@@ -111,4 +111,9 @@ dist
packages/react-three-rapier/docs
-.yarn
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/sdks
+!.yarn/versions
diff --git a/.nvmrc b/.nvmrc
index 33206cba..a3745049 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-v22.0.0
+v22.22.2
diff --git a/.prettierignore b/.prettierignore
index 8ec7bfa0..932e2a43 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,3 +1,10 @@
-dist
-docs
-readme.md
\ No newline at end of file
+**/dist
+**/node_modules
+**/coverage
+**/docs/out
+packages/react-three-rapier/docs
+.yarn
+yarn.lock
+.changeset/**/*.md
+CHANGELOG.md
+readme.md
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 00000000..bb684893
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,9 @@
+{
+ "trailingComma": "none",
+ "tabWidth": 2,
+ "useTabs": false,
+ "semi": true,
+ "singleQuote": false,
+ "arrowParens": "always",
+ "endOfLine": "lf"
+}
diff --git a/.yarnrc.yml b/.yarnrc.yml
index 3186f3f0..063ed601 100644
--- a/.yarnrc.yml
+++ b/.yarnrc.yml
@@ -1 +1,8 @@
+approvedGitRepositories:
+ - "**"
+
+enableScripts: true
+
nodeLinker: node-modules
+
+npmMinimalAgeGate: 0
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
new file mode 100644
index 00000000..9b7c21e2
--- /dev/null
+++ b/DEVELOPMENT.md
@@ -0,0 +1,93 @@
+# Development
+
+This repository is a Yarn workspace monorepo:
+
+- `packages/react-three-rapier` - `@react-three/rapier`
+- `packages/react-three-rapier-addons` - `@react-three/rapier-addons`
+- `demo` - the examples app (Vite, private, not published)
+
+## Setup
+
+- **Node 22** (see `.nvmrc`; tooling needs 22.22.2 or newer).
+- **Yarn 4**, provided by Corepack through the `packageManager` field. Enable Corepack once:
+
+```sh
+corepack enable
+yarn install
+```
+
+Installing also sets up the git hooks (`simple-git-hooks`): commit messages are checked by
+commitlint (conventional commits), and pre-commit runs `yarn typecheck`.
+
+## Commands
+
+| Command | What it does |
+| --------------------- | ------------------------------------------------------------------------------- |
+| `yarn build` | Build every package into its `dist/` (see "Build" below) |
+| `yarn typecheck` | `tsc` over the packages' `src` and `tests` |
+| `yarn test` | Typecheck, then run the vitest suite once |
+| `yarn vitest` | Vitest in watch mode (`yarn test:ui` for the UI) |
+| `yarn check:packages` | `publint --strict` and `attw --pack` for each package (run `yarn build` first) |
+| `yarn lint` | ESLint (flat config in `eslint.config.mjs`) |
+| `yarn format` | Prettier, writing changes (`yarn format:check` only checks) |
+| `yarn demo` | Start the demo dev server |
+| `yarn demo:build` | Typecheck and build the demo |
+| `yarn ci` | Typecheck, build, package checks and tests - roughly what CI runs |
+| `yarn changeset` | Add a changeset describing your change (needed for anything that gets released) |
+
+Tests, the root typecheck and the demo all resolve `@react-three/rapier` and
+`@react-three/rapier-addons` to the workspace **sources** (vitest aliases, Vite aliases and
+tsconfig `paths`), so none of them needs a build first, and the demo hot-reloads library
+changes.
+
+## Demo app and examples
+
+The demo runs on **WebGPU by default** when the browser has `navigator.gpu`, otherwise WebGL.
+Use the WebGPU / WebGL buttons in the sidebar, or `?renderer=webgl|webgpu` in the URL (the
+choice is also remembered in localStorage). Switching reloads the page. Examples must work on
+both renderers; if one genuinely cannot (custom `ShaderMaterial`, `onBeforeCompile`, troika
+``, postprocessing...) mark it `webglOnly` (below) and the demo shows a notice on WebGPU.
+
+**Adding an example needs no edits to `App.tsx`.** Routes are discovered with `import.meta.glob`
+(`demo/src/examples.ts`) and loaded lazily:
+
+- Create `demo/src/examples//index.ts(x)` whose **default export** is the example
+ component. The route is `/` and the sidebar label is the humanised folder name.
+- Optionally export `meta` from the same module: `{ title?, description?, webglOnly? }`
+ (`export { default, meta } from "./MyExample"`). It is shown in a header while the example is open.
+- A folder **without** an `index` still registers: every `*Example*.tsx` file in it becomes a
+ route (`/` for a single file, `//` for several), using the
+ default export or the first exported capitalised function as the component.
+- Examples can use `useDemo()` and `Demo` from `../../App` (re-exported from `demo/src/demo-context.ts`).
+
+## Build
+
+Each package's `yarn build` runs `tsc -p tsconfig.build.json` (typecheck + per-file
+declarations in `dist/types`) and then rollup with the shared config in
+`scripts/rollup-library.mjs`: esbuild produces `dist/.mjs` and `dist/.cjs`, and
+rollup-plugin-dts rolls the declarations into `dist/.d.ts`, `.d.mts` and `.d.cts`.
+
+Entry points are read from the package's `exports` map. `@react-three/rapier/soft-bodies` is the
+reference subpath entry: it has `src/soft-bodies/index.ts`, a `"./soft-bodies"` entry in
+`exports` (same shape as `"."`), `"dist/soft-bodies.*"` in `files` and a `typesVersions` entry for
+node10 resolution. A new subpath needs the same four things. Subpath entries must import core through
+`../index`, which the build rewrites to `@react-three/rapier` so core is never bundled twice.
+
+## Linting and formatting
+
+ESLint 9 (flat config) and Prettier 3 are configured, the codebase is formatted, and it has no
+lint errors. CI runs `yarn lint` and `yarn format:check` as a blocking job, so run `yarn format`
+before pushing. Lint warnings (unused variables, hook dependencies) don't fail the build, but
+avoid adding new ones. The one-off Prettier 3 reformat is listed in `.git-blame-ignore-revs`
+(GitHub skips it automatically; set `blame.ignoreRevsFile` to skip it locally).
+
+## Releases
+
+Releases are managed with [changesets](https://github.com/changesets/changesets) and
+`.github/workflows/release.yml`, which opens a "Version Packages" PR and publishes when it is
+merged.
+
+- `main` publishes stable versions.
+- `next` (the 3.0 integration branch) is in prerelease mode (`.changeset/pre.json`): it publishes
+ `3.0.0-alpha.n` under the `alpha` dist-tag. Run `yarn prerelease:exit` before merging `next`
+ into `main`.
diff --git a/babel.config.js b/babel.config.js
deleted file mode 100644
index 5925d264..00000000
--- a/babel.config.js
+++ /dev/null
@@ -1,15 +0,0 @@
-module.exports = {
- presets: [
- [
- "@babel/preset-env",
- {
- bugfixes: true,
- targets: {
- esmodules: true
- }
- }
- ],
- "@babel/preset-react",
- "@babel/preset-typescript"
- ]
-};
diff --git a/demo/index.html b/demo/index.html
index 5986aabf..f119e4ee 100644
--- a/demo/index.html
+++ b/demo/index.html
@@ -1,4 +1,4 @@
-
+
diff --git a/demo/package.json b/demo/package.json
index 3dc431b8..6dc1cb43 100644
--- a/demo/package.json
+++ b/demo/package.json
@@ -5,28 +5,28 @@
"license": "MIT",
"scripts": {
"dev": "vite",
+ "typecheck": "tsc",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@react-three/csg": "3.3.0",
- "@react-three/drei": "10.0.1",
- "@react-three/fiber": "9.0.4",
- "@react-three/rapier": "2.2.0",
- "@react-three/rapier-addons": "5.0.0",
- "@types/three": "0.172.0",
+ "@react-three/drei": "11.0.0-alpha.7",
+ "@react-three/fiber": "10.0.0-canary.a056612",
+ "@react-three/rapier": "workspace:*",
+ "@react-three/rapier-addons": "workspace:*",
+ "@types/three": "0.186.0",
"leva": "0.10.0",
- "r3f-perf": "7.2.3",
- "react": "19.0.0",
- "react-dom": "19.0.0",
+ "react": "19.2.8",
+ "react-dom": "19.2.8",
"react-router-dom": "7.1.3",
- "three": "0.172.0"
+ "three": "0.186.1"
},
"devDependencies": {
- "@types/react": "19.0.8",
- "@types/react-dom": "19.0.3",
+ "@types/react": "19.2.18",
+ "@types/react-dom": "19.2.7",
"@vitejs/plugin-react": "4.3.4",
- "typescript": "5.7.3",
+ "typescript": "5.9.3",
"vite": "6.0.11"
},
"repository": "https://github.com/pmndrs/react-three-rapier/tree/master/demo"
diff --git a/demo/src/App.tsx b/demo/src/App.tsx
index 403e858e..8f189737 100644
--- a/demo/src/App.tsx
+++ b/demo/src/App.tsx
@@ -1,67 +1,50 @@
-import { Box, Environment, OrbitControls } from "@react-three/drei";
-import { Canvas } from "@react-three/fiber";
+import { Box, Environment, OrbitControls, Stats } from "@react-three/drei";
+import { Canvas, useStore } from "@react-three/fiber";
import { Physics, RigidBody } from "@react-three/rapier";
-import { Perf } from "r3f-perf";
import {
- JSX,
ReactNode,
StrictMode,
Suspense,
- createContext,
- useContext,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
useRef,
useState
} from "react";
-import { NavLink, NavLinkProps, Route, Routes } from "react-router-dom";
-import { ActiveCollisionTypesExample } from "./examples/active-collision-types/ActiveCollisionTypesExample";
-import { AllCollidersExample } from "./examples/all-colliders/AllCollidersExample";
-import { AllShapesExample } from "./examples/all-shapes/AllShapesExample";
-import { ApiUsage } from "./examples/api-usage/ApiUsageExample";
-import { AttractorExample } from "./examples/attractors/AttractorsExample";
-import { Car } from "./examples/car/CarExample";
-import { Cluster } from "./examples/cluster/ClusterExample";
-import { Colliders } from "./examples/colliders/CollidersExample";
-import { CollisionEventsExample } from "./examples/collision-events/CollisionEventsExample";
-import { ComponentsExample } from "./examples/components/ComponentsExample";
-import { ContactForceEventsExample } from "./examples/contact-force-events/ContactForceEventsExample";
-import { ContactSkinExample } from "./examples/contact-skin/ContactSkinExample";
-import { CradleExample } from "./examples/cradle/CradleExample";
-import { Damping } from "./examples/damping/DampingExample";
-import { DynamicTypeChangeExample } from "./examples/dynamic-type-change/DynamicTypeChangeExample";
-import { ImmutablePropsExample } from "./examples/immutable-props/ImmutablePropsExample";
-import { InstancedMeshes } from "./examples/instanced-meshes/InstancedMeshesExample";
-import { InstancedMeshesCompound } from "./examples/instances-meshes-compound/InstancedMeshesCompoundExample";
-import { Joints } from "./examples/joints/JointsExample";
-import { Kinematics } from "./examples/kinematics/KinematicsExample";
-import { LockedTransformsExample } from "./examples/locked-transforms/LockedTransformsExample";
-import { ManualStepExample } from "./examples/manual-step/ManualStepExamples";
-import { MeshColliderTest } from "./examples/mesh-collider-test/MeshColliderExample";
-import { OneWayPlatform } from "./examples/one-way-platform/OneWayPlatform";
-import { PerformanceExample } from "./examples/performance/PeformanceExample";
-import Shapes from "./examples/plinko/ShapesExample";
-import { RopeJointExample } from "./examples/rope-joint/RopeJointExample";
-import { SensorsExample } from "./examples/sensors/SensorsExample";
-import { SnapshotExample } from "./examples/snapshot/SnapshotExample";
-import { SpringExample } from "./examples/spring/SpringExample";
-import { StutteringExample } from "./examples/stuttering/StutteringExample";
-import { Transforms } from "./examples/transforms/TransformsExample";
-import { OrbitControls as OrbitControlsImpl } from "three-stdlib";
+import {
+ Navigate,
+ NavLink,
+ NavLinkProps,
+ Route,
+ Routes,
+ useLocation
+} from "react-router-dom";
+import { demoContext } from "./demo-context";
+import { DEFAULT_EXAMPLE, ExampleMeta, examples } from "./examples";
+import { getRenderer, hasWebGPU, switchRenderer } from "./renderer";
+import type { OrbitControls as OrbitControlsImpl } from "three/addons/controls/OrbitControls.js";
import { useResetOrbitControls } from "./hooks/use-reset-orbit-controls";
-type DemoContext = {
- setDebug: (f: boolean) => void;
- setPaused: (f: boolean) => void;
- setCameraEnabled: (f: boolean) => void;
- orbitControlRef: React.RefObject;
-};
-
-const demoContext = createContext>({});
+// Kept here so examples can keep importing them from "../../App"
+export { useDemo } from "./demo-context";
+export type { Demo, DemoContext } from "./demo-context";
-export const useDemo = () => useContext(demoContext);
-
-export interface Demo {
- (props: { children?: ReactNode }): JSX.Element;
-}
+/**
+ * r3f v10 leaves `state.gl` null on WebGPU roots (it is the legacy WebGL alias of
+ * `state.renderer`), but drei 11 alpha still reads `gl.domElement` () and
+ * `gl.initTexture` (useTexture). Point it at the WebGPU renderer so those keep working.
+ * Remove once drei uses `state.renderer`.
+ */
+const GlShim = ({ children }: { children: ReactNode }) => {
+ const store = useStore();
+ const [ready, setReady] = useState(false);
+ useLayoutEffect(() => {
+ const { gl, renderer } = store.getState();
+ if (!gl && renderer) store.setState({ gl: renderer } as never);
+ setReady(true);
+ }, [store]);
+ return ready ? <>{children}> : null;
+};
const Floor = () => {
return (
@@ -78,41 +61,6 @@ const Floor = () => {
);
};
-const routes: Record = {
- "": ,
- joints: ,
- components: ,
- cradle: ,
- transforms: ,
- cluster: ,
- "all-shapes": ,
- attractors: ,
- car: ,
- "api-usage": ,
- kinematics: ,
- "mesh-collider-test": ,
- colliders: ,
- "instanced-meshes": ,
- damping: ,
- "instanced-meshes-compound": ,
- "all-colliders": ,
- "collision-events": ,
- "contact-force-events": ,
- sensors: ,
- "manual-step": ,
- "locked-transforms": ,
- performance: ,
- "dynamic-type-changes": ,
- stuttering: ,
- "immutable-props": ,
- snapshot: ,
- spring: ,
- "rope-joint": ,
- "active-collision-types": ,
- "contact-skin": ,
- "one-way-platform":
-};
-
export const App = () => {
const [debug, setDebug] = useState(false);
const [perf, setPerf] = useState(false);
@@ -121,6 +69,19 @@ export const App = () => {
const [physicsKey, setPhysicsKey] = useState(0);
const [cameraEnabled, setCameraEnabled] = useState(true);
const orbitControlRef = useRef(null!);
+ const renderer = useMemo(getRenderer, []);
+ const { pathname } = useLocation();
+ const [meta, setMeta] = useState({});
+
+ const current = examples.find((e) => "/" + e.path === pathname);
+ useEffect(() => {
+ let live = true;
+ setMeta({});
+ current?.loadMeta().then((m) => live && setMeta(m));
+ return () => {
+ live = false;
+ };
+ }, [current]);
useResetOrbitControls();
@@ -148,52 +109,96 @@ export const App = () => {
}}
/>
-
+
-
-
-
+
+
+
+
-
+
-
-
- {Object.keys(routes).map((key, index, array) => (
-
- ))}
-
-
+
+
+
+ }
+ />
+ {examples.map(({ path, Component }) => (
+ } />
+ ))}
+
+
+
-
+
- {perf && }
-
+ {/* r3f-perf does not support r3f v10 yet; drei stands in */}
+ {perf && }
+
+
+ {(meta.title ||
+ meta.description ||
+ (meta.webglOnly && renderer === "webgpu")) && (
+
+ {meta.title &&
{meta.title} }
+ {meta.description &&
{meta.description}
}
+ {meta.webglOnly && renderer === "webgpu" && (
+
+ WebGL only: this example does not support the WebGPU renderer.
+ Switch to WebGL to see it working.
+
+ )}
+
+ )}
+
{
r3/rapier demos
+
+ switchRenderer("webgpu")}
+ />
+ switchRenderer("webgl")}
+ />
+
+
{
/>
- {Object.keys(routes).map((key) => (
-
- {key.replace(/-/g, " ") || "Plinko"}
+ {examples.map(({ path, title }) => (
+
+ {title}
))}
@@ -276,13 +295,17 @@ const Link = (props: NavLinkProps) => {
const ToggleButton = ({
label,
value,
+ disabled,
onClick
}: {
label: string;
value: boolean;
+ disabled?: boolean;
onClick(): void;
}) => (
void;
+ setPaused: (f: boolean) => void;
+ setCameraEnabled: (f: boolean) => void;
+ orbitControlRef: React.RefObject;
+ /** The renderer the canvas was created with */
+ renderer: RendererKind;
+};
+
+export const demoContext = createContext>({});
+
+export const useDemo = () => useContext(demoContext);
+
+export interface Demo {
+ (props: { children?: ReactNode }): JSX.Element;
+}
diff --git a/demo/src/examples.ts b/demo/src/examples.ts
new file mode 100644
index 00000000..abe617dc
--- /dev/null
+++ b/demo/src/examples.ts
@@ -0,0 +1,93 @@
+import { ComponentType, LazyExoticComponent, lazy } from "react";
+
+/**
+ * Optional metadata an example exports as `meta` from its folder's `index.tsx`
+ * (or, for folders without an index, from the example file itself). It is read when the
+ * example's route is opened, so the sidebar never has to import every example.
+ */
+export interface ExampleMeta {
+ /** Heading shown while the example is open. Defaults to the sidebar label. */
+ title?: string;
+ description?: string;
+ /** Uses WebGL-only features (ShaderMaterial, onBeforeCompile, ...): shows a notice on WebGPU. */
+ webglOnly?: boolean;
+}
+
+export interface ExampleRoute {
+ /** URL path, e.g. `cradle` or `soft-bodies/cloth` */
+ path: string;
+ /** Sidebar label (the humanised folder name) */
+ title: string;
+ loadMeta: () => Promise;
+ Component: LazyExoticComponent;
+}
+
+type Mod = { default?: unknown; meta?: ExampleMeta; [key: string]: unknown };
+type Loader = () => Promise;
+
+// Convention: src/examples//index.tsx -> default export component + optional `meta`.
+const indexLoaders = import.meta.glob("./examples/*/index.{ts,tsx}");
+
+// Fallback for folders without an index: every `*Example*.tsx` file in them is a route, so
+// examples written before the convention (or in other branches) register themselves too.
+const fileLoaders = import.meta.glob("./examples/*/*Example*.tsx");
+
+const humanise = (s: string) => s.replace(/-/g, " ");
+const kebab = (s: string) =>
+ s
+ .replace(/Examples?$/, "")
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
+ .toLowerCase();
+
+/** default export, else the first exported function component */
+const pickComponent = (mod: Mod): ComponentType => {
+ if (typeof mod.default === "function") return mod.default as ComponentType;
+ const first = Object.entries(mod).find(
+ ([key, value]) => /^[A-Z]/.test(key) && typeof value === "function"
+ );
+ if (!first) throw new Error("Example module exports no component");
+ return first[1] as ComponentType;
+};
+
+const make = (path: string, title: string, load: Loader): ExampleRoute => ({
+ path,
+ title,
+ loadMeta: async () => (await load()).meta ?? {},
+ Component: lazy(async () => ({ default: pickComponent(await load()) }))
+});
+
+const build = (): ExampleRoute[] => {
+ const routes: ExampleRoute[] = [];
+ const folderWithIndex = new Set();
+
+ for (const [file, load] of Object.entries(indexLoaders)) {
+ const folder = file.split("/")[2];
+ folderWithIndex.add(folder);
+ routes.push(make(folder, humanise(folder), load));
+ }
+
+ const byFolder = new Map();
+ for (const file of Object.keys(fileLoaders)) {
+ const folder = file.split("/")[2];
+ if (folderWithIndex.has(folder)) continue;
+ byFolder.set(folder, [...(byFolder.get(folder) ?? []), file]);
+ }
+ for (const [folder, files] of byFolder) {
+ for (const file of files) {
+ const name = file.split("/")[3].replace(/\.tsx$/, "");
+ const single = files.length === 1;
+ const path = single ? folder : `${folder}/${kebab(name)}`;
+ const title = single
+ ? humanise(folder)
+ : `${humanise(folder)}: ${humanise(kebab(name))}`;
+ routes.push(make(path, title, fileLoaders[file]));
+ }
+ }
+
+ return routes.sort((a, b) => a.path.localeCompare(b.path));
+};
+
+export const examples = build();
+
+/** Redirect target for `/` */
+export const DEFAULT_EXAMPLE = "plinko";
diff --git a/demo/src/examples/active-collision-types/ActiveCollisionTypesExample.tsx b/demo/src/examples/active-collision-types/ActiveCollisionTypesExample.tsx
index 4a837b8c..64360aac 100644
--- a/demo/src/examples/active-collision-types/ActiveCollisionTypesExample.tsx
+++ b/demo/src/examples/active-collision-types/ActiveCollisionTypesExample.tsx
@@ -13,7 +13,7 @@ const Ball = () => {
const [color, setColor] = useState("blue");
- useFrame(({ clock: { elapsedTime } }) => {
+ useFrame(({ elapsed: elapsedTime }) => {
if (!rb.current) return;
rb.current.setTranslation(
diff --git a/demo/src/examples/active-collision-types/index.ts b/demo/src/examples/active-collision-types/index.ts
new file mode 100644
index 00000000..e88a2c22
--- /dev/null
+++ b/demo/src/examples/active-collision-types/index.ts
@@ -0,0 +1 @@
+export { ActiveCollisionTypesExample as default } from "./ActiveCollisionTypesExample";
diff --git a/demo/src/examples/all-colliders/AllCollidersExample.tsx b/demo/src/examples/all-colliders/AllCollidersExample.tsx
index 954edb47..8234b9f7 100644
--- a/demo/src/examples/all-colliders/AllCollidersExample.tsx
+++ b/demo/src/examples/all-colliders/AllCollidersExample.tsx
@@ -16,7 +16,7 @@ import {
RoundCylinderCollider
} from "@react-three/rapier";
import { useSuzanne } from "../all-shapes/AllShapesExample";
-import { RoundedBoxGeometry } from "three-stdlib";
+import { RoundedBoxGeometry } from "three/examples/jsm/geometries/RoundedBoxGeometry.js";
import { PlaneGeometry } from "three";
import { useEffect, useRef } from "react";
import { useResetOrbitControls } from "../../hooks/use-reset-orbit-controls";
diff --git a/demo/src/examples/all-colliders/index.ts b/demo/src/examples/all-colliders/index.ts
new file mode 100644
index 00000000..90c30c51
--- /dev/null
+++ b/demo/src/examples/all-colliders/index.ts
@@ -0,0 +1 @@
+export { AllCollidersExample as default } from "./AllCollidersExample";
diff --git a/demo/src/examples/all-shapes/AllShapesExample.tsx b/demo/src/examples/all-shapes/AllShapesExample.tsx
index c1ba5383..610bac61 100644
--- a/demo/src/examples/all-shapes/AllShapesExample.tsx
+++ b/demo/src/examples/all-shapes/AllShapesExample.tsx
@@ -15,10 +15,9 @@ import { Mesh } from "three";
import { useResetOrbitControls } from "../../hooks/use-reset-orbit-controls";
export const useSuzanne = () => {
- // @ts-ignore
return useGLTF(
new URL("../../models/susanne.glb", import.meta.url).toString()
- ) as {
+ ) as unknown as {
nodes: {
Suzanne: Mesh;
};
@@ -26,10 +25,9 @@ export const useSuzanne = () => {
};
const useOffsetTorus = () => {
- // @ts-ignore
return useGLTF(
new URL("../../models/offset-torus.glb", import.meta.url).toString()
- ) as {
+ ) as unknown as {
nodes: {
Torus: Mesh;
};
diff --git a/demo/src/examples/all-shapes/index.ts b/demo/src/examples/all-shapes/index.ts
new file mode 100644
index 00000000..8e0363a5
--- /dev/null
+++ b/demo/src/examples/all-shapes/index.ts
@@ -0,0 +1 @@
+export { AllShapesExample as default } from "./AllShapesExample";
diff --git a/demo/src/examples/api-usage/index.ts b/demo/src/examples/api-usage/index.ts
new file mode 100644
index 00000000..dbf0d9d7
--- /dev/null
+++ b/demo/src/examples/api-usage/index.ts
@@ -0,0 +1 @@
+export { ApiUsage as default } from "./ApiUsageExample";
diff --git a/demo/src/examples/attractors/index.ts b/demo/src/examples/attractors/index.ts
new file mode 100644
index 00000000..201ee513
--- /dev/null
+++ b/demo/src/examples/attractors/index.ts
@@ -0,0 +1 @@
+export { AttractorExample as default } from "./AttractorsExample";
diff --git a/demo/src/examples/car/index.ts b/demo/src/examples/car/index.ts
new file mode 100644
index 00000000..d0ad458e
--- /dev/null
+++ b/demo/src/examples/car/index.ts
@@ -0,0 +1 @@
+export { Car as default } from "./CarExample";
diff --git a/demo/src/examples/character-controller/CharacterControllerExample.tsx b/demo/src/examples/character-controller/CharacterControllerExample.tsx
new file mode 100644
index 00000000..444bd207
--- /dev/null
+++ b/demo/src/examples/character-controller/CharacterControllerExample.tsx
@@ -0,0 +1,208 @@
+import { Box, Sphere } from "@react-three/drei";
+import { useFrame } from "@react-three/fiber";
+import {
+ CapsuleCollider,
+ CharacterController,
+ CharacterControllerRef,
+ RapierRigidBody,
+ RigidBody,
+ useBeforePhysicsStep
+} from "@react-three/rapier";
+import { useEffect, useMemo, useRef } from "react";
+import { MathUtils } from "three";
+import { Demo } from "../../App";
+import { useResetOrbitControls } from "../../hooks/use-reset-orbit-controls";
+
+// Course layout idea originally by @AlaricBaraou in pmndrs/react-three-rapier#702.
+
+const Stairs = ({
+ steps = 10,
+ stepHeight = 0.2,
+ stepDepth = 0.9,
+ position
+}: {
+ steps?: number;
+ stepHeight?: number;
+ stepDepth?: number;
+ position: [number, number, number];
+}) => (
+
+ {Array.from({ length: steps }, (_, i) => (
+
+
+
+ ))}
+
+);
+
+const Slope = ({
+ angle,
+ position
+}: {
+ angle: number;
+ position: [number, number, number];
+}) => (
+
+
+
+
+
+);
+
+const Ball = ({ x }: { x: number }) => {
+ const rb = useRef(null);
+
+ const restart = () => {
+ rb.current?.setTranslation({ x, y: 7, z: -10 }, true);
+ rb.current?.setLinvel({ x: 0, y: 0, z: 5 }, true);
+ };
+
+ useEffect(restart, []);
+ useFrame(() => {
+ const t = rb.current?.translation();
+ if (t && (t.z > 25 || t.y < -4)) restart();
+ });
+
+ return (
+
+
+
+
+
+ );
+};
+
+const useKeys = () => {
+ const keys = useRef(new Set());
+ useEffect(() => {
+ const down = (e: KeyboardEvent) => keys.current.add(e.code);
+ const up = (e: KeyboardEvent) => keys.current.delete(e.code);
+ window.addEventListener("keydown", down);
+ window.addEventListener("keyup", up);
+ return () => {
+ window.removeEventListener("keydown", down);
+ window.removeEventListener("keyup", up);
+ };
+ }, []);
+ return keys;
+};
+
+const SPEED = 5;
+const JUMP_SPEED = 7;
+const GRAVITY = -20;
+
+const Player = () => {
+ const ref = useRef(null);
+ const keys = useKeys();
+ // Vertical velocity is userland: Rapier's controller only resolves movement.
+ const verticalVelocity = useRef(0);
+ const jumpQueued = useRef(false);
+
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ if (e.code === "Space") jumpQueued.current = true;
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, []);
+
+ // Runs once per fixed physics step, so the motion is framerate independent.
+ useBeforePhysicsStep((_world, dt) => {
+ const character = ref.current;
+ if (!character) return;
+ const k = keys.current;
+
+ const x =
+ (k.has("KeyD") || k.has("ArrowRight") ? 1 : 0) -
+ (k.has("KeyA") || k.has("ArrowLeft") ? 1 : 0);
+ const z =
+ (k.has("KeyS") || k.has("ArrowDown") ? 1 : 0) -
+ (k.has("KeyW") || k.has("ArrowUp") ? 1 : 0);
+ const len = Math.hypot(x, z) || 1;
+
+ if (character.grounded && verticalVelocity.current < 0) {
+ verticalVelocity.current = 0;
+ }
+ if (jumpQueued.current && character.grounded) {
+ verticalVelocity.current = JUMP_SPEED;
+ }
+ jumpQueued.current = false;
+ verticalVelocity.current += GRAVITY * dt;
+
+ character.setVelocity({
+ x: (x / len) * SPEED,
+ y: verticalVelocity.current,
+ z: (z / len) * SPEED
+ });
+ });
+
+ return (
+ console.log("grounded")}
+ onAirborne={() => console.log("airborne")}
+ >
+
+
+
+
+
+
+ );
+};
+
+/**
+ * WASD / arrows to move, Space to jump. Stairs of varying height on the
+ * left and right, slopes of increasing angle in front, bouncing balls to push.
+ */
+export const CharacterControllerExample: Demo = () => {
+ useResetOrbitControls(30, [0, 1, 1]);
+
+ const balls = useMemo(
+ () => Array.from({ length: 10 }, (_, i) => ),
+ []
+ );
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {balls}
+
+ >
+ );
+};
diff --git a/demo/src/examples/cluster/index.ts b/demo/src/examples/cluster/index.ts
new file mode 100644
index 00000000..b65df0b7
--- /dev/null
+++ b/demo/src/examples/cluster/index.ts
@@ -0,0 +1 @@
+export { Cluster as default } from "./ClusterExample";
diff --git a/demo/src/examples/colliders/index.ts b/demo/src/examples/colliders/index.ts
new file mode 100644
index 00000000..54abdb71
--- /dev/null
+++ b/demo/src/examples/colliders/index.ts
@@ -0,0 +1 @@
+export { Colliders as default } from "./CollidersExample";
diff --git a/demo/src/examples/collision-events/CollisionEventsExample.tsx b/demo/src/examples/collision-events/CollisionEventsExample.tsx
index 597bf6b8..668cf360 100644
--- a/demo/src/examples/collision-events/CollisionEventsExample.tsx
+++ b/demo/src/examples/collision-events/CollisionEventsExample.tsx
@@ -59,6 +59,8 @@ heightFieldGeometry.rotateX(-Math.PI / 2);
heightFieldGeometry.rotateY(-Math.PI / 2);
heightFieldGeometry.computeVertexNormals();
+let explosionId = 0;
+
const Explosion = ({ position }: { position: [number, number, number] }) => {
return (
@@ -92,7 +94,10 @@ const Collisioner = (
if (contact) {
setExplosions((curr: ReactNode[]) => [
...curr,
-
+
]);
}
},
diff --git a/demo/src/examples/collision-events/index.ts b/demo/src/examples/collision-events/index.ts
new file mode 100644
index 00000000..5e751471
--- /dev/null
+++ b/demo/src/examples/collision-events/index.ts
@@ -0,0 +1 @@
+export { CollisionEventsExample as default } from "./CollisionEventsExample";
diff --git a/demo/src/examples/components/ComponentsExample.tsx b/demo/src/examples/components/ComponentsExample.tsx
index d820c181..be7554fd 100644
--- a/demo/src/examples/components/ComponentsExample.tsx
+++ b/demo/src/examples/components/ComponentsExample.tsx
@@ -11,7 +11,6 @@ type GroupProps = ThreeElements["group"];
const Map = () => {
const { nodes } = useGLTF(
- // @ts-ignore
new URL("../../models/map.glb", import.meta.url).toString()
) as unknown as { nodes: { map: Mesh } };
diff --git a/demo/src/examples/components/index.ts b/demo/src/examples/components/index.ts
new file mode 100644
index 00000000..3ace821c
--- /dev/null
+++ b/demo/src/examples/components/index.ts
@@ -0,0 +1 @@
+export { ComponentsExample as default } from "./ComponentsExample";
diff --git a/demo/src/examples/contact-force-events/index.ts b/demo/src/examples/contact-force-events/index.ts
new file mode 100644
index 00000000..5666f09d
--- /dev/null
+++ b/demo/src/examples/contact-force-events/index.ts
@@ -0,0 +1 @@
+export { ContactForceEventsExample as default } from "./ContactForceEventsExample";
diff --git a/demo/src/examples/contact-skin/index.ts b/demo/src/examples/contact-skin/index.ts
new file mode 100644
index 00000000..444b22f8
--- /dev/null
+++ b/demo/src/examples/contact-skin/index.ts
@@ -0,0 +1 @@
+export { ContactSkinExample as default } from "./ContactSkinExample";
diff --git a/demo/src/examples/cradle/CradleExample.tsx b/demo/src/examples/cradle/CradleExample.tsx
index a7299b23..fb077f4f 100644
--- a/demo/src/examples/cradle/CradleExample.tsx
+++ b/demo/src/examples/cradle/CradleExample.tsx
@@ -1,11 +1,10 @@
import { Cylinder, Sphere } from "@react-three/drei";
import {
BallCollider,
- CylinderCollider,
RapierRigidBody,
RigidBody,
RigidBodyOptions,
- useSphericalJoint
+ useRevoluteJoint
} from "@react-three/rapier";
import { useRef } from "react";
import { Demo } from "../../App";
@@ -15,30 +14,31 @@ const Rod = (props: RigidBodyOptions) => {
const anchor = useRef(null!);
const rod = useRef(null!);
- useSphericalJoint(anchor, rod, [
+ useRevoluteJoint(anchor, rod, [
[0, 0, 0],
- [0, 0, 0]
+ [0, 0, 0],
+ [0, 0, 1] // swing in the XY plane
]);
return (
-
+ {/* a fixed anchor: no collider, and it never moves */}
+
-
+
-
-
+
);
@@ -48,12 +48,14 @@ export const CradleExample: Demo = () => {
useResetOrbitControls();
return (
-
+ // No group rotation/scale: body poses come from world matrices, so a tilted parent would
+ // make the rods hang (and swing) in a tilted plane. Scale the rod geometry instead.
+
-
-
-
-
+
+
+
+
);
};
diff --git a/demo/src/examples/cradle/index.ts b/demo/src/examples/cradle/index.ts
new file mode 100644
index 00000000..113082ce
--- /dev/null
+++ b/demo/src/examples/cradle/index.ts
@@ -0,0 +1 @@
+export { CradleExample as default } from "./CradleExample";
diff --git a/demo/src/examples/damping/index.ts b/demo/src/examples/damping/index.ts
new file mode 100644
index 00000000..03fc30c3
--- /dev/null
+++ b/demo/src/examples/damping/index.ts
@@ -0,0 +1 @@
+export { Damping as default } from "./DampingExample";
diff --git a/demo/src/examples/dynamic-type-change/index.ts b/demo/src/examples/dynamic-type-change/index.ts
new file mode 100644
index 00000000..5c88ffa3
--- /dev/null
+++ b/demo/src/examples/dynamic-type-change/index.ts
@@ -0,0 +1 @@
+export { DynamicTypeChangeExample as default } from "./DynamicTypeChangeExample";
diff --git a/demo/src/examples/immutable-props/ImmutablePropsExample.tsx b/demo/src/examples/immutable-props/ImmutablePropsExample.tsx
index 6a5fb142..4e85e2c4 100644
--- a/demo/src/examples/immutable-props/ImmutablePropsExample.tsx
+++ b/demo/src/examples/immutable-props/ImmutablePropsExample.tsx
@@ -20,8 +20,8 @@ export const ImmutablePropsExample: Demo = () => {
});
useFrame(() => {
- let rot = performance.now() / 1000;
- rb.current!.setRotation(
+ const rot = performance.now() / 1000;
+ rb.current?.setRotation(
quat().setFromEuler(euler({ x: 0, y: rot, z: 0 })),
true
);
diff --git a/demo/src/examples/immutable-props/index.ts b/demo/src/examples/immutable-props/index.ts
new file mode 100644
index 00000000..ec1f616c
--- /dev/null
+++ b/demo/src/examples/immutable-props/index.ts
@@ -0,0 +1 @@
+export { ImmutablePropsExample as default } from "./ImmutablePropsExample";
diff --git a/demo/src/examples/instanced-meshes/index.ts b/demo/src/examples/instanced-meshes/index.ts
new file mode 100644
index 00000000..54975431
--- /dev/null
+++ b/demo/src/examples/instanced-meshes/index.ts
@@ -0,0 +1 @@
+export { InstancedMeshes as default } from "./InstancedMeshesExample";
diff --git a/demo/src/examples/instances-meshes-compound/InstancedMeshesCompoundExample.tsx b/demo/src/examples/instances-meshes-compound/InstancedMeshesCompoundExample.tsx
index dd4f0e12..595b24a4 100644
--- a/demo/src/examples/instances-meshes-compound/InstancedMeshesCompoundExample.tsx
+++ b/demo/src/examples/instances-meshes-compound/InstancedMeshesCompoundExample.tsx
@@ -64,9 +64,13 @@ export const InstancedMeshesCompound: Demo = () => {
scale: [0.5 + Math.random(), 0.5 + Math.random(), 0.5 + Math.random()]
}))}
colliderNodes={[
- ,
- ,
-
+ ,
+ ,
+
]}
>
(null);
const { nodes, materials } = useGLTF(
- // @ts-ignore
new URL("../../models/plinko.glb", import.meta.url).toString()
) as unknown as GLTFResult;
diff --git a/demo/src/examples/plinko/index.ts b/demo/src/examples/plinko/index.ts
new file mode 100644
index 00000000..b42746c9
--- /dev/null
+++ b/demo/src/examples/plinko/index.ts
@@ -0,0 +1 @@
+export { default } from "./ShapesExample";
diff --git a/demo/src/examples/rope-joint/index.ts b/demo/src/examples/rope-joint/index.ts
new file mode 100644
index 00000000..b9d8ca76
--- /dev/null
+++ b/demo/src/examples/rope-joint/index.ts
@@ -0,0 +1 @@
+export { RopeJointExample as default } from "./RopeJointExample";
diff --git a/demo/src/examples/sensors/SensorsExample.tsx b/demo/src/examples/sensors/SensorsExample.tsx
index 4d7bd733..eb8a3b4f 100644
--- a/demo/src/examples/sensors/SensorsExample.tsx
+++ b/demo/src/examples/sensors/SensorsExample.tsx
@@ -1,4 +1,5 @@
-import { Box, Sphere, Text } from "@react-three/drei";
+// instead of troika : Text is WebGL-only (ShaderMaterial)
+import { Box, Html, Sphere } from "@react-three/drei";
import { useFrame } from "@react-three/fiber";
import {
CuboidCollider,
@@ -49,7 +50,13 @@ const Goal = (props: RigidBodyProps) => {
castShadow
/>
- {intersecting && Goal }
+ {intersecting && (
+
+
+ Goal
+
+
+ )}
{
+ useResetOrbitControls(8, [0, 2, 0]);
+ const flag = useRef(null);
+
+ useBeforePhysicsStep(() => {
+ const body = flag.current?.body;
+ if (!body) return;
+ const t = performance.now() / 1000;
+ const gust = 0.6 + 0.4 * Math.sin(t * 1.7);
+ body.addForce({ x: 0, y: 0, z: 6 * gust }, true);
+ });
+
+ return (
+ <>
+ {/* flagpole */}
+
+
+
+
+
+
+
+ p.x < -1.49}
+ >
+
+
+
+
+
+
+ {/* a tablecloth over a ball */}
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/demo/src/examples/soft-bodies/JellyExample.tsx b/demo/src/examples/soft-bodies/JellyExample.tsx
new file mode 100644
index 00000000..2eca2cd5
--- /dev/null
+++ b/demo/src/examples/soft-bodies/JellyExample.tsx
@@ -0,0 +1,37 @@
+import { RigidBody } from "@react-three/rapier";
+import { SoftBody } from "@react-three/rapier/soft-bodies";
+import { Demo } from "../../App";
+import { useResetOrbitControls } from "../../hooks/use-reset-orbit-controls";
+
+/**
+ * Volumetric (jelly) bodies: a cube and a sphere dropped on the floor, and a
+ * rigid crate that lands on them.
+ */
+export const JellyExample: Demo = () => {
+ useResetOrbitControls(10, [0, 1, 0]);
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/demo/src/examples/soft-bodies/RopeExample.tsx b/demo/src/examples/soft-bodies/RopeExample.tsx
new file mode 100644
index 00000000..904972ec
--- /dev/null
+++ b/demo/src/examples/soft-bodies/RopeExample.tsx
@@ -0,0 +1,54 @@
+import { RapierRigidBody, RigidBody } from "@react-three/rapier";
+import { SoftBody } from "@react-three/rapier/soft-bodies";
+import { useMemo, useRef } from "react";
+import { BufferGeometry, Vector3 } from "three";
+import { Demo } from "../../App";
+import { useResetOrbitControls } from "../../hooks/use-reset-orbit-controls";
+
+const POINTS = 24;
+const LENGTH = 5;
+
+/**
+ * A rope built from the points of a line: pinned at one end, with a heavy
+ * ball attached to its free end.
+ */
+export const RopeExample: Demo = () => {
+ useResetOrbitControls(10, [0, 3, 0]);
+ const ball = useRef(null);
+
+ const geometry = useMemo(
+ () =>
+ new BufferGeometry().setFromPoints(
+ Array.from(
+ { length: POINTS },
+ (_, i) => new Vector3((i / (POINTS - 1)) * LENGTH, 6, 0)
+ )
+ ),
+ []
+ );
+
+ return (
+ <>
+ {/* the ball is declared first so it exists when the rope attaches to it */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/demo/src/examples/spring/SpringExample.tsx b/demo/src/examples/spring/SpringExample.tsx
index aaacf1a5..12fd6c8c 100644
--- a/demo/src/examples/spring/SpringExample.tsx
+++ b/demo/src/examples/spring/SpringExample.tsx
@@ -6,10 +6,8 @@ import {
RigidBodyOptions,
useSpringJoint
} from "@react-three/rapier";
-import { forwardRef, useMemo, useRef } from "react";
+import { RefObject, forwardRef, useMemo, useRef } from "react";
import { Demo } from "../../App";
-import { useForwardedRef } from "@react-three/rapier/src/hooks/use-forwarded-ref";
-import { vectorArrayToVector3 } from "@react-three/rapier/src/utils/utils";
import { useResetOrbitControls } from "../../hooks/use-reset-orbit-controls";
import { Vector3 } from "three";
@@ -36,7 +34,8 @@ const BoxRigidBody = ({ color, ...props }: BoxRigidBodyProps) => {
const BallSpring = forwardRef(
(props, floorRef) => {
- const floor = useForwardedRef(floorRef);
+ // the parent always passes a ref object; don't deep-import library internals
+ const floor = floorRef as RefObject;
const ball = useRef(null!);
const stiffness = 1.0e3;
@@ -83,7 +82,7 @@ export const SpringExample: Demo = () => {
const vectorArr = useMemo(() => {
return Array.from({ length: 30 }).map((_, i) => {
- return vectorArrayToVector3([-20 + 1.5 * (i + 1), 7.5, -30]);
+ return new Vector3(-20 + 1.5 * (i + 1), 7.5, -30);
});
}, []);
diff --git a/demo/src/examples/spring/index.ts b/demo/src/examples/spring/index.ts
new file mode 100644
index 00000000..ff20f324
--- /dev/null
+++ b/demo/src/examples/spring/index.ts
@@ -0,0 +1 @@
+export { SpringExample as default } from "./SpringExample";
diff --git a/demo/src/examples/stuttering/StutteringExample.tsx b/demo/src/examples/stuttering/StutteringExample.tsx
index 6a74f819..b98507f8 100644
--- a/demo/src/examples/stuttering/StutteringExample.tsx
+++ b/demo/src/examples/stuttering/StutteringExample.tsx
@@ -1,4 +1,4 @@
-import { Box, Html, Sphere, Torus, useCamera } from "@react-three/drei";
+import { Box, Html, Sphere, Torus } from "@react-three/drei";
import { useFrame, useThree } from "@react-three/fiber";
import { RigidBody, RapierRigidBody, vec3 } from "@react-three/rapier";
import { useControls } from "leva";
@@ -23,7 +23,9 @@ export const StutteringExample: Demo = () => {
pos.y += 15;
pos.z += 15;
camera.position.lerp(pos, 0.04);
- camera.lookAt(bodyModel.current?.getWorldPosition(vec3())!);
+ if (bodyModel.current) {
+ camera.lookAt(bodyModel.current.getWorldPosition(vec3()));
+ }
}
});
diff --git a/demo/src/examples/stuttering/index.ts b/demo/src/examples/stuttering/index.ts
new file mode 100644
index 00000000..c27729ac
--- /dev/null
+++ b/demo/src/examples/stuttering/index.ts
@@ -0,0 +1 @@
+export { StutteringExample as default } from "./StutteringExample";
diff --git a/demo/src/examples/transforms/index.ts b/demo/src/examples/transforms/index.ts
new file mode 100644
index 00000000..46a3fdeb
--- /dev/null
+++ b/demo/src/examples/transforms/index.ts
@@ -0,0 +1 @@
+export { Transforms as default } from "./TransformsExample";
diff --git a/demo/src/examples/vehicle/VehicleExample.tsx b/demo/src/examples/vehicle/VehicleExample.tsx
new file mode 100644
index 00000000..6690afed
--- /dev/null
+++ b/demo/src/examples/vehicle/VehicleExample.tsx
@@ -0,0 +1,236 @@
+import { Box, Cylinder } from "@react-three/drei";
+import { useFrame } from "@react-three/fiber";
+import {
+ CuboidCollider,
+ RapierRigidBody,
+ RigidBody,
+ useBeforePhysicsStep,
+ useRapier
+} from "@react-three/rapier";
+import { useEffect, useRef } from "react";
+import { Group } from "three";
+import { Demo, useDemo } from "../../App";
+import { useResetOrbitControls } from "../../hooks/use-reset-orbit-controls";
+
+/**
+ * A raycast vehicle built straight on Rapier's DynamicRayCastVehicleController. There is no
+ * component on purpose: this file is meant to be copied and adapted.
+ *
+ * Drive with WASD / arrow keys, brake with Space.
+ */
+
+const WHEEL_RADIUS = 0.4;
+const SUSPENSION_REST_LENGTH = 0.35;
+
+// Wheel hard points in chassis space. The first two are the steered front wheels.
+const WHEELS: [number, number, number][] = [
+ [-0.95, -0.1, 1.3],
+ [0.95, -0.1, 1.3],
+ [-0.95, -0.1, -1.3],
+ [0.95, -0.1, -1.3]
+];
+
+const useKeys = () => {
+ const keys = useRef(new Set());
+
+ useEffect(() => {
+ const down = (e: KeyboardEvent) => keys.current.add(e.code);
+ const up = (e: KeyboardEvent) => keys.current.delete(e.code);
+ const blur = () => keys.current.clear();
+ window.addEventListener("keydown", down);
+ window.addEventListener("keyup", up);
+ window.addEventListener("blur", blur);
+ return () => {
+ window.removeEventListener("keydown", down);
+ window.removeEventListener("keyup", up);
+ window.removeEventListener("blur", blur);
+ };
+ }, []);
+
+ return keys;
+};
+
+const Vehicle = () => {
+ const { rapier, world } = useRapier();
+ const chassis = useRef(null);
+ const controller = useRef | null>(null);
+ const wheelGroups = useRef<(Group | null)[]>([]);
+ const wheelSpin = useRef<(Group | null)[]>([]);
+ const keys = useKeys();
+ const { orbitControlRef } = useDemo();
+
+ useEffect(() => {
+ const body = chassis.current;
+ if (!body) return;
+
+ const vehicle = world.createVehicleController(body);
+ // the controller defaults to y-up, z-forward
+ WHEELS.forEach(([x, y, z]) => {
+ vehicle.addWheel(
+ { x, y, z },
+ { x: 0, y: -1, z: 0 }, // suspension direction
+ { x: -1, y: 0, z: 0 }, // axle
+ SUSPENSION_REST_LENGTH,
+ WHEEL_RADIUS
+ );
+ });
+ for (let i = 0; i < WHEELS.length; i++) {
+ vehicle.setWheelSuspensionStiffness(i, 24);
+ vehicle.setWheelMaxSuspensionTravel(i, 0.3);
+ vehicle.setWheelFrictionSlip(i, 2.5);
+ vehicle.setWheelSideFrictionStiffness(i, 1);
+ }
+ controller.current = vehicle;
+
+ return () => {
+ controller.current = null;
+ world.removeVehicleController(vehicle);
+ };
+ }, [world]);
+
+ useBeforePhysicsStep((_world, dt) => {
+ const vehicle = controller.current;
+ if (!vehicle) return;
+
+ const k = keys.current;
+ const forward =
+ (k.has("KeyW") || k.has("ArrowUp") ? 1 : 0) -
+ (k.has("KeyS") || k.has("ArrowDown") ? 1 : 0);
+ const steer =
+ (k.has("KeyA") || k.has("ArrowLeft") ? 1 : 0) -
+ (k.has("KeyD") || k.has("ArrowRight") ? 1 : 0);
+ const brake = k.has("Space") ? 1 : 0;
+
+ for (let i = 0; i < WHEELS.length; i++) {
+ vehicle.setWheelEngineForce(i, forward * 12);
+ vehicle.setWheelBrake(i, brake * 1.5);
+ }
+ vehicle.setWheelSteering(0, steer * 0.5);
+ vehicle.setWheelSteering(1, steer * 0.5);
+
+ // EXCLUDE_SENSORS: wheel rays ignore sensor colliders. Without it the green trigger zone
+ // below would be treated as ground (#693).
+ vehicle.updateVehicle(dt, rapier.QueryFilterFlags.EXCLUDE_SENSORS);
+ });
+
+ // Wheel meshes are children of the chassis body, so they live in chassis space - exactly
+ // what the controller reports - and follow its (interpolated) transform.
+ useFrame(() => {
+ const vehicle = controller.current;
+ if (!vehicle) return;
+
+ // keep the orbit camera centred on the car while preserving the user's view offset
+ const controls = orbitControlRef?.current;
+ const body = chassis.current;
+ if (controls && body) {
+ const { x, y, z } = body.translation();
+ controls.object.position.x += x - controls.target.x;
+ controls.object.position.y += y - controls.target.y;
+ controls.object.position.z += z - controls.target.z;
+ controls.target.set(x, y, z);
+ controls.update();
+ }
+
+ for (let i = 0; i < WHEELS.length; i++) {
+ const group = wheelGroups.current[i];
+ const spin = wheelSpin.current[i];
+ if (!group || !spin) continue;
+
+ const [x, y, z] = WHEELS[i];
+ const suspension =
+ vehicle.wheelSuspensionLength(i) ?? SUSPENSION_REST_LENGTH;
+ group.position.set(x, y - suspension, z);
+ group.rotation.y = vehicle.wheelSteering(i) ?? 0;
+ spin.rotation.x = vehicle.wheelRotation(i) ?? 0;
+ }
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+ {WHEELS.map((_, i) => (
+ void (wheelGroups.current[i] = g)}>
+ void (wheelSpin.current[i] = g)}>
+
+
+
+ {/* a hub stripe so the spin is visible */}
+
+
+
+
+
+ ))}
+
+ );
+};
+
+export const VehicleExample: Demo = () => {
+ useResetOrbitControls(25, [0, 0.6, 1]);
+
+ return (
+ <>
+
+
+ {/* ground */}
+
+
+
+
+
+
+
+ {/* ramp */}
+
+
+
+
+
+
+ {/* a sensor zone the wheel rays must ignore (QueryFilterFlags.EXCLUDE_SENSORS) */}
+
+
+
+
+
+
+
+ {/* things to bump into */}
+ {[0, 1, 2, 3, 4].map((i) => (
+
+
+
+
+
+ ))}
+ >
+ );
+};
+
+export const meta = {
+ title: "Raycast vehicle",
+ description:
+ "Rapier's DynamicRayCastVehicleController with QueryFilterFlags.EXCLUDE_SENSORS. WASD/arrows to drive, Space to brake. Copy-paste example, not a component API."
+};
+
+export default VehicleExample;
diff --git a/demo/src/examples/vehicle/index.ts b/demo/src/examples/vehicle/index.ts
new file mode 100644
index 00000000..5014c11c
--- /dev/null
+++ b/demo/src/examples/vehicle/index.ts
@@ -0,0 +1 @@
+export { default, meta } from "./VehicleExample";
diff --git a/demo/src/renderer.ts b/demo/src/renderer.ts
new file mode 100644
index 00000000..81c5ac38
--- /dev/null
+++ b/demo/src/renderer.ts
@@ -0,0 +1,45 @@
+export type RendererKind = "webgpu" | "webgl";
+
+const STORAGE_KEY = "r3-rapier-demo-renderer";
+
+export const hasWebGPU = () =>
+ typeof navigator !== "undefined" && "gpu" in navigator;
+
+const isKind = (v: unknown): v is RendererKind =>
+ v === "webgpu" || v === "webgl";
+
+/**
+ * `?renderer=webgl|webgpu` wins, then the last choice saved in localStorage, then WebGPU when
+ * the browser has it, otherwise WebGL. A WebGPU request without `navigator.gpu` falls back.
+ */
+export const getRenderer = (): RendererKind => {
+ let wanted: unknown = new URLSearchParams(window.location.search).get(
+ "renderer"
+ );
+ if (!isKind(wanted)) {
+ try {
+ wanted = localStorage.getItem(STORAGE_KEY);
+ } catch {
+ /* storage unavailable */
+ }
+ }
+ if (isKind(wanted)) {
+ return wanted === "webgpu" && !hasWebGPU() ? "webgl" : wanted;
+ }
+ return hasWebGPU() ? "webgpu" : "webgl";
+};
+
+/**
+ * Persist the choice and reload: the canvas owns a WebGL or WebGPU context for its whole life,
+ * so a full reload is the cleanest way to swap (and avoids leaking contexts).
+ */
+export const switchRenderer = (kind: RendererKind) => {
+ try {
+ localStorage.setItem(STORAGE_KEY, kind);
+ } catch {
+ /* storage unavailable */
+ }
+ const url = new URL(window.location.href);
+ url.searchParams.set("renderer", kind);
+ window.location.href = url.toString();
+};
diff --git a/demo/tsconfig.json b/demo/tsconfig.json
index f3fd0423..2719b3c7 100644
--- a/demo/tsconfig.json
+++ b/demo/tsconfig.json
@@ -1,6 +1,6 @@
{
"compilerOptions": {
- "target": "ESNext",
+ "target": "ES2022",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
@@ -10,10 +10,19 @@
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
- "moduleResolution": "Node",
+ "moduleResolution": "Bundler",
"resolveJsonModule": true,
"noEmit": true,
- "jsx": "react-jsx"
+ "jsx": "react-jsx",
+ // The demo runs against the workspace sources (see the aliases in vite.config.mts), so it
+ // needs no `yarn build` first and picks up library changes with HMR.
+ "paths": {
+ "@react-three/rapier": ["../packages/react-three-rapier/src/index.ts"],
+ "@react-three/rapier/*": ["../packages/react-three-rapier/src/*"],
+ "@react-three/rapier-addons": [
+ "../packages/react-three-rapier-addons/src/index.ts"
+ ]
+ }
},
- "include": ["src"]
+ "include": ["src", "vite-env.d.ts"]
}
diff --git a/demo/vite.config.mts b/demo/vite.config.mts
index 9f7c0ca4..212cad84 100644
--- a/demo/vite.config.mts
+++ b/demo/vite.config.mts
@@ -1,7 +1,30 @@
-// vite.config.js
+import { fileURLToPath } from "node:url";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
+const src = (pkg: string) =>
+ fileURLToPath(new URL(`../packages/${pkg}/src`, import.meta.url));
+
export default defineConfig({
- plugins: [react()]
+ plugins: [react()],
+ resolve: {
+ // Use the workspace sources directly so the demo needs no `yarn build` and gets HMR on
+ // library changes. Subpath entries map to `src//index.ts` (see tsconfig.json `paths`).
+ alias: [
+ {
+ find: /^@react-three\/rapier-addons$/,
+ replacement: `${src("react-three-rapier-addons")}/index.ts`
+ },
+ {
+ find: /^@react-three\/rapier$/,
+ replacement: `${src("react-three-rapier")}/index.ts`
+ },
+ {
+ find: /^@react-three\/rapier\/(.+)$/,
+ replacement: `${src("react-three-rapier")}/$1/index.ts`
+ }
+ ],
+ // the aliased sources import react/three/r3f themselves; make sure they share the demo's copy
+ dedupe: ["react", "react-dom", "three", "@react-three/fiber"]
+ }
});
diff --git a/docs/advanced/collision-events.mdx b/docs/advanced/collision-events.mdx
new file mode 100644
index 00000000..fc0699fb
--- /dev/null
+++ b/docs/advanced/collision-events.mdx
@@ -0,0 +1,96 @@
+---
+title: Collision events
+description: onCollisionEnter, onCollisionExit, onSleep and onWake on bodies and colliders.
+nav: 20
+---
+
+You can subscribe to collision and state events on a RigidBody:
+
+๐งฉ See [onCollisionEnter / onCollisionExit docs](https://pmndrs.github.io/react-three-rapier/interfaces/RigidBodyProps.html#onCollisionEnter) for more information.
+
+```tsx
+const RigidBottle = () => {
+ const [isAsleep, setIsAsleep] = useState(false);
+
+ return (
+ setIsAsleep(true)}
+ onWake={() => setIsAsleep(false)}
+ name="Bally McBallFace"
+ onCollisionEnter={({ manifold, target, other }) => {
+ // Manifolds are temporary: read what you need inside the callback
+ console.log(
+ "Collision at world position ",
+ manifold.solverContactPoint(0)
+ );
+
+ if (other.rigidBodyObject) {
+ console.log(
+ // this rigid body's Object3D
+ target.rigidBodyObject.name,
+ " collided with ",
+ // the other rigid body's Object3D
+ other.rigidBodyObject.name
+ );
+ }
+ }}
+ >
+
+
+
+
+ );
+};
+```
+
+You may also subscribe to collision events on individual Colliders:
+
+```tsx
+ {
+ /* ... */
+ }}
+ onCollisionExit={(payload) => {
+ /* ... */
+ }}
+/>
+```
+
+The `payload` object for all collision callbacks contains the following properties:
+
+- `target`
+ `CollisionTarget` of the object firing the event.
+- `other`
+ `CollisionTarget` of the other object involved in the event.
+- `manifold` (onCollisionEnter only)
+ The [contact manifold](https://rapier.rs/javascript3d/classes/TempContactManifold.html) generated by the collision event.
+- `flipped` (onCollisionEnter only)
+ `true` if the data in the `manifold` [is flipped](https://rapier.rs/javascript3d/classes/World.html#contactPair).
+
+A `CollisionTarget` is an object containing references to objects involved in a collision event. It has the following properties:
+
+- `rigidBody` (if exists): `Rapier.RigidBody`
+- `rigidBodyObject` (if exists): `Three.Object3D`
+- `collider`: `Rapier.Collider`
+- `colliderObject`: `Three.Object3D`
+
+### When events fire
+
+Collision, intersection and contact-force events are dispatched after every physics step, not once per frame. With a
+fixed `timeStep`, a frame can run several steps, so a handler may run several times in one frame (or not at all). Events
+also run before the frame's mesh sync, so the `rigidBodyObject` / `colliderObject` transforms in a payload are the
+previous frame's rendered (interpolated) pose. For the simulated pose at that step, read
+`payload.target.rigidBody.translation()` / `.rotation()`. See [Update loop](/advanced/update-loop).
+
+### Contact manifolds in Rapier 0.21
+
+The `manifold` is a Rapier `TempContactManifold`. It is only valid during the callback, so copy out what you need.
+
+- `solverContactPoint(i)` now returns the world-space **midpoint** of the two per-body surface points.
+- `solverContactFriction(i)` and `solverContactRestitution(i)` were replaced by `friction()` and `restitution()` (per manifold).
+- Penetration depth is available as `contactDist(i)` and `solverContactDist(i)` (negative means penetrating).
+ See the [FAQ](/advanced/faq#how-do-i-get-the-penetration-depth-of-a-collision).
+- `numContacts()`, `normal()`, `localContactPoint1(i)` and `localContactPoint2(i)` are unchanged.
+
+See also [collision groups](/advanced/collision-groups), [contact forces](/advanced/contact-forces) and [sensors](/advanced/sensors).
diff --git a/docs/advanced/collision-groups.mdx b/docs/advanced/collision-groups.mdx
new file mode 100644
index 00000000..4945615b
--- /dev/null
+++ b/docs/advanced/collision-groups.mdx
@@ -0,0 +1,35 @@
+---
+title: Collision and solver groups
+description: Filtering which colliders interact using interactionGroups.
+nav: 21
+---
+
+Both `` as well as all collider components allow you to configure `collisionsGroups` and `solverGroups` properties that configures which groups the colliders are in, and what other groups they should interact with in potential collision and solving events (you will find more details on this in the [Rapier documentation](https://rapier.rs/docs/user_guides/javascript/colliders/#collision-groups-and-solver-groups).)
+
+Since these are set as bitmasks and bitmasks can get a bit unwieldy to generate, this library provides a helper called `interactionGroups` that can be used to generate bitmasks from numbers and arrays of groups, where groups are identified using numbers from 0 to 15.
+
+The first argument is the group, or an array of groups, that the collider is a member of; the second argument is the group, or an array of groups, that the collider should interact with.
+
+Here the collider is in group 0, and interacts with colliders from groups 0, 1 and 2:
+
+```tsx
+
+```
+
+This collider is in multiple groups, but only interacts with colliders from a single group:
+
+```tsx
+
+```
+
+When the second argument is omitted, the collider will interact with all groups:
+
+```tsx
+
+```
+
+> [!NOTE]
+> Please remember that in Rapier, for a collision (or solving) event to occur, both colliders involved in the event must match the related interaction groups -- a one-way match will be ignored.
+
+> [!NOTE]
+> By default, colliders are members of all groups, and will interact with all other groups.
diff --git a/docs/advanced/contact-forces.mdx b/docs/advanced/contact-forces.mdx
new file mode 100644
index 00000000..508f88a9
--- /dev/null
+++ b/docs/advanced/contact-forces.mdx
@@ -0,0 +1,51 @@
+---
+title: Contact force events
+description: onContactForce payloads and what changed in Rapier 0.21.
+nav: 22
+---
+
+Contact force events are triggered on `` and any collider components when two objects collider.
+
+```tsx
+ {
+ console.log(`The total force generated was: ${payload.totalForce}`);
+ }}
+>
+
+
+
+
+```
+
+The payload for the contact force event contains the following properties:
+
+- `target`
+ `CollisionTarget` of the object firing the event
+- `other`
+ `CollisionTarget` of the other object involved in the event
+- `totalForce`
+ The sum of all the forces between the two colliders
+- `totalForceMagnitude`
+ The sum of the magnitudes of each force between the two colliders
+- `maxForceDirection`
+ The world-space (unit) direction of the force with strongest magnitude
+- `maxForceMagnitude`
+ The magnitude of the largest force at a contact point of this contact pair
+
+More information about each property can be found in the rapier [TempContactForceEvent API documentation](https://rapier.rs/javascript3d/classes/TempContactForceEvent.html).
+
+You can also add the `onContactForce` event to any collider.
+
+```tsx
+ {
+ /* ... */
+ }}
+/>
+```
+
+> [!NOTE]
+> Rapier 0.21 reports smaller contact impulses than 0.19 (roughly by a factor of `1 / (iterations + 1)`), because the
+> solver now works in substeps. Thresholds you tuned against 2.x may need to be lowered.
diff --git a/docs/advanced/faq.mdx b/docs/advanced/faq.mdx
new file mode 100644
index 00000000..a89e47fe
--- /dev/null
+++ b/docs/advanced/faq.mdx
@@ -0,0 +1,125 @@
+---
+title: FAQ and troubleshooting
+description: Answers to the questions that come up most often.
+nav: 29
+---
+
+## CSP refuses to compile WebAssembly
+
+_(Issue #699)_ With a strict `Content-Security-Policy` you get an error mentioning `unsafe-eval` or WebAssembly compilation.
+You do not need `'unsafe-eval'`. WebAssembly is gated by the narrower `'wasm-unsafe-eval'` source, which does not allow
+JavaScript `eval`:
+
+```
+Content-Security-Policy: script-src 'self' 'wasm-unsafe-eval'
+```
+
+It is supported by Chrome 95+, Firefox 102+ and Safari 16+. Switching to a non-compat Rapier package does not avoid this.
+
+## Could not resolve "fs"
+
+_(Issue #698)_ This error comes from `opentype.js`, which is used by drei's `Text3D`, when your bundler targets a worker
+or server runtime (for example Shopify Hydrogen). It is not caused by Rapier. Alias `fs` to `false` / mark it external for
+that target, exclude `Text3D` from server bundles, or avoid `Text3D`. 3.0 removes `three-stdlib` from this library's dependencies.
+
+## Heightfield collider errors or lets bodies fall through
+
+_(Issues #730, #564)_ A `HeightfieldCollider` with `args={[w, h, heights, scale]}` needs `heights.length === (w + 1) * (h + 1)`,
+because `w` and `h` count cells and heights are per vertex. The values are column-major. Also use a positive `scale`: a negative
+`scale.z` makes bodies fall through. To mirror a heightfield, rotate its body by 180 degrees around Y.
+
+## How do I get the penetration depth of a collision?
+
+_(Issue #768)_ The `manifold` in `onCollisionEnter` is a Rapier `TempContactManifold`. Read `manifold.contactDist(i)` or
+`manifold.solverContactDist(i)` for each of `manifold.numContacts()` contacts; negative values mean penetration. The manifold is
+temporary, so read it inside the callback and copy values out. The depth is for the first contact, so for resting contacts use
+[`onContactForce`](/advanced/contact-forces).
+
+```tsx
+ {
+ for (let i = 0; i < manifold.numContacts(); i++) {
+ console.log(manifold.contactDist(i), manifold.solverContactDist(i));
+ }
+ }}
+/>
+```
+
+## How do I raycast?
+
+_(Issue #714)_ There is no `raycaster`. Use `useRapier()` and call queries on the world:
+
+```tsx
+const { world, rapier } = useRapier();
+const ray = new rapier.Ray({ x: 0, y: 5, z: 0 }, { x: 0, y: -1, z: 0 });
+const hit = world.castRay(ray, 10, true);
+```
+
+To know whether a body is on the floor, cast a short ray down, or use a sensor collider below it. See
+[Hooks](/api/hooks#raycasting-and-queries).
+
+## setNextKinematicTranslation does nothing
+
+_(Issue #726)_ Rapier's setters take objects, not arrays: `body.setNextKinematicTranslation({ x: 0, y: 1, z: 0 })`.
+Passing `[0, 1, 0]` silently does nothing. Call it every frame (for example in `useFrame`) on a `kinematicPosition` body.
+
+## Using drei `Html` inside a RigidBody
+
+_(Issue #645)_ `` does not re-center its children. If the label looks centered, it comes from the `center` prop of `Html`, or from
+centered geometry. Give the `Html` an explicit position relative to the body, or wrap it in a positioned group:
+
+```tsx
+
+ ...
+
+ label
+
+
+```
+
+## Hull or trimesh collider pivots around the wrong point
+
+_(Issue #267)_ The collider is built from the geometry as it is, in the object's local space. If the geometry is not centered on its
+origin (common for models from glTF files, URDF links or hamburgers), the center of mass is off-center and the body spins around an
+unexpected point or looks "heavy" on one side. Re-center the geometry (`geometry.center()`, or fix it in your modeling tool), or
+wrap the mesh in a group with the opposite offset, so that the origin matches the intended pivot.
+
+## Rotating a parent of a RigidBody after mount
+
+_(Issue #36)_ Since 3.0 this works. A ``'s visual is written in its parent's **current** space every frame, so
+moving, rotating or re-parenting an ancestor after mount (drei `PresentationControls` or ``, a group animated in
+`useFrame`) keeps the visual on the simulated body. If the ancestor moves during `update`, after the physics sync, a late
+pass right before `render` re-applies the transforms. Nested bodies are synced parent-first.
+
+Note that ancestors do not _drag_ bodies: the simulation still decides where a body is in the world. To move bodies with a
+group, move the bodies themselves (for example kinematic bodies with `setNextKinematicTranslation`). In 2.x the parent
+transform was only read at creation; if you are still on 2.x, rotate the camera instead or keep physics objects outside
+the transformed group. See [Update loop: moving parents](/advanced/update-loop#moving-parents).
+
+## React Native
+
+_(Issue #383)_ React Native is not supported. Rapier is WebAssembly, which the Hermes engine does not provide. Polyfills work only for
+tiny scenes and are unsupported. Look at `callstack/react-native-rapier` for native bindings.
+
+## Kinematic body passes through fixed colliders
+
+_(Issue #694)_ A kinematic body is never pushed out of other colliders by contacts. `activeCollisionTypes={ActiveCollisionTypes.ALL}` only
+makes kinematic-vs-fixed pairs _report events_; it does not resolve penetration. For a player character use the
+[character controller](/api/character-controller) (`` or `useCharacterController`), which resolves the
+movement against walls, slopes and stairs before applying it, or a dynamic body driven by velocity.
+
+## Vehicle wheels react to sensors
+
+_(Issue #693)_ Rapier's raycast vehicle controller casts rays that hit sensors by default. Pass a filter flag to `updateVehicle`:
+
+```tsx
+const { rapier } = useRapier();
+vehicle.updateVehicle(delta, rapier.QueryFilterFlags.EXCLUDE_SENSORS);
+```
+
+The same flag applies to `world.castRay` and the other queries.
+
+## raweventqueue_new of undefined
+
+Usually a duplicate or mismatched `@dimforge/rapier3d-compat` in `node_modules`, which leaves the wasm module uninitialized. Make sure
+exactly one copy is installed (`npm ls @dimforge/rapier3d-compat`) and dedupe it.
diff --git a/docs/advanced/migration.mdx b/docs/advanced/migration.mdx
new file mode 100644
index 00000000..fc31960e
--- /dev/null
+++ b/docs/advanced/migration.mdx
@@ -0,0 +1,206 @@
+---
+title: Migrating from 2.x to 3.0
+description: Peer dependencies, WebGPU, Rapier 0.21 breaking changes, the new update loop and the new package layout.
+nav: 28
+---
+
+3.0 is a breaking release. It moves to react-three-fiber v10 (with WebGPU renderer support), Rapier.js 0.21, a
+new build with an `exports` map, and an update loop built on the react-three-fiber v10 scheduler. It also adds a
+character controller and soft bodies.
+
+Install the prerelease with `npm install @react-three/rapier@alpha` (and `@react-three/rapier-addons@alpha` if you use
+the addons).
+
+## 1. Peer dependencies
+
+| package | 2.x | 3.0 |
+| -------------------------------------------------------- | --------- | --------------------------------------------- |
+| `@react-three/fiber` | `^9` | `>=10.0.0-0` |
+| `react` / `react-dom` | `^19` | `>=19.0 <19.3` (follows react-three-fiber 10) |
+| `three` | `>=0.159` | `>=0.185` |
+| `@dimforge/rapier3d-compat` | `0.19.2` | `0.21.0` (dependency) |
+| `@react-three/rapier` (for `@react-three/rapier-addons`) | `^2.0.0` | `^3.0.0-0` |
+
+If you are on react-three-fiber v9 you will need to migrate it to v10 first; follow the
+[react-three-fiber migration guide](https://r3f.docs.pmnd.rs/). The fixed-step scheduler phases need
+`@pmndrs/scheduler` 0.3 or later (shipped with current react-three-fiber v10 builds); older builds fall back to an
+internal accumulator, see [Update loop and stepping](/advanced/update-loop).
+
+In your own frame callbacks, `state.clock` is gone in react-three-fiber v10: use `state.elapsed` and `state.delta`.
+
+## 2. WebGPU
+
+react-three-fiber v10 can drive three's `WebGPURenderer`. `@react-three/rapier` itself does not touch the renderer;
+everything it draws works on both backends.
+
+- **``** works on WebGL and WebGPU. Rapier's RGBA debug colors are uploaded as a 4-component vertex
+ color attribute, which both renderers read. See [Debug](/api/debug).
+- **`` debug helper** (`@react-three/rapier-addons`) draws with `MeshBasicMaterial` and three's
+ `VertexNormalsHelper`, which `WebGPURenderer` supports, so it works on both renderers.
+- Materials you use for your own meshes must be WebGPU-compatible (node materials or the built-in ones). Custom
+ `ShaderMaterial` / `onBeforeCompile` code is WebGL-only.
+
+> [!NOTE]
+> Some helpers from other libraries still read `state.gl`, which react-three-fiber v10 leaves `null` on WebGPU roots
+> (use `state.renderer`). That is unrelated to this library, but you may hit it in the same migration.
+
+## 3. Rapier 0.21
+
+`@dimforge/rapier3d-compat` jumps from 0.19.2 to 0.21.0 (Rapier engine 0.35 and 0.36). This changes behavior as well as API.
+
+### API changes
+
+- **`minIslandSize` is removed.** Rapier no longer has this setting. The `` prop is deprecated and ignored; delete it.
+- **Manifold API.** `manifold.solverContactFriction(i)` and `manifold.solverContactRestitution(i)` are replaced by
+ `manifold.friction()` and `manifold.restitution()`. `manifold.solverContactPoint(i)` now returns the world-space midpoint of the
+ two per-body surface points. Read manifold data inside the event callback, since manifolds are temporary.
+- **Rapier package layout.** Files moved into `dist/` and the `exports` map only exposes `.`. Imports like
+ `@dimforge/rapier3d-compat/math` (use the root exports `Rotation` and `Vector`) and pinned CDN file URLs must be updated.
+- **Low-level pipelines.** Code that uses `useRapier()` to reach `PhysicsPipeline`, `DebugRenderPipeline`,
+ `SerializationPipeline` or the body/collider sets must account for the new `SoftBodySet` arguments, and `NarrowPhase.contactPair`
+ also requires the rigid-body set. The `World` convenience methods that this library uses are unchanged.
+- **Snapshots.** Snapshots from older Rapier versions cannot be restored. Recreate saved snapshots after upgrading and
+ store the Rapier version alongside persisted snapshot data.
+
+### Behavior changes
+
+- **CCD.** Fast dynamic bodies use CCD against fixed colliders automatically. The `ccd` prop enables additional sweeps against kinematic and
+ dynamic bodies ("bullet"), and `ccd={false}` no longer disables all CCD. Use `` to disable CCD entirely.
+- **`additionalSolverIterations`** now adds whole solver substeps for the constraint-connected bodies.
+- **Sleeping.** Bodies fall asleep sooner (linear sleep threshold `0.4` to `0.05`, time-until-sleep `2.0s` to `0.5s`), and islands sleep and wake as a unit.
+- **Velocity caps.** Linear velocity is capped at 400 units/s and angular rotation at about 45 degrees per step.
+- **Contact forces.** Contact impulses and contact-force events are smaller; restitution is applied in an end-of-step pass.
+- **Contact defaults.** `` keeps `allowedLinearError={0.001}` and `predictionDistance={0.002}`. Rapier's new native defaults are `0.005` and `0.02`;
+ set them explicitly to adopt them.
+- Existing scenes may need retuning and numerical trajectories will differ.
+
+### Bundle size
+
+The Rapier wasm roughly doubled (about 1.6 MB to 3.1 MB) because the soft-body solver is now part of it.
+
+See the [TypeScript binding changelog](https://github.com/dimforge/rapier/blob/master/bindings/typescript/CHANGELOG.md)
+and the [engine changelog](https://github.com/dimforge/rapier/blob/master/CHANGELOG.md).
+
+## 4. Update loop and stepping
+
+Physics now runs on the react-three-fiber v10 scheduler. The full description is in
+[Update loop and stepping](/advanced/update-loop); this is what changes for 2.x code.
+
+**Where physics runs.** With the default `updateLoop="follow"`, `` registers three scheduler jobs per world:
+
+- a **step job** in the world's own fixed-timestep phase, `rapier:fixed:`, right after r3f's `physics` phase. It runs
+ 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`, which writes interpolated transforms to your meshes;
+- a **late check** in a `rapier:late` phase, just before `render`, which re-applies them if an ancestor of a body moved
+ during `update`.
+
+The phase order is `start โ input โ physics โ rapier:fixed:โฆ โ rapier โ update โ rapier:late โ render โ finish`, so a
+`useFrame` in the default `update` phase always sees meshes that were already synced this frame. The built-in `physics`
+phase timestep is never changed: 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 react-three-fiber's render loop. It is now only the order of this world's jobs within their phases
+(higher runs first, matching r3f v10's `{ priority }`). React-three-fiber 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. Time beyond that is dropped, so a
+stalled tab no longer makes physics fast-forward (#697). 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`.**
+
+```tsx
+useBeforePhysicsStep((world, dt) => {
+ // dt === timeStep for fixed steps, or the clamped frame delta for "vary"
+});
+```
+
+Existing `(world) => โฆ` callbacks keep working. Prefer `dt` over `world.timestep`, which is only updated when the step
+runs.
+
+**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 the previous frame's rendered (interpolated) pose. For
+the simulated pose at that step, read `payload.target.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, an `interpolate` or `timeStep` change, 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.** `` 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, so `updateLoop="independent"`
+is no longer needed for this. Physics only notices bodies on a frame: 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 (#36).** A ``'s visual is written into its parent's current space every frame. Moving,
+rotating or re-parenting an ancestor after mount (`PresentationControls`, drei ``, a group animated in `useFrame`)
+no longer desyncs visuals from bodies, even when the ancestor moves during `update`. Ancestors do not drag bodies along;
+the simulation still decides where they are. Nested ``s are synced parent-first, so they no longer lag the body
+that carries them.
+
+**Changing `timeStep` at runtime** works and retimes only that world's phase, so several worlds with different
+`timeStep`s can share a canvas. 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)`.
+
+**`timeStep="vary"`** steps once per frame with the frame delta clamped to 0.5s, without interpolation, as before.
+
+**`updateLoop="independent"`** keeps its own `requestAnimationFrame` loop, now with the same accumulator, `maxSubSteps`
+and interpolation rules. Its first frame no longer reports a large catch-up delta.
+
+**Debug lines.** `` draws the latest simulated step. With interpolation, the lines can lead the rendered
+meshes by up to one step. The debug renderer now reuses one geometry and disposes it on unmount instead of allocating one
+per frame and leaking on toggle (#666).
+
+**World teardown.** The Rapier world is freed after the unmount commit, so cleanups of children (bodies, colliders,
+joints) run against a live world, and StrictMode remounts are safe.
+
+**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`), `` falls back to its own accumulator in a per-frame job. The behavior is the same,
+including `maxSubSteps`, `paused` and interpolation.
+
+## 5. Package exports
+
+The packages are built with rollup instead of preconstruct and ship an `exports` map with ESM (`.mjs`) and CommonJS
+(`.cjs`) builds and matching type declarations. Import from `@react-three/rapier` only; deep imports such as
+`@react-three/rapier/dist/...` or `@react-three/rapier/src/...` no longer resolve. Optional features live on named entry
+points: soft bodies are on `@react-three/rapier/soft-bodies`. Ensure your bundler and TypeScript use a resolution mode that
+understands `exports` (`moduleResolution: "bundler"`, `"node16"` or `"nodenext"`); a `typesVersions` fallback covers
+`"node"` / node10 resolution.
+
+## 6. Dependencies
+
+- **`three-stdlib` is removed.** The library uses `three/examples/jsm` (`three/addons`) for the helpers it needed. If you
+ relied on `three-stdlib` being installed transitively, add it to your own dependencies. This also removes the
+ `Could not resolve "fs"` problem that `opentype.js` caused for some bundlers.
+- The minimum React is 19.0 and three is 0.185.
+
+## 7. Bug fixes with visible behavior changes
+
+- **Refs are cleared on destroy (#780).** Forwarded refs of ``, colliders and ``
+ instances are set to `null` when the Rapier object is destroyed, instead of pointing at freed objects. Guard ref access
+ in cleanups and async code (`ref.current?.โฆ`).
+- **Instanced frustum culling (#683).** `` disables frustum culling on its instanced mesh by default,
+ since its bounds go stale as bodies move. Pass `frustumCulled` to re-enable it.
+- **Collider mass props are reactive (#711).** `mass`, `density` and `massProperties` on colliders now update after mount.
+- **Trimesh and hull colliders (#781, #627)** read interleaved, normalized and quantized position attributes correctly,
+ and throw a descriptive error when a collider cannot be built.
+- **Heightfields (#564, #730).** Args are scaled per axis and no longer mutated (scale used to compound on remount).
+ `heights` must have `(rows + 1) * (cols + 1)` values; a mismatch warns in development.
+- **Enum re-exports (#703).** `ActiveCollisionTypes`, `ActiveEvents`, `ActiveHooks` and `QueryFilterFlags` are exported
+ from `@react-three/rapier`.
+- **No `key` spread warning (#691)** from `` on React 19.
+
+## 8. New features
+
+- **Character controller.** `useCharacterController` and `` wrap Rapier's kinematic character
+ controller: walls, slopes, stairs and ground snapping, applied once per fixed step with the step's `dt`. See
+ [Character controller](/api/character-controller).
+- **Soft bodies.** `` and `useSoftBody` build cloth, ropes and volumetric bodies from a mesh or line, with
+ pinning, attachments to rigid bodies and tearing. They live on `@react-three/rapier/soft-bodies`. See
+ [Soft bodies](/api/soft-bodies).
+- **`maxSubSteps`** on ``, and `dt` in step callbacks (see [Update loop and stepping](/advanced/update-loop)).
+- Per-axis spherical-joint motors are available through Rapier's own API (`useRapier()`).
diff --git a/docs/advanced/performance.mdx b/docs/advanced/performance.mdx
new file mode 100644
index 00000000..d5371516
--- /dev/null
+++ b/docs/advanced/performance.mdx
@@ -0,0 +1,58 @@
+---
+title: Performance
+description: Keeping simulations fast - instancing, simple colliders, sleeping and solver settings.
+nav: 27
+---
+
+Rapier is fast, but every body, collider and contact costs time on every step. Most performance problems come from
+the number of colliders and their complexity.
+
+## Prefer simple colliders
+
+- `cuboid` and `ball` are the cheapest shapes. Use them whenever the visual shape is close enough.
+- `hull` is a good compromise for convex objects. It is much cheaper than `trimesh`.
+- `trimesh` is only appropriate for fixed bodies (level geometry). Dynamic trimesh bodies are expensive and unreliable.
+- Hand-place a few simple colliders on a [compound body](/api/colliders#collider-components) instead of one detailed mesh collider.
+- Turn off automatic colliders you do not need: ``.
+
+## Use instancing for many identical objects
+
+Use [`InstancedRigidBodies`](/api/instanced-rigid-bodies) rather than hundreds of separate meshes. Each instance still
+costs a rigid body in the simulation, but you pay only one draw call. Give every instance a stable `key`.
+
+## Let bodies sleep
+
+Bodies at rest are put to sleep and skipped by the solver. Rapier 0.21 sleeps bodies sooner than 0.19 did. Do not set
+`canSleep={false}` unless you need to. Sleeping is also what lets [on-demand rendering](/advanced/update-loop#on-demand-rendering)
+idle.
+
+## Tune the solver sparingly
+
+`numSolverIterations`, `numInternalPgsIterations` and `additionalSolverIterations` trade stability for speed. Raise them
+only for the bodies or scenes that need it. Use `ccd` on small, fast bodies rather than very small time steps.
+
+## Reduce overhead
+
+- Leave `debug` off in production.
+- Avoid running heavy code in `onCollisionEnter` and `onContactForce`, which fire often.
+- Do not create many `` worlds.
+
+## Fixed-step catch-up
+
+When a frame takes longer than one `timeStep`, `` runs several steps in that frame to catch up. `maxSubSteps`
+(default `5`) caps how many, and drops any time beyond `maxSubSteps * timeStep`. This keeps a slow device or a stalled
+tab from spending ever more time on physics (a "spiral of death"), at the cost of the simulation running slower than
+real time when frames are very long.
+
+- The cost of a frame is roughly `steps ร step cost`. A smaller `timeStep` means more steps per frame, so raise
+ `maxSubSteps` with it only if you must not lose simulation time (for example `timeStep={1 / 240}` with
+ `maxSubSteps={12}`).
+- Code in `useBeforePhysicsStep` / `useAfterPhysicsStep` and collision handlers runs once per step, so it is multiplied by
+ the same factor. Keep it light.
+
+## On-demand rendering
+
+With ``, the default `updateLoop="follow"` calls `invalidate()` once per frame while any
+dynamic or kinematic body is awake, and stops when everything sleeps. Fixed bodies never keep the canvas awake. Combined
+with sleeping this lets a settled scene stop rendering entirely. See
+[on-demand rendering](/advanced/update-loop#on-demand-rendering).
diff --git a/docs/advanced/physics-hooks.mdx b/docs/advanced/physics-hooks.mdx
new file mode 100644
index 00000000..dc751163
--- /dev/null
+++ b/docs/advanced/physics-hooks.mdx
@@ -0,0 +1,98 @@
+---
+title: Physics hooks
+description: Collision filtering and one-way platforms with useFilterContactPair and useFilterIntersectionPair.
+nav: 24
+---
+
+You can implement advanced collision behaviors like one-way platforms by using physics hooks. These hooks allow you to filter collision and intersection pairs during the physics step.
+
+`r3/rapier` provides two hooks for collision filtering:
+
+- `useFilterContactPair` - Filter collision pairs and control solver behavior
+- `useFilterIntersectionPair` - Filter intersection pairs for sensors
+
+#### Filter Contact Pairs
+
+`useFilterContactPair` allows you to control how collisions are processed. The callback should return:
+
+- `SolverFlags.COMPUTE_IMPULSE` (1) - Process the collision normally
+- `SolverFlags.EMPTY` (0) - Ignore the collision
+- `null` - Let other hooks decide, or use default behavior
+
+#### Filter Intersection Pairs
+
+`useFilterIntersectionPair` controls which sensor intersections are detected. The callback should return:
+
+- `true` - Allow the intersection to be detected
+- `false` - Block the intersection
+
+If multiple hooks are registered:
+
+- For contact pairs, the **first hook that returns non-null wins**
+- For intersection pairs, the **first hook that returns false blocks** the intersection
+
+**Important:** To avoid Rust aliasing errors, you **cannot** access rigid body properties (like `translation()` or `linvel()`) directly during the physics step. Instead, cache the needed state before the step using `useBeforePhysicsStep`.
+
+๐งฉ See [useFilterContactPair docs](https://pmndrs.github.io/react-three-rapier/functions/useFilterContactPair.html) and [useFilterIntersectionPair docs](https://pmndrs.github.io/react-three-rapier/functions/useFilterIntersectionPair.html) for more information.
+
+```tsx
+import {
+ useRapier,
+ useBeforePhysicsStep,
+ useFilterContactPair
+} from "@react-three/rapier";
+
+const OneWayPlatform = () => {
+ const platformRef = useRef(null);
+ const ballRef = useRef(null);
+ const colliderRef = useRef(null);
+
+ // Cache for storing body states before physics step
+ const bodyStateCache = useRef(new Map());
+
+ const { rapier } = useRapier();
+
+ // Cache body states BEFORE the physics step
+ useBeforePhysicsStep(() => {
+ if (platformRef.current && ballRef.current) {
+ const ballPos = ballRef.current.translation();
+ const ballVel = ballRef.current.linvel();
+
+ bodyStateCache.current.set(ballRef.current.handle, {
+ position: ballPos,
+ velocity: ballVel
+ });
+ }
+ });
+
+ // Filter collisions using cached data
+ useFilterContactPair((collider1, collider2, body1, body2) => {
+ const ballState = bodyStateCache.current.get(body1);
+ if (!ballState) return null; // Let other hooks or default behavior handle it
+
+ // Allow collision only if ball is moving down and above platform
+ if (ballState.velocity.y < 0 && ballState.position.y > 0) {
+ return rapier.SolverFlags.COMPUTE_IMPULSE; // Process collision
+ }
+ return rapier.SolverFlags.EMPTY; // Ignore collision
+ });
+
+ useEffect(() => {
+ // Enable active hooks on the collider (required for filtering)
+ colliderRef.current?.setActiveHooks(
+ rapier.ActiveHooks.FILTER_CONTACT_PAIRS
+ );
+ }, []);
+
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+};
+```
diff --git a/docs/advanced/sensors.mdx b/docs/advanced/sensors.mdx
new file mode 100644
index 00000000..93651aa1
--- /dev/null
+++ b/docs/advanced/sensors.mdx
@@ -0,0 +1,37 @@
+---
+title: Sensors
+description: Detecting intersections with sensor colliders.
+nav: 23
+---
+
+A Collider can be set to be a sensor, which means that it will not generate any contact points, and will not be affected by forces. This is useful for detecting when a collider enters or leaves another collider, without affecting the other collider.
+
+To detect when a collider enters or leaves another collider, you can use the `onIntersectionEnter` and `onIntersectionExit` events on the collider.
+
+๐งฉ See [onIntersectionEnter / onIntersectionExit docs](https://pmndrs.github.io/react-three-rapier/interfaces/RigidBodyProps.html#onIntersectionEnter) for more information.
+
+```tsx
+
+
+
+ console.log("Goal!")}
+ />
+
+```
+
+### Sensors example
+
+
+
+
+
+> [!NOTE]
+> Raycasts and vehicle wheel casts also hit sensors unless you exclude them. See the
+> [FAQ](/advanced/faq#vehicle-wheels-react-to-sensors).
diff --git a/docs/advanced/snapshots.mdx b/docs/advanced/snapshots.mdx
new file mode 100644
index 00000000..9d02f80d
--- /dev/null
+++ b/docs/advanced/snapshots.mdx
@@ -0,0 +1,47 @@
+---
+title: Snapshots
+description: Serializing and restoring the physics world.
+nav: 26
+---
+
+The `world` can be serialized as a `Uint8Array` using `world.takeSnapshot()`, see Rapier's docs on [Serialization](https://rapier.rs/docs/user_guides/javascript/serialization/) for more info.
+
+The snapshot can be used to construct a new world. In `r3/rapier`, you need to replace the world with this snapshot.
+
+> [!NOTE]
+> This only works if the snapshotted world is identical to the restored one. If objects, or the order of creation of objects vary, expect RigidBodies to scramble.
+> Snapshot formats are version-dependent. Store the Rapier version alongside persisted snapshot data and check compatibility before restoring after an upgrade.
+
+```tsx
+import { useRapier } from "@react-three/rapier";
+
+const SnapshottingComponent = () => {
+ const { world, setWorld, rapier } = useRapier();
+ const worldSnapshot = useRef();
+
+ // Store the snapshot
+ const takeSnapshot = () => {
+ const snapshot = world.takeSnapshot();
+ worldSnapshot.current = snapshot;
+ };
+
+ // Create a new World from the snapshot, and replace the current one
+ const restoreSnapshot = () => {
+ setWorld(rapier.World.restoreSnapshot(worldSnapshot.current));
+ };
+
+ return (
+ <>
+ ...
+ ...
+ ...
+ ...
+ ...
+ >
+ );
+};
+```
+
+> [!WARNING]
+> Snapshots taken with Rapier 0.19 or older **cannot** be restored with Rapier 0.21. Recreate persisted snapshots
+> after upgrading.
diff --git a/docs/advanced/update-loop.mdx b/docs/advanced/update-loop.mdx
new file mode 100644
index 00000000..710f6d6d
--- /dev/null
+++ b/docs/advanced/update-loop.mdx
@@ -0,0 +1,166 @@
+---
+title: Update loop and stepping
+description: Fixed time steps on the react-three-fiber v10 scheduler, interpolation, manual stepping and on-demand rendering.
+nav: 25
+---
+
+`` runs on the react-three-fiber v10 scheduler (`@pmndrs/scheduler`). By default it simulates at a fixed rate
+of 60 steps per second, independent of the frame rate, and interpolates the rendered transforms between the last two
+steps so motion looks smooth at any refresh rate.
+
+## Where physics runs
+
+With the default `updateLoop="follow"`, every `` world registers three scheduler jobs:
+
+- A **step job** in the world's own fixed-timestep phase, `rapier:fixed:`. It runs right after r3f's built-in
+ `physics` phase, once per whole `timeStep` of banked frame time, so zero, one or several times per frame.
+- A **sync job** in the per-frame `rapier` phase, just before `update`. It writes the interpolated transforms of all
+ bodies into their meshes, once per frame.
+- A **late check** in the `rapier:late` phase, just before `render`. It re-applies those transforms if an ancestor of
+ a body moved during `update` (see [moving parents](#moving-parents)).
+
+The resulting 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.
+
+Each world owns its fixed phase, and the built-in `physics` phase timestep is never changed. Other libraries and your
+own `useFrame(cb, { phase: "physics" })` code keep their 1/60, and several `` worlds with different
+`timeStep`s can run in one canvas. To run code in lockstep with Rapier, use
+[`useBeforePhysicsStep` / `useAfterPhysicsStep`](#running-code-every-step), not the `physics` phase.
+
+## Configuring time step size
+
+`timeStep` sets the length of one fixed step, in seconds (default `1/60`):
+
+```tsx
+{/* ... */}
+```
+
+The `timeStep` prop may also be set to `"vary"`, which steps once per frame with that frame's delta (clamped to 0.5s).
+There is no interpolation in this mode:
+
+```tsx
+{/* ... */}
+```
+
+> [!NOTE]
+> `"vary"` is useful when you need exactly one step per frame, but it may cause instability in the simulation and it
+> is not deterministic. Please use with care!
+
+Changing `timeStep` at runtime works and retimes only that world's phase. Each change drops the fraction of a step that
+was already banked, so do not animate `timeStep` every frame (for example for slow motion). Prefer scaling forces or
+gravity, or use `paused` with [manual stepping](#manual-stepping).
+
+## Catch-up limit: `maxSubSteps`
+
+When a frame takes longer than one `timeStep`, several fixed steps run in that frame to catch up. `maxSubSteps`
+(default `5`) caps how many. Simulation time beyond `maxSubSteps * timeStep` is dropped rather than queued, so a stalled
+tab, a debugger pause or a heavy frame does not make physics fast-forward or spiral.
+
+```tsx
+// Small steps need a higher cap to keep real-time speed at low frame rates
+
+ {/* ... */}
+
+```
+
+Raise it if you use a small `timeStep` and must not lose simulation time at low frame rates. It is ignored with
+`timeStep="vary"`.
+
+## Interpolation
+
+With `interpolate` (default `true`), the sync job renders each body between the pose before and the pose after the
+latest step, using how far the frame is into the next step (the phase's overstep) as the blend factor. Rendered
+transforms therefore lag the simulation by less than one step. `interpolate={false}` renders the latest simulated pose.
+
+Interpolation restarts from the current pose after a manual `step()`, after `setWorld()`, and when `paused`,
+`interpolate` or `timeStep` change, so visuals never lerp backwards.
+
+### Teleports
+
+Teleport a body with `setTranslation` (or `setRotation`) inside `useBeforePhysicsStep`. The pose before the step is
+recorded after the before-step callbacks, so the jump is not interpolated. A teleport made elsewhere (an event handler,
+a regular `useFrame`) is interpolated from the previous step's pose for at most one step.
+
+## Running code every step
+
+`useBeforePhysicsStep` and `useAfterPhysicsStep` run once per fixed step, inside the step job, and receive the world and
+the length of that step:
+
+```tsx
+useBeforePhysicsStep((world, dt) => {
+ // dt === timeStep for fixed steps, or the clamped frame delta with timeStep="vary"
+});
+```
+
+Use `dt` rather than the frame delta to keep logic framerate independent. Callbacks that only take `(world)` keep
+working. Collision, intersection and contact-force events are also dispatched after every step, so a handler may run
+several times in one frame. See [Collision events](/advanced/collision-events).
+
+## Pausing
+
+`paused` disables the step job. No time is banked while paused, so resuming does not jump ahead. Meshes keep syncing
+(showing the latest simulated pose), and manual stepping still works.
+
+## Manual stepping
+
+You can manually step the physics simulation by calling the `step` method from the `useRapier` hook. It runs one step of
+`dt` seconds (with the step callbacks and events) and syncs the meshes immediately, also while `paused`.
+
+```tsx
+const { step } = useRapier();
+
+step(1 / 60);
+```
+
+## `updatePriority`
+
+`updatePriority` orders this world's jobs within their phases. Higher runs first, like r3f v10's `{ priority }`
+option. It no longer takes over rendering: r3f keeps rendering in all cases.
+
+> [!NOTE]
+> In 2.x, a positive `updatePriority` was passed to `useFrame` and made react-three-fiber stop rendering automatically.
+> If you relied on that to render manually, register your own `useFrame(cb, { phase: "render" })`.
+
+## On-demand rendering
+
+`` works with the default `updateLoop="follow"`. While any dynamic or kinematic body is awake,
+the sync job calls `invalidate()` once per frame, so the canvas keeps rendering. When everything sleeps it stops.
+Fixed bodies never keep the canvas awake.
+
+- See https://docs.pmnd.rs/react-three-fiber/advanced/scaling-performance#on-demand-rendering, for info on on-demand
+ rendering in `@react-three/fiber`.
+
+```tsx
+
+ ...
+
+```
+
+Physics only notices bodies on a frame. If you wake or move a body outside a frame while the canvas is idle (for example
+`applyImpulse` in a DOM click handler), call `invalidate()` once.
+
+## Independent loop
+
+` ` steps physics in its own `requestAnimationFrame` loop instead of the
+react-three-fiber scheduler. It uses the same fixed-step accumulator, `maxSubSteps`, `paused` and interpolation rules,
+and invalidates the canvas while bodies are awake. Since `"follow"` supports `frameloop="demand"` itself, you only need
+this to decouple physics from the canvas' frame loop.
+
+## Moving parents
+
+A ``'s visual is written in its parent's current space every frame. Moving, rotating or re-parenting an
+ancestor after mount (drei `PresentationControls` or ``, a group animated in `useFrame`) keeps the visual on the
+simulated body, even when the ancestor moves during `update`, after the sync, because the `rapier:late` check catches
+it. Ancestors do not drag the bodies along: the simulation still decides where bodies are. Nested ``s are
+synced parent-first, so an inner body never lags the body that carries it.
+
+## Older react-three-fiber builds
+
+If your `@react-three/fiber` ships a scheduler without fixed phases (`@pmndrs/scheduler` < 0.3, for example
+`10.0.0-alpha.5`), `` falls back to its own accumulator in a per-frame job in the `rapier` phase. Behavior is
+the same, including `maxSubSteps`, `paused` and interpolation.
diff --git a/docs/api/attractors.mdx b/docs/api/attractors.mdx
new file mode 100644
index 00000000..688d3585
--- /dev/null
+++ b/docs/api/attractors.mdx
@@ -0,0 +1,101 @@
+---
+title: Attractors
+description: The Attractor addon, a gravity source that pulls or pushes rigid bodies.
+nav: 9
+---
+
+Attractors live in the separate `@react-three/rapier-addons` package.
+
+```bash
+npm install @react-three/rapier-addons
+```
+
+An attractor simulates a source of gravity. Any `RigidBody` within range will be _pulled_ (attracted) toward the attractor.
+Setting the `strength` to a negative value will cause the `RigidBody` to be _pushed_ (repelled) away from the attractor.
+
+The force applied to rigid-bodies within range is calculated differently depending on the `type`.
+
+```tsx
+type AttractorProps = {
+ /**
+ * The relative position of this attractor
+ */
+ position?: Object3DProps["position"];
+
+ /**
+ * The strength of the attractor.
+ * Positive values attract, negative values repel.
+ *
+ * @defaultValue 1
+ */
+ strength?: number;
+
+ /**
+ * The range of the attractor. Will not affect objects outside of this range.
+ *
+ * @defaultValue 10
+ * @min 0
+ */
+ range?: number;
+
+ /**
+ * The type of gravity to use.
+ * - static: The gravity is constant and does not change over time.
+ * - linear: The gravity is linearly interpolated the closer the object is to the attractor.
+ * - newtonian: The gravity is calculated using the newtonian gravity formula.
+ * @defaultValue "static"
+ */
+ type?: string;
+
+ /**
+ * The mass of the attractor. Used when type is `newtonian`.
+ * @defaultValue 6.673e-11
+ */
+ gravitationalConstant?: number;
+
+ /**
+ * The collision groups that this attractor will apply effects to. If a RigidBody contains one or more colliders that are in one of the mask group, it will be affected by this attractor.
+ * If not specified, the attractor will apply effects to all RigidBodies.
+ */
+ collisionGroups?: InteractionGroups;
+};
+```
+
+```tsx
+import { Attractor } from "@react-three/rapier-addons"
+
+// Standard attractor
+
+
+// An attractor with negative strength, repels RigidBodies
+
+
+// You can also assign InteractionGroups.
+// An attractor belonging to group 0 only affecting bodies in group 2, and 3
+
+```
+
+Gravity types:
+
+- "static" (Default)
+ Static gravity means that the same force (`strength`) is applied on all rigid-bodies within range, regardless of distance.
+
+- "linear"
+ Linear gravity means that force is calculated as `strength * distance / range`. That means the force applied decreases the farther a rigid-body is from the attractor position.
+
+- "newtonian"
+ Newtonian gravity uses the traditional method of calculating gravitational force (`F = GMm/r^2`) and as such force is calculated as `gravitationalConstant * mass1 * mass2 / Math.pow(distance, 2)`.
+ - `gravitationalConstant` defaults to 6.673e-11 but you can provide your own
+ - `mass1` here is the "mass" of the Attractor, which is just the `strength` property
+ - `mass2` is the mass of the rigid-body at the time of calculation. Note that rigid-bodies with colliders will use the mass provided to the collider. This is not a value you can control from the attractor, only from wherever you're creating rigid-body components in the scene.
+ - `distance` is the distance between the attractor and rigid-body at the time of calculation
+
+### Attractors example
+
+
+
+
diff --git a/docs/api/character-controller.mdx b/docs/api/character-controller.mdx
new file mode 100644
index 00000000..3d19d78e
--- /dev/null
+++ b/docs/api/character-controller.mdx
@@ -0,0 +1,166 @@
+---
+title: Character controller
+description: Kinematic character movement against walls, slopes and stairs with useCharacterController and CharacterController.
+nav: 11
+---
+
+A kinematic character controller moves a body through the world while resolving collisions itself: it slides along
+walls, climbs slopes up to a maximum angle, steps up stairs and snaps to the ground. `@react-three/rapier` wraps
+Rapier's `KinematicCharacterController` in two APIs:
+
+- ``: a `kinematicPosition` `` you give a velocity to. The easiest way to get started.
+- `useCharacterController`: creates and owns the Rapier controller and gives you a `move()` function for your own body.
+
+Both apply the motion once per fixed physics step, with the step's `dt`, so movement is framerate independent. Gravity,
+jumping and input are left to you.
+
+## ``
+
+```tsx
+import { useRef } from "react";
+import {
+ CapsuleCollider,
+ CharacterController,
+ CharacterControllerRef,
+ useBeforePhysicsStep
+} from "@react-three/rapier";
+
+const Player = () => {
+ const ref = useRef(null);
+ const vy = useRef(0);
+ const input = useInput(); // your own input, returns { x, z, jump }
+
+ useBeforePhysicsStep((_world, dt) => {
+ const c = ref.current!;
+ vy.current = c.grounded && !input.jump ? 0 : vy.current - 9.81 * dt;
+ if (c.grounded && input.jump) vy.current = 6;
+ c.setVelocity({ x: input.x * 4, y: vy.current, z: input.z * 4 });
+ });
+
+ return (
+ console.log("landed")}
+ >
+
+
+ );
+};
+```
+
+Children are colliders and meshes, exactly like ``. The first non-sensor collider is used to compute the
+movement. `` defaults to `colliders={false}`, so add an explicit collider (a capsule is the usual
+choice), or pass `colliders` to generate one.
+
+It accepts every [controller option](#options), every `` prop except `type`, and:
+
+| prop | description |
+| ---------------------- | ---------------------------------------------------------------------------------------------------------------------- |
+| `onGrounded` | Called once when the character lands (edge triggered). |
+| `onAirborne` | Called once when the character leaves the ground (edge triggered). |
+| `onCharacterCollision` | Called for every collision found while moving, on every step. The collision object is pooled: read it, do not keep it. |
+| `filterFlags` | Rapier `QueryFilterFlags` used while moving. |
+| `filterGroups` | Rapier interaction groups: only matching colliders are considered. |
+| `filterPredicate` | `(collider) => boolean`. Return `false` to ignore a collider. The character's own colliders are always ignored. |
+| `excludeSensors` | Ignore sensor colliders. Default `true`. |
+
+### Ref
+
+The ref (`CharacterControllerRef`) exposes:
+
+| member | description |
+| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
+| `controller` | The Rapier `KinematicCharacterController`. |
+| `rigidBody` | The character's kinematic rigid body. |
+| `grounded` | Whether the character stood on the ground during the last step. |
+| `setVelocity(v)` | Sets the persistent desired velocity in units per second. It is scaled by `dt` on every step. |
+| `translate(delta)` | Queues a one-off displacement applied on the next step, on top of the velocity (knockback, pushes). Calls before a step accumulate. |
+
+> [!NOTE]
+> The component registers its step callback in its own effect, which runs before callbacks registered by a parent
+> component. A `setVelocity` / `translate` from your `useBeforePhysicsStep` in a parent is therefore applied on the next
+> step (one fixed step of latency), and `grounded` reflects the previous step.
+
+## `useCharacterController`
+
+Use the hook when you want to drive your own ``. It creates the controller on mount and frees it on unmount
+(StrictMode safe), applies options reactively, and returns a referentially stable api:
+
+```tsx
+import { useRef } from "react";
+import {
+ CapsuleCollider,
+ RapierRigidBody,
+ RigidBody,
+ useBeforePhysicsStep,
+ useCharacterController
+} from "@react-three/rapier";
+
+const Player = () => {
+ const body = useRef(null);
+ const vy = useRef(0);
+ const cc = useCharacterController({
+ autostep: { maxHeight: 0.3, minWidth: 0.2 }
+ });
+
+ useBeforePhysicsStep((_world, dt) => {
+ vy.current -= 9.81 * dt;
+ const { grounded } = cc.move(body.current!, {
+ x: 0,
+ y: vy.current * dt,
+ z: 0
+ });
+ if (grounded) vy.current = 0;
+ });
+
+ return (
+
+
+
+ );
+};
+```
+
+`move(rigidBody, desiredTranslation, options?)` computes the collision-resolved movement for that step, applies it with
+`setNextKinematicTranslation` and returns `{ movement, grounded, collisions }`. Call it once per step from
+`useBeforePhysicsStep`, with a translation already scaled by `dt`. The result object (including `movement` and
+`collisions`) is reused between calls: read it, do not keep it.
+
+`options` takes `collider` (defaults to the body's first non-sensor collider), `filterFlags`, `filterGroups`,
+`filterPredicate` and `excludeSensors` (default `true`), with the same meaning as the component props above.
+
+`controller` is the underlying Rapier controller, valid while the hook is mounted. Calling `move` after unmount is a
+no-op that warns in development.
+
+## Options
+
+Shared by `` and `useCharacterController`. Every option is applied reactively. Options left
+`undefined` (or changed back to `undefined`) use Rapier's defaults.
+
+| option | default | description |
+| ------------------------------ | -------------- | ----------------------------------------------------------------------------- |
+| `offset` | `0.01` | Small gap kept between the character and the environment. |
+| `up` | `[0, 1, 0]` | The character's up direction (`[x, y, z]` or `{ x, y, z }`). |
+| `slide` | `true` | Slide along obstacles instead of stopping. |
+| `maxSlopeClimbAngle` | Rapier default | Steepest slope the character can climb, in radians. |
+| `minSlopeSlideAngle` | Rapier default | Slope angle, in radians, from which the character slides down. |
+| `autostep` | disabled | `{ maxHeight, minWidth, includeDynamicBodies? }` to climb stairs, or `false`. |
+| `snapToGround` | disabled | Maximum distance to snap down to the ground (stairs, slopes), or `false`. |
+| `applyImpulsesToDynamicBodies` | Rapier default | Push dynamic bodies the character walks into. |
+| `characterMass` | Rapier default | Mass used when pushing dynamic bodies. |
+| `normalNudgeFactor` | Rapier default | See Rapier's docs. |
+
+## Notes
+
+- A kinematic body is never pushed out of other colliders by contacts. The controller is what keeps it out of walls,
+ so move the character only through `move()` / `setVelocity()`.
+- Steps run at the fixed `timeStep`, and the rendered body is [interpolated](/advanced/update-loop#interpolation)
+ between steps like any other body.
+- The `character-controller` demo shows stairs, slopes, jumping and pushing balls.
+
+๐งฉ See [CharacterControllerProps](https://pmndrs.github.io/react-three-rapier/interfaces/CharacterControllerProps.html)
+and [useCharacterController](https://pmndrs.github.io/react-three-rapier/functions/useCharacterController.html) in the
+generated reference.
diff --git a/docs/api/colliders.mdx b/docs/api/colliders.mdx
new file mode 100644
index 00000000..e8c89a8e
--- /dev/null
+++ b/docs/api/colliders.mdx
@@ -0,0 +1,131 @@
+---
+title: Colliders
+description: Automatic colliders, collider components, compound shapes and MeshCollider.
+nav: 5
+---
+
+## Automatic colliders
+
+RigidBodies generate automatic colliders by default for all meshes that it contains. You can control the default collider by setting the `colliders` prop on a ` `, or change it globally by setting `colliders` on ` `. Setting `colliders={false}` disables auto-generation.
+
+Supported values:
+
+- `"cuboid"`, creates a CuboidCollider based on the bounding box of the mesh
+- `"ball"`, creates a SphereCollider based on the bounding sphere of the mesh
+- `"trimesh"`, creates a TrimeshCollider based on the mesh's geometry
+- `"hull"`, creates a ConvexHullCollider based on the mesh's geometry
+- `false`, disables auto-generation
+
+Generate ConvexHull colliders for all meshes in a RigidBody by default:
+
+```tsx
+const Scene = () => (
+
+
+
+
+
+
+
+
+);
+```
+
+Turn off automatic collider generation globally, but apply auto generation locally:
+
+```tsx
+const Scene = () => (
+
+ {/* Use an automatic CuboidCollider for all meshes inside this RigidBody */}
+
+
+
+
+ {/* Use an automatic BallCollider for all meshes inside this RigidBody */}
+
+
+
+
+);
+```
+
+If part of your meshes are invisible and you want to include them in the collider creation, use the `includeInvisible` flag.
+
+```tsx
+
+
+
+
+
+```
+
+## Collider components
+
+You can also create `Colliders` by hand and add them to a `RigidBody` to create compound colliders. This is useful for creating more complex shapes, for creating simplified shapes for performance reasons, or for detecting collisions on specific parts of a mesh.
+
+๐งฉ See [ColliderProps docs](https://pmndrs.github.io/react-three-rapier/interfaces/ColliderProps.html) for available props.
+
+```tsx
+const Scene = () => (<>
+ {/* Make a compound shape with two custom BallColliders */}
+
+
+
+
+
+
+ {/* Make a compound shape with two custom BallColliders, an automatic BallCollider,
+ Two automatic MeshColliders, based on two different shape types */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+<>)
+```
+
+### Heightfields
+
+`HeightfieldCollider` takes `args={[widthSegments, heightSegments, heights, scale]}`. The segment counts are _cells_,
+and `heights` must hold one value per vertex: `(widthSegments + 1) * (heightSegments + 1)` values. Rapier lays heights
+out column-major. Use a positive `scale`; negative scales are not supported (mirror with a rotation of the body instead).
+
+### Hull and trimesh pivots
+
+Hull and trimesh colliders are built from the geometry exactly as it is. If the geometry is not centered on the
+object's origin, the collider and the body's center of mass are off-center, and the body appears to rotate around a
+different pivot. See the [FAQ](/advanced/faq#hull-or-trimesh-collider-pivots-around-the-wrong-point).
+
+## MeshCollider
+
+`` generates colliders from the meshes it wraps and can sit
+inside a `` next to other colliders. See the compound example above.
+
+### Collider examples
+
+
+
+
+
+
+
+
+๐งฉ See [ColliderProps](https://pmndrs.github.io/react-three-rapier/interfaces/ColliderProps.html) and
+[MeshColliderProps](https://pmndrs.github.io/react-three-rapier/interfaces/MeshColliderProps.html) for all props.
diff --git a/docs/api/debug.mdx b/docs/api/debug.mdx
new file mode 100644
index 00000000..e9a73da0
--- /dev/null
+++ b/docs/api/debug.mdx
@@ -0,0 +1,48 @@
+---
+title: Debug
+description: Rendering collider outlines with the debug option.
+nav: 8
+---
+
+Set the `debug` prop on ` ` to see live representations of all colliders in a scene, using the live debug buffer from the physics engine.
+
+```tsx
+import { Box, Sphere } from "@react-three/drei";
+import { RigidBody } from "@react-three/rapier";
+
+const Scene = () => {
+ return (
+
+
+
+
+
+
+
+
+ );
+};
+```
+
+## How it works
+
+`` mounts a `` component that draws Rapier's debug lines (`world.debugRender()`) with a
+`lineSegments` and a `lineBasicMaterial` using vertex colors.
+
+- It keeps **one** `BufferGeometry` for its whole lifetime. Every frame the position and color attributes are rewritten in
+ place, only the used range is uploaded, and the draw range is set to the current vertex count. The attributes grow in
+ powers of two when Rapier returns more lines, disposing the outgrown GPU buffers. The geometry is disposed when debug is
+ turned off or `` unmounts, so toggling `debug` does not leak memory.
+- The lines are updated in the `update` phase, after this frame's physics steps, so they show the latest simulated step.
+ With [interpolation](/advanced/update-loop#interpolation) on, they can lead the rendered meshes by up to one step.
+ Use `interpolate={false}` if you need them to line up exactly.
+
+## WebGPU
+
+The debug renderer works with both `WebGLRenderer` and react-three-fiber v10's `WebGPURenderer`. Rapier's colors are RGBA
+and are uploaded as a 4-component color attribute: WebGL reads it through `vertexAlphas`, and three's WebGPU node
+materials read vertex colors as `vec4`, so no conversion is needed.
+
+> [!NOTE]
+> Debug rendering calls `world.debugRender()` every frame, which copies every collider outline out of wasm. Leave it off
+> in production.
diff --git a/docs/api/hooks.mdx b/docs/api/hooks.mdx
new file mode 100644
index 00000000..4f9543f2
--- /dev/null
+++ b/docs/api/hooks.mdx
@@ -0,0 +1,111 @@
+---
+title: Hooks
+description: useRapier, step callbacks, physics hooks, joints and queries.
+nav: 10
+---
+
+## useRapier
+
+Gives you access to the `world`, direct access to the `rapier` module, and more. It throws if used outside ``.
+๐งฉ See [RapierContext docs](https://pmndrs.github.io/react-three-rapier/interfaces/RapierContext.html).
+
+```tsx
+import { useRapier } from "@react-three/rapier";
+
+const { world, rapier } = useRapier();
+```
+
+### Raycasting and queries
+
+Queries live on the world; there is no separate raycaster.
+
+```tsx
+const { world, rapier } = useRapier();
+
+const castDown = (origin: { x: number; y: number; z: number }) => {
+ const ray = new rapier.Ray(origin, { x: 0, y: -1, z: 0 });
+ const maxToi = 10;
+ const solid = true;
+ const hit = world.castRay(ray, maxToi, solid);
+ if (hit) {
+ const point = ray.pointAt(hit.timeOfImpact);
+ // hit.collider is the Rapier collider that was hit
+ }
+};
+```
+
+`world.castRayAndGetNormal`, `world.intersectionsWithRay`, `world.projectPoint` and shape casts work the same way.
+
+## useBeforePhysicsStep / useAfterPhysicsStep
+
+Run code right before or right after every physics step. The callback receives the `world` and `dt`, the length of
+that step in seconds: the fixed `timeStep`, or the clamped frame delta with `timeStep="vary"`.
+
+- `useBeforePhysicsStep(callback)`
+ ๐งฉ See [docs](https://pmndrs.github.io/react-three-rapier/functions/useBeforePhysicsStep.html).
+- `useAfterPhysicsStep(callback)`
+ ๐งฉ See [docs](https://pmndrs.github.io/react-three-rapier/functions/useAfterPhysicsStep.html).
+
+```tsx
+import { useBeforePhysicsStep, useAfterPhysicsStep } from "@react-three/rapier";
+
+const Thruster = ({ body }: { body: RefObject }) => {
+ useBeforePhysicsStep((world, dt) => {
+ // Runs once per fixed step, so this is framerate independent
+ body.current?.applyImpulse({ x: 0, y: 12 * dt, z: 0 }, true);
+ });
+
+ useAfterPhysicsStep((world) => {
+ // Read the simulated state of this step
+ });
+
+ return null;
+};
+```
+
+With a fixed `timeStep` the callbacks run zero, one or several times per frame, inside the world's fixed-step
+scheduler phase, before the meshes are synced. Use `dt`, not the frame delta. Callbacks that only take `(world)` keep
+working. The callback can change between renders without re-registering. Teleports (`setTranslation`) made in
+`useBeforePhysicsStep` are not interpolated. See [Update loop](/advanced/update-loop).
+
+## useFilterContactPair / useFilterIntersectionPair
+
+Collision filtering for one-way platforms and similar. See [Physics hooks](/advanced/physics-hooks).
+
+## Joint hooks
+
+`useFixedJoint`, `useSphericalJoint`, `useRevoluteJoint`, `usePrismaticJoint`, `useRopeJoint`, `useSpringJoint`.
+See [Joints](/api/joints).
+
+## useCharacterController
+
+Creates and owns a Rapier `KinematicCharacterController` and returns a stable `{ controller, move }` api. Call `move`
+once per physics step from `useBeforePhysicsStep` with a translation already scaled by `dt`:
+
+```tsx
+const cc = useCharacterController({
+ autostep: { maxHeight: 0.3, minWidth: 0.2 },
+ snapToGround: 0.2
+});
+
+useBeforePhysicsStep((_world, dt) => {
+ const { grounded } = cc.move(body.current!, {
+ x: 4 * dt,
+ y: -9.81 * dt,
+ z: 0
+ });
+});
+```
+
+`` wraps the hook in a kinematic ``. See [Character controller](/api/character-controller).
+
+## Soft bodies
+
+`` and `useSoftBody` (cloth, ropes and volumetric "jelly" bodies) live on a separate entry point, so the extra
+code is only bundled when you use them:
+
+```tsx
+import { SoftBody, useSoftBody } from "@react-three/rapier/soft-bodies";
+```
+
+See [Soft bodies](/api/soft-bodies).
diff --git a/docs/api/instanced-rigid-bodies.mdx b/docs/api/instanced-rigid-bodies.mdx
new file mode 100644
index 00000000..6e4967a4
--- /dev/null
+++ b/docs/api/instanced-rigid-bodies.mdx
@@ -0,0 +1,115 @@
+---
+title: InstancedRigidBodies
+description: One rigid body per instance of an InstancedMesh.
+nav: 6
+---
+
+Instanced meshes can also be used and have automatic colliders generated from their mesh.
+
+By wrapping exactly one `Three.InstancedMesh` in ` `, each instance will be attached to an individual `RigidBody`.
+
+๐งฉ See [InstancedRigidBodiesProps docs](https://pmndrs.github.io/react-three-rapier/interfaces/InstancedRigidBodiesProps.html) for available props.
+
+```tsx
+import { InstancedRigidBodies, RapierRigidBody } from "@react-three/rapier";
+
+const COUNT = 1000;
+
+const Scene = () => {
+ const rigidBodies = useRef(null);
+
+ useEffect(() => {
+ if (!rigidBodies.current) {
+ return;
+ }
+
+ // You can access individual instanced by their index
+ rigidBodies.current[40].applyImpulse({ x: 0, y: 10, z: 0 }, true);
+ rigidBodies.current.at(100).applyImpulse({ x: 0, y: 10, z: 0 }, true);
+
+ // Or update all instances
+ rigidBodies.current.forEach((api) => {
+ api.applyImpulse({ x: 0, y: 10, z: 0 }, true);
+ });
+ }, []);
+
+ // We can set the initial positions, and rotations, and scales, of
+ // the instances by providing an array of InstancedRigidBodyProps
+ // which is the same as RigidBodyProps, but with an additional "key" prop.
+ const instances = useMemo(() => {
+ const instances: InstancedRigidBodyProps[] = [];
+
+ for (let i = 0; i < COUNT; i++) {
+ instances.push({
+ key: "instance_" + Math.random(),
+ position: [Math.random() * 10, Math.random() * 10, Math.random() * 10],
+ rotation: [Math.random(), Math.random(), Math.random()]
+ });
+ }
+
+ return instances;
+ }, []);
+
+ return (
+
+
+
+ );
+};
+```
+
+We can also create compound shapes for instanced meshes by providing an array of `Colliders` in the `colliderNodes` prop.
+
+```tsx
+import {
+ InstancedRigidBodies,
+ BoxCollider,
+ SphereCollider
+} from "@react-three/rapier";
+const COUNT = 500;
+
+const Scene = () => {
+ const instances = useMemo(() => {
+ const instances: InstancedRigidBodyProps[] = [];
+
+ for (let i = 0; i < COUNT; i++) {
+ instances.push({
+ key: "instance_" + Math.random(),
+ position: [Math.random() * 10, Math.random() * 10, Math.random() * 10],
+ rotation: [Math.random(), Math.random(), Math.random()]
+ });
+ }
+
+ return instances;
+ }, []);
+
+ return (
+ ,
+
+ ]}
+ >
+
+
+ );
+};
+```
+
+> [!NOTE]
+> Three.js cannot grow an `InstancedMesh` past the count it was allocated with. To add instances at runtime,
+> allocate a maximum (`args={[geometry, material, MAX_COUNT]}`) and set `count` to the number of live instances.
+
+## Frustum culling
+
+Three.js culls an `InstancedMesh` with bounds computed once, which go stale as the bodies move, so instances could
+vanish at the edge of the screen (#683). `` therefore sets `frustumCulled={false}` on its instanced
+mesh by default. Pass `frustumCulled` to re-enable culling when the instances stay within their initial bounds.
+
+See also [Performance](/advanced/performance).
diff --git a/docs/api/joints.mdx b/docs/api/joints.mdx
new file mode 100644
index 00000000..d4fd2a17
--- /dev/null
+++ b/docs/api/joints.mdx
@@ -0,0 +1,267 @@
+---
+title: Joints
+description: Fixed, spherical, revolute, prismatic, rope and spring joints as hooks.
+nav: 7
+---
+
+Joints can be made between two `RigidBodies` to provide a way to restrict a motion of a body in relation to another.
+
+> Read more about joints in Rapier: https://rapier.rs/docs/user_guides/javascript/joints
+
+Joints are available in `r3/rapier` as hooks.
+
+There are 6 different joint types available:
+
+- Fixed (two bodies are fixed together)
+- Spherical (two bodies are connected by a ball and socket, for things like arms or chains)
+- Revolute (two bodies are connected by a hinge, for things like doors or wheels)
+- Prismatic (two bodies are connected by a sliding joint, for things like pistons or sliders)
+- Rope (limits the max distance between two bodies)
+- Spring (applies a force proportional to the distance between two bodies)
+
+Each joint hook returns a RefObject containing the raw reference to the joint instance.
+
+```tsx
+const WheelJoint = ({ bodyA, bodyB }) => {
+ const joint = useRevoluteJoint(bodyA, bodyB, [
+ [0, 0, 0],
+ [0, 0, 0],
+ [0, 0, 0]
+ ]);
+
+ useFrame(() => {
+ if (joint.current) {
+ joint.current.configureMotorVelocity(10, 2);
+ }
+ }, []);
+
+ return null;
+};
+```
+
+### Fixed Joint
+
+A fixed joint ensures that two rigid-bodies don't move relative to each other. Fixed joints are characterized by one local frame (represented by an isometry) on each rigid-body. The fixed-joint makes these frames coincide in world-space.
+
+๐งฉ See [FixedJoint docs](https://pmndrs.github.io/react-three-rapier/functions/useFixedJoint.html) for available options.
+
+```tsx
+const JointedThing = () => {
+ const bodyA = useRef(null);
+ const bodyB = useRef(null);
+
+ const joint = useFixedJoint(bodyA, bodyB, [
+ // Position of the joint in bodyA's local space
+ [0, 0, 0],
+ // Orientation of the joint in bodyA's local space
+ [0, 0, 0, 1],
+ // Position of the joint in bodyB's local space
+ [0, 0, 0],
+ // Orientation of the joint in bodyB's local space
+ [0, 0, 0, 1]
+ ]);
+
+ return (
+
+
+
+
+
+
+
+
+ );
+};
+```
+
+### Spherical Joint
+
+The spherical joint ensures that two points on the local-spaces of two rigid-bodies always coincide (it prevents any relative translational motion at this points).
+
+๐งฉ See [SphericalJoint docs](https://pmndrs.github.io/react-three-rapier/functions/useSphericalJoint.html) for available options.
+
+```tsx
+const JointedThing = () => {
+ const bodyA = useRef(null);
+ const bodyB = useRef(null);
+
+ const joint = useSphericalJoint(bodyA, bodyB, [
+ // Position of the joint in bodyA's local space
+ [0, 0, 0],
+ // Position of the joint in bodyB's local space
+ [0, 0, 0]
+ ]);
+
+ return (
+
+
+
+
+
+
+
+
+ );
+};
+```
+
+### Revolute Joint
+
+The revolute joint prevents any relative movement between two rigid-bodies, except for relative rotations along one axis. This is typically used to simulate wheels, fans, etc.
+
+๐งฉ See [RevoluteJoint docs](https://pmndrs.github.io/react-three-rapier/functions/useRevoluteJoint.html) for available options.
+
+```tsx
+const JointedThing = () => {
+ const bodyA = useRef(null);
+ const bodyB = useRef(null);
+
+ const joint = useRevoluteJoint(bodyA, bodyB, [
+ // Position of the joint in bodyA's local space
+ [0, 0, 0],
+ // Position of the joint in bodyB's local space
+ [0, 0, 0],
+ // Axis of the joint, expressed in the local-space of
+ // the rigid-bodies it is attached to. Cannot be [0,0,0].
+ [0, 1, 0]
+ ]);
+
+ return (
+
+
+
+
+
+
+
+
+ );
+};
+```
+
+### Prismatic Joint
+
+The prismatic joint prevents any relative movement between two rigid-bodies, except for relative translations along one axis.
+
+๐งฉ See [PrismaticJoint docs](https://pmndrs.github.io/react-three-rapier/functions/usePrismaticJoint.html) for available options.
+
+```tsx
+const JointedThing = () => {
+ const bodyA = useRef(null);
+ const bodyB = useRef(null);
+
+ const joint = usePrismaticJoint(bodyA, bodyB, [
+ // Position of the joint in bodyA's local space
+ [0, 0, 0],
+ // Position of the joint in bodyB's local space
+ [0, 0, 0],
+ // Axis of the joint, expressed in the local-space of
+ // the rigid-bodies it is attached to. Cannot be [0,0,0].
+ [0, 1, 0]
+ ]);
+
+ return (
+
+
+
+
+
+
+
+
+ );
+};
+```
+
+### Rope Joint
+
+The rope joint limits the max distance between two bodies.
+
+๐งฉ See [RopeJoint docs](https://pmndrs.github.io/react-three-rapier/functions/useRopeJoint.html) for available options.
+
+```tsx
+const JointedThing = () => {
+ const bodyA = useRef(null);
+ const bodyB = useRef(null);
+
+ const joint = useRopeJoint(bodyA, bodyB, [
+ // Position of the joint in bodyA's local space
+ [0, 0, 0],
+ // Position of the joint in bodyB's local space
+ [0, 0, 0],
+ // The max distance between the two bodies / length of the rope
+ 1
+ ]);
+
+ return (
+
+
+
+
+
+
+
+
+ );
+};
+```
+
+### Spring Joint
+
+The spring joint applies a force proportional to the distance between two bodies.
+
+๐งฉ See [SpringJoint docs](https://pmndrs.github.io/react-three-rapier/functions/useSpringJoint.html) for available options.
+
+```tsx
+const JointedThing = () => {
+ const bodyA = useRef(null);
+ const bodyB = useRef(null);
+
+ const mass = 1;
+ const springRestLength = 0;
+ const stiffness = 1.0e3;
+ const criticalDamping = 2.0 * Math.sqrt(stiffness * mass);
+ const dampingRatio = props.jointNum / (props.total / 2);
+ const damping = dampingRatio * criticalDamping;
+
+ const joint = useSpringJoint(bodyA, bodyB, [
+ // Position of the joint in bodyA's local space
+ [0, 0, 0],
+ // Position of the joint in bodyB's local space
+ [0, 0, 0],
+ // Spring rest length
+ springRestLength,
+ // Spring stiffness
+ stiffness,
+ // Spring damping
+ damping
+ ]);
+
+ return (
+
+
+
+
+
+
+
+
+ );
+};
+```
+
+### Joints example
+
+
+
+
+
+> [!NOTE]
+> Joints are created once when the hook mounts. Changing the arguments afterwards is ignored, and the JS bindings
+> do not expose enabling or disabling a joint. To toggle one, mount and unmount a wrapper component, or disable one of
+> the bodies with `setEnabled(false)`. Rapier 0.21 added per-axis motors on spherical joints (`configureMotor*` with
+> `JointAxis`), available through the joint ref.
diff --git a/docs/api/physics.mdx b/docs/api/physics.mdx
new file mode 100644
index 00000000..37d5601a
--- /dev/null
+++ b/docs/api/physics.mdx
@@ -0,0 +1,60 @@
+---
+title: Physics
+description: The Physics world component and its props.
+nav: 3
+---
+
+The ` ` component is the root component of your physics world. It is responsible for creating the physics world and managing the simulation. It relies on lazily initiating `Rapier` and needs to be wrapped in ` `.
+
+๐งฉ See [PhysicsProps docs](https://pmndrs.github.io/react-three-rapier/interfaces/PhysicsProps.html) for available props.
+
+```tsx
+const Scene = () => {
+ return (
+
+
+
+ ...
+
+
+
+ );
+};
+```
+
+## Props
+
+| prop | default | description |
+| -------------------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `gravity` | `[0, -9.81, 0]` | World gravity vector. |
+| `colliders` | `"cuboid"` | Default [automatic collider](/api/colliders#automatic-colliders) for all meshes in rigid bodies. `false` disables generation. |
+| `debug` | `false` | Render all colliders. See [Debug](/api/debug). |
+| `paused` | `false` | Pause the simulation. No time is banked while paused; `step()` from `useRapier()` still works. |
+| `timeStep` | `1/60` | Fixed time step in seconds, or `"vary"` to step once per frame with the frame delta (clamped to 0.5s). See [update loop](/advanced/update-loop). |
+| `maxSubSteps` | `5` | Most fixed steps one frame may run. Surplus time is dropped, so slow frames cannot spiral. Ignored with `"vary"`. |
+| `interpolate` | `true` | Interpolate rendered transforms between the last two fixed steps. No effect when `timeStep` is `"vary"`. |
+| `updateLoop` | `"follow"` | `"follow"` steps on the react-three-fiber v10 scheduler (a fixed-step phase per world), `"independent"` runs its own `requestAnimationFrame` loop. |
+| `updatePriority` | `0` | Order of this world's jobs within their scheduler phases (higher runs first). Does not take over rendering. |
+| `numSolverIterations` | `4` | Constraint solver iterations. Higher is more rigid and more expensive. |
+| `numInternalPgsIterations` | `1` | Internal PGS iterations per solver iteration. Improves stability cheaply. |
+| `allowedLinearError` | `0.001` | Penetration the engine will not attempt to correct. |
+| `predictionDistance` | `0.002` | Maximal distance separating two objects that generates predictive contacts. |
+| `maxCcdSubsteps` | `1` | Maximum CCD substeps. `0` disables all continuous collision detection, see [CCD](/api/rigid-body#continuous-collision-detection). |
+| `contactNaturalFrequency` | `30` | Controls how stiffly contacts are corrected. |
+| `lengthUnit` | `1` | Approximate size of most dynamic objects, used to scale length-based tolerances. |
+| `minIslandSize` | | **Removed in Rapier 0.20.** Deprecated and ignored. |
+
+> [!NOTE]
+> `` keeps `allowedLinearError={0.001}` and `predictionDistance={0.002}` from earlier versions. Rapier 0.21's
+> native defaults are `0.005` and `0.02`, which reduce tunneling. Set them explicitly to adopt them.
+
+## Stepping
+
+With the default `updateLoop="follow"`, each `` world steps in its own fixed-timestep phase of the
+react-three-fiber v10 scheduler (`rapier:fixed:`, right after r3f's `physics` phase), syncs interpolated transforms
+into your meshes once per frame in a `rapier` phase before `update`, and re-applies them in a `rapier:late` phase before
+`render` if an ancestor moved. Use [`useBeforePhysicsStep` / `useAfterPhysicsStep`](/api/hooks)
+to run code every step. See [Update loop and stepping](/advanced/update-loop) for the details, including
+`frameloop="demand"`, teleports and moving parents.
+
+For the generated reference see the [TypeDoc API docs](https://pmndrs.github.io/react-three-rapier/interfaces/PhysicsProps.html).
diff --git a/docs/api/rigid-body.mdx b/docs/api/rigid-body.mdx
new file mode 100644
index 00000000..81ebd25c
--- /dev/null
+++ b/docs/api/rigid-body.mdx
@@ -0,0 +1,101 @@
+---
+title: RigidBody
+description: The RigidBody component, body types, forces and continuous collision detection.
+nav: 4
+---
+
+The ` ` component is used to add a `mesh` into the physics world. You use it by wrapping one or more `meshes` and setting desired props. By default, this will automatically generate `Colliders` based on the shape of the wrapped `meshes` (see [Automatic colliders](/api/colliders#automatic-colliders)).
+
+๐งฉ See [RigidBodyProps docs](https://pmndrs.github.io/react-three-rapier/interfaces/RigidBodyProps.html) for available props.
+
+```tsx
+const RigidBodyMesh = () => (
+
+
+
+);
+```
+
+## Body types
+
+`type` selects the Rapier body type: `"dynamic"` (default, moved by forces), `"fixed"` (never moves),
+`"kinematicPosition"` and `"kinematicVelocity"` (moved by you).
+
+Kinematic bodies are not moved by contacts: they push dynamic bodies and nothing pushes them back. A kinematic body
+will not stop at a fixed collider, even with `activeCollisionTypes={ActiveCollisionTypes.ALL}`, which only enables
+_events_ for that pair. See the [FAQ](/advanced/faq#kinematic-body-passes-through-fixed-colliders).
+
+Move a kinematic body with `setNextKinematicTranslation({ x, y, z })` and `setNextKinematicRotation({ x, y, z, w })`,
+passing objects, not arrays.
+
+## Moving things around
+
+See [Basic usage](/getting-started/basic-usage#moving-things-around-and-applying-forces) for applying impulses and
+forces, and for converting between Rapier and three.js types with `vec3`, `quat` and `euler`.
+
+## Continuous collision detection
+
+Rapier 0.21 rewrote CCD as a sweep-based time of impact:
+
+- Fast dynamic bodies **always** run CCD against fixed colliders automatically.
+- The `ccd` prop (`enableCcd`) now means "bullet": the body is additionally swept against kinematic and dynamic bodies.
+ `ccd={false}` no longer disables all CCD.
+- `` disables CCD entirely.
+
+```tsx
+
+
+
+
+
+```
+
+## Solver iterations
+
+`additionalSolverIterations` now adds whole solver substeps for the constraint-connected group of bodies, rather than
+only extra iterations. Scenes that set it may need retuning.
+
+## Sleeping
+
+Rapier 0.21 rewrote sleeping around persistent islands and made it more aggressive (linear threshold `0.4` to `0.05`,
+time until sleep `2.0s` to `0.5s`). Bodies fall asleep sooner, and an island sleeps and wakes as a unit. `onSleep` and
+`onWake` still fire per body. Set `canSleep={false}` on bodies that must stay awake.
+
+## Transformed parents
+
+RigidBodies work inside other transformed objects as well. Simulation runs in world space and is transformed to the objects local space, so that things act as you'd expect.
+
+```tsx
+import { Box } from "@react-three/drei";
+import { RigidBody, CuboidCollider } from "@react-three/rapier";
+
+const Scene = () => (
+
+
+
+
+
+
+);
+```
+
+> [!NOTE]
+> The rendered pose is written in the parent's current space every frame, so moving or rotating a parent after mount
+> (for example drei `PresentationControls`) keeps the visual on the body. Parents do not drag the body along, though.
+> See the [FAQ](/advanced/faq#rotating-a-parent-of-a-rigidbody-after-mount).
+
+> [!NOTE]
+> It is best to create RigidBodies where the center of gravity is at the center of the object, otherwise you might
+> get unexpected behavior during simulation interpolation.
+
+## Events
+
+`` and every collider accept `onCollisionEnter`, `onCollisionExit`, `onIntersectionEnter`,
+`onIntersectionExit`, `onContactForce`, `onSleep` and `onWake`. See [Collision events](/advanced/collision-events).
+
+## Character controller
+
+For player characters, `` wraps a `kinematicPosition` `` with Rapier's kinematic
+character controller (walls, slopes, stairs, ground snapping). See [Character controller](/api/character-controller).
+
+๐งฉ See [RigidBodyProps](https://pmndrs.github.io/react-three-rapier/interfaces/RigidBodyProps.html) for all props.
diff --git a/docs/api/soft-bodies.mdx b/docs/api/soft-bodies.mdx
new file mode 100644
index 00000000..caa3a3fd
--- /dev/null
+++ b/docs/api/soft-bodies.mdx
@@ -0,0 +1,182 @@
+---
+title: Soft bodies
+description: Cloth, ropes and volumetric (jelly) bodies with SoftBody and useSoftBody.
+nav: 12
+---
+
+Rapier 0.21 simulates deformable bodies. `@react-three/rapier` turns the geometry of a mesh (or line) into one and keeps
+the geometry in sync with the simulation. Soft bodies collide with the colliders and rigid bodies of the same
+`` world, can be pinned in place, can be attached to rigid bodies, and can tear.
+
+The API lives on its own entry point, so the extra code is only bundled when you use it:
+
+```tsx
+import { SoftBody, useSoftBody } from "@react-three/rapier/soft-bodies";
+```
+
+## ``
+
+Wrap exactly one `` (cloth, volume) or `` / `` (rope) inside ``:
+
+```tsx
+import { Physics } from "@react-three/rapier";
+import { SoftBody } from "@react-three/rapier/soft-bodies";
+import { DoubleSide } from "three";
+
+const Flag = () => (
+
+ p.x < -1.49}
+ >
+
+
+
+
+
+
+);
+```
+
+### Types
+
+| `type` | built from | use with |
+| ------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
+| `"cloth"` (default) | The welded vertices are particles, connected along the triangle edges. | Open surfaces: planes, curtains, flags. |
+| `"rope"` | The geometry's vertices, in order, are the particles of a chain. | A `` or `` whose positions list the rope from start to end. |
+| `"volume"` | The closed surface is filled with tetrahedra, and the rendered vertices are skinned onto them. | Closed shapes: jelly cubes, spheres. |
+
+### How the geometry is used
+
+- The geometry is replaced by a copy that is welded on **position only**, so vertices split for UV seams or hard
+ normals become one particle. The original render data (UVs, hard normals) is kept until a cloth tears.
+- The child's transform sets the initial pose of the body and is baked into the geometry. From then on the object's own
+ transform is the identity, and its geometry holds positions in the **parent's** local space.
+- The geometry is written once per rendered frame, after the rigid bodies, and interpolated between the last two
+ physics steps with the same alpha. Cloth attached to an interpolated rigid body therefore stays on it. Normals and
+ bounds are recomputed when the positions change.
+
+### Props
+
+Creation props are read once, when the body is created, like ``'s. Changing them afterwards has no effect;
+remount with a `key` to rebuild the body.
+
+| prop | description |
+| --------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
+| `type` | `"cloth"`, `"rope"` or `"volume"`. Default `"cloth"`. |
+| `stiffness` | Natural frequency (Hz) of the springs. Higher is stiffer. |
+| `damping` | Damping ratio of the springs: `0` oscillates forever, `1` is critically damped. |
+| `material` | A Rapier `SoftBodyMaterial` (or a function returning one), for full control. Overrides `stiffness` and `damping`. |
+| `mass` | Total mass, spread over the particles. |
+| `pinned` | Particles that never move: particle indices, or `(restPosition, index) => boolean`. |
+| `attachments` | `[{ particles, body }]`: particles anchored to a rigid body (a Rapier body or a `` ref). |
+| `cellSize` | `volume` only: edge length of the tetrahedral cells. Defaults to a fifth of the largest dimension. |
+| `cellModel` | `volume` only: the constitutive model of the cells. |
+| `solver` | The Rapier solver used to hold the body together. |
+| `volumePreservation` | Resist changes of volume (`volume` bodies). |
+| `selfContacts` | Collide with itself. |
+| `particleRadius` | Thickness of the particles, which keeps cloth off surfaces. |
+| `gravityScale` | Multiplier on world gravity. Default `1`. |
+| `linearDamping` | Velocity lost to drag. |
+| `additionalSolverIterations` | Extra solver iterations, for a more rigid body. |
+| `canSleep` | Whether the body may fall asleep. Default `true`. |
+| `collisions` | Collide with rigid bodies and colliders. Default `true`. |
+| `friction`, `restitution` | Contact response against colliders. |
+| `collisionGroups`, `solverGroups` | Rapier group bitmasks for the body's colliders. |
+| `configure` | `(desc) => desc \| void`. Receives the `SoftBodyDesc` right before creation, for Rapier settings without a prop. |
+| `onTear` | Called when the body tears, with `{ softBody, pieces, tornEdges, tornCells }`. |
+| `ref` | Receives the `SoftBodyState` once the body exists. |
+
+#### Selecting particles
+
+`pinned` and the `particles` of an attachment take either explicit indices or a predicate. The predicate is called for
+every particle with its rest position in the **mesh's local space** (its geometry before the soft body was created) and
+its index. For `cloth` and `rope`, indices are vertex indices of the welded geometry. For `volume` they index internal
+simulation particles, so prefer a predicate.
+
+```tsx
+// Pin the top edge of a 1x1 plane
+ p.y > 0.49}>
+ ...
+
+```
+
+#### Attachments
+
+Attached particles follow a rigid body. A ref must already be populated when the soft body is created, so declare the
+`` before the ``:
+
+```tsx
+const ball = useRef(null);
+
+<>
+
+
+
+
+
+
+
+
+
+>;
+```
+
+### The `SoftBodyState` ref
+
+| member | description |
+| --------------------- | ----------------------------------------------------------------------------------------------------------------------- |
+| `body` | The Rapier soft body created from the geometry. Use it for forces, e.g. `body.addForce(...)` in `useBeforePhysicsStep`. |
+| `bodies` | `body` and every piece it tore into. All are removed on unmount. |
+| `object` | The object whose geometry is driven by the simulation. |
+| `type` | The soft body type. |
+| `cut(blade)` | Cuts along a blade (a triangle of three world-space points). Returns `false` if nothing was cut. |
+| `tear(edges, cells?)` | Tears the given edges (and cells) of the main body at once. |
+
+## Tearing
+
+Set `material.tearStrain` or a tear force on the material, or call `cut()` / `tear()`, to tear a body. Tear events are
+handled after every physics substep: for `cloth` and `rope` the rendered triangles that cross a tear are removed and the
+torn seam gets vertices of its own, and `onTear` is called. After the first tear a cloth's vertices are split per
+particle, so seams of the original geometry (UV / hard-edge duplicates) are lost, and torn pieces render the latest step
+without interpolation.
+
+## `useSoftBody`
+
+The hook behind ``, for when you already hold a ref to a ``, `` or ``. It takes the same
+options and returns a ref to the `SoftBodyState`, populated once the body exists:
+
+```tsx
+const mesh = useRef(null);
+const soft = useSoftBody(mesh, { type: "cloth", pinned: (p) => p.y > 0.99 });
+
+return (
+
+
+
+);
+```
+
+## Helpers
+
+`weldGeometry(geometry)` returns a copy of a geometry welded on position only, and `weld` / `applyWeldMap` expose the
+underlying mapping, if you want to prepare geometry yourself.
+
+## Known limitations
+
+- Soft bodies do not emit collision events (enter / exit) yet.
+- Tears re-topologize the rendered geometry for `cloth` and `rope` only. When a `volume` body tears, the rendered skin
+ only follows the main piece; pieces that split off are simulated but not rendered.
+- `cut()` on a triangle-mesh cloth may not disconnect pieces (bend edges keep them together). `tear()` with explicit edges
+ works.
+- Props are not applied after mount (see [Props](#props)).
+- The visuals of a soft body follow its parent's transform at sync time. Unlike rigid bodies, a parent that moves later in
+ the same frame (after the `rapier` phase) is picked up on the next frame.
+
+See the `soft-bodies` demos (cloth, rope, jelly) for complete scenes.
diff --git a/docs/getting-started/basic-usage.mdx b/docs/getting-started/basic-usage.mdx
new file mode 100644
index 00000000..6f4f0294
--- /dev/null
+++ b/docs/getting-started/basic-usage.mdx
@@ -0,0 +1,118 @@
+---
+title: Basic usage
+description: A first physics scene - Physics, RigidBody, colliders and moving bodies around.
+nav: 2
+---
+
+Everything physical lives inside ``, the way everything three.js draws lives inside a ``.
+Wrap meshes in `` to make them part of the simulation.
+
+```tsx
+import { Box, Torus } from "@react-three/drei";
+import { Canvas } from "@react-three/fiber";
+import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
+
+const App = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+```
+
+What is happening here:
+
+1. [``](/api/physics) creates the Rapier world and steps it every frame. `debug` draws every collider.
+2. [``](/api/rigid-body) turns the `Torus` into a dynamic body. Because `colliders="hull"`, a convex hull
+ collider is generated from the mesh geometry.
+3. [``](/api/colliders) outside any `` creates a fixed collider, used here as a floor.
+
+## Moving things around, and applying forces
+
+You can access the instance for a RigidBody by storing its `ref`. This allows you to perform any operation on the underlying physics object directly.
+
+`r3/rapier` exposes a `RapierRigidBody` and `RapierCollider` as aliases for `rapiers` underlying base objects.
+
+For all available methods, see the [Rapier docs](https://rapier.rs/javascript3d/classes/RigidBody.html).
+
+```tsx
+import { RigidBody, RapierRigidBody } from "@react-three/rapier";
+
+const Scene = () => {
+ const rigidBody = useRef(null);
+
+ useEffect(() => {
+ if (rigidBody.current) {
+ // A one-off "push"
+ rigidBody.current.applyImpulse({ x: 0, y: 10, z: 0 }, true);
+
+ // A continuous force
+ rigidBody.current.addForce({ x: 0, y: 10, z: 0 }, true);
+
+ // A one-off torque rotation
+ rigidBody.current.applyTorqueImpulse({ x: 0, y: 10, z: 0 }, true);
+
+ // A continuous torque
+ rigidBody.current.addTorque({ x: 0, y: 10, z: 0 }, true);
+ }
+ }, []);
+
+ return (
+
+
+
+
+
+
+ );
+};
+```
+
+Rapier's API returns quaternions and vectors that are not compatible with Three.js, `r3/rapier` therefore exposes some helper functions (`vec3`, `quat`, `euler`) for quick type conversions. These helper functions can also be used as a shorthand for creating new objects.
+
+```tsx
+import { RapierRigidBody, quat, vec3, euler } from "@react-three/rapier";
+
+const Scene = () => {
+ const rigidBody = useRef(null);
+
+ useEffect(() => {
+ if (rigidBody.current) {
+ const position = vec3(rigidBody.current.translation());
+ const quaternion = quat(rigidBody.current.rotation());
+ const eulerRot = euler().setFromQuaternion(
+ quat(rigidBody.current.rotation())
+ );
+
+ // While Rapier's return types need conversion, setting values can be done directly with Three.js types
+ rigidBody.current.setTranslation(position, true);
+ rigidBody.current.setRotation(quaternion, true);
+ rigidBody.current.setAngvel({ x: 0, y: 2, z: 0 }, true);
+ }
+ }, []);
+
+ return (
+
+
+
+
+
+
+ );
+};
+```
+
+## Next steps
+
+- [Physics](/api/physics) props and the world.
+- [Colliders](/api/colliders), automatic and hand-placed.
+- [Collision events](/advanced/collision-events).
diff --git a/docs/getting-started/installation.mdx b/docs/getting-started/installation.mdx
new file mode 100644
index 00000000..a88b9da1
--- /dev/null
+++ b/docs/getting-started/installation.mdx
@@ -0,0 +1,60 @@
+---
+title: Installation
+description: Installing @react-three/rapier and its peer dependencies, bundler notes and CSP requirements.
+nav: 1
+---
+
+```bash
+npm install three @react-three/fiber @react-three/rapier
+```
+
+`@dimforge/rapier3d-compat` is installed for you as a dependency. Import Rapier types and enums from it, or from
+`@react-three/rapier`, never from deep paths.
+
+## Peer dependencies
+
+| package | 3.0 | 2.x |
+| --------------------- | --------------------------------------------- | ----- |
+| `@react-three/fiber` | `>=10.0.0-0` | `^9` |
+| `react` / `react-dom` | `>=19.0 <19.3` (follows react-three-fiber 10) | `^19` |
+| `three` | `>=0.185` | |
+
+If you are on react-three-fiber v8 and React 18, use `@react-three/rapier` v1.
+
+> [!NOTE]
+> The 3.0 ranges are the planned ones and may change before the stable release.
+
+## Suspense
+
+`` loads the Rapier WebAssembly module lazily, so it **must** be inside a `` boundary.
+
+```tsx
+
+
+ {/* ... */}
+
+
+```
+
+## Bundle size
+
+Rapier 0.21 roughly doubled the size of the inlined WebAssembly (about 1.6 MB to 3.1 MB). The module is loaded
+with a dynamic `import()` when `` first mounts, so it does not block your initial JavaScript, but account for
+it in your loading strategy.
+
+## Content Security Policy
+
+WebAssembly compilation is gated by CSP. If your site sends a `Content-Security-Policy` header with a `script-src`
+directive, add `'wasm-unsafe-eval'`. This allows WebAssembly without allowing JavaScript `eval`. See the
+[FAQ](/advanced/faq#csp-refuses-to-compile-webassembly).
+
+## Bundlers
+
+Use the public `@dimforge/rapier3d-compat` entry point when you import Rapier yourself. Since Rapier 0.20 its files
+live in `dist/` behind an `exports` map, so deep imports such as `@dimforge/rapier3d-compat/math` and hard-coded CDN
+file URLs no longer work. Make sure only one copy of `@dimforge/rapier3d-compat` is installed; a duplicate or
+mismatched copy shows up as `raweventqueue_new of undefined`.
+
+## React Native
+
+React Native is not supported, since Rapier needs WebAssembly. See the [FAQ](/advanced/faq#react-native).
diff --git a/docs/getting-started/introduction.mdx b/docs/getting-started/introduction.mdx
new file mode 100644
index 00000000..600d147f
--- /dev/null
+++ b/docs/getting-started/introduction.mdx
@@ -0,0 +1,66 @@
+---
+title: Introduction
+description: Rapier physics in React, a react-three-fiber wrapper around the Rapier WebAssembly physics engine.
+nav: 0
+---
+
+
+ `@react-three/rapier` (or `r3/rapier`) is a wrapper around the [Rapier](https://rapier.rs/docs/user_guides/javascript)
+ WASM-based physics engine, designed to slot seamlessly into a
+ [react-three-fiber](https://github.com/pmndrs/react-three-fiber) pipeline. Wrap a mesh in `` and it falls.
+
+
+The goal of this library is to provide a fast physics engine with minimal friction and a small, straightforward API.
+
+```bash
+npm install @react-three/rapier
+```
+
+```tsx
+import { Canvas } from "@react-three/fiber";
+import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
+
+export default function App() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+```
+
+> [!NOTE]
+> **Version 3.0 is in alpha.** It is a breaking release that targets react-three-fiber v10 (including the
+> WebGPU renderer), Rapier.js 0.21 and three 0.185+. If you are upgrading, read
+> [Migrating from 2.x to 3.0](/advanced/migration).
+
+## What you get
+
+- Automatic colliders generated from your meshes, or hand-placed colliders for compound shapes ([RigidBody](/api/rigid-body), [Colliders](/api/colliders)).
+- Instanced meshes with one body per instance ([InstancedRigidBodies](/api/instanced-rigid-bodies)).
+- Joints as hooks ([Joints](/api/joints)).
+- Collision, intersection and contact-force events, collision groups and physics hooks ([Advanced](/advanced/collision-events)).
+- Direct access to the Rapier `world` for queries such as raycasts ([Hooks](/api/hooks)).
+- Physics stepped at a fixed rate on the react-three-fiber v10 scheduler, with interpolation ([Update loop](/advanced/update-loop)).
+- A kinematic [character controller](/api/character-controller) and [soft bodies](/api/soft-bodies) (cloth, ropes, jelly).
+- A debug renderer ([Debug](/api/debug)) and an [Attractor](/api/attractors) addon.
+
+## Where to go next
+
+- [Installation](/getting-started/installation)
+- [Basic usage](/getting-started/basic-usage)
+- [Frequently asked questions](/advanced/faq)
+
+For contributions, please read the [contribution guide](https://github.com/pmndrs/react-three-rapier/blob/main/packages/react-three-rapier/CONTRIBUTING.md).
+Questions are welcome on [Discord](https://discord.gg/ZZjjNvJ) and in [GitHub issues](https://github.com/pmndrs/react-three-rapier/issues).
diff --git a/eslint.config.mjs b/eslint.config.mjs
new file mode 100644
index 00000000..815f4636
--- /dev/null
+++ b/eslint.config.mjs
@@ -0,0 +1,84 @@
+// ESLint 9 flat config, mirroring react-three-jolt's eslint.config.mjs.
+//
+// Formatting is not linted: it's a separate `yarn format` / `yarn format:check` (Prettier) step,
+// and eslint-config-prettier (last) switches off every stylistic rule that would fight it.
+// See LINTING notes in DEVELOPMENT.md. Do not run `eslint --fix` repo-wide.
+import js from "@eslint/js";
+import eslintConfigPrettier from "eslint-config-prettier";
+import jsxA11y from "eslint-plugin-jsx-a11y";
+import react from "eslint-plugin-react";
+import reactHooks from "eslint-plugin-react-hooks";
+import globals from "globals";
+import tseslint from "typescript-eslint";
+
+export default tseslint.config(
+ {
+ ignores: [
+ "**/dist",
+ "**/node_modules",
+ "**/coverage",
+ "**/docs/out",
+ "packages/react-three-rapier/docs",
+ ".yarn"
+ ]
+ },
+
+ js.configs.recommended,
+ tseslint.configs.recommended,
+ react.configs.flat.recommended,
+ // every package and the demo compile with the automatic JSX runtime (`"jsx": "react-jsx"`)
+ react.configs.flat["jsx-runtime"],
+ jsxA11y.flatConfigs.recommended,
+
+ {
+ languageOptions: {
+ globals: {
+ ...globals.browser,
+ ...globals.node,
+ ...globals.es2021
+ }
+ },
+ settings: {
+ react: {
+ version: "detect"
+ }
+ },
+ plugins: {
+ "react-hooks": reactHooks
+ },
+ rules: {
+ "@typescript-eslint/no-explicit-any": "off",
+ "@typescript-eslint/no-non-null-assertion": "off",
+ "@typescript-eslint/consistent-type-imports": "off",
+
+ "@typescript-eslint/no-unused-vars": [
+ "warn",
+ {
+ argsIgnorePattern: "^_",
+ varsIgnorePattern: "^_",
+ caughtErrorsIgnorePattern: "^_"
+ }
+ ],
+
+ // Only the two classic hooks rules. eslint-plugin-react-hooks 7's presets also enable the
+ // React Compiler rule set (purity, immutability, refs, ...), which this codebase was never
+ // written against - opt into those deliberately, not as part of the tooling migration.
+ "react-hooks/rules-of-hooks": "error",
+ "react-hooks/exhaustive-deps": "warn",
+
+ // eslint-plugin-react only knows DOM attributes, so every r3f intrinsic
+ // ( ) is a false positive.
+ "react/no-unknown-property": "off",
+ // props are typed with TypeScript, not PropTypes
+ "react/prop-types": "off",
+ // anonymous components passed to memo()/forwardRef() are idiomatic here
+ "react/display-name": "off",
+
+ "jsx-a11y/no-static-element-interactions": "warn",
+ "@typescript-eslint/no-unused-expressions": "warn"
+ }
+ },
+
+ // Must be last: turns off stylistic rules that would otherwise fight Prettier.
+ eslintConfigPrettier
+);
diff --git a/package.json b/package.json
index 8d54ce83..fe0aab66 100644
--- a/package.json
+++ b/package.json
@@ -1,67 +1,72 @@
{
"name": "react-three-rapier",
"version": "0.1.0",
- "main": "index.js",
+ "private": true,
"repository": "https://github.com/pmndrs/react-three-rapier",
"author": "Hugo Wiledal ",
"license": "MIT",
- "preconstruct": {
- "packages": [
- "packages/*"
- ]
- },
- "prettier": {
- "trailingComma": "none",
- "tabWidth": 2,
- "useTabs": false,
- "semi": true,
- "singleQuote": false,
- "arrowParens": "always"
- },
- "dependencies": {
- "@babel/core": "7.17.10",
- "@babel/preset-env": "7.17.10",
- "@babel/preset-react": "7.16.7",
- "@babel/preset-typescript": "7.16.7",
- "@changesets/cli": "2.27.1",
+ "devDependencies": {
+ "@arethetypeswrong/cli": "0.18.5",
+ "@changesets/cli": "3.0.3",
"@commitlint/cli": "17.1.2",
"@commitlint/config-conventional": "17.1.0",
- "@manypkg/cli": "0.19.1",
- "@preconstruct/cli": "2.8.8",
- "@testing-library/dom": "10.4.0",
- "@testing-library/react": "16.2.0",
- "@vitejs/plugin-react": "4.3.4",
- "@vitest/ui": "3.0.4",
- "jsdom": "24.0.0",
- "prettier": "2.7.1",
+ "@eslint/js": "9.39.5",
+ "@rollup/plugin-node-resolve": "16.0.3",
+ "@testing-library/dom": "10.4.2",
+ "@testing-library/react": "16.3.3",
+ "@vitest/ui": "5.0.3",
+ "esbuild": "0.28.2",
+ "eslint": "9.39.5",
+ "eslint-config-prettier": "10.1.8",
+ "eslint-plugin-jsx-a11y": "6.10.2",
+ "eslint-plugin-react": "7.37.5",
+ "eslint-plugin-react-hooks": "7.1.1",
+ "globals": "17.13.0",
+ "jsdom": "30.1.2",
+ "prettier": "3.9.9",
+ "publint": "0.3.25",
+ "rollup": "4.64.3",
+ "rollup-plugin-dts": "6.5.1",
+ "rollup-plugin-esbuild": "6.2.1",
+ "rollup-plugin-filesize": "10.0.0",
"simple-git-hooks": "2.8.0",
"typedoc": "0.23.24",
- "typescript": "5.7.3",
- "vitest": "3.0.4",
- "vitest-canvas-mock": "0.3.3"
+ "typescript": "5.9.3",
+ "typescript-eslint": "8.71.1",
+ "vite": "8.3.4",
+ "vitest": "5.0.3",
+ "vitest-canvas-mock": "1.2.0"
},
"scripts": {
- "ci": "preconstruct validate && manypkg check && yarn test",
- "postinstall": "preconstruct dev",
- "build": "preconstruct build",
- "dev": "preconstruct dev",
- "watch": "preconstruct watch",
- "demo": "cd demo && yarn dev",
- "release": "yarn build && changeset publish",
- "test": "tsc && vitest run",
+ "build": "yarn workspaces foreach -A -t --no-private run build",
+ "typecheck": "tsc -p tsconfig.json",
+ "test": "yarn typecheck && vitest run",
"test:ui": "vitest --ui",
+ "check:packages": "yarn workspaces foreach -A --no-private run check:package",
+ "lint": "eslint .",
+ "format": "prettier --write .",
+ "format:check": "prettier --check .",
+ "ci": "yarn typecheck && yarn build && yarn check:packages && vitest run",
+ "demo": "yarn workspace demo dev",
+ "demo:build": "yarn workspace demo build",
+ "docs": "curl -sL https://raw.githubusercontent.com/pmndrs/docs/refs/heads/main/preview.sh | MDX=docs ICON=๐คบ LOGO=https://github.com/pmndrs/react-three-rapier/raw/main/packages/react-three-rapier/misc/hero.svg NEXT_PUBLIC_LIBNAME='React Three Rapier' NEXT_PUBLIC_LIBNAME_SHORT='r3/rapier' GITHUB=https://github.com/pmndrs/react-three-rapier sh",
+ "docs:site:build": "npx -y @pmndrs/docs@4 build docs docs/out --format website --libname \"React Three Rapier\" --libname-short \"r3/rapier\" --home-redirect \"/getting-started/introduction\" --icon \"๐คบ\" --logo \"https://github.com/pmndrs/react-three-rapier/raw/main/packages/react-three-rapier/misc/hero.svg\" --github \"https://github.com/pmndrs/react-three-rapier\" --discord \"https://discord.gg/ZZjjNvJ\"",
"docs:build": "typedoc --out ./packages/react-three-rapier/docs ./packages/react-three-rapier/src/index.ts --excludeInternal",
- "canary:enter": "changeset pre enter canary",
- "canary:exit": "changeset pre exit"
+ "changeset:version": "changeset version && yarn install --mode update-lockfile",
+ "changeset:publish": "yarn build && yarn test && changeset publish",
+ "prerelease:enter": "changeset pre enter alpha",
+ "prerelease:exit": "changeset pre exit"
},
"simple-git-hooks": {
"commit-msg": "npx --no -- commitlint --edit ${1}",
- "pre-commit": "yarn prettier packages/ demo/ --check; yarn ci"
+ "pre-commit": "yarn typecheck"
},
- "private": true,
"workspaces": [
"packages/*",
"demo"
],
- "packageManager": "yarn@1.22.21+sha1.1959a18351b811cdeedbd484a8f86c3cc3bbaf72"
+ "engines": {
+ "node": ">=22"
+ },
+ "packageManager": "yarn@4.18.1"
}
diff --git a/packages/react-three-rapier-addons/package.json b/packages/react-three-rapier-addons/package.json
index 418512c3..9ec6e84f 100644
--- a/packages/react-three-rapier-addons/package.json
+++ b/packages/react-three-rapier-addons/package.json
@@ -1,28 +1,68 @@
{
"name": "@react-three/rapier-addons",
"version": "5.0.0",
- "main": "dist/react-three-rapier-addons.cjs.js",
- "module": "dist/react-three-rapier-addons.esm.js",
+ "description": "Addons for @react-three/rapier",
+ "keywords": [
+ "physics",
+ "rapier",
+ "react-three-fiber",
+ "react",
+ "three"
+ ],
"license": "MIT",
+ "homepage": "https://github.com/pmndrs/react-three-rapier",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/pmndrs/react-three-rapier.git",
+ "directory": "packages/react-three-rapier-addons"
+ },
+ "bugs": {
+ "url": "https://github.com/pmndrs/react-three-rapier/issues"
+ },
+ "type": "commonjs",
+ "engines": {
+ "node": ">=22"
+ },
+ "main": "./dist/index.cjs",
+ "module": "./dist/index.mjs",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "import": {
+ "types": "./dist/index.d.mts",
+ "default": "./dist/index.mjs"
+ },
+ "require": {
+ "types": "./dist/index.d.cts",
+ "default": "./dist/index.cjs"
+ },
+ "default": "./dist/index.mjs"
+ },
+ "./package.json": "./package.json"
+ },
"sideEffects": false,
- "peerDependencies": {
- "@react-three/fiber": "*",
- "@react-three/rapier": "^2.0.0",
- "react": "^19",
- "three": "*"
+ "files": [
+ "dist/index.*",
+ "CHANGELOG.md"
+ ],
+ "scripts": {
+ "build": "tsc -p tsconfig.build.json && rollup --config rollup.config.mjs",
+ "check:package": "publint --strict && attw --pack ."
},
- "dependencies": {
- "three-stdlib": "^2.35.12"
+ "peerDependencies": {
+ "@react-three/fiber": ">=10.0.0-0",
+ "@react-three/rapier": "^3.0.0-0",
+ "react": ">=19.0 <19.3",
+ "three": ">=0.185.0"
},
"devDependencies": {
- "@react-three/drei": "10.0.1",
- "@react-three/fiber": "9.0.4",
- "@react-three/rapier": "2.2.0",
- "@types/react": "19.0.8",
- "@types/react-dom": "19.0.3",
- "react": "19.0.0",
- "react-dom": "19.0.0",
- "three": "0.172.0"
- },
- "repository": "https://github.com/pmndrs/react-three-rapier/tree/master/packages/react-three-rapier-addons"
+ "@react-three/fiber": "10.0.0-canary.a056612",
+ "@react-three/rapier": "workspace:*",
+ "@types/react": "19.2.18",
+ "@types/react-dom": "19.2.7",
+ "@types/three": "0.186.0",
+ "react": "19.2.8",
+ "react-dom": "19.2.8",
+ "three": "0.186.1"
+ }
}
diff --git a/packages/react-three-rapier-addons/rollup.config.mjs b/packages/react-three-rapier-addons/rollup.config.mjs
new file mode 100644
index 00000000..85d73596
--- /dev/null
+++ b/packages/react-three-rapier-addons/rollup.config.mjs
@@ -0,0 +1,4 @@
+import { defineLibraryConfig } from "../../scripts/rollup-library.mjs";
+
+// Entry points come from the `exports` map in package.json - see scripts/rollup-library.mjs.
+export default defineLibraryConfig(import.meta.url);
diff --git a/packages/react-three-rapier-addons/src/addons/attractor/Attractor.tsx b/packages/react-three-rapier-addons/src/addons/attractor/Attractor.tsx
index 78fd66fe..ff1447e8 100644
--- a/packages/react-three-rapier-addons/src/addons/attractor/Attractor.tsx
+++ b/packages/react-three-rapier-addons/src/addons/attractor/Attractor.tsx
@@ -54,8 +54,9 @@ export interface AttractorProps {
collisionGroups?: InteractionGroups;
}
-export interface AttractorState
- extends Required> {
+export interface AttractorState extends Required<
+ Omit
+> {
collisionGroups?: InteractionGroups;
}
diff --git a/packages/react-three-rapier-addons/src/addons/attractor/AttractorDebugHelper.tsx b/packages/react-three-rapier-addons/src/addons/attractor/AttractorDebugHelper.tsx
index c7d3dc30..0b0664f4 100644
--- a/packages/react-three-rapier-addons/src/addons/attractor/AttractorDebugHelper.tsx
+++ b/packages/react-three-rapier-addons/src/addons/attractor/AttractorDebugHelper.tsx
@@ -7,7 +7,7 @@ import {
SphereGeometry,
Vector3
} from "three";
-import { VertexNormalsHelper } from "three-stdlib";
+import { VertexNormalsHelper } from "three/examples/jsm/helpers/VertexNormalsHelper.js";
import { AttractorState } from "./Attractor";
const _v3 = new Vector3();
diff --git a/packages/react-three-rapier-addons/tsconfig.build.json b/packages/react-three-rapier-addons/tsconfig.build.json
new file mode 100644
index 00000000..d09f2bb2
--- /dev/null
+++ b/packages/react-three-rapier-addons/tsconfig.build.json
@@ -0,0 +1,13 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "noEmit": false,
+ "declaration": true,
+ "emitDeclarationOnly": true,
+ "declarationMap": true,
+ "rootDir": "./src",
+ "outDir": "./dist/types",
+ "paths": {}
+ },
+ "include": ["src"]
+}
diff --git a/packages/react-three-rapier/package.json b/packages/react-three-rapier/package.json
index a39566fd..738072aa 100644
--- a/packages/react-three-rapier/package.json
+++ b/packages/react-three-rapier/package.json
@@ -1,33 +1,90 @@
{
"name": "@react-three/rapier",
"version": "2.2.0",
- "source": "src/index.ts",
- "main": "dist/react-three-rapier.cjs.js",
- "module": "dist/react-three-rapier.esm.js",
- "types": "dist/react-three-rapier.cjs.d.ts",
+ "description": "Rapier physics in React",
+ "keywords": [
+ "physics",
+ "rapier",
+ "react-three-fiber",
+ "react",
+ "three"
+ ],
+ "license": "MIT",
+ "homepage": "https://github.com/pmndrs/react-three-rapier",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/pmndrs/react-three-rapier.git",
+ "directory": "packages/react-three-rapier"
+ },
+ "bugs": {
+ "url": "https://github.com/pmndrs/react-three-rapier/issues"
+ },
+ "type": "commonjs",
+ "engines": {
+ "node": ">=22"
+ },
+ "main": "./dist/index.cjs",
+ "module": "./dist/index.mjs",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "import": {
+ "types": "./dist/index.d.mts",
+ "default": "./dist/index.mjs"
+ },
+ "require": {
+ "types": "./dist/index.d.cts",
+ "default": "./dist/index.cjs"
+ },
+ "default": "./dist/index.mjs"
+ },
+ "./soft-bodies": {
+ "import": {
+ "types": "./dist/soft-bodies.d.mts",
+ "default": "./dist/soft-bodies.mjs"
+ },
+ "require": {
+ "types": "./dist/soft-bodies.d.cts",
+ "default": "./dist/soft-bodies.cjs"
+ },
+ "default": "./dist/soft-bodies.mjs"
+ },
+ "./package.json": "./package.json"
+ },
+ "typesVersions": {
+ "*": {
+ "soft-bodies": [
+ "./dist/soft-bodies.d.ts"
+ ]
+ }
+ },
+ "sideEffects": false,
"files": [
- "dist"
+ "dist/index.*",
+ "dist/soft-bodies.*",
+ "CHANGELOG.md"
],
- "devDependencies": {
- "@react-three/drei": "10.0.1",
- "@react-three/fiber": "9.0.4",
- "@react-three/test-renderer": "9.0.1",
- "@types/react": "19.0.8",
- "@types/react-dom": "19.0.3",
- "@types/three": "0.172.0",
- "react": "19.0.0",
- "react-dom": "19.0.0",
- "three": "0.172.0"
+ "scripts": {
+ "build": "tsc -p tsconfig.build.json && rollup --config rollup.config.mjs",
+ "check:package": "publint --strict && attw --pack ."
},
"peerDependencies": {
- "@react-three/fiber": "^9.0.4",
- "react": "^19",
- "three": ">=0.159.0"
+ "@react-three/fiber": ">=10.0.0-0",
+ "react": ">=19.0 <19.3",
+ "three": ">=0.185.0"
},
"dependencies": {
"@dimforge/rapier3d-compat": "0.21.0",
- "suspend-react": "^0.1.3",
- "three-stdlib": "^2.35.12"
+ "suspend-react": "^0.1.3"
},
- "repository": "https://github.com/pmndrs/react-three-rapier/tree/master/packages/react-three-rapier"
+ "devDependencies": {
+ "@react-three/fiber": "10.0.0-canary.a056612",
+ "@react-three/test-renderer": "10.0.0-canary.a056612",
+ "@types/react": "19.2.18",
+ "@types/react-dom": "19.2.7",
+ "@types/three": "0.186.0",
+ "react": "19.2.8",
+ "react-dom": "19.2.8",
+ "three": "0.186.1"
+ }
}
diff --git a/packages/react-three-rapier/readme.md b/packages/react-three-rapier/readme.md
index a4c11cd2..816a6fbb 100644
--- a/packages/react-three-rapier/readme.md
+++ b/packages/react-three-rapier/readme.md
@@ -2,30 +2,26 @@
-
-๐ฃ @react-three/rapier v2 has been released, which adds support for @react-three/fiber v9 and react v19. If you are using react v18, you will need to use @react-three/rapier v1 and @react-three/fiber v8.
-
-
-
-
-For contributions, please read the ๐ชง Contribution Guide .
-
-For available APIs, see ๐งฉ API Docs
-
+`@react-three/rapier` (or `r3/rapier`) is a wrapper around the [Rapier](https://rapier.rs/docs/user_guides/javascript) WASM-based physics engine, designed to slot seamlessly into a [`@react-three/fiber`](https://github.com/pmndrs/react-three-fiber) pipeline.
----
+The goal of this library is to provide a fast physics engine with minimal friction and a small, straightforward API.
-`react-three/rapier` (or `r3/rapier`) is a wrapper library around the Rapier (https://rapier.rs/docs/user_guides/javascript) WASM-based physics engine, designed to slot seamlessly into a `react-three/fiber` pipeline.
+> **3.0 is in development.** It targets `@react-three/fiber` v10 (including WebGPU), Rapier.js 0.21 and three 0.185+. If you are on react-three-fiber v9 stay on `@react-three/rapier` v2; for v8 / React 18 use v1. See the [migration guide](https://github.com/pmndrs/react-three-rapier/blob/HEAD/docs/advanced/migration.mdx).
-The goal of this library to is to provide a fast physics engine with minimal friction and small, straight forward API.
+## Installation
-
-## Basic Usage
+```bash
+npm install three @react-three/fiber @react-three/rapier
+```
+
+`` loads the Rapier WebAssembly module lazily and must be wrapped in ``.
+
+## Basic usage
```tsx
import { Box, Torus } from "@react-three/drei";
@@ -49,992 +45,26 @@ const App = () => {
};
```
----
-
-
-## ๐ Readme note
-
-Below follows a guide on core concepts for `react-three/rapier`.
-For full API outline and documentation, see ๐งฉ [API Docs](https://pmndrs.github.io/react-three-rapier/).
-
----
-
-
-## Readme Topics
-
-- [The Physics Component](#the-physics-component)
-- [The RigidBody Component](#the-rigidbody-component)
-- [Automatic Colliders](#automatic-colliders)
-- [Collider Components](#collider-components)
- - [๐ผ Collider Examples](#-collider-examples)
-- [Instanced Meshes](#instanced-meshes)
-- [Debug](#debug)
-- [Moving things around, and applying forces](#moving-things-around-and-applying-forces)
-- [Collision Events](#collision-events)
- - [Configuring collision and solver groups](#configuring-collision-and-solver-groups)
-- [Contact force events](#contact-force-events)
-- [Sensors](#sensors)
- - [๐ผ Sensors Example](#-sensors-example)
-- [Configuring Time Step Size](#configuring-time-step-size)
-- [Joints](#joints)
- - [Fixed Joint](#fixed-joint)
- - [Spherical Joint](#spherical-joint)
- - [Revolute Joint](#revolute-joint)
- - [Prismatic Joint](#prismatic-joint)
- - [Rope Joint](#rope-joint)
- - [Spring Joint](#spring-joint)
- - [๐ผ Joints Example](#-joints-example)
-- [Advanced hooks usage](#advanced-hooks-usage)
- - [Physics Hooks (Collision Filtering)](#physics-hooks-collision-filtering)
- - [Filter Contact Pairs](#filter-contact-pairs)
- - [Filter Intersection Pairs](#filter-intersection-pairs)
- - [Manual stepping](#manual-stepping)
- - [On-demand rendering](#on-demand-rendering)
-- [Snapshots](#snapshots)
-
----
-
-## The Physics Component
-
-The ` ` component is the root component of your physics world. It is responsible for creating the physics world and managing the simulation. It relies on lazily initiating `Rapier` and needs to be wrapped in ` `.
-
-๐งฉ See [PhysicsProps docs](https://pmndrs.github.io/react-three-rapier/interfaces/PhysicsProps.html) for available props.
-
-```tsx
-const Scene = () => {
- return (
-
-
-
- ...
-
-
-
- );
-};
-```
-
-## The RigidBody Component
-
-The ` ` component is used to add a `mesh` into the physics world. You use it by wrapping one or more `meshes` and setting desired props. By default, this will automatically generate `Colliders` based on the shape of the wrapped `meshes` (see [Automatic colliders](#automatic-colliders)).
-
-๐งฉ See [RigidBodyProps docs](https://pmndrs.github.io/react-three-rapier/interfaces/RigidBodyProps.html) for available props.
-
-```tsx
-const RigidBodyMesh = () => (
-
-
-
-);
-```
-
-## Automatic Colliders
-
-RigidBodies generate automatic colliders by default for all meshes that it contains. You can control the default collider by setting the `colliders` prop on a ` `, or change it globally by setting `colliders` on ` `. Setting `colliders={false}` disables auto-generation.
-
-Supported values:
-
-- `"cuboid"`, creates a CuboidCollider based on the bounding box of the mesh
-- `"ball"`, creates a SphereCollider based on the bounding sphere of the mesh
-- `"trimesh"`, creates a TrimeshCollider based on the mesh's geometry
-- `"hull"`, creates a ConvexHullCollider based on the mesh's geometry
-- `false`, disables auto-generation
-
-Generate ConvexHull colliders for all meshes in a RigidBody by default:
-
-```tsx
-const Scene = () => (
-
-
-
-
-
-
-
-
-);
-```
-
-Turn off automatic collider generation globally, but apply auto generation locally:
+## Documentation
-```tsx
-const Scene = () => (
-
- {/* Use an automatic CuboidCollider for all meshes inside this RigidBody */}
-
-
-
-
- {/* Use an automatic BallCollider for all meshes inside this RigidBody */}
-
-
-
-
-);
-```
-
-## Collider Components
-
-You can also create `Colliders` by hand and add them to a `RigidBody` to create compound colliders. This is useful for creating more complex shapes, for creating simplified shapes for performance reasons, or for detecting collisions on specific parts of a mesh.
-
-๐งฉ See [ColliderProps docs](https://pmndrs.github.io/react-three-rapier/interfaces/ColliderProps.html) for available props.
-
-```tsx
-const Scene = () => (<>
- {/* Make a compound shape with two custom BallColliders */}
-
-
-
-
-
+The guides live in [`docs/`](https://github.com/pmndrs/react-three-rapier/tree/HEAD/docs) and are published with [pmndrs/docs](https://github.com/pmndrs/docs). Preview them locally with `yarn docs`.
- {/* Make a compound shape with two custom BallColliders, an automatic BallCollider,
- Two automatic MeshColliders, based on two different shape types */}
-
-
-
-
+- Getting started: [Introduction](https://github.com/pmndrs/react-three-rapier/blob/HEAD/docs/getting-started/introduction.mdx), [Installation](https://github.com/pmndrs/react-three-rapier/blob/HEAD/docs/getting-started/installation.mdx), [Basic usage](https://github.com/pmndrs/react-three-rapier/blob/HEAD/docs/getting-started/basic-usage.mdx)
+- API: [Physics](https://github.com/pmndrs/react-three-rapier/blob/HEAD/docs/api/physics.mdx), [RigidBody](https://github.com/pmndrs/react-three-rapier/blob/HEAD/docs/api/rigid-body.mdx), [Colliders](https://github.com/pmndrs/react-three-rapier/blob/HEAD/docs/api/colliders.mdx), [InstancedRigidBodies](https://github.com/pmndrs/react-three-rapier/blob/HEAD/docs/api/instanced-rigid-bodies.mdx), [Joints](https://github.com/pmndrs/react-three-rapier/blob/HEAD/docs/api/joints.mdx), [Debug](https://github.com/pmndrs/react-three-rapier/blob/HEAD/docs/api/debug.mdx), [Attractors](https://github.com/pmndrs/react-three-rapier/blob/HEAD/docs/api/attractors.mdx), [Hooks](https://github.com/pmndrs/react-three-rapier/blob/HEAD/docs/api/hooks.mdx), [Character controller](https://github.com/pmndrs/react-three-rapier/blob/HEAD/docs/api/character-controller.mdx), [Soft bodies](https://github.com/pmndrs/react-three-rapier/blob/HEAD/docs/api/soft-bodies.mdx)
+- Advanced: [Collision events](https://github.com/pmndrs/react-three-rapier/blob/HEAD/docs/advanced/collision-events.mdx), [Contact forces](https://github.com/pmndrs/react-three-rapier/blob/HEAD/docs/advanced/contact-forces.mdx), [Sensors](https://github.com/pmndrs/react-three-rapier/blob/HEAD/docs/advanced/sensors.mdx), [Collision groups](https://github.com/pmndrs/react-three-rapier/blob/HEAD/docs/advanced/collision-groups.mdx), [Physics hooks](https://github.com/pmndrs/react-three-rapier/blob/HEAD/docs/advanced/physics-hooks.mdx), [Update loop](https://github.com/pmndrs/react-three-rapier/blob/HEAD/docs/advanced/update-loop.mdx), [Snapshots](https://github.com/pmndrs/react-three-rapier/blob/HEAD/docs/advanced/snapshots.mdx), [Performance](https://github.com/pmndrs/react-three-rapier/blob/HEAD/docs/advanced/performance.mdx)
+- [Migrating from 2.x to 3.0](https://github.com/pmndrs/react-three-rapier/blob/HEAD/docs/advanced/migration.mdx)
+- [FAQ and troubleshooting](https://github.com/pmndrs/react-three-rapier/blob/HEAD/docs/advanced/faq.mdx)
-
-
-
+The generated TypeDoc reference is at [pmndrs.github.io/react-three-rapier](https://pmndrs.github.io/react-three-rapier/).
-
-
-
-
-
-<>)
-```
-
-RigidBodies work inside other transformed objects as well. Simulation runs in world space and is transformed to the objects local space, so that things act as you'd expect.
-
-> **Note** It's always best to create RigidBodies where the center of gravity is in the center of the object, otherwise you might get some unexpected behavior during simulation interpolation.
-
-```tsx
-import { Box } from "@react-three/drei";
-import { RigidBody, CuboidCollider } from "@react-three/rapier";
-
-const Scene = () => (
-
-
-
-
-
-
-);
-```
-
-If part of our meshes are invisible and you want to include them in the collider creation, use the `includeInvisible` flag.
-
-```tsx
-
-
-
-
-
-```
-
-### ๐ผ Collider Examples
+## Examples
-
-## Instanced Meshes
-
-Instanced meshes can also be used and have automatic colliders generated from their mesh.
-
-By wrapping exactly one `Three.InstancedMesh` in ` `, each instance will be attached to an individual `RigidBody`.
-
-๐งฉ See [InstancedRigidBodiesProps docs](https://pmndrs.github.io/react-three-rapier/interfaces/InstancedRigidBodiesProps.html) for available props.
-
-```tsx
-import { InstancedRigidBodies, RapierRigidBody } from "@react-three/rapier";
-
-const COUNT = 1000;
-
-const Scene = () => {
- const rigidBodies = useRef(null);
-
- useEffect(() => {
- if (!rigidBodies.current) {
- return;
- }
-
- // You can access individual instanced by their index
- rigidBodies.current[40].applyImpulse({ x: 0, y: 10, z: 0 }, true);
- rigidBodies.current.at(100).applyImpulse({ x: 0, y: 10, z: 0 }, true);
-
- // Or update all instances
- rigidBodies.current.forEach((api) => {
- api.applyImpulse({ x: 0, y: 10, z: 0 }, true);
- });
- }, []);
-
- // We can set the initial positions, and rotations, and scales, of
- // the instances by providing an array of InstancedRigidBodyProps
- // which is the same as RigidBodyProps, but with an additional "key" prop.
- const instances = useMemo(() => {
- const instances: InstancedRigidBodyProps[] = [];
-
- for (let i = 0; i < COUNT; i++) {
- instances.push({
- key: "instance_" + Math.random(),
- position: [Math.random() * 10, Math.random() * 10, Math.random() * 10],
- rotation: [Math.random(), Math.random(), Math.random()]
- });
- }
-
- return instances;
- }, []);
-
- return (
-
-
-
- );
-};
-```
-
-We can also create compound shapes for instanced meshes by providing an array of `Colliders` in the `colliderNodes` prop.
-
-```tsx
-import {
- InstancedRigidBodies,
- BoxCollider,
- SphereCollider
-} from "@react-three/rapier";
-const COUNT = 500;
-
-const Scene = () => {
- const instances = useMemo(() => {
- const instances: InstancedRigidBodyProps[] = [];
-
- for (let i = 0; i < COUNT; i++) {
- instances.push({
- key: "instance_" + Math.random(),
- position: [Math.random() * 10, Math.random() * 10, Math.random() * 10],
- rotation: [Math.random(), Math.random(), Math.random()]
- });
- }
-
- return instances;
- }, []);
-
- return (
- ,
-
- ]}
- >
-
-
- );
-};
-```
-
-## Debug
-
-Set the `debug` prop on ` ` to see live representations of all colliders in a scene, using the live debug buffer from the physics engine.
-
-```tsx
-import { Box, Sphere } from "@react-three/drei";
-import { RigidBody } from "@react-three/rapier";
-
-const Scene = () => {
- return (
-
-
-
-
-
-
-
-
- );
-};
-```
-
-## Moving things around, and applying forces
-
-You can access the instance for a RigidBody by storing its `ref`. This allows you to perform any operation on the underlying physics object directly.
-
-`r3/rapier` exposes a `RapierRigidBody` and `RapierCollider` as aliases for `rapiers` underlying base objects.
-
-For all available methods, see the [Rapier docs](https://rapier.rs/javascript3d/classes/RigidBody.html).
-
-```tsx
-import { RigidBody, RapierRigidBody } from "@react-three/rapier";
-
-const Scene = () => {
- const rigidBody = useRef(null);
-
- useEffect(() => {
- if (rigidBody.current) {
- // A one-off "push"
- rigidBody.current.applyImpulse({ x: 0, y: 10, z: 0 }, true);
-
- // A continuous force
- rigidBody.current.addForce({ x: 0, y: 10, z: 0 }, true);
-
- // A one-off torque rotation
- rigidBody.current.applyTorqueImpulse({ x: 0, y: 10, z: 0 }, true);
-
- // A continuous torque
- rigidBody.current.addTorque({ x: 0, y: 10, z: 0 }, true);
- }
- }, []);
-
- return (
-
-
-
-
-
-
- );
-};
-```
-
-Rapier's API returns quaternions and vectors that are not compatible with Three.js, `r3/rapier` therefore exposes some helper functions (`vec3`, `quat`, `euler`) for quick type conversions. These helper functions can also be used as a shorthand for creating new objects.
-
-```tsx
-import { RapierRigidBody, quat, vec3, euler } from "@react-three/rapier";
-
-const Scene = () => {
- const rigidBody = useRef(null);
-
- useEffect(() => {
- if (rigidBody.current) {
- const position = vec3(rigidBody.current.translation());
- const quaternion = quat(rigidBody.current.rotation());
- const eulerRot = euler().setFromQuaternion(
- quat(rigidBody.current.rotation())
- );
-
- // While Rapier's return types need conversion, setting values can be done directly with Three.js types
- rigidBody.current.setTranslation(position, true);
- rigidBody.current.setRotation(quaternion, true);
- rigidBody.current.setAngvel({ x: 0, y: 2, z: 0 }, true);
- }
- }, []);
-
- return (
-
-
-
-
-
-
- );
-};
-```
-
-## Collision Events
-
-You can subscribe to collision and state events on a RigidBody:
-
-๐งฉ See [onCollisionEnter / onCollisionExit docs](https://pmndrs.github.io/react-three-rapier/interfaces/RigidBodyProps.html#onCollisionEnter) for more information.
-
-```tsx
-const RigidBottle = () => {
- const [isAsleep, setIsAsleep] = useState(false);
-
- return (
- setIsAsleep(true)}
- onWake={() => setIsAsleep(false)}
- name="Bally McBallFace"
- onCollisionEnter={({ manifold, target, other }) => {
- console.log(
- "Collision at world position ",
- manifold.solverContactPoint(0)
- );
-
- if (other.rigidBodyObject) {
- console.log(
- // this rigid body's Object3D
- target.rigidBodyObject.name,
- " collided with ",
- // the other rigid body's Object3D
- other.rigidBodyObject.name
- );
- }
- }}
- >
-
-
-
-
- );
-};
-```
-
-You may also subscribe to collision events on individual Colliders:
-
-```tsx
- {
- /* ... */
- }}
- onCollisionExit={(payload) => {
- /* ... */
- }}
-/>
-```
-
-The `payload` object for all collision callbacks contains the following properties:
-
-- `target`
- `CollisionTarget` of the object firing the event.
-- `other`
- `CollisionTarget` of the other object involved in the event.
-- `manifold` (onCollisionEnter only)
- The [contact manifold](https://rapier.rs/javascript3d/classes/TempContactManifold.html) generated by the collision event.
-- `flipped` (onCollisionEnter only)
- `true` if the data in the `manifold` [is flipped](https://rapier.rs/javascript3d/classes/World.html#contactPair).
-
-A `CollisionTarget` is an object containing references to objects involved in a collision event. It has the following properties:
-
-- `rigidBody` (if exists): `Rapier.RigidBody`
-- `rigidBodyObject` (if exists): `Three.Object3D`
-- `collider`: `Rapier.Collider`
-- `colliderObject`: `Three.Object3D`
-
-### Configuring collision and solver groups
-
-Both `` as well as all collider components allow you to configure `collisionsGroups` and `solverGroups` properties that configures which groups the colliders are in, and what other groups they should interact with in potential collision and solving events (you will find more details on this in the [Rapier documentation](https://rapier.rs/docs/user_guides/javascript/colliders/#collision-groups-and-solver-groups).)
-
-Since these are set as bitmasks and bitmasks can get a bit unwieldy to generate, this library provides a helper called `interactionGroups` that can be used to generate bitmasks from numbers and arrays of groups, where groups are identified using numbers from 0 to 15.
-
-The first argument is the group, or an array of groups, that the collider is a member of; the second argument is the group, or an array of groups, that the collider should interact with.
-
-Here the collider is in group 0, and interacts with colliders from groups 0, 1 and 2:
-
-```tsx
-
-```
-
-This collider is in multiple groups, but only interacts with colliders from a single group:
-
-```tsx
-
-```
-
-When the second argument is omitted, the collider will interact with all groups:
-
-```tsx
-
-```
-
-> **Note** Please remember that in Rapier, for a collision (or solving) event to occur, both colliders involved in the event must match the related interaction groups -- a one-way match will be ignored.
-
-> **Note** By default, colliders are members of all groups, and will interact with all other groups.
-
-## Contact force events
-
-Contact force events are triggered on `` and any collider components when two objects collider.
-
-```tsx
- {
- console.log(`The total force generated was: ${payload.totalForce}`);
- }}
->
-
-
-
-
-```
-
-The payload for the contact force event contains the following properties:
-
-- `target`
- `CollisionTarget` of the object firing the event
-- `other`
- `CollisionTarget` of the other object involved in the event
-- `totalForce`
- The sum of all the forces between the two colliders
-- `totalForceMagnitude`
- The sum of the magnitudes of each force between the two colliders
-- `maxForceDirection`
- The magnitude of the largest force at a contact point of this contact pair
-- `maxForceMagnitude`
- The world-space (unit) direction of the force with strongest magnitude
-
-More information about each property can be found in the rapier [TempContactForceEvent API documentation](https://rapier.rs/javascript3d/classes/TempContactForceEvent.html).
-
-You can also add the `onContactForce` event to any collider.
-
-```tsx
- {
- /* ... */
- }}
-/>
-```
-
-## Sensors
-
-A Collider can be set to be a sensor, which means that it will not generate any contact points, and will not be affected by forces. This is useful for detecting when a collider enters or leaves another collider, without affecting the other collider.
-
-To detect when a collider enters or leaves another collider, you can use the `onIntersectionEnter` and `onIntersectionExit` events on the collider.
-
-๐งฉ See [onIntersectionEnter / onIntersectionExit docs](https://pmndrs.github.io/react-three-rapier/interfaces/RigidBodyProps.html#onIntersectionEnter) for more information.
-
-```tsx
-
-
-
- console.log("Goal!")}
- />
-
-```
-
-### ๐ผ Sensors Example
-
-
-## Configuring Time Step Size
-
-By default, `` will simulate the physics world at a fixed rate of 60 frames per second. This can be changed by setting the `timeStep` prop on ``:
-
-```tsx
-{/* ... */}
-```
-
-The `timeStep` prop may also be set to `"vary"`, which will cause the simulation's time step to adjust to every frame's frame delta:
-
-```tsx
-{/* ... */}
-```
-
-> **Note** This is useful for games that run at variable frame rates, but may cause instability in the simulation. It also prevents the physics simulation from being fully deterministic. Please use with care!
-
-## Joints
-
-Joints can be made between two `RigidBodies` to provide a way to restrict a motion of a body in relation to another.
-
-> Read more about joints in Rapier: https://rapier.rs/docs/user_guides/javascript/joints
-
-Joints are available in `r3/rapier` as hooks.
-
-There are 6 different joint types available:
-
-- Fixed (two bodies are fixed together)
-- Spherical (two bodies are connected by a ball and socket, for things like arms or chains)
-- Revolute (two bodies are connected by a hinge, for things like doors or wheels)
-- Prismatic (two bodies are connected by a sliding joint, for things like pistons or sliders)
-- Rope (limits the max distance between two bodies)
-- Spring (applies a force proportional to the distance between two bodies)
-
-Each joint hook returns a RefObject containing the raw reference to the joint instance.
-
-```tsx
-const WheelJoint = ({ bodyA, bodyB }) => {
- const joint = useRevoluteJoint(bodyA, bodyB, [
- [0, 0, 0],
- [0, 0, 0],
- [0, 0, 0]
- ]);
-
- useFrame(() => {
- if (joint.current) {
- joint.current.configureMotorVelocity(10, 2);
- }
- }, []);
-
- return null;
-};
-```
-
-### Fixed Joint
-
-A fixed joint ensures that two rigid-bodies don't move relative to each other. Fixed joints are characterized by one local frame (represented by an isometry) on each rigid-body. The fixed-joint makes these frames coincide in world-space.
-
-๐งฉ See [FixedJoint docs](https://pmndrs.github.io/react-three-rapier/functions/useFixedJoint.html) for available options.
-
-```tsx
-const JointedThing = () => {
- const bodyA = useRef(null);
- const bodyB = useRef(null);
-
- const joint = useFixedJoint(bodyA, bodyB, [
- // Position of the joint in bodyA's local space
- [0, 0, 0],
- // Orientation of the joint in bodyA's local space
- [0, 0, 0, 1],
- // Position of the joint in bodyB's local space
- [0, 0, 0],
- // Orientation of the joint in bodyB's local space
- [0, 0, 0, 1]
- ]);
-
- return (
-
-
-
-
-
-
-
-
- );
-};
-```
-
-### Spherical Joint
-
-The spherical joint ensures that two points on the local-spaces of two rigid-bodies always coincide (it prevents any relative translational motion at this points).
-
-๐งฉ See [SphericalJoint docs](https://pmndrs.github.io/react-three-rapier/functions/useSphericalJoint.html) for available options.
-
-```tsx
-const JointedThing = () => {
- const bodyA = useRef(null);
- const bodyB = useRef(null);
-
- const joint = useSphericalJoint(bodyA, bodyB, [
- // Position of the joint in bodyA's local space
- [0, 0, 0],
- // Position of the joint in bodyB's local space
- [0, 0, 0]
- ]);
-
- return (
-
-
-
-
-
-
-
-
- );
-};
-```
-
-### Revolute Joint
-
-The revolute joint prevents any relative movement between two rigid-bodies, except for relative rotations along one axis. This is typically used to simulate wheels, fans, etc.
-
-๐งฉ See [RevoluteJoint docs](https://pmndrs.github.io/react-three-rapier/functions/useRevoluteJoint.html) for available options.
-
-```tsx
-const JointedThing = () => {
- const bodyA = useRef(null);
- const bodyB = useRef(null);
-
- const joint = useRevoluteJoint(bodyA, bodyB, [
- // Position of the joint in bodyA's local space
- [0, 0, 0],
- // Position of the joint in bodyB's local space
- [0, 0, 0],
- // Axis of the joint, expressed in the local-space of
- // the rigid-bodies it is attached to. Cannot be [0,0,0].
- [0, 1, 0]
- ]);
-
- return (
-
-
-
-
-
-
-
-
- );
-};
-```
-
-### Prismatic Joint
-
-The prismatic joint prevents any relative movement between two rigid-bodies, except for relative translations along one axis.
-
-๐งฉ See [PrismaticJoint docs](https://pmndrs.github.io/react-three-rapier/functions/usePrismaticJoint.html) for available options.
-
-```tsx
-const JointedThing = () => {
- const bodyA = useRef(null);
- const bodyB = useRef(null);
-
- const joint = usePrismaticJoint(bodyA, bodyB, [
- // Position of the joint in bodyA's local space
- [0, 0, 0],
- // Position of the joint in bodyB's local space
- [0, 0, 0],
- // Axis of the joint, expressed in the local-space of
- // the rigid-bodies it is attached to. Cannot be [0,0,0].
- [0, 1, 0]
- ]);
-
- return (
-
-
-
-
-
-
-
-
- );
-};
-```
-
-### Rope Joint
-
-The rope joint limits the max distance between two bodies.
-
-๐งฉ See [RopeJoint docs](https://pmndrs.github.io/react-three-rapier/functions/useRopeJoint.html) for available options.
-
-```tsx
-const JointedThing = () => {
- const bodyA = useRef(null);
- const bodyB = useRef(null);
-
- const joint = useRopeJoint(bodyA, bodyB, [
- // Position of the joint in bodyA's local space
- [0, 0, 0],
- // Position of the joint in bodyB's local space
- [0, 0, 0],
- // The max distance between the two bodies / length of the rope
- 1
- ]);
-
- return (
-
-
-
-
-
-
-
-
- );
-};
-```
-
-### Spring Joint
-
-The spring joint applies a force proportional to the distance between two bodies.
-
-๐งฉ See [SpringJoint docs](https://pmndrs.github.io/react-three-rapier/functions/useSpringJoint.html) for available options.
-
-```tsx
-const JointedThing = () => {
- const bodyA = useRef(null);
- const bodyB = useRef(null);
-
- const mass = 1;
- const springRestLength = 0;
- const stiffness = 1.0e3;
- const criticalDamping = 2.0 * Math.sqrt(stiffness * mass);
- const dampingRatio = props.jointNum / (props.total / 2);
- const damping = dampingRatio * criticalDamping;
-
- const joint = useSpringJoint(bodyA, bodyB, [
- // Position of the joint in bodyA's local space
- [0, 0, 0],
- // Position of the joint in bodyB's local space
- [0, 0, 0],
- // Spring rest length
- springRestLength,
- // Spring stiffness
- stiffness,
- // Spring damping
- damping
- ]);
-
- return (
-
-
-
-
-
-
-
-
- );
-};
-```
-
-### ๐ผ Joints Example
-
+
-## Advanced hooks usage
-
-Advanced users might need granular access to the physics loop and direct access to the `world` instance. This can be done by using the following hooks:
-
-- `useRapier`
- Gives you access to the `world`, direct access to `rapier`, and more.
- ๐งฉ See [useRapier docs](https://pmndrs.github.io/react-three-rapier/interfaces/RapierContext.html) for more information.
-- `useBeforePhysicsStep`
- Allows you to run code before the physics simulation is stepped.
- ๐งฉ See [useBeforePhysicsStep docs](https://pmndrs.github.io/react-three-rapier/functions/useBeforePhysicsStep.html) for more information.
-- `useAfterPhysicsStep`
- Allows you to run code after the physics simulation is stepped.
- ๐งฉ See [useAfterPhysicsStep docs](https://pmndrs.github.io/react-three-rapier/functions/useAfterPhysicsStep.html) for more information.
-
-### Physics Hooks (Collision Filtering)
-
-You can implement advanced collision behaviors like one-way platforms by using physics hooks. These hooks allow you to filter collision and intersection pairs during the physics step.
-
-`r3/rapier` provides two hooks for collision filtering:
-- `useFilterContactPair` - Filter collision pairs and control solver behavior
-- `useFilterIntersectionPair` - Filter intersection pairs for sensors
-
-#### Filter Contact Pairs
+## Contributing
-`useFilterContactPair` allows you to control how collisions are processed. The callback should return:
-- `SolverFlags.COMPUTE_IMPULSE` (1) - Process the collision normally
-- `SolverFlags.EMPTY` (0) - Ignore the collision
-- `null` - Let other hooks decide, or use default behavior
-
-#### Filter Intersection Pairs
-
-`useFilterIntersectionPair` controls which sensor intersections are detected. The callback should return:
-- `true` - Allow the intersection to be detected
-- `false` - Block the intersection
-
-If multiple hooks are registered:
-- For contact pairs, the **first hook that returns non-null wins**
-- For intersection pairs, the **first hook that returns false blocks** the intersection
-
-**Important:** To avoid Rust aliasing errors, you **cannot** access rigid body properties (like `translation()` or `linvel()`) directly during the physics step. Instead, cache the needed state before the step using `useBeforePhysicsStep`.
-
-๐งฉ See [useFilterContactPair docs](https://pmndrs.github.io/react-three-rapier/functions/useFilterContactPair.html) and [useFilterIntersectionPair docs](https://pmndrs.github.io/react-three-rapier/functions/useFilterIntersectionPair.html) for more information.
-
-```tsx
-import {
- useRapier,
- useBeforePhysicsStep,
- useFilterContactPair
-} from "@react-three/rapier";
-
-const OneWayPlatform = () => {
- const platformRef = useRef(null);
- const ballRef = useRef(null);
- const colliderRef = useRef(null);
-
- // Cache for storing body states before physics step
- const bodyStateCache = useRef(new Map());
-
- const { rapier } = useRapier();
-
- // Cache body states BEFORE the physics step
- useBeforePhysicsStep(() => {
- if (platformRef.current && ballRef.current) {
- const ballPos = ballRef.current.translation();
- const ballVel = ballRef.current.linvel();
-
- bodyStateCache.current.set(ballRef.current.handle, {
- position: ballPos,
- velocity: ballVel
- });
- }
- });
-
- // Filter collisions using cached data
- useFilterContactPair((collider1, collider2, body1, body2) => {
- const ballState = bodyStateCache.current.get(body1);
- if (!ballState) return null; // Let other hooks or default behavior handle it
-
- // Allow collision only if ball is moving down and above platform
- if (ballState.velocity.y < 0 && ballState.position.y > 0) {
- return rapier.SolverFlags.COMPUTE_IMPULSE; // Process collision
- }
- return rapier.SolverFlags.EMPTY; // Ignore collision
- });
-
- useEffect(() => {
- // Enable active hooks on the collider (required for filtering)
- colliderRef.current?.setActiveHooks(rapier.ActiveHooks.FILTER_CONTACT_PAIRS);
- }, []);
-
- return (
- <>
-
-
-
-
-
-
- >
- );
-};
-```
-
-### Manual stepping
-
-You can manually step the physics simulation by calling the `step` method from the `useRapier` hook.
-
-```tsx
-const { step } = useRapier();
-
-step(1 / 60);
-```
-
-### On-demand rendering
-
-By default `@react-three/rapier` will update the physics simulation when a frame renders. This is fine for most cases, but if you want to only render the scene when things have changed, you need to run the physics simulation independently from the render loop.
-
-- See https://docs.pmnd.rs/react-three-fiber/advanced/scaling-performance#on-demand-rendering, for info on on-demand rendering in `@react-tree/fiber`.
-
-Setting ` ` will make the physics simulation run in it's own `requestAnimationFrame` loop, and call `invalidate` on the canvas only when there are active (moving) bodies.
-
-```tsx
-
- ...
-
-```
-
-## Snapshots
-The `world` can be serialized as a `Uint8Array` using `world.takeSnapshot()`, see Rapier's docs on [Serialization](https://rapier.rs/docs/user_guides/javascript/serialization/) for more info.
-
-The snapshot can be used to construct a new world. In `r3/rapier`, you need to replace the world with this snapshot.
-
-> [!NOTE]
-> This only works if the snapshotted world is identical to the restored one. If objects, or the order of creation of objects vary, expect RigidBodies to scramble.
-> Snapshot formats are version-dependent. Store the Rapier version alongside persisted snapshot data and check compatibility before restoring after an upgrade.
-
-```tsx
-import { useRapier } from '@react-three/rapier';
-
-const SnapshottingComponent = () => {
- const { world, setWorld, rapier } = useRapier();
- const worldSnapshot = useRef();
-
- // Store the snapshot
- const takeSnapshot = () => {
- const snapshot = world.takeSnapshot()
- worldSnapshot.current = snapshot
- }
-
- // Create a new World from the snapshot, and replace the current one
- const restoreSnapshot = () => {
- setWorld(rapier.World.restoreSnapshot(worldSnapshot.current))
- }
-
- return <>
- ...
- ...
- ...
- ...
- ...
- >
-}
-```
+Please read the [contribution guide](https://github.com/pmndrs/react-three-rapier/blob/HEAD/packages/react-three-rapier/CONTRIBUTING.md). Questions are welcome on [Discord](https://discord.gg/ZZjjNvJ).
diff --git a/packages/react-three-rapier/rollup.config.mjs b/packages/react-three-rapier/rollup.config.mjs
new file mode 100644
index 00000000..85d73596
--- /dev/null
+++ b/packages/react-three-rapier/rollup.config.mjs
@@ -0,0 +1,4 @@
+import { defineLibraryConfig } from "../../scripts/rollup-library.mjs";
+
+// Entry points come from the `exports` map in package.json - see scripts/rollup-library.mjs.
+export default defineLibraryConfig(import.meta.url);
diff --git a/packages/react-three-rapier/src/components/AnyCollider.tsx b/packages/react-three-rapier/src/components/AnyCollider.tsx
index ffd31ce8..37a5ccc6 100644
--- a/packages/react-three-rapier/src/components/AnyCollider.tsx
+++ b/packages/react-three-rapier/src/components/AnyCollider.tsx
@@ -2,6 +2,7 @@ import { Collider } from "@dimforge/rapier3d-compat";
import React, {
ForwardedRef,
memo,
+ MutableRefObject,
ReactNode,
Ref,
useEffect,
@@ -52,6 +53,9 @@ export const AnyCollider = memo((props: ColliderProps) => {
const { world, colliderEvents, colliderStates } = useRapier();
const rigidBodyContext = useRigidBodyContext();
const colliderRef = useForwardedRef(props.ref);
+ // Destroy closures can outlive a render; always use the latest ref
+ const latestRef = useRef(props.ref);
+ latestRef.current = props.ref;
const objectRef = useRef(null);
// We spread the props out here to make sure that the ref is updated when the props change.
@@ -71,8 +75,8 @@ export const AnyCollider = memo((props: ColliderProps) => {
rigidBodyContext?.getRigidBody
);
- if (typeof props.ref == "function") {
- props.ref(collider);
+ if (typeof latestRef.current == "function") {
+ latestRef.current(collider);
}
colliderRef.current = collider;
@@ -82,6 +86,17 @@ export const AnyCollider = memo((props: ColliderProps) => {
if (world.getCollider(collider.handle)) {
world.removeCollider(collider, true);
}
+
+ // Don't leave the consumer holding a reference to a freed rapier object
+ const currentRef = latestRef.current;
+ if (typeof currentRef === "function") {
+ currentRef(null);
+ } else if (currentRef && currentRef.current === collider) {
+ currentRef.current = null;
+ }
+ if (colliderRef.current === collider) {
+ (colliderRef as MutableRefObject).current = null;
+ }
},
[...immutablePropArray, rigidBodyContext]
);
diff --git a/packages/react-three-rapier/src/components/CharacterController.tsx b/packages/react-three-rapier/src/components/CharacterController.tsx
new file mode 100644
index 00000000..ed91032b
--- /dev/null
+++ b/packages/react-three-rapier/src/components/CharacterController.tsx
@@ -0,0 +1,238 @@
+import {
+ CharacterCollision,
+ Collider,
+ InteractionGroups,
+ KinematicCharacterController,
+ QueryFilterFlags
+} from "@dimforge/rapier3d-compat";
+import React, { Ref, useImperativeHandle, useMemo, useRef } from "react";
+import { useBeforePhysicsStep } from "../hooks/hooks";
+import {
+ CharacterControllerOptions,
+ useCharacterController
+} from "../hooks/use-character-controller";
+import { RapierRigidBody, Vector3Object } from "../types";
+import { RigidBody, RigidBodyProps } from "./RigidBody";
+
+/**
+ * Imperative handle exposed by the `` ref.
+ * @category Character Controller
+ */
+export interface CharacterControllerRef {
+ /** The underlying Rapier `KinematicCharacterController`. */
+ readonly controller: KinematicCharacterController;
+ /** The kinematic rigid body of the character. */
+ readonly rigidBody: RapierRigidBody | null;
+ /** Whether the character was standing on the ground during the last step. */
+ readonly grounded: boolean;
+ /**
+ * Sets the persistent desired velocity (units per second).
+ * It is converted to a translation with the fixed step dt on every physics step,
+ * so the resulting motion is framerate independent.
+ */
+ setVelocity(velocity: Vector3Object): void;
+ /**
+ * Queues a one-off displacement (world units) applied on the next physics step
+ * on top of the velocity, e.g. for knockback or teleport-like pushes.
+ * Multiple calls before the step accumulate.
+ */
+ translate(delta: Vector3Object): void;
+}
+
+/**
+ * Props of {@link CharacterController}.
+ * @category Character Controller
+ */
+export interface CharacterControllerProps
+ extends CharacterControllerOptions, Omit {
+ ref?: Ref;
+ /** Called once when the character becomes grounded (edge triggered). */
+ onGrounded?: () => void;
+ /** Called once when the character leaves the ground (edge triggered). */
+ onAirborne?: () => void;
+ /**
+ * Called for every collision found while moving, on every step.
+ * The collision object is pooled: read it, do not retain it.
+ */
+ onCharacterCollision?: (collision: CharacterCollision) => void;
+ /** Rapier query filter flags used while moving. */
+ filterFlags?: QueryFilterFlags;
+ /** Rapier interaction groups used to filter what the character collides with. */
+ filterGroups?: InteractionGroups;
+ /** Return `false` to ignore a collider. The character's own colliders are always ignored. */
+ filterPredicate?: (collider: Collider) => boolean;
+ /** Ignore sensor colliders. @default true */
+ excludeSensors?: boolean;
+}
+
+/**
+ * A kinematic character controller: a `kinematicPosition` RigidBody whose
+ * movement is resolved against the world (walls, slopes, stairs) by Rapier's
+ * `KinematicCharacterController`.
+ *
+ * Children are colliders / meshes, exactly like ``. The first
+ * non-sensor collider is used to compute the movement.
+ *
+ * The desired motion is applied once per fixed physics step with the fixed
+ * `dt`, so it is framerate independent.
+ *
+ * Callback ordering: the component registers its step callback in its own
+ * effect, which runs before callbacks registered by a parent component. Values
+ * you set with `setVelocity` / `translate` from your own `useBeforePhysicsStep`
+ * are therefore applied on the next step (one fixed step of latency), and
+ * `grounded` reflects the previous step.
+ *
+ * Defaults to `colliders={false}`: add explicit colliders as children
+ * (pass `colliders` to override). Gravity and jumping stay in userland:
+ * integrate your own vertical velocity in `useBeforePhysicsStep` and call
+ * `setVelocity`.
+ *
+ * @example
+ * ```tsx
+ * const Player = () => {
+ * const ref = useRef(null);
+ * const vy = useRef(0);
+ * const input = useInput(); // your own input, returns { x, z, jump }
+ *
+ * useBeforePhysicsStep((_world, dt) => {
+ * const c = ref.current!;
+ * vy.current = c.grounded && !input.jump ? 0 : vy.current - 9.81 * dt;
+ * if (c.grounded && input.jump) vy.current = 6;
+ * c.setVelocity({ x: input.x * 4, y: vy.current, z: input.z * 4 });
+ * });
+ *
+ * return (
+ * console.log("landed")}
+ * >
+ *
+ *
+ * );
+ * };
+ * ```
+ * @category Components
+ */
+export const CharacterController = (props: CharacterControllerProps) => {
+ const {
+ ref,
+ children,
+ onGrounded,
+ onAirborne,
+ onCharacterCollision,
+ filterFlags,
+ filterGroups,
+ filterPredicate,
+ excludeSensors,
+ offset,
+ up,
+ slide,
+ maxSlopeClimbAngle,
+ minSlopeSlideAngle,
+ autostep,
+ snapToGround,
+ applyImpulsesToDynamicBodies,
+ characterMass,
+ normalNudgeFactor,
+ colliders = false,
+ ...rigidBodyProps
+ } = props;
+
+ const bodyRef = useRef(null);
+ const cc = useCharacterController({
+ offset,
+ up,
+ slide,
+ maxSlopeClimbAngle,
+ minSlopeSlideAngle,
+ autostep,
+ snapToGround,
+ applyImpulsesToDynamicBodies,
+ characterMass,
+ normalNudgeFactor
+ });
+
+ const state = useMemo(
+ () => ({
+ grounded: false,
+ velocity: { x: 0, y: 0, z: 0 },
+ pending: { x: 0, y: 0, z: 0 },
+ desired: { x: 0, y: 0, z: 0 }
+ }),
+ []
+ );
+
+ // Latest props, read inside the step callback.
+ const latest = useRef(props);
+ latest.current = props;
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ get controller() {
+ return cc.controller;
+ },
+ get rigidBody() {
+ return bodyRef.current;
+ },
+ get grounded() {
+ return state.grounded;
+ },
+ setVelocity(v) {
+ state.velocity.x = v.x;
+ state.velocity.y = v.y;
+ state.velocity.z = v.z;
+ },
+ translate(d) {
+ state.pending.x += d.x;
+ state.pending.y += d.y;
+ state.pending.z += d.z;
+ }
+ }),
+ [cc, state]
+ );
+
+ useBeforePhysicsStep((_world, dt) => {
+ const body = bodyRef.current;
+ if (!body) return;
+ const { velocity, pending, desired } = state;
+ desired.x = velocity.x * dt + pending.x;
+ desired.y = velocity.y * dt + pending.y;
+ desired.z = velocity.z * dt + pending.z;
+ pending.x = pending.y = pending.z = 0;
+
+ const p = latest.current;
+ const result = cc.move(body, desired, {
+ filterFlags: p.filterFlags,
+ filterGroups: p.filterGroups,
+ filterPredicate: p.filterPredicate,
+ excludeSensors: p.excludeSensors
+ });
+
+ if (p.onCharacterCollision) {
+ for (let i = 0; i < result.collisions.length; i++) {
+ p.onCharacterCollision(result.collisions[i]);
+ }
+ }
+
+ if (result.grounded !== state.grounded) {
+ state.grounded = result.grounded;
+ if (result.grounded) p.onGrounded?.();
+ else p.onAirborne?.();
+ }
+ });
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/packages/react-three-rapier/src/components/Debug.tsx b/packages/react-three-rapier/src/components/Debug.tsx
index c8e7532d..cb7fb301 100644
--- a/packages/react-three-rapier/src/components/Debug.tsx
+++ b/packages/react-three-rapier/src/components/Debug.tsx
@@ -1,32 +1,82 @@
-import React, { memo, useRef } from "react";
+import React, { memo, useEffect, useMemo } from "react";
import { useFrame } from "@react-three/fiber";
-import { BufferAttribute, BufferGeometry, LineSegments } from "three";
+import { BufferAttribute, BufferGeometry, DynamicDrawUsage } from "three";
import { useRapier } from "../hooks/hooks";
-import { _vector3 } from "../utils/shared-objects";
+const MIN_CAPACITY = 1024;
+
+const nextCapacity = (count: number) => {
+ let capacity = MIN_CAPACITY;
+ while (capacity < count) capacity *= 2;
+ return capacity;
+};
+
+/**
+ * Grows the geometry's attributes when Rapier hands us more vertices than fit.
+ * Disposing first releases the GPU buffers of the outgrown attributes; the
+ * geometry object itself is reused and re-uploaded on the next render.
+ */
+const ensureCapacity = (geometry: BufferGeometry, vertexCount: number) => {
+ const position = geometry.getAttribute("position") as
+ BufferAttribute | undefined;
+ if (position && position.count >= vertexCount) return;
+
+ const capacity = nextCapacity(vertexCount);
+ geometry.dispose();
+ geometry.setAttribute(
+ "position",
+ new BufferAttribute(new Float32Array(capacity * 3), 3).setUsage(
+ DynamicDrawUsage
+ )
+ );
+ // Rapier's debug colors are RGBA. Both WebGLRenderer (vertexAlphas) and
+ // WebGPURenderer (vec4 vertexColor node) read a 4-component color attribute.
+ geometry.setAttribute(
+ "color",
+ new BufferAttribute(new Float32Array(capacity * 4), 4).setUsage(
+ DynamicDrawUsage
+ )
+ );
+};
+
+const write = (attribute: BufferAttribute, data: Float32Array) => {
+ (attribute.array as Float32Array).set(data);
+ attribute.clearUpdateRanges();
+ attribute.addUpdateRange(0, data.length);
+ attribute.needsUpdate = true;
+};
+
+/**
+ * Draws Rapier's debug lines. One persistent geometry whose attributes are
+ * rewritten every frame (and only reallocated when they must grow), disposed on
+ * unmount, so toggling debug on and off does not leak GPU buffers (#666).
+ */
export const Debug = memo(() => {
const { world } = useRapier();
- const ref = useRef(null);
+ const geometry = useMemo(() => new BufferGeometry(), []);
- useFrame(() => {
- const mesh = ref.current;
- if (!mesh) return;
+ useEffect(() => () => geometry.dispose(), [geometry]);
- const buffers = world.debugRender();
+ // Runs in `update`, after this frame's steps. The lines show the latest
+ // simulated step, so with interpolation they lead the rendered meshes by up
+ // to one step ((1 - alpha) * timeStep).
+ useFrame(
+ () => {
+ const { vertices, colors } = world.debugRender();
+ const vertexCount = vertices.length / 3;
- const geometry = new BufferGeometry();
- geometry.setAttribute("position", new BufferAttribute(buffers.vertices, 3));
- geometry.setAttribute("color", new BufferAttribute(buffers.colors, 4));
-
- mesh.geometry.dispose();
- mesh.geometry = geometry;
- });
+ ensureCapacity(geometry, vertexCount);
+ write(geometry.getAttribute("position") as BufferAttribute, vertices);
+ write(geometry.getAttribute("color") as BufferAttribute, colors);
+ geometry.setDrawRange(0, vertexCount);
+ },
+ { phase: "update" }
+ );
return (
-
+
-
);
diff --git a/packages/react-three-rapier/src/components/FrameStepper.tsx b/packages/react-three-rapier/src/components/FrameStepper.tsx
index 7382cb6e..7a44974b 100644
--- a/packages/react-three-rapier/src/components/FrameStepper.tsx
+++ b/packages/react-three-rapier/src/components/FrameStepper.tsx
@@ -1,36 +1,229 @@
-import { useFrame } from "@react-three/fiber";
-import React, { memo } from "react";
-import { useRaf } from "../utils/utils-physics";
+import { getScheduler, useFrame } from "@react-three/fiber";
+import React, { memo, useLayoutEffect, useMemo, useRef, useState } from "react";
+import {
+ AccumulatorState,
+ FixedStepScheduler,
+ MAX_VARIABLE_STEP,
+ RAPIER_PHASE,
+ StepperScheduler,
+ accumulatorAlpha,
+ acquireFixedPhase,
+ advanceAccumulator,
+ ensureLatePhase,
+ ensureRapierPhase,
+ releaseFixedPhase,
+ supportsFixedPhases,
+ useRaf
+} from "../utils/utils-physics";
import { PhysicsProps } from "./Physics";
+/**
+ * How a world is advanced. Resolved from props and scheduler support.
+ * - `fixed-phase`: scheduler-owned fixed timestep phase (scheduler >= 0.3)
+ * - `accumulator`: our own fixed-step accumulator inside a per-frame job
+ * - `vary`: one variable step per frame
+ * - `independent`: our own requestAnimationFrame loop
+ * @internal
+ */
+export type SteppingMode =
+ "fixed-phase" | "accumulator" | "vary" | "independent";
+
interface FrameStepperProps {
- type?: PhysicsProps["updateLoop"];
- onStep: (dt: number) => void;
+ type: NonNullable;
+ timeStep: number | "vary";
+ maxSubSteps: number;
+ paused: boolean;
+ interpolate: boolean;
updatePriority?: number;
+ /** Advance the world by exactly one step of `dt` seconds */
+ stepWorld: (dt: number) => void;
+ /** Write body poses into their Object3Ds, interpolated by `alpha` */
+ syncMeshes: (alpha: number) => void;
+ /** Re-express synced poses if an ancestor moved after the sync */
+ lateSync: () => void;
}
-const UseFrameStepper = ({ onStep, updatePriority }: FrameStepperProps) => {
- useFrame((_, dt) => {
- onStep(dt);
- }, updatePriority);
+/** @internal */
+export const resolveSteppingMode = (
+ type: FrameStepperProps["type"],
+ timeStep: FrameStepperProps["timeStep"],
+ scheduler: Partial | null | undefined
+): SteppingMode => {
+ if (type === "independent") return "independent";
+ if (timeStep === "vary") return "vary";
+ return supportsFixedPhases(scheduler) ? "fixed-phase" : "accumulator";
+};
+
+/**
+ * Steps once per fixed substep inside this world's own fixed scheduler phase,
+ * then syncs meshes once per frame using the phase's overstep as the alpha.
+ */
+const FixedPhaseStepper = (props: FrameStepperProps & { timeStep: number }) => {
+ const { timeStep, maxSubSteps } = props;
+ const scheduler = getScheduler() as unknown as FixedStepScheduler;
+ const [phase, setPhase] = useState(null);
+
+ // Own a phase while mounted (released for reuse on unmount). Acquired in an
+ // effect so StrictMode's double mount releases and re-acquires the same one.
+ useLayoutEffect(() => {
+ const name = acquireFixedPhase(scheduler, timeStep, maxSubSteps);
+ setPhase(name);
+ return () => releaseFixedPhase(scheduler, name);
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- retimed below
+ }, [scheduler]);
+
+ // Retime our own phase in place; this never touches other worlds' phases.
+ useLayoutEffect(() => {
+ if (phase) {
+ scheduler.setPhaseTimestep(phase, timeStep, { maxSubsteps: maxSubSteps });
+ }
+ }, [scheduler, phase, timeStep, maxSubSteps]);
+
+ return phase ? (
+
+ ) : null;
+};
+
+const FixedPhaseJobs = ({
+ phase,
+ scheduler,
+ paused,
+ interpolate,
+ updatePriority = 0,
+ stepWorld,
+ syncMeshes
+}: FrameStepperProps & { phase: string; scheduler: FixedStepScheduler }) => {
+ useFrame(
+ (_, dt) => {
+ stepWorld(dt);
+ },
+ { phase, priority: updatePriority, enabled: !paused }
+ );
+
+ useFrame(
+ (state) => {
+ const alpha =
+ paused || !interpolate
+ ? 1
+ : scheduler.getOverstep(phase, state.internal.rootId);
+ syncMeshes(alpha);
+ },
+ { phase: RAPIER_PHASE, priority: updatePriority }
+ );
return null;
};
-const RafStepper = ({ onStep }: FrameStepperProps) => {
+const createFrameAdvancer = (
+ accumulator: AccumulatorState,
+ { timeStep, maxSubSteps, paused, interpolate, stepWorld }: FrameStepperProps
+) => {
+ return (delta: number) => {
+ if (paused) return 1;
+
+ if (timeStep === "vary") {
+ const dt = Math.min(Math.max(delta, 0), MAX_VARIABLE_STEP);
+ if (dt > 0) stepWorld(dt);
+ return 1;
+ }
+
+ const steps = advanceAccumulator(accumulator, delta, timeStep, maxSubSteps);
+ for (let i = 0; i < steps; i++) {
+ stepWorld(timeStep);
+ }
+
+ return interpolate ? accumulatorAlpha(accumulator, timeStep) : 1;
+ };
+};
+
+/** Accumulator state, reset whenever the timestep changes (banked time would
+ * otherwise be reinterpreted in the new step length). */
+const useAccumulator = (timeStep: FrameStepperProps["timeStep"]) => {
+ const state = useRef({
+ accumulator: 0,
+ timeStep
+ });
+ if (state.current.timeStep !== timeStep) {
+ state.current.timeStep = timeStep;
+ state.current.accumulator = 0;
+ }
+ return state.current;
+};
+
+/**
+ * Fallback without scheduler fixed-step support, and `timeStep="vary"`:
+ * one per-frame job that steps and then syncs.
+ */
+const FrameJobStepper = (props: FrameStepperProps) => {
+ const { updatePriority = 0, syncMeshes, timeStep } = props;
+ const scheduler = getScheduler() as unknown as StepperScheduler;
+ const accumulator = useAccumulator(timeStep);
+
+ useMemo(() => {
+ ensureRapierPhase(scheduler);
+ }, [scheduler]);
+
+ const advance = createFrameAdvancer(accumulator, props);
+
+ useFrame(
+ (_, dt) => {
+ syncMeshes(advance(dt));
+ },
+ { phase: RAPIER_PHASE, priority: updatePriority }
+ );
+
+ return null;
+};
+
+/**
+ * `updateLoop="independent"`: our own requestAnimationFrame loop.
+ */
+const RafStepper = (props: FrameStepperProps) => {
+ const { syncMeshes, timeStep } = props;
+ const accumulator = useAccumulator(timeStep);
+
+ const advance = createFrameAdvancer(accumulator, props);
+
useRaf((dt) => {
- onStep(dt);
+ syncMeshes(advance(dt));
});
return null;
};
-const FrameStepper = ({ onStep, type, updatePriority }: FrameStepperProps) => {
- return type === "independent" ? (
-
- ) : (
-
- );
+/**
+ * Right before render: re-express synced poses if an ancestor moved after the
+ * sync (e.g. in a regular `useFrame`). Nearly free when nothing moved.
+ */
+const LateSync = ({ lateSync }: Pick) => {
+ const scheduler = getScheduler() as unknown as StepperScheduler;
+ const phase = useMemo(() => ensureLatePhase(scheduler), [scheduler]);
+
+ useFrame(lateSync, { phase });
+
+ return null;
};
+const Stepper = (props: FrameStepperProps) => {
+ const mode = resolveSteppingMode(props.type, props.timeStep, getScheduler());
+
+ switch (mode) {
+ case "independent":
+ return ;
+ case "fixed-phase":
+ return (
+
+ );
+ default:
+ return ;
+ }
+};
+
+const FrameStepper = (props: FrameStepperProps) => (
+ <>
+
+
+ >
+);
+
export default memo(FrameStepper);
diff --git a/packages/react-three-rapier/src/components/InstancedRigidBodies.tsx b/packages/react-three-rapier/src/components/InstancedRigidBodies.tsx
index 507436b9..146b8c2a 100644
--- a/packages/react-three-rapier/src/components/InstancedRigidBodies.tsx
+++ b/packages/react-three-rapier/src/components/InstancedRigidBodies.tsx
@@ -23,6 +23,13 @@ export interface InstancedRigidBodiesProps extends Omit {
colliderNodes?: ReactNode[];
children: ReactNode;
ref?: React.Ref<(RapierRigidBody | null)[] | null>;
+ /**
+ * Whether the child InstancedMesh is frustum culled. Physics moves the
+ * instances away from where three computed the mesh's bounding sphere, so
+ * culling is disabled by default to keep instances from vanishing (#683).
+ * @defaultValue false
+ */
+ frustumCulled?: boolean;
}
export const InstancedRigidBodies = memo(
@@ -36,6 +43,7 @@ export const InstancedRigidBodies = memo(
children,
instances,
colliderNodes = [],
+ frustumCulled = false,
// wrapper object props
position,
@@ -74,6 +82,20 @@ export const InstancedRigidBodies = memo(
}
}, []);
+ // Three computes an InstancedMesh's bounding sphere once, from the
+ // initial instance matrices. Physics then moves the instances around,
+ // so the cached bounds go stale and the whole mesh gets culled when the
+ // camera moves (#683). Recomputing the sphere every step is O(instances)
+ // per frame, so we opt out of frustum culling by default instead.
+ // Runs after every render so that a re-created InstancedMesh (e.g. when the
+ // instance count changes) is also covered; it is cheap and idempotent.
+ useEffect(() => {
+ const instancedMesh = getInstancedMesh();
+ if (instancedMesh && instancedMesh.frustumCulled !== frustumCulled) {
+ instancedMesh.frustumCulled = frustumCulled;
+ }
+ });
+
// Update the RigidBodyStates whenever the instances change
const applyInstancedState = (state: RigidBodyState, index: number) => {
const instancedMesh = getInstancedMesh();
@@ -107,8 +129,9 @@ export const InstancedRigidBodies = memo(
>
{children}
- {instances?.map((instance, index) => (
+ {instances?.map(({ key, ...instance }, index) => (
{
diff --git a/packages/react-three-rapier/src/components/Physics.tsx b/packages/react-three-rapier/src/components/Physics.tsx
index 69c8fc81..bab111e7 100644
--- a/packages/react-three-rapier/src/components/Physics.tsx
+++ b/packages/react-three-rapier/src/components/Physics.tsx
@@ -6,6 +6,7 @@ import {
PhysicsHooks,
RigidBody,
RigidBodyHandle,
+ SoftBodyTearEvent,
SolverFlags,
World
} from "@dimforge/rapier3d-compat";
@@ -17,9 +18,9 @@ import React, {
useCallback,
useEffect,
useMemo,
- useState
+ useRef
} from "react";
-import { MathUtils, Matrix4, Object3D, Quaternion, Vector3 } from "three";
+import { Matrix4, Object3D, Quaternion, Vector3 } from "three";
import { suspend } from "suspend-react";
import {
CollisionPayload,
@@ -31,26 +32,37 @@ import {
RigidBodyAutoCollider,
Vector3Tuple
} from "../types";
+import { _matrix4, _scale } from "../utils/shared-objects";
import {
- _matrix4,
- _position,
- _rotation,
- _scale
-} from "../utils/shared-objects";
-import {
- rapierQuaternionToQuaternion,
useConst,
vectorArrayToVector3,
vector3ToRapierVector
} from "../utils/utils";
+import {
+ anyParentSpaceMoved,
+ beginParentSpacePass,
+ ParentSpace,
+ refreshParentInverse
+} from "../utils/utils-rigidbody";
+import { sanitizeMaxSubSteps } from "../utils/utils-physics";
import FrameStepper from "./FrameStepper";
import { Debug } from "./Debug";
import { createSingletonProxy } from "../utils/singleton-proxy";
+// Scratch objects for syncMeshes
+const _syncPosition = new Vector3();
+const _syncRotation = new Quaternion();
+const _syncRotationPrev = new Quaternion();
+
export interface RigidBodyState {
meshType: "instancedMesh" | "mesh";
rigidBody: RigidBody;
object: Object3D;
+ /**
+ * Inverse of the parent's world matrix: the space synced poses are written
+ * into. Refreshed every frame from the CURRENT parent transform, so moving or
+ * rotating an ancestor after mount keeps the visual on its body.
+ */
invertedWorldMatrix: Matrix4;
setMatrix: (matrix: Matrix4) => void;
getMatrix: (matrix: Matrix4) => Matrix4;
@@ -59,14 +71,119 @@ export interface RigidBodyState {
*/
scale: Vector3;
isSleeping: boolean;
+ /**
+ * Pose before the latest physics step, used for interpolation.
+ * @internal
+ */
+ previousPose?: { position: Vector3; rotation: Quaternion; valid: boolean };
+ /**
+ * World-space pose last written to the object (after interpolation), reused
+ * when only the parent space changes later in the frame.
+ * @internal
+ */
+ renderedPose?: { position: Vector3; rotation: Quaternion };
+ /**
+ * The parent space `invertedWorldMatrix` was last refreshed from.
+ * @internal
+ */
+ parentSpace?: ParentSpace;
+ /**
+ * Version of that parent space.
+ * @internal
+ */
+ parentSpaceVersion?: number;
}
export type RigidBodyStateMap = Map;
-export type WorldStepCallback = (world: World) => void;
+const sceneDepth = (object: Object3D) => {
+ let depth = 0;
+ for (let o = object.parent; o; o = o.parent) depth++;
+ return depth;
+};
+
+/**
+ * The rigid body state map, iterable parent-first. The order is rebuilt (a
+ * stable sort by scene depth) only when bodies register or unregister, so a
+ * nested is always synced after the body that carries it.
+ * @internal
+ */
+export class RigidBodyStateRegistry extends Map<
+ RigidBody["handle"],
+ RigidBodyState
+> {
+ private orderedEntries: [RigidBody["handle"], RigidBodyState][] = [];
+ private orderDirty = false;
+
+ set(handle: RigidBody["handle"], state: RigidBodyState) {
+ this.orderDirty = true;
+ return super.set(handle, state);
+ }
+
+ delete(handle: RigidBody["handle"]) {
+ this.orderDirty = true;
+ return super.delete(handle);
+ }
+
+ clear() {
+ this.orderDirty = true;
+ super.clear();
+ }
+
+ /** Entries ordered parent-first (by scene depth at registration time). */
+ ordered() {
+ if (this.orderDirty) {
+ this.orderDirty = false;
+ this.orderedEntries = Array.from(this.entries())
+ .map((entry) => ({ entry, depth: sceneDepth(entry[1].object) }))
+ .sort((a, b) => a.depth - b.depth)
+ .map(({ entry }) => entry);
+ }
+ return this.orderedEntries;
+ }
+}
+
+/**
+ * Called once per physics step.
+ *
+ * @param world The world being stepped
+ * @param dt The length of this step in seconds (the fixed `timeStep`, or the
+ * clamped frame delta with `timeStep="vary"`)
+ */
+export type WorldStepCallback = (world: World, dt: number) => void;
+
+/**
+ * Called for every soft body tear event drained after a world step. The
+ * event is only valid for the duration of the call.
+ */
+export type SoftBodyTearCallback = (event: SoftBodyTearEvent) => void;
export type WorldStepCallbackSet = Set<{ current: WorldStepCallback }>;
+/**
+ * Something whose visuals are written once per rendered frame, next to the
+ * rigid bodies (e.g. a soft body's geometry).
+ * @internal
+ */
+export interface FrameSyncCallback {
+ /**
+ * Write the visuals, interpolated by `alpha` between the previous and the
+ * latest step (1 = the latest step). Called in the per-frame sync, after
+ * the rigid bodies were written. Return `true` while the visuals still
+ * move, to keep a `frameloop="demand"` canvas ticking.
+ */
+ sync: (alpha: number) => boolean | void;
+ /**
+ * The previous step's state must not be interpolated from anymore (manual
+ * step, `setWorld`, or a `paused`/`interpolate`/`timeStep` change). Render
+ * the latest step until the next one.
+ */
+ resetInterpolation?: () => void;
+}
+
+/** @internal */
+export type FrameSyncCallbackSet = Set<{ current: FrameSyncCallback }>;
+
export type FilterContactPairCallback = (
collider1: ColliderHandle,
collider2: ColliderHandle,
@@ -146,6 +263,19 @@ export interface RapierContext {
*/
afterStepCallbacks: WorldStepCallbackSet;
+ /**
+ * Triggered for every soft body tear event produced by the world step
+ * @internal
+ */
+ softBodyTearCallbacks: Set;
+
+ /**
+ * Visuals synced once per rendered frame with the interpolation alpha,
+ * right after the rigid bodies (e.g. soft bodies)
+ * @internal
+ */
+ frameSyncCallbacks: FrameSyncCallbackSet;
+
/**
* Hooks to filter contact pairs
* @internal
@@ -208,7 +338,8 @@ export interface RapierContext {
isPaused: boolean;
/**
- * Step the physics world one step
+ * Step the physics world one step and sync meshes to the result.
+ * Works while `paused`, which is how manual stepping is done.
*
* @param deltaTime The delta time to step the world with
*
@@ -267,7 +398,7 @@ const getCollisionPayloadFromSource = (
});
const importRapier = async () => {
- let r = await import("@dimforge/rapier3d-compat");
+ const r = await import("@dimforge/rapier3d-compat");
await r.init();
return r;
};
@@ -369,24 +500,39 @@ export interface PhysicsProps {
/**
* Set the timestep for the simulation.
- * Setting this to a number (eg. 1/60) will run the
- * simulation at that framerate. Alternatively, you can set this to
- * "vary", which will cause the simulation to always synchronize with
- * the current frame delta times.
+ * Setting this to a number (eg. 1/60) runs the simulation in fixed steps of
+ * that length, decoupled from the frame rate. With `updateLoop="follow"` and
+ * a fiber whose scheduler supports fixed phases, the steps run in a dedicated
+ * fixed-timestep scheduler phase (`rapier:fixed::`).
+ *
+ * Alternatively, set this to "vary" to step once per frame with the frame
+ * delta (clamped to 0.5s). Not deterministic and no interpolation.
*
* @defaultValue 1/60
*/
timeStep?: number | "vary";
/**
- * Pause the physics simulation
+ * The most fixed steps a single frame may run. Simulation time beyond
+ * `maxSubSteps * timeStep` is dropped instead of queued, so a long frame
+ * (tab switch, debugger pause, heavy load) can't snowball into an ever
+ * growing backlog. Ignored when `timeStep="vary"`.
+ *
+ * @defaultValue 5
+ */
+ maxSubSteps?: number;
+
+ /**
+ * Pause the physics simulation. No time is banked while paused, so resuming
+ * does not burst. `step()` from `useRapier()` still works while paused.
*
* @defaultValue false
*/
paused?: boolean;
/**
- * Interpolate the world transform using the frame delta times.
+ * Interpolate rendered transforms between the last two fixed steps, using
+ * how far the frame is into the next step.
* Has no effect if timeStep is set to "vary".
*
* @defaultValue true
@@ -394,27 +540,29 @@ export interface PhysicsProps {
interpolate?: boolean;
/**
- * The update priority at which the physics simulation should run.
- * Only used when `updateLoop` is set to "follow".
+ * Ordering of this world's jobs within their scheduler phase when
+ * `updateLoop="follow"`. Higher runs first (the r3f v10 convention).
+ *
+ * Breaking in 3.0: this no longer takes over r3f's render loop. Physics
+ * always steps before `update`, and r3f keeps rendering.
*
- * @see https://docs.pmnd.rs/react-three-fiber/api/hooks#taking-over-the-render-loop
- * @defaultValue undefined
+ * @defaultValue 0
*/
updatePriority?: number;
/**
* Set the update loop strategy for the physics world.
*
- * If set to "follow", the physics world will be stepped
- * in a `useFrame` callback, managed by @react-three/fiber.
- * You can use `updatePriority` prop to manage the scheduling.
+ * If set to "follow", the physics world is stepped by the
+ * @react-three/fiber scheduler: in a fixed-timestep phase between `physics`
+ * and `update` (or a per-frame job if fixed phases are unavailable or
+ * `timeStep="vary"`). Meshes are synced once per frame in the `rapier`
+ * phase, before `update`. Works with `frameloop="demand"`: the canvas is
+ * invalidated while any body is awake.
*
- * If set to "independent", the physics world will be stepped
- * in a separate loop, not tied to the render loop.
- * This is useful when using the "demand" `frameloop` strategy for the
- * @react-three/fiber ` `.
+ * If set to "independent", the physics world is stepped
+ * in a separate requestAnimationFrame loop, not tied to the render loop.
*
- * @see https://docs.pmnd.rs/react-three-fiber/advanced/scaling-performance#on-demand-rendering
* @defaultValue "follow"
*/
updateLoop?: "follow" | "independent";
@@ -435,6 +583,7 @@ export const Physics: FC = (props) => {
colliders = "cuboid",
children,
timeStep = 1 / 60,
+ maxSubSteps: maxSubStepsProp,
paused = false,
interpolate = true,
updatePriority,
@@ -450,10 +599,11 @@ export const Physics: FC = (props) => {
contactNaturalFrequency = 30,
lengthUnit = 1
} = props;
+ const maxSubSteps = sanitizeMaxSubSteps(maxSubStepsProp);
const rapier = suspend(importRapier, ["@react-thee/rapier", importRapier]);
const { invalidate } = useThree();
- const rigidBodyStates = useConst(() => new Map());
+ const rigidBodyStates = useConst(() => new RigidBodyStateRegistry());
const colliderStates = useConst(() => new Map());
const rigidBodyEvents = useConst(() => new Map());
const colliderEvents = useConst(() => new Map());
@@ -483,6 +633,10 @@ export const Physics: FC = (props) => {
}));
const beforeStepCallbacks = useConst(() => new Set());
const afterStepCallbacks = useConst(() => new Set());
+ const softBodyTearCallbacks = useConst>(
+ () => new Set()
+ );
+ const frameSyncCallbacks = useConst(() => new Set());
/**
* Initiate the world
@@ -498,10 +652,28 @@ export const Physics: FC = (props) => {
() => new rapier.World(vectorArrayToVector3(gravity))
)
);
+
+ /**
+ * World teardown.
+ *
+ * React runs a parent's unmount cleanup before its children's, so freeing the
+ * world here directly would leave every /collider/joint cleanup
+ * running against a freed world (and the lazy proxy would silently create a
+ * new, leaked one). Defer the free to a microtask, which runs after the whole
+ * commit. StrictMode's mount -> unmount -> mount happens inside that window,
+ * so the microtask only frees when we did not remount.
+ */
+ const alive = useRef(true);
useEffect(() => {
+ alive.current = true;
return () => {
- worldProxy.free();
- resetWorldProxy();
+ alive.current = false;
+ queueMicrotask(() => {
+ if (alive.current) return;
+ worldProxy.free();
+ resetWorldProxy();
+ eventQueue.free();
+ });
};
}, []);
@@ -568,280 +740,344 @@ export const Physics: FC = (props) => {
return source;
}, []);
- const [steppingState] = useState<{
- accumulator: number;
- previousState: Record;
- }>({
- previousState: {},
- accumulator: 0
- });
+ // Latest props, read by the stable stepping functions below.
+ const config = useRef({ timeStep, interpolate });
+ config.current.timeStep = timeStep;
+ config.current.interpolate = interpolate;
- const step = useCallback(
- (dt: number) => {
- const world = worldProxy;
+ const drainEvents = (world: World) => {
+ eventQueue.drainCollisionEvents((handle1, handle2, started) => {
+ const source1 = getSourceFromColliderHandle(handle1);
+ const source2 = getSourceFromColliderHandle(handle2);
- /* Check if the timestep is supposed to be variable. We'll do this here
- once so we don't have to string-check every frame. */
- const timeStepVariable = timeStep === "vary";
+ // Collision Events
+ if (!source1?.collider.object || !source2?.collider.object) {
+ return;
+ }
- /**
- * Fixed timeStep simulation progression
- * @see https://gafferongames.com/post/fix_your_timestep/
- */
+ const collisionPayload1 = getCollisionPayloadFromSource(source1, source2);
+ const collisionPayload2 = getCollisionPayloadFromSource(source2, source1);
+
+ if (started) {
+ world.contactPair(
+ source1.collider.object,
+ source2.collider.object,
+ (manifold, flipped) => {
+ /* RigidBody events */
+ source1.rigidBody.events?.onCollisionEnter?.({
+ ...collisionPayload1,
+ manifold,
+ flipped
+ });
- const clampedDelta = MathUtils.clamp(dt, 0, 0.5);
+ source2.rigidBody.events?.onCollisionEnter?.({
+ ...collisionPayload2,
+ manifold,
+ flipped
+ });
- const stepWorld = (delta: number) => {
- // Trigger beforeStep callbacks
- beforeStepCallbacks.forEach((callback) => {
- callback.current(world);
- });
+ /* Collider events */
+ source1.collider.events?.onCollisionEnter?.({
+ ...collisionPayload1,
+ manifold,
+ flipped
+ });
- world.timestep = delta;
+ source2.collider.events?.onCollisionEnter?.({
+ ...collisionPayload2,
+ manifold,
+ flipped
+ });
+ }
+ );
+ } else {
+ source1.rigidBody.events?.onCollisionExit?.(collisionPayload1);
+ source2.rigidBody.events?.onCollisionExit?.(collisionPayload2);
+ source1.collider.events?.onCollisionExit?.(collisionPayload1);
+ source2.collider.events?.onCollisionExit?.(collisionPayload2);
+ }
- const hasHooks =
- filterContactPairHooks.size > 0 ||
- filterIntersectionPairHooks.size > 0;
+ // Sensor Intersections
+ if (started) {
+ if (
+ world.intersectionPair(
+ source1.collider.object,
+ source2.collider.object
+ )
+ ) {
+ source1.rigidBody.events?.onIntersectionEnter?.(collisionPayload1);
- world.step(eventQueue, hasHooks ? hooks : undefined);
+ source2.rigidBody.events?.onIntersectionEnter?.(collisionPayload2);
- // Trigger afterStep callbacks
- afterStepCallbacks.forEach((callback) => {
- callback.current(world);
- });
- };
+ source1.collider.events?.onIntersectionEnter?.(collisionPayload1);
- if (timeStepVariable) {
- stepWorld(clampedDelta);
+ source2.collider.events?.onIntersectionEnter?.(collisionPayload2);
+ }
} else {
- // don't step time forwards if paused
- // Increase accumulator
- steppingState.accumulator += clampedDelta;
-
- while (steppingState.accumulator >= timeStep) {
- // Set up previous state
- // needed for accurate interpolations if the world steps more than once
- if (interpolate) {
- steppingState.previousState = {};
- world.forEachRigidBody((body) => {
- steppingState.previousState[body.handle] = {
- position: body.translation(),
- rotation: body.rotation()
- };
- });
- }
+ source1.rigidBody.events?.onIntersectionExit?.(collisionPayload1);
+ source2.rigidBody.events?.onIntersectionExit?.(collisionPayload2);
+ source1.collider.events?.onIntersectionExit?.(collisionPayload1);
+ source2.collider.events?.onIntersectionExit?.(collisionPayload2);
+ }
+ });
- stepWorld(timeStep);
+ eventQueue.drainContactForceEvents((event) => {
+ const source1 = getSourceFromColliderHandle(event.collider1());
+ const source2 = getSourceFromColliderHandle(event.collider2());
- steppingState.accumulator -= timeStep;
- }
+ // Collision Events
+ if (!source1?.collider.object || !source2?.collider.object) {
+ return;
}
- const interpolationAlpha =
- timeStepVariable || !interpolate || paused
- ? 1
- : steppingState.accumulator / timeStep;
+ const collisionPayload1 = getCollisionPayloadFromSource(source1, source2);
+ const collisionPayload2 = getCollisionPayloadFromSource(source2, source1);
- // Update meshes
- rigidBodyStates.forEach((state, handle) => {
- const rigidBody = world.getRigidBody(handle);
+ source1.rigidBody.events?.onContactForce?.({
+ ...collisionPayload1,
+ totalForce: event.totalForce(),
+ totalForceMagnitude: event.totalForceMagnitude(),
+ maxForceDirection: event.maxForceDirection(),
+ maxForceMagnitude: event.maxForceMagnitude()
+ });
- const events = rigidBodyEvents.get(handle);
- if (events?.onSleep || events?.onWake) {
- if (rigidBody.isSleeping() && !state.isSleeping) {
- events?.onSleep?.();
- }
- if (!rigidBody.isSleeping() && state.isSleeping) {
- events?.onWake?.();
- }
- state.isSleeping = rigidBody.isSleeping();
- }
+ source2.rigidBody.events?.onContactForce?.({
+ ...collisionPayload2,
+ totalForce: event.totalForce(),
+ totalForceMagnitude: event.totalForceMagnitude(),
+ maxForceDirection: event.maxForceDirection(),
+ maxForceMagnitude: event.maxForceMagnitude()
+ });
- if (
- !rigidBody ||
- (rigidBody.isSleeping() && !("isInstancedMesh" in state.object)) ||
- !state.setMatrix
- ) {
- return;
- }
+ source1.collider.events?.onContactForce?.({
+ ...collisionPayload1,
+ totalForce: event.totalForce(),
+ totalForceMagnitude: event.totalForceMagnitude(),
+ maxForceDirection: event.maxForceDirection(),
+ maxForceMagnitude: event.maxForceMagnitude()
+ });
- // New states
- let t = rigidBody.translation() as Vector3;
- let r = rigidBody.rotation() as Quaternion;
-
- let previousState = steppingState.previousState[handle];
-
- if (previousState) {
- // Get previous simulated world position
- _matrix4
- .compose(
- previousState.position,
- rapierQuaternionToQuaternion(previousState.rotation),
- state.scale
- )
- .premultiply(state.invertedWorldMatrix)
- .decompose(_position, _rotation, _scale);
-
- // Apply previous tick position
- if (state.meshType == "mesh") {
- state.object.position.copy(_position);
- state.object.quaternion.copy(_rotation);
- }
- }
+ source2.collider.events?.onContactForce?.({
+ ...collisionPayload2,
+ totalForce: event.totalForce(),
+ totalForceMagnitude: event.totalForceMagnitude(),
+ maxForceDirection: event.maxForceDirection(),
+ maxForceMagnitude: event.maxForceMagnitude()
+ });
+ });
- // Get new position
- _matrix4
- .compose(t, rapierQuaternionToQuaternion(r), state.scale)
- .premultiply(state.invertedWorldMatrix)
- .decompose(_position, _rotation, _scale);
-
- if (state.meshType == "instancedMesh") {
- state.setMatrix(_matrix4);
- } else {
- // Interpolate to new position
- state.object.position.lerp(_position, interpolationAlpha);
- state.object.quaternion.slerp(_rotation, interpolationAlpha);
- }
+ eventQueue.drainSoftBodyTearEvents((event) => {
+ softBodyTearCallbacks.forEach((callback) => callback(event));
+ });
+ };
+
+ /**
+ * Advance the world by exactly one step of `dt` seconds: before-step
+ * callbacks, `world.step`, after-step callbacks, then collision and
+ * contact-force events. Runs once per fixed substep.
+ */
+ const stepWorld = useConst(() => (dt: number) => {
+ if (!alive.current) return;
+ const world = worldProxy;
+
+ beforeStepCallbacks.forEach((callback) => {
+ callback.current(world, dt);
+ });
+
+ // Snapshot the pose each body is about to leave, so a frame that runs
+ // several substeps interpolates across the last one only. Taken after the
+ // before-step callbacks, so a teleport made there is not interpolated.
+ if (config.current.interpolate && config.current.timeStep !== "vary") {
+ rigidBodyStates.forEach((state, handle) => {
+ const rigidBody = world.getRigidBody(handle);
+ if (!rigidBody) return;
+
+ const pose = (state.previousPose ??= {
+ position: new Vector3(),
+ rotation: new Quaternion(),
+ valid: false
+ });
+ const t = rigidBody.translation();
+ const r = rigidBody.rotation();
+ pose.position.set(t.x, t.y, t.z);
+ pose.rotation.set(r.x, r.y, r.z, r.w);
+ pose.valid = true;
});
+ }
- eventQueue.drainCollisionEvents((handle1, handle2, started) => {
- const source1 = getSourceFromColliderHandle(handle1);
- const source2 = getSourceFromColliderHandle(handle2);
+ world.timestep = dt;
- // Collision Events
- if (!source1?.collider.object || !source2?.collider.object) {
- return;
- }
+ const hasHooks =
+ filterContactPairHooks.size > 0 || filterIntersectionPairHooks.size > 0;
- const collisionPayload1 = getCollisionPayloadFromSource(
- source1,
- source2
- );
- const collisionPayload2 = getCollisionPayloadFromSource(
- source2,
- source1
- );
+ world.step(eventQueue, hasHooks ? hooks : undefined);
- if (started) {
- world.contactPair(
- source1.collider.object,
- source2.collider.object,
- (manifold, flipped) => {
- /* RigidBody events */
- source1.rigidBody.events?.onCollisionEnter?.({
- ...collisionPayload1,
- manifold,
- flipped
- });
-
- source2.rigidBody.events?.onCollisionEnter?.({
- ...collisionPayload2,
- manifold,
- flipped
- });
-
- /* Collider events */
- source1.collider.events?.onCollisionEnter?.({
- ...collisionPayload1,
- manifold,
- flipped
- });
-
- source2.collider.events?.onCollisionEnter?.({
- ...collisionPayload2,
- manifold,
- flipped
- });
- }
- );
- } else {
- source1.rigidBody.events?.onCollisionExit?.(collisionPayload1);
- source2.rigidBody.events?.onCollisionExit?.(collisionPayload2);
- source1.collider.events?.onCollisionExit?.(collisionPayload1);
- source2.collider.events?.onCollisionExit?.(collisionPayload2);
- }
+ afterStepCallbacks.forEach((callback) => {
+ callback.current(world, dt);
+ });
- // Sensor Intersections
- if (started) {
- if (
- world.intersectionPair(
- source1.collider.object,
- source2.collider.object
- )
- ) {
- source1.rigidBody.events?.onIntersectionEnter?.(collisionPayload1);
+ drainEvents(world);
+ });
- source2.rigidBody.events?.onIntersectionEnter?.(collisionPayload2);
+ const writePose = (
+ state: RigidBodyState,
+ position: Vector3,
+ rotation: Quaternion
+ ) => {
+ _matrix4
+ .compose(position, rotation, state.scale)
+ .premultiply(state.invertedWorldMatrix);
+
+ if (state.meshType === "instancedMesh") {
+ state.setMatrix(_matrix4);
+ } else {
+ _matrix4.decompose(
+ state.object.position,
+ state.object.quaternion,
+ _scale
+ );
+ }
+ };
- source1.collider.events?.onIntersectionEnter?.(collisionPayload1);
+ const rememberPose = (
+ state: RigidBodyState,
+ position: Vector3,
+ rotation: Quaternion
+ ) => {
+ const pose = (state.renderedPose ??= {
+ position: new Vector3(),
+ rotation: new Quaternion()
+ });
+ pose.position.copy(position);
+ pose.rotation.copy(rotation);
+ return pose;
+ };
- source2.collider.events?.onIntersectionEnter?.(collisionPayload2);
- }
- } else {
- source1.rigidBody.events?.onIntersectionExit?.(collisionPayload1);
- source2.rigidBody.events?.onIntersectionExit?.(collisionPayload2);
- source1.collider.events?.onIntersectionExit?.(collisionPayload1);
- source2.collider.events?.onIntersectionExit?.(collisionPayload2);
- }
- });
+ // Parent spaces read by the last sync, checked again right before render.
+ const syncedSpaces = useConst(() => []);
- eventQueue.drainContactForceEvents((event) => {
- const source1 = getSourceFromColliderHandle(event.collider1());
- const source2 = getSourceFromColliderHandle(event.collider2());
+ /**
+ * Write body poses into their Object3Ds / instances, once per rendered frame.
+ *
+ * Bodies are visited parent-first. Poses are written in the CURRENT parent
+ * space (the parent's inverse world matrix is recomputed every frame, not
+ * captured at spawn), interpolated by `alpha` between the previous and the
+ * latest step. Also dispatches sleep/wake events and invalidates the canvas
+ * once if any body is awake, so `frameloop="demand"` keeps ticking while
+ * things move.
+ */
+ const syncMeshes = useConst(() => (alpha: number) => {
+ if (!alive.current) return;
+ const world = worldProxy;
+ beginParentSpacePass();
+ syncedSpaces.length = 0;
+
+ let anyAwake = false;
+
+ const entries = rigidBodyStates.ordered();
+ for (let i = 0; i < entries.length; i++) {
+ const [handle, state] = entries[i];
+ const rigidBody = world.getRigidBody(handle);
+ if (!rigidBody) continue;
+
+ const sleeping = rigidBody.isSleeping();
+ const events = rigidBodyEvents.get(handle);
+ if (sleeping && !state.isSleeping) events?.onSleep?.();
+ if (!sleeping && state.isSleeping) events?.onWake?.();
+ state.isSleeping = sleeping;
+
+ if (!state.setMatrix || !state.object.parent) continue;
+
+ const parentMoved = refreshParentInverse(state, syncedSpaces);
+
+ // A sleeping body doesn't move, but its parent might have.
+ if (sleeping && !parentMoved && state.renderedPose) continue;
+ // Fixed bodies are still synced (they can be teleported), but never
+ // keep a demand frameloop awake.
+ if (!sleeping && !rigidBody.isFixed()) anyAwake = true;
+
+ const t = rigidBody.translation();
+ const r = rigidBody.rotation();
+ _syncPosition.set(t.x, t.y, t.z);
+ _syncRotation.set(r.x, r.y, r.z, r.w);
+
+ const previous = state.previousPose;
+ if (!sleeping && previous?.valid && alpha < 1) {
+ // Interpolating in world space, then mapping into parent space once,
+ // equals mapping both poses and interpolating (affine parent).
+ _syncPosition.lerpVectors(previous.position, _syncPosition, alpha);
+ _syncRotationPrev.copy(previous.rotation).slerp(_syncRotation, alpha);
+ _syncRotation.copy(_syncRotationPrev);
+ }
- // Collision Events
- if (!source1?.collider.object || !source2?.collider.object) {
- return;
- }
+ const pose = rememberPose(state, _syncPosition, _syncRotation);
+ writePose(state, pose.position, pose.rotation);
+ }
- const collisionPayload1 = getCollisionPayloadFromSource(
- source1,
- source2
- );
- const collisionPayload2 = getCollisionPayloadFromSource(
- source2,
- source1
- );
+ frameSyncCallbacks.forEach((callback) => {
+ if (callback.current.sync(alpha)) anyAwake = true;
+ });
- source1.rigidBody.events?.onContactForce?.({
- ...collisionPayload1,
- totalForce: event.totalForce(),
- totalForceMagnitude: event.totalForceMagnitude(),
- maxForceDirection: event.maxForceDirection(),
- maxForceMagnitude: event.maxForceMagnitude()
- });
+ if (anyAwake) invalidate();
+ });
- source2.rigidBody.events?.onContactForce?.({
- ...collisionPayload2,
- totalForce: event.totalForce(),
- totalForceMagnitude: event.totalForceMagnitude(),
- maxForceDirection: event.maxForceDirection(),
- maxForceMagnitude: event.maxForceMagnitude()
- });
+ /**
+ * Late pass, right before render: if an ancestor moved after the sync (e.g.
+ * animated in a regular `useFrame`), re-express the already synced world
+ * poses in the new parent spaces. Costs one matrix compare per distinct
+ * parent when nothing moved.
+ */
+ const lateSync = useConst(() => () => {
+ if (!alive.current || syncedSpaces.length === 0) return;
+ if (!anyParentSpaceMoved(syncedSpaces)) return;
+
+ const world = worldProxy;
+ beginParentSpacePass();
+ syncedSpaces.length = 0;
+
+ const entries = rigidBodyStates.ordered();
+ for (let i = 0; i < entries.length; i++) {
+ const [handle, state] = entries[i];
+ if (!state.setMatrix || !state.object.parent) continue;
+ if (!refreshParentInverse(state, syncedSpaces)) continue;
+
+ let pose = state.renderedPose;
+ if (!pose) {
+ const rigidBody = world.getRigidBody(handle);
+ if (!rigidBody) continue;
+ const t = rigidBody.translation();
+ const r = rigidBody.rotation();
+ _syncPosition.set(t.x, t.y, t.z);
+ _syncRotation.set(r.x, r.y, r.z, r.w);
+ pose = rememberPose(state, _syncPosition, _syncRotation);
+ }
+ writePose(state, pose.position, pose.rotation);
+ }
+ });
- source1.collider.events?.onContactForce?.({
- ...collisionPayload1,
- totalForce: event.totalForce(),
- totalForceMagnitude: event.totalForceMagnitude(),
- maxForceDirection: event.maxForceDirection(),
- maxForceMagnitude: event.maxForceMagnitude()
- });
+ /**
+ * Forget the previous-step poses, so the next frames render the current
+ * pose until a new step gives something to interpolate from. Used whenever
+ * the pose may have jumped outside a regular step.
+ */
+ const resetInterpolation = useConst(() => () => {
+ rigidBodyStates.forEach((state) => {
+ if (state.previousPose) state.previousPose.valid = false;
+ });
+ frameSyncCallbacks.forEach((callback) => {
+ callback.current.resetInterpolation?.();
+ });
+ });
- source2.collider.events?.onContactForce?.({
- ...collisionPayload2,
- totalForce: event.totalForce(),
- totalForceMagnitude: event.totalForceMagnitude(),
- maxForceDirection: event.maxForceDirection(),
- maxForceMagnitude: event.maxForceMagnitude()
- });
- });
+ useEffect(() => {
+ resetInterpolation();
+ }, [paused, interpolate, timeStep]);
- world.forEachActiveRigidBody(() => {
- invalidate();
- });
- },
- [paused, timeStep, interpolate, worldProxy]
- );
+ const step = useCallback((dt: number) => {
+ stepWorld(dt);
+ resetInterpolation();
+ syncMeshes(1);
+ }, []);
const context = useMemo(
() => ({
@@ -849,6 +1085,7 @@ export const Physics: FC = (props) => {
world: worldProxy,
setWorld: (world: World) => {
setWorldProxy(world);
+ resetInterpolation();
},
physicsOptions: {
colliders,
@@ -860,6 +1097,8 @@ export const Physics: FC = (props) => {
colliderEvents,
beforeStepCallbacks,
afterStepCallbacks,
+ softBodyTearCallbacks,
+ frameSyncCallbacks,
isPaused: paused,
isDebug: debug,
step,
@@ -869,21 +1108,18 @@ export const Physics: FC = (props) => {
[paused, step, debug, colliders, gravity]
);
- const stepCallback = useCallback(
- (delta: number) => {
- if (!paused) {
- step(delta);
- }
- },
- [paused, step]
- );
-
return (
{debug && }
{children}
diff --git a/packages/react-three-rapier/src/components/RigidBody.tsx b/packages/react-three-rapier/src/components/RigidBody.tsx
index 36d6e0bd..63b3263a 100644
--- a/packages/react-three-rapier/src/components/RigidBody.tsx
+++ b/packages/react-three-rapier/src/components/RigidBody.tsx
@@ -3,6 +3,7 @@ import React, {
forwardRef,
memo,
ReactNode,
+ MutableRefObject,
Ref,
RefObject,
useContext,
@@ -60,6 +61,9 @@ export const RigidBody = memo((props: RigidBodyProps) => {
const objectRef = useRef(null);
const rigidBodyRef = useForwardedRef(ref);
+ // Destroy closures can outlive a render; always use the latest ref
+ const latestRef = useRef(ref);
+ latestRef.current = ref;
const { world, rigidBodyStates, physicsOptions, rigidBodyEvents } =
useRapier();
@@ -85,8 +89,8 @@ export const RigidBody = memo((props: RigidBodyProps) => {
const desc = rigidBodyDescFromOptions(mergedOptions);
const rigidBody = world.createRigidBody(desc);
- if (typeof ref === "function") {
- ref(rigidBody);
+ if (typeof latestRef.current === "function") {
+ latestRef.current(rigidBody);
}
rigidBodyRef.current = rigidBody;
@@ -96,6 +100,18 @@ export const RigidBody = memo((props: RigidBodyProps) => {
if (world.getRigidBody(rigidBody.handle)) {
world.removeRigidBody(rigidBody);
}
+
+ // Don't leave the consumer holding a reference to a freed rapier object
+ const currentRef = latestRef.current;
+ if (typeof currentRef === "function") {
+ currentRef(null);
+ } else if (currentRef && currentRef.current === rigidBody) {
+ currentRef.current = null;
+ }
+ if (rigidBodyRef.current === rigidBody) {
+ (rigidBodyRef as MutableRefObject).current =
+ null;
+ }
},
immutablePropArray
);
diff --git a/packages/react-three-rapier/src/hooks/hooks.ts b/packages/react-three-rapier/src/hooks/hooks.ts
index 309c60e1..54fa244e 100644
--- a/packages/react-three-rapier/src/hooks/hooks.ts
+++ b/packages/react-three-rapier/src/hooks/hooks.ts
@@ -39,7 +39,20 @@ export const useRapier = (): RapierContext => {
};
/**
- * Registers a callback to be called before the physics step
+ * Registers a callback to be called before every physics step.
+ *
+ * With a fixed `timeStep` this runs once per fixed substep (zero or more times
+ * per rendered frame), so it is the place for per-step logic such as forces,
+ * character controllers and vehicles. The callback receives the world and the
+ * step's `dt` in seconds.
+ *
+ * @example
+ * ```tsx
+ * useBeforePhysicsStep((world, dt) => {
+ * body.current?.applyImpulse({ x: 0, y: thrust * dt, z: 0 }, true);
+ * });
+ * ```
+ *
* @category Hooks
*/
export const useBeforePhysicsStep = (callback: WorldStepCallback) => {
@@ -57,7 +70,8 @@ export const useBeforePhysicsStep = (callback: WorldStepCallback) => {
};
/**
- * Registers a callback to be called after the physics step
+ * Registers a callback to be called after every physics step, with the world
+ * and the step's `dt` in seconds. Runs once per fixed substep.
* @category Hooks
*/
export const useAfterPhysicsStep = (callback: WorldStepCallback) => {
diff --git a/packages/react-three-rapier/src/hooks/use-character-controller.ts b/packages/react-three-rapier/src/hooks/use-character-controller.ts
new file mode 100644
index 00000000..383ccff0
--- /dev/null
+++ b/packages/react-three-rapier/src/hooks/use-character-controller.ts
@@ -0,0 +1,344 @@
+import {
+ CharacterCollision,
+ Collider,
+ InteractionGroups,
+ KinematicCharacterController,
+ QueryFilterFlags,
+ RigidBody
+} from "@dimforge/rapier3d-compat";
+import { useEffect, useMemo, useRef } from "react";
+import { Vector3Object } from "../types";
+import { useRapier } from "./hooks";
+import { useImperativeInstance } from "./use-imperative-instance";
+
+/**
+ * Autostep (stair climbing) configuration.
+ * @category Character Controller
+ */
+export interface CharacterAutostepOptions {
+ /** Maximum height of a step the character can climb. */
+ maxHeight: number;
+ /** Minimum free horizontal width required on top of the step. */
+ minWidth: number;
+ /** Whether dynamic bodies can be stepped over. Defaults to `false`. */
+ includeDynamicBodies?: boolean;
+}
+
+/**
+ * Configuration of the Rapier `KinematicCharacterController`.
+ * Every option is applied reactively: changing a value updates the live controller.
+ * Options left `undefined` (or reset to `undefined`) revert to Rapier's defaults;
+ * `autostep` and `snapToGround` default to disabled.
+ * @category Character Controller
+ */
+export interface CharacterControllerOptions {
+ /**
+ * Small gap kept between the character and the environment.
+ * @default 0.01
+ */
+ offset?: number;
+ /** The "up" direction of the character. @default [0, 1, 0] */
+ up?: [number, number, number] | Vector3Object;
+ /** Whether the character slides along obstacles. @default true */
+ slide?: boolean;
+ /** Maximum angle (radians) of a slope the character can climb. */
+ maxSlopeClimbAngle?: number;
+ /** Minimum angle (radians) from which the character starts sliding down slopes. */
+ minSlopeSlideAngle?: number;
+ /** `false` disables stair climbing, otherwise an autostep configuration. */
+ autostep?: false | CharacterAutostepOptions;
+ /** `false` disables snap-to-ground, otherwise the max snapping distance. */
+ snapToGround?: false | number;
+ /** Whether the character pushes dynamic bodies it touches. */
+ applyImpulsesToDynamicBodies?: boolean;
+ /** Mass used when pushing dynamic bodies. */
+ characterMass?: number;
+ /** Normal nudge factor, see the Rapier docs. */
+ normalNudgeFactor?: number;
+}
+
+/**
+ * Per-call options for {@link CharacterControllerApi.move}.
+ * @category Character Controller
+ */
+export interface CharacterMoveOptions {
+ /**
+ * The collider used to compute the movement.
+ * Defaults to the first non-sensor collider of the rigid body.
+ */
+ collider?: Collider;
+ /** Rapier query filter flags. */
+ filterFlags?: QueryFilterFlags;
+ /** Rapier interaction groups: only colliders matching these are considered. */
+ filterGroups?: InteractionGroups;
+ /** Return `false` to ignore a collider. Colliders of the character's own body are always ignored. */
+ filterPredicate?: (collider: Collider) => boolean;
+ /** Ignore sensor colliders. @default true */
+ excludeSensors?: boolean;
+}
+
+/**
+ * Result of a {@link CharacterControllerApi.move} call.
+ *
+ * The returned object (including `movement` and `collisions`) is reused between
+ * calls to avoid allocations: read it, do not retain it.
+ * @category Character Controller
+ */
+export interface CharacterMoveResult {
+ /** The translation that was applied to the body (after collision resolution). */
+ movement: Vector3Object;
+ /** Whether the character is standing on the ground. */
+ grounded: boolean;
+ /** Collisions that happened while computing the movement (pooled objects). */
+ collisions: CharacterCollision[];
+}
+
+/**
+ * Imperative API returned by {@link useCharacterController}.
+ * @category Character Controller
+ */
+export interface CharacterControllerApi {
+ /** The underlying Rapier controller. Only valid while the hook is mounted. */
+ readonly controller: KinematicCharacterController;
+ /**
+ * Computes a collision-resolved movement for `rigidBody` and applies it with
+ * `setNextKinematicTranslation`. Call it once per physics step from
+ * `useBeforePhysicsStep`, with a translation already scaled by the step `dt`.
+ */
+ move(
+ rigidBody: RigidBody,
+ desiredTranslation: Vector3Object,
+ options?: CharacterMoveOptions
+ ): CharacterMoveResult;
+}
+
+/**
+ * Creates and owns a Rapier `KinematicCharacterController`.
+ *
+ * The controller is created on mount and freed on unmount (StrictMode safe),
+ * options are applied reactively, and the returned api is referentially stable.
+ *
+ * Gravity, jumping and input are left to you. Compute a desired translation per
+ * fixed physics step (`velocity * dt`) inside `useBeforePhysicsStep` and pass it to `move`.
+ *
+ * @example
+ * ```tsx
+ * const Player = () => {
+ * const body = useRef(null);
+ * const vy = useRef(0);
+ * const cc = useCharacterController({ autostep: { maxHeight: 0.3, minWidth: 0.2 } });
+ *
+ * useBeforePhysicsStep((_world, dt) => {
+ * vy.current -= 9.81 * dt;
+ * const { grounded } = cc.move(body.current!, { x: 0, y: vy.current * dt, z: 0 });
+ * if (grounded) vy.current = 0;
+ * });
+ *
+ * return (
+ *
+ *
+ *
+ * );
+ * };
+ * ```
+ * @category Hooks
+ */
+export const useCharacterController = (
+ options: CharacterControllerOptions = {}
+): CharacterControllerApi => {
+ const { world } = useRapier();
+
+ const {
+ offset,
+ slide,
+ maxSlopeClimbAngle,
+ minSlopeSlideAngle,
+ autostep,
+ snapToGround,
+ applyImpulsesToDynamicBodies,
+ characterMass,
+ normalNudgeFactor,
+ up
+ } = options;
+
+ const upX = up ? (Array.isArray(up) ? up[0] : up.x) : undefined;
+ const upY = up ? (Array.isArray(up) ? up[1] : up.y) : undefined;
+ const upZ = up ? (Array.isArray(up) ? up[2] : up.z) : undefined;
+
+ const autostepHeight = autostep ? autostep.maxHeight : undefined;
+ const autostepWidth = autostep ? autostep.minWidth : undefined;
+ const autostepDynamic = autostep
+ ? !!autostep.includeDynamicBodies
+ : undefined;
+
+ // Rapier's own defaults, captured from the freshly created controller.
+ const defaults = useRef<{
+ offset: number;
+ up: Vector3Object;
+ slide: boolean;
+ maxSlope: number;
+ minSlide: number;
+ impulses: boolean;
+ mass: number | null;
+ nudge: number;
+ }>(null!);
+ // Set once the controller has been freed (guards move() after unmount).
+ const destroyed = useRef(false);
+
+ const getController = useImperativeInstance(
+ () => {
+ const c = world.createCharacterController(offset ?? 0.01);
+ defaults.current = {
+ offset: c.offset(),
+ up: { x: c.up().x, y: c.up().y, z: c.up().z },
+ slide: c.slideEnabled(),
+ maxSlope: c.maxSlopeClimbAngle(),
+ minSlide: c.minSlopeSlideAngle(),
+ impulses: c.applyImpulsesToDynamicBodies(),
+ mass: c.characterMass(),
+ nudge: c.normalNudgeFactor()
+ };
+ return c;
+ },
+ (controller) => {
+ destroyed.current = true;
+ world.removeCharacterController(controller);
+ },
+ [world]
+ );
+
+ // The hook is (re)mounted: allow use again (StrictMode re-runs effects).
+ useEffect(() => {
+ destroyed.current = false;
+ return () => {
+ destroyed.current = true;
+ };
+ }, []);
+
+ useEffect(() => {
+ const c = getController();
+ const d = defaults.current;
+ c.setOffset(offset ?? d.offset);
+ c.setUp(upX !== undefined ? { x: upX, y: upY!, z: upZ! } : d.up);
+ c.setSlideEnabled(slide ?? d.slide);
+ c.setMaxSlopeClimbAngle(maxSlopeClimbAngle ?? d.maxSlope);
+ c.setMinSlopeSlideAngle(minSlopeSlideAngle ?? d.minSlide);
+ if (autostepHeight !== undefined) {
+ c.enableAutostep(autostepHeight, autostepWidth!, autostepDynamic!);
+ } else {
+ c.disableAutostep();
+ }
+ if (typeof snapToGround === "number") c.enableSnapToGround(snapToGround);
+ else c.disableSnapToGround();
+ c.setApplyImpulsesToDynamicBodies(
+ applyImpulsesToDynamicBodies ?? d.impulses
+ );
+ c.setCharacterMass((characterMass ?? d.mass) as number);
+ c.setNormalNudgeFactor(normalNudgeFactor ?? d.nudge);
+ }, [
+ getController,
+ offset,
+ upX,
+ upY,
+ upZ,
+ slide,
+ maxSlopeClimbAngle,
+ minSlopeSlideAngle,
+ autostepHeight,
+ autostepWidth,
+ autostepDynamic,
+ snapToGround,
+ applyImpulsesToDynamicBodies,
+ characterMass,
+ normalNudgeFactor
+ ]);
+
+ return useMemo(() => {
+ // Scratch state, reused across calls so move() does not allocate.
+ let bodyHandle = -1;
+ let userPredicate: ((collider: Collider) => boolean) | undefined;
+ const predicate = (collider: Collider) => {
+ if (collider.parent()?.handle === bodyHandle) return false;
+ return userPredicate ? userPredicate(collider) : true;
+ };
+
+ const movement: Vector3Object = { x: 0, y: 0, z: 0 };
+ const target: Vector3Object = { x: 0, y: 0, z: 0 };
+ const collisions: CharacterCollision[] = [];
+ const pool: CharacterCollision[] = [];
+ const result: CharacterMoveResult = {
+ movement,
+ grounded: false,
+ collisions
+ };
+
+ return {
+ get controller() {
+ return getController();
+ },
+ move(rigidBody, desired, opts) {
+ if (destroyed.current) {
+ if (process.env.NODE_ENV !== "production") {
+ console.warn(
+ "react-three-rapier: character controller move() called after the controller was destroyed; ignoring."
+ );
+ }
+ result.movement.x = result.movement.y = result.movement.z = 0;
+ return result;
+ }
+ const controller = getController();
+
+ let collider = opts?.collider;
+ if (!collider) {
+ const n = rigidBody.numColliders();
+ for (let i = 0; i < n; i++) {
+ const candidate = rigidBody.collider(i);
+ if (!candidate.isSensor()) {
+ collider = candidate;
+ break;
+ }
+ }
+ }
+ if (!collider) {
+ throw new Error(
+ "react-three-rapier: character controller move() needs a non-sensor collider on the rigid body."
+ );
+ }
+
+ bodyHandle = rigidBody.handle;
+ userPredicate = opts?.filterPredicate;
+
+ let flags = opts?.filterFlags ?? 0;
+ if (opts?.excludeSensors ?? true)
+ flags |= QueryFilterFlags.EXCLUDE_SENSORS;
+
+ controller.computeColliderMovement(
+ collider,
+ desired,
+ flags || undefined,
+ opts?.filterGroups,
+ predicate
+ );
+ userPredicate = undefined;
+
+ controller.computedMovement(movement);
+ const t = rigidBody.translation();
+ target.x = t.x + movement.x;
+ target.y = t.y + movement.y;
+ target.z = t.z + movement.z;
+ rigidBody.setNextKinematicTranslation(target);
+
+ const n = controller.numComputedCollisions();
+ collisions.length = n;
+ for (let i = 0; i < n; i++) {
+ const c = (pool[i] ??= new CharacterCollision());
+ controller.computedCollision(i, c);
+ collisions[i] = c;
+ }
+
+ result.grounded = controller.computedGrounded();
+ return result;
+ }
+ };
+ }, [getController, destroyed]);
+};
diff --git a/packages/react-three-rapier/src/index.ts b/packages/react-three-rapier/src/index.ts
index 2f696b6e..92a1ef16 100644
--- a/packages/react-three-rapier/src/index.ts
+++ b/packages/react-three-rapier/src/index.ts
@@ -26,13 +26,27 @@ export type {
} from "./components/Physics";
export type { MeshColliderProps } from "./components/MeshCollider";
+export type {
+ CharacterControllerProps,
+ CharacterControllerRef
+} from "./components/CharacterController";
+export type {
+ CharacterControllerOptions,
+ CharacterControllerApi,
+ CharacterAutostepOptions,
+ CharacterMoveOptions,
+ CharacterMoveResult
+} from "./hooks/use-character-controller";
+
export { Physics } from "./components/Physics";
+export { CharacterController } from "./components/CharacterController";
export { RigidBody } from "./components/RigidBody";
export { MeshCollider } from "./components/MeshCollider";
export { InstancedRigidBodies } from "./components/InstancedRigidBodies";
export * from "./components/AnyCollider";
export * from "./hooks/joints";
+export { useCharacterController } from "./hooks/use-character-controller";
export {
useRapier,
useBeforePhysicsStep,
diff --git a/packages/react-three-rapier/src/soft-bodies/SoftBody.tsx b/packages/react-three-rapier/src/soft-bodies/SoftBody.tsx
new file mode 100644
index 00000000..438ba8a7
--- /dev/null
+++ b/packages/react-three-rapier/src/soft-bodies/SoftBody.tsx
@@ -0,0 +1,91 @@
+import React, {
+ Children,
+ cloneElement,
+ isValidElement,
+ ReactElement,
+ Ref,
+ useCallback,
+ useEffect,
+ useRef
+} from "react";
+import type { Object3D } from "three";
+import { useRapier } from "../index";
+import { SoftBodyState } from "./soft-body-state";
+import type { SoftBodyOptions } from "./types";
+import { useSoftBody } from "./use-soft-body";
+
+/**
+ * @category Soft Bodies
+ */
+export interface SoftBodyProps extends SoftBodyOptions {
+ /**
+ * Exactly one `` (cloth, volume) or ``/`` (rope)
+ * element. Its geometry is consumed and driven by the simulation; its
+ * transform sets the initial pose of the body.
+ */
+ children: ReactElement;
+ /** Receives the {@link SoftBodyState} once the body exists. */
+ ref?: Ref;
+}
+
+const setRef = (ref: Ref | undefined, value: T | null) => {
+ if (typeof ref === "function") ref(value);
+ else if (ref) (ref as { current: T | null }).current = value;
+};
+
+/**
+ * A deformable body: cloth, rope or volumetric (jelly) body built from the
+ * geometry of its child mesh. The mesh is kept in sync with the simulation
+ * once per rendered frame, interpolated like the rigid bodies.
+ *
+ * Soft bodies collide with colliders and rigid bodies in the same
+ * `` world, can be pinned, and can be attached to rigid bodies.
+ * Creation props are read once, when the body is created.
+ *
+ * @example
+ * ```tsx
+ *
+ * p.y > 0.99} stiffness={12}>
+ *
+ *
+ *
+ *
+ *
+ *
+ * ```
+ *
+ * @category Soft Bodies
+ */
+export const SoftBody = (props: SoftBodyProps) => {
+ const { children, ref, ...options } = props;
+
+ const meshRef = useRef(null);
+ const state = useSoftBody(meshRef, options);
+ const { world } = useRapier();
+
+ // Expose the state through the ref. The state is created in an effect of
+ // useSoftBody, which runs before this one.
+ useEffect(() => {
+ setRef(ref, state.current);
+ return () => setRef(ref, null);
+ }, [ref, state, world]);
+
+ const onlyChild = Children.only(children);
+ if (!isValidElement(onlyChild)) {
+ throw new Error("react-three-rapier: needs exactly one child.");
+ }
+
+ const childRef = (onlyChild as ReactElement<{ ref?: Ref }>).props
+ .ref;
+ const mergedRef = useCallback(
+ (instance: Object3D | null) => {
+ meshRef.current = instance;
+ setRef(childRef, instance);
+ },
+ [childRef]
+ );
+
+ return cloneElement(onlyChild as ReactElement<{ ref?: Ref }>, {
+ ref: mergedRef
+ });
+};
diff --git a/packages/react-three-rapier/src/soft-bodies/geometry.ts b/packages/react-three-rapier/src/soft-bodies/geometry.ts
new file mode 100644
index 00000000..1ea02c6c
--- /dev/null
+++ b/packages/react-three-rapier/src/soft-bodies/geometry.ts
@@ -0,0 +1,114 @@
+import { BufferAttribute, BufferGeometry } from "three";
+
+/**
+ * The result of welding a geometry on position only.
+ * @category Soft Bodies
+ */
+export interface WeldResult {
+ /** Flattened positions of the unique vertices, in first-seen order. */
+ positions: Float32Array;
+ /** Triangle indices into `positions`. */
+ index: Uint32Array;
+ /** For every original vertex, the index of its welded vertex. */
+ map: Uint32Array;
+}
+
+/**
+ * Welds the vertices of `geometry` that share a position (to 1e-4), looking
+ * at **position only**. UV-mapped or hard-edged shapes duplicate vertices
+ * along seams, with equal positions but different `uv`/`normal`; those must be
+ * one particle of the simulation or the seam would tear open.
+ *
+ * @category Soft Bodies
+ */
+export const weld = (geometry: BufferGeometry): WeldResult => {
+ const position = geometry.attributes.position as BufferAttribute | undefined;
+ if (!position) {
+ throw new Error(
+ "react-three-rapier: SoftBody geometry must have a position attribute."
+ );
+ }
+ const map = new Uint32Array(position.count);
+ const unique: number[] = [];
+ const seen = new Map();
+ for (let i = 0; i < position.count; i++) {
+ const x = position.getX(i);
+ const y = position.getY(i);
+ const z = position.getZ(i);
+ const key = `${Math.round(x * 1e4)},${Math.round(y * 1e4)},${Math.round(
+ z * 1e4
+ )}`;
+ let welded = seen.get(key);
+ if (welded === undefined) {
+ welded = unique.length / 3;
+ seen.set(key, welded);
+ unique.push(x, y, z);
+ }
+ map[i] = welded;
+ }
+
+ const source = geometry.index;
+ const count = source ? source.count : position.count;
+ const index = new Uint32Array(count);
+ for (let i = 0; i < count; i++) {
+ index[i] = map[source ? source.getX(i) : i];
+ }
+ return { positions: new Float32Array(unique), index, map };
+};
+
+/**
+ * Builds an indexed geometry with one vertex per welded vertex, using a weld
+ * map from {@link weld}. Other attributes (`uv`, `color`, ...) are taken from
+ * the first original vertex that welded into each slot.
+ *
+ * @category Soft Bodies
+ */
+export const applyWeldMap = (
+ geometry: BufferGeometry,
+ map: Uint32Array,
+ weldedCount: number
+): BufferGeometry => {
+ const firstSource = new Int32Array(weldedCount).fill(-1);
+ for (let i = 0; i < map.length; i++) {
+ if (firstSource[map[i]] === -1) firstSource[map[i]] = i;
+ }
+
+ const result = new BufferGeometry();
+ const source = geometry.index;
+ const index = new Uint32Array(source ? source.count : map.length);
+ for (let i = 0; i < index.length; i++) {
+ index[i] = map[source ? source.getX(i) : i];
+ }
+ result.setIndex(new BufferAttribute(index, 1));
+
+ const getters = ["getX", "getY", "getZ", "getW"] as const;
+ for (const name of Object.keys(geometry.attributes)) {
+ const attribute = geometry.attributes[name] as BufferAttribute;
+ const { itemSize } = attribute;
+ const array = new (
+ attribute.array.constructor as new (
+ length: number
+ ) => typeof attribute.array
+ )(weldedCount * itemSize);
+ for (let v = 0; v < weldedCount; v++) {
+ for (let k = 0; k < itemSize; k++) {
+ array[v * itemSize + k] = attribute[getters[k]](firstSource[v]);
+ }
+ }
+ const welded = new BufferAttribute(array, itemSize, attribute.normalized);
+ if (name === "position") welded.setUsage(35048 /* DynamicDrawUsage */);
+ result.setAttribute(name, welded);
+ }
+ return result;
+};
+
+/**
+ * Returns a copy of `geometry` welded on position only (see {@link weld}),
+ * always indexed.
+ *
+ * @category Soft Bodies
+ */
+export const weldGeometry = (geometry: BufferGeometry): BufferGeometry => {
+ const { positions, map } = weld(geometry);
+ return applyWeldMap(geometry, map, positions.length / 3);
+};
diff --git a/packages/react-three-rapier/src/soft-bodies/index.ts b/packages/react-three-rapier/src/soft-bodies/index.ts
new file mode 100644
index 00000000..2606b3b8
--- /dev/null
+++ b/packages/react-three-rapier/src/soft-bodies/index.ts
@@ -0,0 +1,13 @@
+export { SoftBody } from "./SoftBody";
+export type { SoftBodyProps } from "./SoftBody";
+export { useSoftBody } from "./use-soft-body";
+export { SoftBodyState } from "./soft-body-state";
+export { applyWeldMap, weld, weldGeometry } from "./geometry";
+export type { WeldResult } from "./geometry";
+export type {
+ SoftBodyAttachment,
+ SoftBodyOptions,
+ SoftBodyParticleSelector,
+ SoftBodyTearPayload,
+ SoftBodyType
+} from "./types";
diff --git a/packages/react-three-rapier/src/soft-bodies/soft-body-state.ts b/packages/react-three-rapier/src/soft-bodies/soft-body-state.ts
new file mode 100644
index 00000000..98816530
--- /dev/null
+++ b/packages/react-three-rapier/src/soft-bodies/soft-body-state.ts
@@ -0,0 +1,702 @@
+import {
+ ColliderDesc,
+ SoftBody as RawSoftBody,
+ SoftBodyDesc,
+ SoftBodyMaterial,
+ SoftBodyTearEvent,
+ Vector,
+ World
+} from "@dimforge/rapier3d-compat";
+import {
+ BufferAttribute,
+ BufferGeometry,
+ Matrix4,
+ Object3D,
+ Vector3
+} from "three";
+import { applyWeldMap, weld } from "./geometry";
+import type {
+ SoftBodyOptions,
+ SoftBodyParticleSelector,
+ SoftBodyTearPayload
+} from "./types";
+
+const _matrix = new Matrix4();
+const _inverse = new Matrix4();
+const _vector = new Vector3();
+
+type Renderable = Object3D & { geometry: BufferGeometry };
+
+const hasGeometry = (object: Object3D): object is Renderable =>
+ !!(object as Renderable).geometry?.isBufferGeometry;
+
+/**
+ * A live soft body: the Rapier soft body (or bodies, once it tore), and the
+ * object whose geometry it drives. Created by {@link useSoftBody} and
+ * {@link SoftBody}, exposed through their `ref`.
+ *
+ * @category Soft Bodies
+ */
+export class SoftBodyState {
+ /** The object whose geometry is driven by the simulation. */
+ readonly object: Renderable;
+ /** The kind of soft body. */
+ readonly type: NonNullable;
+ /** The Rapier soft body that was created from the geometry. */
+ readonly body: RawSoftBody;
+ /**
+ * `body` and every piece it tore into. Pieces are removed with the state.
+ */
+ readonly bodies: RawSoftBody[];
+ /** Number of vertices of the rendered geometry. */
+ vertexCount: number;
+
+ private readonly world: World;
+ private disposed = false;
+ private synced = false;
+
+ // For cloth and rope every vertex is a particle; after a tear its particle
+ // may live in a different (piece) soft body.
+ private owners: RawSoftBody[] = [];
+ private ownerParticles: Uint32Array = new Uint32Array(0);
+ private torn = false;
+ private warnedVolume = false;
+ // Render vertex -> particle, while the render geometry is the original one
+ // (UVs and hard normals intact). Null once a cloth tore, see handleTear.
+ private weldMap: Uint32Array | null = null;
+ private weldedCount = 0;
+ private knownVersions = new Map();
+ // Particle buffers of the last two steps, for interpolation. `capture`
+ // records them after every step, `sync` blends them once per frame.
+ private prev: Float32Array | null = null;
+ private curr: Float32Array | null = null;
+ // Bumped by every capture that changed `curr`, to skip redundant syncs.
+ private stepVersion = 0;
+ private renderedVersion = -1;
+ private renderedAlpha = -1;
+ private readonly onTear?: SoftBodyOptions["onTear"];
+
+ constructor(
+ world: World,
+ object: Renderable,
+ options: SoftBodyOptions,
+ resolveBody: (
+ body: unknown
+ ) => import("@dimforge/rapier3d-compat").RigidBody | null | undefined
+ ) {
+ const type = options.type ?? "cloth";
+ this.world = world;
+ this.object = object;
+ this.type = type;
+ this.onTear = options.onTear;
+
+ // The object's current transform is baked into the particles: positions
+ // handed to rapier are world space.
+ object.updateWorldMatrix(true, false);
+ const toWorld = object.matrixWorld.clone();
+ const toLocal = toWorld.clone().invert();
+
+ // The simulation runs on a position-welded copy of the geometry, while
+ // the rendered geometry is the original one, skinned through the weld map:
+ // UVs and hard edges survive.
+ let localPositions: Float32Array;
+ let weldIndex: Uint32Array | null = null;
+ let renderGeometry: BufferGeometry;
+ if (type === "rope") {
+ renderGeometry = ropeGeometry(object.geometry);
+ localPositions = new Float32Array(
+ renderGeometry.attributes.position.array
+ );
+ } else {
+ const welded = weld(object.geometry);
+ localPositions = welded.positions;
+ weldIndex = welded.index;
+ this.weldMap = welded.map;
+ renderGeometry = renderCopy(object.geometry);
+ }
+ const count = localPositions.length / 3;
+ this.weldedCount = count;
+
+ const worldPositions = new Float32Array(count * 3);
+ for (let i = 0; i < count; i++) {
+ _vector.fromArray(localPositions, i * 3).applyMatrix4(toWorld);
+ worldPositions[i * 3] = _vector.x;
+ worldPositions[i * 3 + 1] = _vector.y;
+ worldPositions[i * 3 + 2] = _vector.z;
+ }
+
+ let desc: SoftBodyDesc | null;
+ if (type === "volume") {
+ const cellSize = options.cellSize ?? defaultCellSize(worldPositions);
+ desc = SoftBodyDesc.volumetric(
+ worldPositions,
+ weldIndex!,
+ cellSize,
+ true
+ );
+ } else if (type === "rope") {
+ const edges: number[] = [];
+ for (let i = 0; i < count - 1; i++) edges.push(i, i + 1);
+ desc = new SoftBodyDesc(worldPositions).setEdges(edges).setWire(edges);
+ } else {
+ desc = SoftBodyDesc.trimesh(worldPositions, weldIndex!);
+ }
+
+ if (!desc) {
+ throw new Error(
+ `react-three-rapier: could not build a "${type}" soft body from this geometry` +
+ (type === "volume" ? " (volume bodies need a closed surface)." : ".")
+ );
+ }
+
+ // Particles' rest positions in the mesh's local space, for selectors
+ const particleRest = desc.particlePositions();
+ const resolve = (selector: SoftBodyParticleSelector | undefined) =>
+ resolveSelector(selector, particleRest, toLocal);
+
+ if (options.material) {
+ desc.setMaterial(
+ typeof options.material === "function"
+ ? options.material()
+ : options.material
+ );
+ } else if (
+ options.stiffness !== undefined ||
+ options.damping !== undefined
+ ) {
+ const material = SoftBodyMaterial.uniform(
+ options.stiffness ?? 10,
+ options.damping ?? 0.5
+ );
+ desc.setMaterial(material);
+ }
+ if (options.mass !== undefined) desc.setMass(options.mass);
+ if (options.cellModel !== undefined) desc.setCellModel(options.cellModel);
+ if (options.solver !== undefined) desc.setSolver(options.solver);
+ if (options.volumePreservation !== undefined)
+ desc.setVolumePreservation(options.volumePreservation);
+ if (options.selfContacts !== undefined)
+ desc.setSelfContacts(options.selfContacts);
+ if (options.particleRadius !== undefined)
+ desc.setParticleRadius(options.particleRadius);
+ if (options.gravityScale !== undefined)
+ desc.setGravityScale(options.gravityScale);
+ if (options.linearDamping !== undefined)
+ desc.setLinearDamping(options.linearDamping);
+ if (options.additionalSolverIterations !== undefined)
+ desc.setAdditionalSolverIterations(options.additionalSolverIterations);
+ if (options.canSleep !== undefined) desc.setCanSleep(options.canSleep);
+
+ if (options.collisions === false) {
+ desc.setNoSurfaceCollider();
+ } else if (
+ options.friction !== undefined ||
+ options.restitution !== undefined ||
+ options.collisionGroups !== undefined ||
+ options.solverGroups !== undefined
+ ) {
+ // Only the settings of the template are used, its shape is replaced.
+ const template = ColliderDesc.ball(0.01);
+ if (options.friction !== undefined)
+ template.setFriction(options.friction);
+ if (options.restitution !== undefined)
+ template.setRestitution(options.restitution);
+ if (options.collisionGroups !== undefined)
+ template.setCollisionGroups(options.collisionGroups);
+ if (options.solverGroups !== undefined)
+ template.setSolverGroups(options.solverGroups);
+ desc.setSurfaceCollider(template);
+ }
+
+ const pinned = resolve(options.pinned);
+ if (pinned.length) desc.setPinnedParticles(pinned);
+
+ if (options.configure) {
+ desc = options.configure(desc) ?? desc;
+ }
+
+ const body = world.createSoftBody(desc);
+ this.body = body;
+ this.bodies = [body];
+ this.vertexCount = renderGeometry.attributes.position.count;
+ this.knownVersions.set(body, body.topologyVersion());
+
+ if (type === "rope") this.resetOwners(body, count);
+
+ // Attachments to rigid bodies
+ for (const attachment of options.attachments ?? []) {
+ const rigidBody = resolveBody(attachment.body);
+ if (!rigidBody) {
+ console.warn(
+ "react-three-rapier: SoftBody attachment skipped, its rigid body is not available yet. Declare the before the ."
+ );
+ continue;
+ }
+ for (const particle of resolve(attachment.particles)) {
+ body.attachParticle(particle, rigidBody);
+ }
+ }
+
+ // Swap the render geometry in and bake the pose into it: from now on the
+ // geometry holds parent-space positions, so the object's own transform is
+ // the identity.
+ const previous = object.geometry;
+ object.geometry = renderGeometry;
+ previous.dispose();
+ object.position.set(0, 0, 0);
+ object.quaternion.set(0, 0, 0, 1);
+ object.scale.set(1, 1, 1);
+ object.updateMatrix();
+ object.updateMatrixWorld(true);
+ this.sync();
+ }
+
+ private topologyChanged() {
+ for (const body of this.bodies) {
+ if (this.knownVersions.get(body) !== body.topologyVersion()) return true;
+ }
+ return false;
+ }
+
+ private readSource() {
+ return this.type === "volume"
+ ? this.body.meshVertices(0)
+ : this.body.particlePositions();
+ }
+
+ /**
+ * Records the simulated positions of the step that just ran, as the target
+ * of the next frames' interpolation. Called after every physics step.
+ */
+ capture() {
+ if (this.disposed) return;
+ // A tear changes the particle layout; handleTear resets the buffers.
+ if (this.topologyChanged()) return;
+ if (this.torn) {
+ // Torn pieces are read live by sync (no interpolation).
+ this.stepVersion++;
+ return;
+ }
+ if (this.curr && this.body.isSleeping()) {
+ // Nothing moves anymore: settle on the latest step.
+ if (this.prev && this.prev !== this.curr) {
+ this.prev = this.curr;
+ this.stepVersion++;
+ }
+ return;
+ }
+ this.prev = this.curr;
+ this.curr = this.readSource();
+ this.stepVersion++;
+ }
+
+ /**
+ * Stop interpolating from the previous step: the next frames render the
+ * latest step until a new step gives something to interpolate from.
+ */
+ resetInterpolation() {
+ if (this.prev === null) return;
+ this.prev = null;
+ this.stepVersion++;
+ }
+
+ /**
+ * Writes the simulated positions into the object's geometry, in the space
+ * of the object's parent, then updates normals and bounds. `alpha` blends
+ * between the previous and the latest step (1 = the latest step), like the
+ * rigid bodies' interpolation. `` calls it once per rendered frame;
+ * you rarely need to call it yourself.
+ *
+ * @returns whether the body is still moving
+ */
+ sync(alpha = 1): boolean {
+ if (this.disposed) return false;
+ // A tear changes the particle layout; wait until handleTear has remapped.
+ if (this.topologyChanged()) return true;
+
+ const blend =
+ this.prev && this.prev !== this.curr
+ ? Math.min(Math.max(alpha, 0), 1)
+ : 1;
+ const moving = this.bodies.some((body) => !body.isSleeping());
+ if (
+ this.synced &&
+ this.renderedVersion === this.stepVersion &&
+ this.renderedAlpha === blend
+ ) {
+ return moving;
+ }
+
+ const { object } = this;
+ const geometry = object.geometry;
+ const position = geometry.attributes.position as BufferAttribute;
+ const array = position.array as Float32Array;
+
+ // Particles are in world space, geometry is in the parent's space
+ // (the object's own transform is the identity).
+ const parent = object.parent;
+ if (parent) {
+ parent.updateWorldMatrix(true, false);
+ _inverse.copy(parent.matrixWorld).invert();
+ } else {
+ _inverse.identity();
+ }
+ const identity = _inverse.equals(_matrix.identity());
+
+ if (this.torn) {
+ // Pieces are re-topologized on every tear; render the latest step.
+ const cache = new Map();
+ this.write(array, null, null, 1, cache, identity);
+ } else {
+ const source = (this.curr ??= this.readSource());
+ const prev = this.prev?.length === source.length ? this.prev : null;
+ this.write(array, source, prev, blend, null, identity);
+ }
+
+ position.needsUpdate = true;
+ if (this.type !== "rope") geometry.computeVertexNormals();
+ geometry.computeBoundingSphere();
+ geometry.computeBoundingBox();
+ this.synced = true;
+ this.renderedVersion = this.stepVersion;
+ this.renderedAlpha = blend;
+ return moving;
+ }
+
+ private resetOwners(body: RawSoftBody, count: number) {
+ this.owners = new Array(count).fill(body);
+ this.ownerParticles = new Uint32Array(count).map((_, i) => i);
+ }
+
+ private write(
+ target: Float32Array,
+ source: Float32Array | null,
+ prev: Float32Array | null,
+ alpha: number,
+ cache: Map | null,
+ identity: boolean
+ ) {
+ const weldMap = this.weldMap;
+ for (let i = 0; i < this.vertexCount; i++) {
+ let x: number, y: number, z: number;
+ if (source) {
+ const p = weldMap ? weldMap[i] : i;
+ x = source[p * 3];
+ y = source[p * 3 + 1];
+ z = source[p * 3 + 2];
+ if (prev && alpha < 1) {
+ x = prev[p * 3] + (x - prev[p * 3]) * alpha;
+ y = prev[p * 3 + 1] + (y - prev[p * 3 + 1]) * alpha;
+ z = prev[p * 3 + 2] + (z - prev[p * 3 + 2]) * alpha;
+ }
+ } else {
+ const owner = this.owners[i];
+ let positions = cache!.get(owner);
+ if (!positions) {
+ positions = owner.particlePositions();
+ cache!.set(owner, positions);
+ }
+ const p = this.ownerParticles[i];
+ x = positions[p * 3];
+ y = positions[p * 3 + 1];
+ z = positions[p * 3 + 2];
+ }
+ // Never write garbage: keep the last good position
+ if (!(Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z))) {
+ continue;
+ }
+
+ if (identity) {
+ target[i * 3] = x;
+ target[i * 3 + 1] = y;
+ target[i * 3 + 2] = z;
+ } else {
+ _vector.set(x, y, z).applyMatrix4(_inverse);
+ target[i * 3] = _vector.x;
+ target[i * 3 + 1] = _vector.y;
+ target[i * 3 + 2] = _vector.z;
+ }
+ }
+ }
+
+ /**
+ * Cuts the soft body along a blade (a triangle of three world-space points).
+ * Pieces that disconnect become soft bodies of their own, and `onTear` is
+ * called. Returns false if nothing was cut.
+ */
+ cut(blade: Vector[]): boolean {
+ if (this.disposed) return false;
+ let cut = false;
+ for (const body of [...this.bodies]) {
+ const event = this.world.cutSoftBody(body, blade);
+ if (event) {
+ this.handleTear(event);
+ event.free();
+ cut = true;
+ }
+ }
+ return cut;
+ }
+
+ /**
+ * Tears the given edges and cells (indices into `body.edges()` and
+ * `body.cells()` of the main body) at once. Pieces that disconnect become
+ * soft bodies of their own, and `onTear` is called.
+ */
+ tear(edges: number[], cells: number[] = []): boolean {
+ if (this.disposed) return false;
+ const event = this.world.tearSoftBody(this.body, edges, cells);
+ if (!event) return false;
+ this.handleTear(event);
+ event.free();
+ return true;
+ }
+
+ /**
+ * @internal Handles a tear event drained after a world step.
+ */
+ handleTear(event: SoftBodyTearEvent) {
+ if (this.disposed) return;
+ const handle = event.softBody();
+ const torn = this.bodies.find((b) => b.handle === handle);
+ if (!torn) return;
+
+ const pieces: RawSoftBody[] = [];
+ for (let i = 0; i < event.numPieces(); i++) {
+ const piece = this.world.softBodies.get(event.pieceSoftBody(i));
+ if (piece) {
+ pieces.push(piece);
+ if (!this.bodies.includes(piece)) this.bodies.push(piece);
+ }
+ }
+
+ if (this.type === "volume" && !this.warnedVolume) {
+ this.warnedVolume = true;
+ console.warn(
+ "react-three-rapier: a volume SoftBody tore. The rendered skin only follows the main piece; pieces that split off are simulated but not rendered."
+ );
+ }
+
+ if (this.type !== "volume") {
+ this.torn = true;
+
+ // Once a cloth tears, vertices are split per particle so each side of
+ // the tear can follow its own piece. This drops the original seams
+ // (UV/hard-edge duplicates) of the render geometry.
+ if (this.weldMap) {
+ const previous = this.object.geometry;
+ this.object.geometry = applyWeldMap(
+ previous,
+ this.weldMap,
+ this.weldedCount
+ );
+ previous.dispose();
+ this.weldMap = null;
+ this.vertexCount = this.weldedCount;
+ this.resetOwners(this.body, this.weldedCount);
+ }
+
+ // Particles the engine split off to open the tear need a rendered
+ // vertex of their own (cloth), or they would stay welded.
+ if (this.type === "cloth") {
+ const split = event.splitParticles();
+ const byParticle = new Map();
+ for (let v = 0; v < this.vertexCount; v++) {
+ if (this.owners[v].handle === handle) {
+ byParticle.set(this.ownerParticles[v], v);
+ }
+ }
+ const sources: number[] = [];
+ const created: number[] = [];
+ for (let i = 0; i + 1 < split.length; i += 2) {
+ const source = byParticle.get(split[i + 1]);
+ if (source === undefined) continue;
+ created.push(split[i]);
+ sources.push(source);
+ }
+ this.appendVertices(sources, torn, created);
+ }
+
+ for (let v = 0; v < this.vertexCount; v++) {
+ if (this.owners[v].handle !== handle) continue;
+ const destination = event.particleDestination(this.ownerParticles[v]);
+ if (!destination) continue;
+ const owner = this.world.softBodies.get(destination.softBody);
+ if (!owner) continue;
+ this.owners[v] = owner;
+ this.ownerParticles[v] = destination.particle;
+ }
+ if (this.type === "cloth") this.rebuildClothIndex();
+ }
+
+ for (const body of this.bodies) {
+ this.knownVersions.set(body, body.topologyVersion());
+ }
+ this.prev = this.curr = null;
+ this.synced = false;
+ this.stepVersion++;
+ // Show the new layout right away (sync was skipped while it was stale)
+ this.sync();
+ this.onTear?.({
+ softBody: torn,
+ pieces,
+ tornEdges: Array.from(event.tornEdges()),
+ tornCells: Array.from(event.tornCells())
+ } as SoftBodyTearPayload);
+ }
+
+ /** Duplicates the given rendered vertices, owned by new particles. */
+ private appendVertices(
+ sources: number[],
+ owner: RawSoftBody,
+ particles: number[]
+ ) {
+ if (!sources.length) return;
+ const geometry = this.object.geometry;
+ const total = this.vertexCount + sources.length;
+ for (const name of Object.keys(geometry.attributes)) {
+ const attribute = geometry.attributes[name] as BufferAttribute;
+ const { itemSize } = attribute;
+ const array = new (
+ attribute.array.constructor as new (
+ length: number
+ ) => typeof attribute.array
+ )(total * itemSize);
+ array.set(attribute.array);
+ sources.forEach((source, i) => {
+ for (let k = 0; k < itemSize; k++) {
+ array[(this.vertexCount + i) * itemSize + k] =
+ attribute.array[source * itemSize + k];
+ }
+ });
+ const grown = new BufferAttribute(array, itemSize, attribute.normalized);
+ grown.setUsage(attribute.usage);
+ geometry.setAttribute(name, grown);
+ }
+ const owners = this.owners.slice();
+ const ownerParticles = new Uint32Array(total);
+ ownerParticles.set(this.ownerParticles);
+ sources.forEach((_, i) => {
+ owners.push(owner);
+ ownerParticles[this.vertexCount + i] = particles[i];
+ });
+ this.owners = owners;
+ this.ownerParticles = ownerParticles;
+ this.vertexCount = total;
+ }
+
+ /**
+ * Rebuilds the triangles of a torn cloth from the surfaces of its soft
+ * bodies, so triangles do not span a tear.
+ */
+ private rebuildClothIndex() {
+ const lookup = new Map>();
+ for (let v = 0; v < this.vertexCount; v++) {
+ let particles = lookup.get(this.owners[v]);
+ if (!particles) lookup.set(this.owners[v], (particles = new Map()));
+ particles.set(this.ownerParticles[v], v);
+ }
+ const indices: number[] = [];
+ for (const body of this.bodies) {
+ const particles = lookup.get(body);
+ if (!particles) continue;
+ const boundary = body.boundary();
+ for (let i = 0; i + 2 < boundary.length; i += 3) {
+ const a = particles.get(boundary[i]);
+ const b = particles.get(boundary[i + 1]);
+ const c = particles.get(boundary[i + 2]);
+ if (a !== undefined && b !== undefined && c !== undefined) {
+ indices.push(a, b, c);
+ }
+ }
+ }
+ this.object.geometry.setIndex(indices);
+ }
+
+ /**
+ * Removes the soft body (and any pieces) from the world and releases the
+ * geometry it created. Safe to call more than once.
+ */
+ dispose() {
+ if (this.disposed) return;
+ this.disposed = true;
+ for (const body of this.bodies) {
+ try {
+ if (body.isValid()) this.world.removeSoftBody(body);
+ } catch {
+ // The world may already be gone (the tree unmounted first).
+ }
+ }
+ this.bodies.length = 0;
+ this.object.geometry.dispose();
+ }
+}
+
+/** A copy of the render geometry with its own dynamic float positions. */
+const renderCopy = (source: BufferGeometry) => {
+ const geometry = source.clone();
+ const position = source.attributes.position as BufferAttribute;
+ const copy = new BufferAttribute(new Float32Array(position.count * 3), 3);
+ for (let i = 0; i < position.count; i++) {
+ copy.setXYZ(i, position.getX(i), position.getY(i), position.getZ(i));
+ }
+ copy.setUsage(35048 /* DynamicDrawUsage */);
+ geometry.setAttribute("position", copy);
+ return geometry;
+};
+
+/** A rope keeps its vertex order: no welding, and no index is needed. */
+const ropeGeometry = (source: BufferGeometry) => {
+ const position = source.attributes.position as BufferAttribute | undefined;
+ if (!position) {
+ throw new Error(
+ "react-three-rapier: SoftBody geometry must have a position attribute."
+ );
+ }
+ const geometry = new BufferGeometry();
+ const copy = new BufferAttribute(new Float32Array(position.count * 3), 3);
+ for (let i = 0; i < position.count; i++) {
+ copy.setXYZ(i, position.getX(i), position.getY(i), position.getZ(i));
+ }
+ copy.setUsage(35048);
+ geometry.setAttribute("position", copy);
+ if (source.index) geometry.setIndex(source.index.clone());
+ for (const name of Object.keys(source.attributes)) {
+ if (name !== "position") {
+ geometry.setAttribute(name, source.attributes[name].clone());
+ }
+ }
+ return geometry;
+};
+
+const defaultCellSize = (worldPositions: Float32Array) => {
+ const min = [Infinity, Infinity, Infinity];
+ const max = [-Infinity, -Infinity, -Infinity];
+ for (let i = 0; i < worldPositions.length; i++) {
+ const axis = i % 3;
+ min[axis] = Math.min(min[axis], worldPositions[i]);
+ max[axis] = Math.max(max[axis], worldPositions[i]);
+ }
+ const largest = Math.max(max[0] - min[0], max[1] - min[1], max[2] - min[2]);
+ return Math.max(largest / 5, 1e-3);
+};
+
+const resolveSelector = (
+ selector: SoftBodyParticleSelector | undefined,
+ worldRest: Float32Array,
+ toLocal: Matrix4
+): number[] => {
+ if (!selector) return [];
+ if (Array.isArray(selector)) return selector;
+ const result: number[] = [];
+ const count = worldRest.length / 3;
+ for (let i = 0; i < count; i++) {
+ _vector
+ .set(worldRest[i * 3], worldRest[i * 3 + 1], worldRest[i * 3 + 2])
+ .applyMatrix4(toLocal);
+ if (selector(_vector, i)) result.push(i);
+ }
+ return result;
+};
+
+export { hasGeometry };
diff --git a/packages/react-three-rapier/src/soft-bodies/types.ts b/packages/react-three-rapier/src/soft-bodies/types.ts
new file mode 100644
index 00000000..13006059
--- /dev/null
+++ b/packages/react-three-rapier/src/soft-bodies/types.ts
@@ -0,0 +1,179 @@
+import type {
+ RigidBody as RawRigidBody,
+ SoftBody as RawSoftBody,
+ SoftBodyCellModel,
+ SoftBodyDesc,
+ SoftBodyMaterial,
+ SoftBodySolver
+} from "@dimforge/rapier3d-compat";
+import type { RefObject } from "react";
+import type { Vector3 } from "three";
+
+/**
+ * The kind of deformable body to build from a geometry.
+ *
+ * - `cloth`: the geometry's welded vertices are particles, connected along the
+ * triangle edges. Use with open surfaces (planes, curtains, flags).
+ * - `volume`: the closed surface is filled with tetrahedra; the rendered
+ * vertices are skinned onto them. Use with closed shapes (jelly cubes,
+ * spheres).
+ * - `rope`: the geometry's vertices, in order, are the particles of a chain.
+ * Use with a `` or `` whose position attribute lists the rope
+ * from start to end.
+ *
+ * @category Soft Bodies
+ */
+export type SoftBodyType = "cloth" | "volume" | "rope";
+
+/**
+ * Selects particles of a soft body: either explicit particle indices, or a
+ * predicate called for every particle with its rest position in the **mesh's
+ * local space** (the space of its geometry before the soft body was created)
+ * and its particle index.
+ *
+ * For `cloth` and `rope` particle indices are vertex indices of the welded
+ * geometry. For `volume` they are indices of the internal simulation
+ * particles, so prefer a predicate.
+ *
+ * @category Soft Bodies
+ */
+export type SoftBodyParticleSelector =
+ number[] | ((position: Vector3, index: number) => boolean);
+
+/**
+ * Anchors particles of a soft body to a rigid body, so the soft body follows it.
+ *
+ * @category Soft Bodies
+ */
+export interface SoftBodyAttachment {
+ /** The particles to attach. */
+ particles: SoftBodyParticleSelector;
+ /**
+ * The rigid body to attach to, or a ref to it (e.g. a ``).
+ * Particles are attached at their current positions. A ref must already be
+ * populated when the soft body is created, so declare the ``
+ * before the ``.
+ */
+ body: RawRigidBody | RefObject;
+}
+
+/**
+ * Payload of `onTear`.
+ *
+ * @category Soft Bodies
+ */
+export interface SoftBodyTearPayload {
+ /** The soft body that was torn. */
+ softBody: RawSoftBody;
+ /** The pieces that disconnected and became soft bodies of their own. */
+ pieces: RawSoftBody[];
+ /**
+ * The edges that tore, as flattened particle index pairs (as in Rapier's
+ * `SoftBodyTearEvent.tornEdges()`), not edge indices.
+ */
+ tornEdges: number[];
+ /**
+ * The cells that tore, as flattened particle indices (empty for `cloth` and
+ * `rope`), as in Rapier's `SoftBodyTearEvent.tornCells()`.
+ */
+ tornCells: number[];
+}
+
+/**
+ * Options shared by {@link SoftBody} and {@link useSoftBody}. They are read
+ * once, when the body is created; changing them afterwards has no effect
+ * (like ``'s creation props). Remount with a `key` to rebuild.
+ *
+ * @category Soft Bodies
+ */
+export interface SoftBodyOptions {
+ /** What to build from the geometry. @defaultValue "cloth" */
+ type?: SoftBodyType;
+
+ /**
+ * Natural frequency (Hz) of the body's springs. Higher is stiffer. Leave
+ * undefined for Rapier's default.
+ */
+ stiffness?: number;
+ /**
+ * Damping ratio of the springs: `0` oscillates forever, `1` is critically
+ * damped. Leave undefined for Rapier's default.
+ */
+ damping?: number;
+ /**
+ * Full material control; overrides `stiffness` and `damping`. Rapier
+ * materials need the wasm to be initialised: pass a function
+ * (`material={() => new SoftBodyMaterial()}`) when defining it at module
+ * scope or before `` has loaded.
+ */
+ material?: SoftBodyMaterial | (() => SoftBodyMaterial);
+
+ /** Total mass of the body, spread over its particles. */
+ mass?: number;
+ /** Particles that never move. */
+ pinned?: SoftBodyParticleSelector;
+ /** Particles anchored to rigid bodies. */
+ attachments?: SoftBodyAttachment[];
+
+ /**
+ * `volume` only: edge length of the tetrahedral cells. Smaller is more
+ * detailed and more expensive. Defaults to a fifth of the largest dimension.
+ */
+ cellSize?: number;
+ /** `volume` only: the constitutive model of the cells. */
+ cellModel?: SoftBodyCellModel;
+ /** The solver used to hold the body together. */
+ solver?: SoftBodySolver;
+ /** Resist changes of volume (`volume` bodies). */
+ volumePreservation?: boolean;
+ /** Collide with itself. */
+ selfContacts?: boolean;
+ /** Thickness of the particles, which keeps cloth off surfaces. */
+ particleRadius?: number;
+
+ /** Multiplier on world gravity. @defaultValue 1 */
+ gravityScale?: number;
+ /** Velocity lost to drag. */
+ linearDamping?: number;
+ /** Extra solver iterations to make the body behave more rigidly. */
+ additionalSolverIterations?: number;
+ /** Whether the body may fall asleep. @defaultValue true */
+ canSleep?: boolean;
+
+ /**
+ * Collide with rigid bodies and colliders. @defaultValue true
+ */
+ collisions?: boolean;
+ /** Friction against colliders. */
+ friction?: number;
+ /** Bounciness against colliders. */
+ restitution?: number;
+ /** Rapier collision groups bitmask for the body's colliders. */
+ collisionGroups?: number;
+ /** Rapier solver groups bitmask for the body's colliders. */
+ solverGroups?: number;
+
+ /**
+ * Escape hatch: receives the `SoftBodyDesc` right before the body is
+ * created, to call any Rapier setter that has no prop. Return a descriptor
+ * to replace it.
+ */
+ configure?: (desc: SoftBodyDesc) => SoftBodyDesc | void;
+
+ /**
+ * Called when the body tears (requires `material.tearStrain` or
+ * `tearForce`, or a manual `softBody.tearEdge()`). For `cloth` and `rope`
+ * the rendered triangles that cross a tear are removed so the tear is
+ * visible. After the first tear a cloth's vertices are split per particle,
+ * so seams of the original geometry (UV/hard-edge duplicates) are lost.
+ *
+ * Volume bodies also report tears, but only the main piece is rendered.
+ */
+ onTear?: (payload: SoftBodyTearPayload) => void;
+}
+
+/**
+ * Re-exported for convenience in props.
+ * @category Soft Bodies
+ */
+export type { RawSoftBody };
diff --git a/packages/react-three-rapier/src/soft-bodies/use-soft-body.ts b/packages/react-three-rapier/src/soft-bodies/use-soft-body.ts
new file mode 100644
index 00000000..056da227
--- /dev/null
+++ b/packages/react-three-rapier/src/soft-bodies/use-soft-body.ts
@@ -0,0 +1,97 @@
+import type { RigidBody } from "@dimforge/rapier3d-compat";
+import { RefObject, useEffect, useRef } from "react";
+import type { Object3D } from "three";
+import { useRapier } from "../index";
+import { hasGeometry, SoftBodyState } from "./soft-body-state";
+import type { SoftBodyOptions } from "./types";
+
+const resolveRigidBody = (body: unknown): RigidBody | null | undefined => {
+ if (body && typeof body === "object" && "current" in body) {
+ return (body as RefObject).current;
+ }
+ return body as RigidBody | undefined;
+};
+
+/**
+ * Turns the geometry of an object into a soft body and keeps the two in sync
+ * once per rendered frame (interpolated like the rigid bodies). This is the
+ * hook behind {@link SoftBody}; use it when you already hold a ref to a
+ * ``, `` or ``.
+ *
+ * The geometry is replaced by a welded copy (vertices sharing a position are
+ * merged), the object's transform is baked into it, and from then on it
+ * receives the deformed vertex positions in the space of the object's parent,
+ * with normals and bounds recomputed.
+ *
+ * Options are read when the body is created. The body is removed from the
+ * world when the component unmounts.
+ *
+ * @example
+ * ```tsx
+ * const mesh = useRef(null)
+ * const soft = useSoftBody(mesh, { type: "cloth", pinned: (p) => p.y > 0.99 })
+ * return
+ * ```
+ *
+ * @returns A ref to the {@link SoftBodyState}, populated once the body exists.
+ * @category Soft Bodies
+ */
+export const useSoftBody = (
+ object: RefObject,
+ options: SoftBodyOptions = {}
+): RefObject => {
+ const {
+ world,
+ afterStepCallbacks,
+ frameSyncCallbacks,
+ softBodyTearCallbacks
+ } = useRapier();
+ const state = useRef(null);
+ const optionsRef = useRef(options);
+ optionsRef.current = options;
+
+ useEffect(() => {
+ const target = object.current;
+ if (!target || !hasGeometry(target)) {
+ console.warn(
+ "react-three-rapier: useSoftBody needs a ref to an object with a geometry."
+ );
+ return;
+ }
+
+ const created = new SoftBodyState(
+ world,
+ target,
+ optionsRef.current,
+ resolveRigidBody
+ );
+ state.current = created;
+
+ // Record the particles after every substep; write the geometry once per
+ // rendered frame, interpolated with the same alpha as the rigid bodies
+ // (so cloth attached to an interpolated body stays on it).
+ const afterStep = { current: () => created.capture() };
+ afterStepCallbacks.add(afterStep);
+ const frameSync = {
+ current: {
+ sync: (alpha: number) => created.sync(alpha),
+ resetInterpolation: () => created.resetInterpolation()
+ }
+ };
+ frameSyncCallbacks.add(frameSync);
+ // Tears are handled per substep, as they are drained after each step.
+ const onTear = created.handleTear.bind(created);
+ softBodyTearCallbacks.add(onTear);
+
+ return () => {
+ afterStepCallbacks.delete(afterStep);
+ frameSyncCallbacks.delete(frameSync);
+ softBodyTearCallbacks.delete(onTear);
+ created.dispose();
+ state.current = null;
+ };
+ // Creation options are read once, like 's.
+ }, [world]);
+
+ return state;
+};
diff --git a/packages/react-three-rapier/src/types.ts b/packages/react-three-rapier/src/types.ts
index a62cf51a..6a461fad 100644
--- a/packages/react-three-rapier/src/types.ts
+++ b/packages/react-three-rapier/src/types.ts
@@ -15,17 +15,19 @@ import { Object3D } from "three";
import { ColliderProps } from ".";
import { RigidBodyState } from "./components/Physics";
-export { CoefficientCombineRule as CoefficientCombineRule } from "@dimforge/rapier3d-compat";
+export {
+ CoefficientCombineRule,
+ ActiveCollisionTypes,
+ ActiveEvents,
+ ActiveHooks,
+ QueryFilterFlags
+} from "@dimforge/rapier3d-compat";
export { RapierCollider, RapierRigidBody };
export type RefGetter = RefObject<() => T | undefined>;
export type RigidBodyAutoCollider =
- | "ball"
- | "cuboid"
- | "hull"
- | "trimesh"
- | false;
+ "ball" | "cuboid" | "hull" | "trimesh" | false;
export type CuboidArgs = [
halfWidth: number,
@@ -35,10 +37,23 @@ export type CuboidArgs = [
export type BallArgs = [radius: number];
export type CapsuleArgs = [halfHeight: number, radius: number];
export type ConvexHullArgs = [vertices: ArrayLike];
+/**
+ * Arguments for a heightfield collider: `[rows, cols, heights, scale]`.
+ *
+ * `rows` and `cols` are the number of *cells* along each axis, not the number
+ * of height samples. The `heights` array therefore must contain
+ * `(rows + 1) * (cols + 1)` values, stored in column-major order
+ * (`heights[col * (rows + 1) + row]`). A different length will produce a
+ * wrong or misaligned collider (a dev-mode warning is logged).
+ *
+ * `scale` is the total size of the heightfield along each axis.
+ *
+ * @see https://rapier.rs/docs/user_guides/javascript/colliders#heightfield
+ */
export type HeightfieldArgs = [
- width: number,
- height: number,
- heights: number[],
+ rows: number,
+ cols: number,
+ heights: ArrayLike,
scale: { x: number; y: number; z: number }
];
export type TrimeshArgs = [
@@ -82,10 +97,7 @@ export type RoundConvexMeshArgs = [
export type UseBodyOptions = Omit;
export type RigidBodyTypeString =
- | "fixed"
- | "dynamic"
- | "kinematicPosition"
- | "kinematicVelocity";
+ "fixed" | "dynamic" | "kinematicPosition" | "kinematicVelocity";
export type ColliderShape =
| "cuboid"
diff --git a/packages/react-three-rapier/src/utils/utils-collider.ts b/packages/react-three-rapier/src/utils/utils-collider.ts
index 984a5d93..2065473f 100644
--- a/packages/react-three-rapier/src/utils/utils-collider.ts
+++ b/packages/react-three-rapier/src/utils/utils-collider.ts
@@ -6,8 +6,16 @@ import {
World
} from "@dimforge/rapier3d-compat";
import { useEffect, useMemo } from "react";
-import { BufferGeometry, Euler, Mesh, Object3D, Vector3 } from "three";
-import { mergeVertices } from "three-stdlib";
+import {
+ BufferAttribute,
+ BufferGeometry,
+ Euler,
+ InterleavedBufferAttribute,
+ Mesh,
+ Object3D,
+ Vector3
+} from "three";
+import { mergeVertices } from "three/examples/jsm/utils/BufferGeometryUtils.js";
import { ColliderProps, RigidBodyProps } from "..";
import {
ColliderState,
@@ -21,7 +29,11 @@ import {
_scale,
_vector3
} from "./shared-objects";
-import { ColliderShape, RigidBodyAutoCollider } from "../types";
+import {
+ ColliderShape,
+ HeightfieldArgs,
+ RigidBodyAutoCollider
+} from "../types";
import { scaleVertices, vectorToTuple } from "./utils";
export const scaleColliderArgs = (
@@ -33,10 +45,9 @@ export const scaleColliderArgs = (
// Heightfield uses a vector
if (shape === "heightfield") {
+ // Don't mutate the user's args object, or scale compounds on every remount
const s = newArgs[3] as { x: number; y: number; z: number };
- s.x *= scale.x;
- s.x *= scale.y;
- s.x *= scale.z;
+ newArgs[3] = { x: s.x * scale.x, y: s.y * scale.y, z: s.z * scale.z };
return newArgs;
}
@@ -52,17 +63,68 @@ export const scaleColliderArgs = (
return newArgs.map((arg, index) => scaleArray[index] * (arg as number));
};
+/**
+ * Copy a position attribute into a tightly packed Float32Array of xyz triples.
+ * Using `getX/getY/getZ` handles InterleavedBufferAttribute, offsets, and
+ * normalized/quantized (e.g. Draco/meshopt) attributes, which `attribute.array` does not.
+ */
+export const getPositionArray = (
+ attribute: BufferAttribute | InterleavedBufferAttribute
+): Float32Array => {
+ const out = new Float32Array(attribute.count * 3);
+ for (let i = 0; i < attribute.count; i++) {
+ out[i * 3] = attribute.getX(i);
+ out[i * 3 + 1] = attribute.getY(i);
+ out[i * 3 + 2] = attribute.getZ(i);
+ }
+ return out;
+};
+
+export const getIndexArray = (index: BufferAttribute): Uint32Array =>
+ index.array instanceof Uint32Array
+ ? index.array
+ : Uint32Array.from(index.array as ArrayLike);
+
export const createColliderFromOptions = (
options: ColliderProps,
world: World,
scale: Vector3,
getRigidBody?: () => RigidBody
) => {
+ if (options.shape === "heightfield") {
+ const [rows, cols, heights] = options.args as HeightfieldArgs;
+ const expected = (rows + 1) * (cols + 1);
+ if (heights?.length !== expected) {
+ console.warn(
+ `@react-three/rapier: heightfield collider expects \`heights\` to have (rows + 1) * (cols + 1) = ${expected} values, but got ${heights?.length}.`
+ );
+ }
+ }
+
const scaledArgs = scaleColliderArgs(options.shape!, options.args, scale);
- // @ts-ignore
+ // @ts-expect-error each shape takes its own args tuple; the args were built for options.shape
const desc = ColliderDesc[options.shape!](...scaledArgs);
- return world.createCollider(desc!, getRigidBody?.());
+ const failure = (cause?: unknown) =>
+ new Error(
+ `@react-three/rapier: Could not create a "${options.shape}" collider from the given args. ` +
+ (options.shape === "convexHull"
+ ? "Rapier failed to compute a convex hull: the points are probably degenerate (coplanar, collinear, duplicated, or contain NaN)."
+ : "Check that the args are valid for this shape."),
+ { cause }
+ );
+
+ if (!desc) {
+ throw failure();
+ }
+
+ const rigidBody = getRigidBody?.();
+
+ try {
+ return world.createCollider(desc, rigidBody);
+ } catch (e) {
+ throw failure(e);
+ }
};
type ImmutableColliderOptions = (keyof ColliderProps)[];
@@ -116,6 +178,25 @@ const setColliderMassOptions = (
}
};
+const massPropertiesToFlatArray = (
+ massProperties: ColliderProps["massProperties"]
+) =>
+ massProperties
+ ? [
+ massProperties.mass,
+ massProperties.centerOfMass.x,
+ massProperties.centerOfMass.y,
+ massProperties.centerOfMass.z,
+ massProperties.principalAngularInertia.x,
+ massProperties.principalAngularInertia.y,
+ massProperties.principalAngularInertia.z,
+ massProperties.angularInertiaLocalFrame.x,
+ massProperties.angularInertiaLocalFrame.y,
+ massProperties.angularInertiaLocalFrame.z,
+ massProperties.angularInertiaLocalFrame.w
+ ]
+ : new Array(11).fill(undefined); // keep the dependency list length constant
+
const mutableColliderOptions: MutableColliderOptions = {
sensor: (collider, value: boolean) => {
collider.setSensor(value);
@@ -200,7 +281,7 @@ export const setColliderOptions = (
const option = options[key];
mutableColliderOptions[key]!(
collider,
- // @ts-ignore Option does not want to fit into the function, but it will
+ // @ts-expect-error Option does not want to fit into the function, but it will
option,
options
);
@@ -220,10 +301,16 @@ export const useUpdateColliderOptions = (
) => {
// TODO: Improve this, split each prop into its own effect
const mutablePropsAsFlatArray = useMemo(
- () =>
- mutableColliderOptionKeys.flatMap((key) => {
+ () => [
+ ...mutableColliderOptionKeys.flatMap((key) => {
return vectorToTuple(props[key as keyof ColliderProps]);
}),
+ // Mass options are exclusive and applied separately, but must also
+ // re-apply when they change
+ props.density,
+ props.mass,
+ ...massPropertiesToFlatArray(props.massProperties)
+ ],
[props]
);
@@ -365,8 +452,10 @@ export const getColliderArgsFromGeometry = (
return {
args: [
- clonedGeometry.attributes.position.array as Float32Array,
- clonedGeometry.index?.array as Uint32Array
+ getPositionArray(clonedGeometry.attributes.position),
+ clonedGeometry.index
+ ? getIndexArray(clonedGeometry.index)
+ : undefined
],
offset: new Vector3()
};
@@ -375,10 +464,8 @@ export const getColliderArgsFromGeometry = (
case "hull":
{
- const g = geometry.clone();
-
return {
- args: [g.attributes.position.array as Float32Array],
+ args: [getPositionArray(geometry.attributes.position)],
offset: new Vector3()
};
}
diff --git a/packages/react-three-rapier/src/utils/utils-physics.ts b/packages/react-three-rapier/src/utils/utils-physics.ts
index e90f81a1..0ed08230 100644
--- a/packages/react-three-rapier/src/utils/utils-physics.ts
+++ b/packages/react-three-rapier/src/utils/utils-physics.ts
@@ -1,9 +1,206 @@
import { useEffect, useRef } from "react";
+/**
+ * The subset of the r3f v10 scheduler (`@pmndrs/scheduler`) the stepper uses.
+ * Fixed-step members (`getOverstep`, `getPhaseTimestep`) only exist from
+ * scheduler 0.3, so they are optional here and feature-detected at runtime.
+ * @internal
+ */
+export interface StepperScheduler {
+ addPhase(
+ name: string,
+ options?: {
+ before?: string;
+ after?: string;
+ timestep?: number;
+ maxSubsteps?: number;
+ }
+ ): void;
+ hasPhase(name: string): boolean;
+ getOverstep?: (phase?: string, rootId?: string) => number;
+ getPhaseTimestep?: (name: string) => number | undefined;
+ setPhaseTimestep?: (
+ name: string,
+ timestep: number | undefined,
+ options?: { maxSubsteps?: number }
+ ) => void;
+}
+
+/**
+ * Per-frame scheduler phase owned by react-three-rapier. Runs after every fixed
+ * physics phase and before `update`, and hosts the mesh sync job (and, without
+ * scheduler fixed-step support or with `timeStep="vary"`, the step itself).
+ * @internal
+ */
+export const RAPIER_PHASE = "rapier";
+
+/**
+ * Per-frame phase right before `render`. Hosts the late sync pass that
+ * re-expresses synced poses when an ancestor moved during `update`.
+ * @internal
+ */
+export const RAPIER_LATE_PHASE = "rapier:late";
+
+/** Upper bound for a single variable step, in seconds. @internal */
+export const MAX_VARIABLE_STEP = 0.5;
+
+/** @internal */
+export const DEFAULT_MAX_SUB_STEPS = 5;
+
+// Same tolerance the scheduler uses, so both clocks agree on float edges
+// (e.g. three deltas of 1/60 must yield exactly three steps).
+const TIMESTEP_EPSILON = 1e-9;
+
+/**
+ * Whether the installed scheduler can run fixed-timestep phases
+ * (`@pmndrs/scheduler` >= 0.3, `@react-three/fiber` >= 10.0.0-canary.a056612).
+ * @internal
+ */
+export const supportsFixedPhases = (
+ scheduler: Partial | null | undefined
+): scheduler is FixedStepScheduler =>
+ !!scheduler &&
+ typeof scheduler.addPhase === "function" &&
+ typeof scheduler.hasPhase === "function" &&
+ typeof scheduler.getOverstep === "function" &&
+ typeof scheduler.getPhaseTimestep === "function" &&
+ typeof scheduler.setPhaseTimestep === "function";
+
+/** @internal */
+export type FixedStepScheduler = StepperScheduler &
+ Required<
+ Pick<
+ StepperScheduler,
+ "getOverstep" | "getPhaseTimestep" | "setPhaseTimestep"
+ >
+ >;
+
+/** @internal */
+export const sanitizeMaxSubSteps = (maxSubSteps: number | undefined) =>
+ maxSubSteps !== undefined && Number.isFinite(maxSubSteps) && maxSubSteps >= 1
+ ? Math.floor(maxSubSteps)
+ : DEFAULT_MAX_SUB_STEPS;
+
+/** @internal */
+export const ensureRapierPhase = (scheduler: StepperScheduler) => {
+ if (!scheduler.hasPhase(RAPIER_PHASE)) {
+ scheduler.addPhase(RAPIER_PHASE, { before: "update" });
+ }
+ return RAPIER_PHASE;
+};
+
+/** @internal */
+export const ensureLatePhase = (scheduler: StepperScheduler) => {
+ if (!scheduler.hasPhase(RAPIER_LATE_PHASE)) {
+ scheduler.addPhase(RAPIER_LATE_PHASE, { before: "render" });
+ }
+ return RAPIER_LATE_PHASE;
+};
+
+const FIXED_PHASE_PREFIX = "rapier:fixed:";
+
+// Fixed phases currently owned by a mounted world. Kept on the scheduler
+// singleton itself so duplicate copies of this module (or HMR) share it.
+const IN_USE = Symbol.for("@react-three/rapier/fixed-phases-in-use");
+const phasesInUse = (scheduler: StepperScheduler): Set =>
+ ((scheduler as any)[IN_USE] ??= new Set());
+
+/**
+ * Gives a world its own fixed-timestep phase, `rapier:fixed:`, reusing a
+ * phase released by an unmounted world when possible.
+ *
+ * Why one phase per world: phase timesteps are global to the scheduler
+ * singleton, so retiming r3f's shared `physics` phase would change it for
+ * every other user and rule out worlds with different timesteps. A phase per
+ * world can be retimed in place (`setPhaseTimestep`) when `timeStep` or
+ * `maxSubSteps` change. The scheduler has no `removePhase`, hence the reuse:
+ * the number of phases is bounded by the most worlds ever mounted at once.
+ * @internal
+ */
+export const acquireFixedPhase = (
+ scheduler: FixedStepScheduler,
+ timeStep: number,
+ maxSubSteps: number
+) => {
+ ensureRapierPhase(scheduler);
+ const inUse = phasesInUse(scheduler);
+
+ let index = 0;
+ while (inUse.has(FIXED_PHASE_PREFIX + index)) index++;
+ const name = FIXED_PHASE_PREFIX + index;
+ inUse.add(name);
+
+ if (scheduler.hasPhase(name)) {
+ scheduler.setPhaseTimestep(name, timeStep, { maxSubsteps: maxSubSteps });
+ } else {
+ scheduler.addPhase(name, {
+ before: RAPIER_PHASE,
+ timestep: timeStep,
+ maxSubsteps: maxSubSteps
+ });
+ }
+ return name;
+};
+
+/** @internal */
+export const releaseFixedPhase = (
+ scheduler: StepperScheduler,
+ name: string
+) => {
+ phasesInUse(scheduler).delete(name);
+};
+
+/** @internal */
+export interface AccumulatorState {
+ accumulator: number;
+}
+
+/**
+ * Banks `delta` into `state.accumulator` and returns how many fixed steps are
+ * owed. Mirrors the scheduler's fixed clock: at most `maxSubSteps` per call,
+ * surplus time is dropped (keeping the sub-step fraction) so a long frame costs
+ * one slow frame instead of a spiral of slower ones.
+ * @internal
+ */
+export const advanceAccumulator = (
+ state: AccumulatorState,
+ delta: number,
+ timeStep: number,
+ maxSubSteps: number
+) => {
+ if (!(delta > 0)) delta = 0;
+ state.accumulator += delta;
+
+ let steps = Math.floor((state.accumulator + TIMESTEP_EPSILON) / timeStep);
+ if (steps < 0) steps = 0;
+ const overflow = steps > maxSubSteps;
+ if (overflow) steps = maxSubSteps;
+
+ state.accumulator -= steps * timeStep;
+ if (overflow && state.accumulator >= timeStep) {
+ state.accumulator %= timeStep;
+ }
+ if (state.accumulator < 0) state.accumulator = 0;
+
+ return steps;
+};
+
+/**
+ * Interpolation alpha for an accumulator, in [0, 1).
+ * @internal
+ */
+export const accumulatorAlpha = (state: AccumulatorState, timeStep: number) =>
+ Math.min(0.999999, Math.max(0, state.accumulator / timeStep));
+
+/**
+ * Runs `callback(deltaSeconds)` on every animation frame, independent of r3f.
+ * The first frame reports a delta of 0.
+ * @internal
+ */
export const useRaf = (callback: (dt: number) => void) => {
const cb = useRef(callback);
const raf = useRef(0);
- const lastFrame = useRef(0);
+ const lastFrame = useRef(null);
useEffect(() => {
cb.current = callback;
@@ -12,15 +209,18 @@ export const useRaf = (callback: (dt: number) => void) => {
useEffect(() => {
const loop = () => {
const now = performance.now();
- const delta = now - lastFrame.current;
+ const delta = lastFrame.current === null ? 0 : now - lastFrame.current;
+ lastFrame.current = now;
raf.current = requestAnimationFrame(loop);
cb.current(delta / 1000);
- lastFrame.current = now;
};
raf.current = requestAnimationFrame(loop);
- return () => cancelAnimationFrame(raf.current);
+ return () => {
+ cancelAnimationFrame(raf.current);
+ lastFrame.current = null;
+ };
}, []);
};
diff --git a/packages/react-three-rapier/src/utils/utils-rigidbody.ts b/packages/react-three-rapier/src/utils/utils-rigidbody.ts
index 05f30017..3280e1c2 100644
--- a/packages/react-three-rapier/src/utils/utils-rigidbody.ts
+++ b/packages/react-three-rapier/src/utils/utils-rigidbody.ts
@@ -65,6 +65,98 @@ export const createRigidBodyState = ({
};
};
+/**
+ * A parent Object3D's world space, as last read by a sync pass.
+ * @internal
+ */
+export interface ParentSpace {
+ object: Object3D;
+ /** Sync pass this entry was last read in */
+ pass: number;
+ /** Bumped whenever the parent's world matrix changes */
+ version: number;
+ inverse: Matrix4;
+ world: Matrix4;
+}
+
+// Parent inverse world matrices, re-read once per sync pass and shared by all
+// bodies with the same parent. Keyed weakly so removed parents are collected.
+const parentSpaces = new WeakMap();
+let currentPass = 0;
+
+/**
+ * Starts a new sync pass: parent world matrices are re-read once per pass.
+ * @internal
+ */
+export const beginParentSpacePass = () => {
+ currentPass++;
+};
+
+/**
+ * Refreshes `state.invertedWorldMatrix` from the object's CURRENT parent world
+ * matrix (#36: it used to be captured once at spawn, so moving or rotating an
+ * ancestor afterwards desynced every visual from its body).
+ *
+ * @param collect receives each parent space the first time it is read in this
+ * pass, so a later pass can cheaply check whether any of them moved
+ * @returns whether the parent space changed (or the body was re-parented)
+ * since this body was last synced
+ * @internal
+ */
+export const refreshParentInverse = (
+ state: RigidBodyState,
+ collect?: ParentSpace[]
+) => {
+ const parent = state.object.parent;
+ if (!parent) return false;
+
+ let space = parentSpaces.get(parent);
+ if (!space) {
+ parent.updateWorldMatrix(true, false);
+ space = {
+ object: parent,
+ pass: currentPass,
+ version: 0,
+ world: parent.matrixWorld.clone(),
+ inverse: parent.matrixWorld.clone().invert()
+ };
+ parentSpaces.set(parent, space);
+ collect?.push(space);
+ } else if (space.pass !== currentPass) {
+ space.pass = currentPass;
+ parent.updateWorldMatrix(true, false);
+ if (!space.world.equals(parent.matrixWorld)) {
+ space.world.copy(parent.matrixWorld);
+ space.inverse.copy(parent.matrixWorld).invert();
+ space.version++;
+ }
+ collect?.push(space);
+ }
+
+ const moved =
+ state.parentSpace !== space || state.parentSpaceVersion !== space.version;
+ if (moved) {
+ state.invertedWorldMatrix.copy(space.inverse);
+ state.parentSpace = space;
+ state.parentSpaceVersion = space.version;
+ }
+ return moved;
+};
+
+/**
+ * Whether any of these parents' world matrices changed since they were read.
+ * Costs one `updateWorldMatrix` and one matrix compare per distinct parent.
+ * @internal
+ */
+export const anyParentSpaceMoved = (spaces: ParentSpace[]) => {
+ for (let i = 0; i < spaces.length; i++) {
+ const { object, world } = spaces[i];
+ object.updateWorldMatrix(true, false);
+ if (!world.equals(object.matrixWorld)) return true;
+ }
+ return false;
+};
+
type ImmutableRigidBodyOptions = (keyof RigidBodyProps)[];
export const immutableRigidBodyOptions: ImmutableRigidBodyOptions = [
diff --git a/packages/react-three-rapier/src/utils/utils.ts b/packages/react-three-rapier/src/utils/utils.ts
index 1fdd57f8..74b5ecec 100644
--- a/packages/react-three-rapier/src/utils/utils.ts
+++ b/packages/react-three-rapier/src/utils/utils.ts
@@ -32,7 +32,7 @@ export const rapierQuaternionToQuaternion = ({
export const vector3ToRapierVector = (v: Vector3Like) => {
if (Array.isArray(v)) {
- return new RapierVector3(v[0], v[1], v[2]);
+ return new RapierVector3(v[0], v[1], v[2] as number);
} else if (typeof v === "number") {
return new RapierVector3(v, v, v);
} else {
@@ -45,7 +45,13 @@ export const quaternionToRapierQuaternion = (v: QuaternionLike) => {
if (Array.isArray(v)) {
return new RapierQuaternion(v[0], v[1], v[2], v[3]);
} else {
- return new RapierQuaternion(v.x, v.y, v.z, v.w);
+ const threeQuaternion = v as Quaternion;
+ return new RapierQuaternion(
+ threeQuaternion.x,
+ threeQuaternion.y,
+ threeQuaternion.z,
+ threeQuaternion.w
+ );
}
};
@@ -97,7 +103,7 @@ export function useConst(initialValue: T | (() => T)): T {
ref.current = {
value:
typeof initialValue === "function"
- ? (initialValue as Function)()
+ ? (initialValue as () => T)()
: initialValue
};
}
diff --git a/packages/react-three-rapier/tests/character-controller.test.tsx b/packages/react-three-rapier/tests/character-controller.test.tsx
new file mode 100644
index 00000000..576ca6ac
--- /dev/null
+++ b/packages/react-three-rapier/tests/character-controller.test.tsx
@@ -0,0 +1,481 @@
+import React, { StrictMode, useEffect, useRef } from "react";
+import ReactThreeTestRenderer from "@react-three/test-renderer";
+import { getScheduler } from "@react-three/fiber";
+import { describe, expect, it, vi } from "vitest";
+import {
+ CapsuleCollider,
+ CharacterController,
+ CharacterControllerOptions,
+ CharacterControllerRef,
+ CuboidCollider,
+ Physics,
+ RapierContext,
+ RapierRigidBody,
+ RigidBody,
+ useCharacterController,
+ useBeforePhysicsStep,
+ useRapier
+} from "../src";
+
+type Harness = {
+ step: (dt: number) => void;
+ ctx: RapierContext;
+ chars: CharacterControllerRef[];
+ renderer: Awaited>;
+};
+
+const Grab = ({ onReady }: { onReady: (ctx: RapierContext) => void }) => {
+ const ctx = useRapier();
+ useEffect(() => {
+ onReady(ctx);
+ }, []);
+ return null;
+};
+
+const Character = ({
+ index,
+ chars,
+ children,
+ ...props
+}: { index: number; chars: CharacterControllerRef[] } & Record<
+ string,
+ any
+>) => {
+ const ref = useRef(null);
+ useEffect(() => {
+ chars[index] = ref.current!;
+ }, []);
+ return (
+
+
+ {children}
+
+ );
+};
+
+const Floor = () => (
+
+
+
+);
+
+const setup = async (
+ children: (chars: CharacterControllerRef[]) => React.ReactNode,
+ { strict = false } = {}
+): Promise => {
+ const chars: CharacterControllerRef[] = [];
+ let ctx!: RapierContext;
+ let renderer!: Harness["renderer"];
+ const tree = (
+
+ {children(chars)}
+ (ctx = c)} />
+
+ );
+ await ReactThreeTestRenderer.act(async () => {
+ renderer = await ReactThreeTestRenderer.create(
+ strict ? {tree} : tree
+ );
+ });
+ while (!ctx) await new Promise((r) => setTimeout(r, 10));
+ return {
+ ctx,
+ chars,
+ renderer,
+ step: (dt) => ctx.step(dt)
+ };
+};
+
+const run = async (h: Harness, seconds: number, dt = 1 / 60) => {
+ await ReactThreeTestRenderer.act(async () => {
+ const n = Math.round(seconds / dt);
+ for (let i = 0; i < n; i++) h.step(dt);
+ });
+};
+
+const pos = (c: CharacterControllerRef) => c.rigidBody!.translation();
+
+describe("character controller", () => {
+ it("creates and removes the controller on mount/unmount", async () => {
+ const h = await setup((chars) => );
+ expect(h.ctx.world.characterControllers.size).toBe(1);
+ await ReactThreeTestRenderer.act(async () => {
+ await h.renderer.unmount();
+ });
+ expect(h.ctx.world.characterControllers.size).toBe(0);
+ });
+
+ it("keeps exactly one controller under StrictMode", async () => {
+ const h = await setup((chars) => , {
+ strict: true
+ });
+ expect(h.ctx.world.characterControllers.size).toBe(1);
+ await ReactThreeTestRenderer.act(async () => {
+ await h.renderer.unmount();
+ });
+ expect(h.ctx.world.characterControllers.size).toBe(0);
+ });
+
+ it("applies options reactively", async () => {
+ let controller: any;
+ const Probe = ({ opts }: { opts: CharacterControllerOptions }) => {
+ const cc = useCharacterController(opts);
+ useEffect(() => {
+ controller = cc.controller;
+ });
+ return null;
+ };
+ let renderer!: Awaited>;
+ await ReactThreeTestRenderer.act(async () => {
+ renderer = await ReactThreeTestRenderer.create(
+
+
+
+ );
+ });
+ expect(controller.slideEnabled()).toBe(false);
+ expect(controller.snapToGroundEnabled()).toBe(true);
+ expect(controller.autostepEnabled()).toBe(false);
+ await ReactThreeTestRenderer.act(async () => {
+ await renderer.update(
+
+
+
+ );
+ });
+ expect(controller.slideEnabled()).toBe(true);
+ expect(controller.snapToGroundEnabled()).toBe(false);
+ expect(controller.autostepEnabled()).toBe(true);
+ expect(controller.autostepMaxHeight()).toBeCloseTo(0.4);
+ expect(controller.maxSlopeClimbAngle()).toBeCloseTo(0.5);
+ });
+
+ it("moves along a floor and reports grounded", async () => {
+ const h = await setup((chars) => (
+ <>
+
+
+ >
+ ));
+ const c = h.chars[0];
+ c.setVelocity({ x: 2, y: -1, z: 0 });
+ await run(h, 1);
+ expect(pos(c).x).toBeGreaterThan(1.9);
+ expect(pos(c).x).toBeLessThan(2.1);
+ expect(c.grounded).toBe(true);
+ expect(pos(c).y).toBeGreaterThan(0.7);
+ });
+
+ it("is blocked by a wall", async () => {
+ const h = await setup((chars) => (
+ <>
+
+
+
+
+
+ >
+ ));
+ const c = h.chars[0];
+ c.setVelocity({ x: 3, y: -1, z: 0 });
+ await run(h, 2);
+ // wall face at x=1.5, capsule radius 0.3 + offset
+ expect(pos(c).x).toBeLessThan(1.25);
+ expect(pos(c).x).toBeGreaterThan(1.0);
+ });
+
+ it("fires edge-triggered grounded/airborne events", async () => {
+ const onGrounded = vi.fn();
+ const onAirborne = vi.fn();
+ const h = await setup((chars) => (
+ <>
+
+
+ >
+ ));
+ const c = h.chars[0];
+ c.setVelocity({ x: 0, y: -2, z: 0 });
+ await run(h, 1);
+ expect(c.grounded).toBe(true);
+ expect(onGrounded).toHaveBeenCalledTimes(1);
+ expect(onAirborne).not.toHaveBeenCalled();
+ c.setVelocity({ x: 0, y: 5, z: 0 });
+ await run(h, 0.2);
+ expect(c.grounded).toBe(false);
+ expect(onAirborne).toHaveBeenCalledTimes(1);
+ expect(onGrounded).toHaveBeenCalledTimes(1);
+ });
+
+ it("reports character collisions", async () => {
+ const onCollision = vi.fn();
+ const h = await setup((chars) => (
+ <>
+
+
+ >
+ ));
+ h.chars[0].setVelocity({ x: 0, y: -1, z: 0 });
+ await run(h, 0.2);
+ expect(onCollision).toHaveBeenCalled();
+ });
+
+ const stepScene =
+ (autostep: boolean) => (chars: CharacterControllerRef[]) => (
+ <>
+
+
+
+
+
+ >
+ );
+
+ it("climbs a small step with autostep", async () => {
+ const h = await setup(stepScene(true));
+ h.chars[0].setVelocity({ x: 2, y: -1, z: 0 });
+ await run(h, 1.5);
+ expect(pos(h.chars[0]).x).toBeGreaterThan(2.0);
+ expect(pos(h.chars[0]).y).toBeGreaterThan(0.95);
+ });
+
+ it("is blocked by the same step without autostep", async () => {
+ const h = await setup(stepScene(false));
+ h.chars[0].setVelocity({ x: 2, y: -1, z: 0 });
+ await run(h, 1.5);
+ expect(pos(h.chars[0]).x).toBeLessThan(1.0);
+ });
+
+ it("is framerate independent with a fixed timeStep", async () => {
+ const dists: number[] = [];
+ for (const dt of [1 / 30, 1 / 60, 1 / 120]) {
+ const h = await setup((chars) => (
+ <>
+
+
+ >
+ ));
+ h.chars[0].setVelocity({ x: 3, y: -1, z: 0 });
+ await run(h, 1, dt);
+ dists.push(pos(h.chars[0]).x);
+ }
+ for (const d of dists)
+ expect(Math.abs(d - dists[1])).toBeLessThan(3 / 60 + 1e-6);
+ expect(dists[1]).toBeGreaterThan(2.9);
+ });
+
+ it("translate(delta) applies a one-off displacement", async () => {
+ const h = await setup((chars) => (
+ <>
+
+
+ >
+ ));
+ const c = h.chars[0];
+ c.translate({ x: 1, y: 0, z: 0 });
+ await run(h, 0.5);
+ expect(pos(c).x).toBeCloseTo(1, 1);
+ });
+
+ it("supports multiple independent characters", async () => {
+ const h = await setup((chars) => (
+ <>
+
+
+
+ >
+ ));
+ expect(h.ctx.world.characterControllers.size).toBe(2);
+ h.chars[0].setVelocity({ x: 2, y: -1, z: 0 });
+ h.chars[1].setVelocity({ x: -2, y: -1, z: 0 });
+ await run(h, 1);
+ expect(pos(h.chars[0]).x).toBeGreaterThan(1.8);
+ expect(pos(h.chars[1]).x).toBeLessThan(-1.8);
+ });
+
+ it("hook move() works from useBeforePhysicsStep and returns pooled results", async () => {
+ let body!: RapierRigidBody;
+ let first: any;
+ let second: any;
+ const Player = () => {
+ const ref = useRef(null);
+ const cc = useCharacterController();
+ useBeforePhysicsStep(() => {
+ body = ref.current!;
+ const r = cc.move(body, { x: 0.1, y: -0.1, z: 0 });
+ if (!first) first = r;
+ else second = r;
+ });
+ return (
+
+
+
+ );
+ };
+ const h = await setup(() => (
+ <>
+
+
+ >
+ ));
+ await run(h, 0.1);
+ expect(second).toBe(first);
+ expect(body.translation().x).toBeGreaterThan(0.1);
+ });
+
+ it("reverts options to Rapier defaults when they become undefined", async () => {
+ let controller: any;
+ const Probe = ({ opts }: { opts: CharacterControllerOptions }) => {
+ const cc = useCharacterController(opts);
+ useEffect(() => {
+ controller = cc.controller;
+ });
+ return null;
+ };
+ let renderer!: Awaited>;
+ await ReactThreeTestRenderer.act(async () => {
+ renderer = await ReactThreeTestRenderer.create(
+
+
+
+ );
+ });
+ const defaultSlope = Math.PI / 4;
+ expect(controller.maxSlopeClimbAngle()).toBeCloseTo(0.1);
+ await ReactThreeTestRenderer.act(async () => {
+ await renderer.update(
+
+
+
+ );
+ });
+ expect(controller.autostepEnabled()).toBe(true);
+ expect(controller.snapToGroundEnabled()).toBe(true);
+ expect(controller.slideEnabled()).toBe(true);
+ expect(controller.maxSlopeClimbAngle()).toBeCloseTo(defaultSlope);
+ await ReactThreeTestRenderer.act(async () => {
+ await renderer.update(
+
+
+
+ );
+ });
+ expect(controller.autostepEnabled()).toBe(false);
+ expect(controller.snapToGroundEnabled()).toBe(false);
+ });
+
+ it("creates only the explicit collider for mesh children", async () => {
+ const h = await setup((chars) => (
+
+
+
+
+
+ ));
+ expect(h.chars[0].rigidBody!.numColliders()).toBe(1);
+ });
+
+ it("move() after unmount is a no-op that does not leak a controller", async () => {
+ let api: any;
+ let body!: RapierRigidBody;
+ const Player = () => {
+ const ref = useRef(null);
+ api = useCharacterController();
+ useEffect(() => {
+ body = ref.current!;
+ }, []);
+ return (
+
+
+
+ );
+ };
+ const h = await setup(() => );
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ await ReactThreeTestRenderer.act(async () => {
+ await h.renderer.unmount();
+ });
+ expect(() => api.move(body, { x: 1, y: 0, z: 0 })).not.toThrow();
+ expect(h.ctx.world.characterControllers.size).toBe(0);
+ warn.mockRestore();
+ });
+
+ it("is framerate independent with timeStep='vary'", async () => {
+ const scheduler = getScheduler();
+ // One second of frames: steady 30 fps, steady 144 fps, and a jittery
+ // 30/120 fps mix. Each frame runs one vary step with that frame's delta,
+ // so a stale dt (e.g. the previous step's world.timestep) would drift.
+ const runs = [
+ Array(30).fill(1 / 30),
+ Array(144).fill(1 / 144),
+ Array.from({ length: 48 }, (_, i) => (i % 2 ? 1 / 120 : 1 / 30))
+ ];
+ const dists: number[] = [];
+ for (const deltas of runs) {
+ const chars: CharacterControllerRef[] = [];
+ const steps: number[] = [];
+ const Recorder = () => {
+ useBeforePhysicsStep((_world, dt) => {
+ steps.push(dt);
+ });
+ return null;
+ };
+ let ctx: RapierContext | undefined;
+ let renderer!: Harness["renderer"];
+ await ReactThreeTestRenderer.act(async () => {
+ renderer = await ReactThreeTestRenderer.create(
+
+
+
+
+ (ctx = c)} />
+
+ );
+ });
+ while (!ctx || !chars[0]) await new Promise((r) => setTimeout(r, 10));
+
+ // Horizontal only: with a downward push, Rapier's ground snapping
+ // loses a little distance per step, which is not what we test here.
+ chars[0].setVelocity({ x: 3, y: 0, z: 0 });
+ const start = pos(chars[0]).x;
+ let time = 0;
+ scheduler.resetTiming();
+ scheduler.step(time);
+ steps.length = 0;
+ for (const delta of deltas) {
+ time += delta * 1000;
+ scheduler.step(time);
+ }
+ expect(steps.length).toBe(deltas.length);
+ steps.forEach((dt, i) => expect(dt).toBeCloseTo(deltas[i], 6));
+ dists.push(pos(chars[0]).x - start);
+
+ await ReactThreeTestRenderer.act(async () => {
+ await renderer.unmount();
+ });
+ }
+ // 1 second at 3 units/s, whatever the frame rate
+ for (const d of dists) expect(d).toBeCloseTo(3, 2);
+ });
+});
diff --git a/packages/react-three-rapier/tests/collider-mass.test.tsx b/packages/react-three-rapier/tests/collider-mass.test.tsx
new file mode 100644
index 00000000..87464088
--- /dev/null
+++ b/packages/react-three-rapier/tests/collider-mass.test.tsx
@@ -0,0 +1,83 @@
+import ReactThreeTestRenderer from "@react-three/test-renderer";
+import React from "react";
+import { describe, expect, it } from "vitest";
+import { CuboidCollider, Physics, RapierCollider } from "../src";
+import { pause } from "./test-utils";
+
+const setup = async (
+ render: (value: number) => React.ReactElement,
+ initial: number
+) => {
+ const renderer = await ReactThreeTestRenderer.create(
+ {render(initial)}
+ );
+ await pause(300);
+ return renderer;
+};
+
+describe("mass options are mutable (#711)", () => {
+ it("updates collider mass when the mass prop changes", async () => {
+ let collider: RapierCollider | null = null;
+ const tree = (mass: number) => (
+ {
+ if (c) collider = c;
+ }}
+ args={[1, 1, 1]}
+ mass={mass}
+ />
+ );
+ const renderer = await setup(tree, 1);
+ expect(collider!.mass()).toBeCloseTo(1);
+
+ await ReactThreeTestRenderer.act(async () => {
+ await renderer.update({tree(50)} );
+ });
+ expect(collider!.mass()).toBeCloseTo(50);
+ });
+
+ it("updates collider density when the density prop changes", async () => {
+ let collider: RapierCollider | null = null;
+ const tree = (density: number) => (
+ {
+ if (c) collider = c;
+ }}
+ args={[1, 1, 1]}
+ density={density}
+ />
+ );
+ const renderer = await setup(tree, 1);
+ const before = collider!.mass();
+
+ await ReactThreeTestRenderer.act(async () => {
+ await renderer.update({tree(10)} );
+ });
+ expect(collider!.mass()).toBeCloseTo(before * 10);
+ });
+
+ it("updates collider mass when massProperties change", async () => {
+ let collider: RapierCollider | null = null;
+ const tree = (mass: number) => (
+ {
+ if (c) collider = c;
+ }}
+ args={[1, 1, 1]}
+ massProperties={{
+ mass,
+ centerOfMass: { x: 0, y: 0, z: 0 },
+ principalAngularInertia: { x: 1, y: 1, z: 1 },
+ angularInertiaLocalFrame: { x: 0, y: 0, z: 0, w: 1 }
+ }}
+ />
+ );
+ const renderer = await setup(tree, 2);
+ expect(collider!.mass()).toBeCloseTo(2);
+
+ await ReactThreeTestRenderer.act(async () => {
+ await renderer.update({tree(7)} );
+ });
+ expect(collider!.mass()).toBeCloseTo(7);
+ });
+});
diff --git a/packages/react-three-rapier/tests/colliders.test.tsx b/packages/react-three-rapier/tests/colliders.test.tsx
index 85183886..69cbfd02 100644
--- a/packages/react-three-rapier/tests/colliders.test.tsx
+++ b/packages/react-three-rapier/tests/colliders.test.tsx
@@ -28,7 +28,7 @@ import { createCollider } from "./test-utils";
describe("colliders", () => {
describe("mutable collider options", () => {
it(`should have sensor property correctly assigned`, async () => {
- for (let value of [true, false, undefined]) {
+ for (const value of [true, false, undefined]) {
const spy = vi.spyOn(Collider.prototype, "setSensor");
try {
@@ -60,7 +60,7 @@ describe("colliders", () => {
];
it(`should have collisionGroups property correctly assigned`, async () => {
- for (let value of interactionGroupsValues) {
+ for (const value of interactionGroupsValues) {
const spy = vi.spyOn(Collider.prototype, "setCollisionGroups");
try {
const collider = await createCollider({
@@ -79,7 +79,7 @@ describe("colliders", () => {
});
it(`should have solverGroups property correctly assigned`, async () => {
- for (let value of interactionGroupsValues) {
+ for (const value of interactionGroupsValues) {
const spy = vi.spyOn(Collider.prototype, "setSolverGroups");
try {
const collider = await createCollider({
@@ -99,7 +99,7 @@ describe("colliders", () => {
});
it(`should have friction property correctly assigned`, async () => {
- for (let value of [1, 2, 5]) {
+ for (const value of [1, 2, 5]) {
const spy = vi.spyOn(Collider.prototype, "setFriction");
try {
const collider = await createCollider({
@@ -117,7 +117,7 @@ describe("colliders", () => {
}
});
it(`should have frictionCombineRule property correctly assigned`, async () => {
- for (let value of [
+ for (const value of [
CoefficientCombineRule.Max,
CoefficientCombineRule.Min,
CoefficientCombineRule.Multiply,
@@ -141,7 +141,7 @@ describe("colliders", () => {
}
});
it(`should have restitutionCombineRule property correctly assigned`, async () => {
- for (let value of [
+ for (const value of [
CoefficientCombineRule.Max,
CoefficientCombineRule.Min,
CoefficientCombineRule.Multiply,
@@ -164,7 +164,7 @@ describe("colliders", () => {
}
});
it(`should have restitution property correctly assigned`, async () => {
- for (let value of [1, 2, 5]) {
+ for (const value of [1, 2, 5]) {
const spy = vi.spyOn(Collider.prototype, "setRestitution");
try {
const collider = await createCollider({
@@ -203,7 +203,7 @@ describe("colliders", () => {
describe("should have mass property correctly assigned", async () => {
const densityAtMass1 = 0.02083333395421505;
- for (let value of [1, 2, 5, undefined]) {
+ for (const value of [1, 2, 5, undefined]) {
it(`should work with value ${value}`, async () => {
const collider = await createCollider({
args: [1, 2, 3],
@@ -329,14 +329,18 @@ describe("colliders", () => {
);
};
- const { world, step, setShowCollider, setShowRigidBody } =
- await new Promise((resolve) => {
- ReactThreeTestRenderer.create(
-
-
-
- );
- });
+ let onMount!: (result: TestSceneResult) => void;
+ const mounted = new Promise(
+ (resolve) => (onMount = resolve)
+ );
+
+ await ReactThreeTestRenderer.create(
+
+
+
+ );
+
+ const { world, step, setShowCollider, setShowRigidBody } = await mounted;
expect(world.colliders.len()).to.equal(0);
diff --git a/packages/react-three-rapier/tests/exports.test.ts b/packages/react-three-rapier/tests/exports.test.ts
new file mode 100644
index 00000000..e05964b9
--- /dev/null
+++ b/packages/react-three-rapier/tests/exports.test.ts
@@ -0,0 +1,31 @@
+import * as Rapier from "@dimforge/rapier3d-compat";
+import { describe, expect, it } from "vitest";
+import * as R3R from "../src";
+
+describe("rapier enum re-exports (#703)", () => {
+ it.each([
+ "ActiveCollisionTypes",
+ "ActiveEvents",
+ "ActiveHooks",
+ "QueryFilterFlags",
+ "CoefficientCombineRule"
+ ] as const)("re-exports %s", (name) => {
+ expect(R3R[name]).toBeDefined();
+ expect(R3R[name]).toBe(Rapier[name]);
+ });
+});
+
+describe("@react-three/rapier/soft-bodies subpath", () => {
+ it("resolves to the same source modules as the relative import", async () => {
+ const subpath = await import("@react-three/rapier/soft-bodies");
+ const relative = await import("../src/soft-bodies");
+ expect(subpath.SoftBody).toBeDefined();
+ expect(subpath.useSoftBody).toBeDefined();
+ expect(subpath.SoftBodyState).toBe(relative.SoftBodyState);
+ });
+
+ it("shares core with the main entry (one rapier context)", async () => {
+ const main = await import("@react-three/rapier");
+ expect(main.useRapier).toBe(R3R.useRapier);
+ });
+});
diff --git a/packages/react-three-rapier/tests/geometry-args.test.ts b/packages/react-three-rapier/tests/geometry-args.test.ts
new file mode 100644
index 00000000..ffd54ee1
--- /dev/null
+++ b/packages/react-three-rapier/tests/geometry-args.test.ts
@@ -0,0 +1,138 @@
+import RAPIER from "@dimforge/rapier3d-compat";
+import {
+ BufferAttribute,
+ BufferGeometry,
+ InterleavedBuffer,
+ InterleavedBufferAttribute,
+ Vector3
+} from "three";
+import { describe, expect, it, vi } from "vitest";
+import {
+ createColliderFromOptions,
+ scaleColliderArgs,
+ getColliderArgsFromGeometry
+} from "../src/utils/utils-collider";
+
+const positions = [0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1];
+
+const interleavedGeometry = () => {
+ // pos(3) + uv(2) interleaved per vertex
+ const data: number[] = [];
+ for (let i = 0; i < 4; i++) {
+ data.push(...positions.slice(i * 3, i * 3 + 3), 0.5, 0.5);
+ }
+ const buffer = new InterleavedBuffer(new Float32Array(data), 5);
+ const g = new BufferGeometry();
+ g.setAttribute("position", new InterleavedBufferAttribute(buffer, 3, 0));
+ return g;
+};
+
+describe("getColliderArgsFromGeometry", () => {
+ it("hull: de-interleaves position attributes (#781)", () => {
+ const { args } = getColliderArgsFromGeometry(interleavedGeometry(), "hull");
+ expect(Array.from(args[0] as Float32Array)).toEqual(positions);
+ });
+
+ it("trimesh: de-interleaves position attributes and returns u32 indices (#781)", () => {
+ const g = interleavedGeometry();
+ g.setIndex(new BufferAttribute(new Uint16Array([0, 1, 2, 0, 2, 3]), 1));
+ const { args } = getColliderArgsFromGeometry(g, "trimesh");
+ expect(Array.from(args[0] as Float32Array)).toEqual(positions);
+ expect(args[1]).toBeInstanceOf(Uint32Array);
+ expect(Array.from(args[1] as Uint32Array)).toEqual([0, 1, 2, 0, 2, 3]);
+ });
+
+ it("trimesh: merges vertices of non-indexed geometry (#698)", () => {
+ const g = new BufferGeometry();
+ g.setAttribute(
+ "position",
+ new BufferAttribute(
+ new Float32Array([...positions.slice(0, 9), ...positions.slice(0, 9)]),
+ 3
+ )
+ );
+ const { args } = getColliderArgsFromGeometry(g, "trimesh");
+ expect((args[0] as Float32Array).length).toBe(9);
+ expect(Array.from(args[1] as Uint32Array)).toEqual([0, 1, 2, 0, 1, 2]);
+ });
+
+ it("hull: handles normalized quantized attributes (#627)", () => {
+ const g = new BufferGeometry();
+ g.setAttribute(
+ "position",
+ new BufferAttribute(
+ new Uint16Array([0, 0, 0, 65535, 0, 0, 0, 65535, 0, 0, 0, 65535]),
+ 3,
+ true
+ )
+ );
+ const { args } = getColliderArgsFromGeometry(g, "hull");
+ expect(Array.from(args[0] as Float32Array)).toEqual(positions);
+ });
+});
+
+describe("createColliderFromOptions", () => {
+ it("throws a clear error when the convex hull cannot be computed (#627)", async () => {
+ await RAPIER.init();
+ const world = new RAPIER.World({ x: 0, y: 0, z: 0 });
+ // all points identical -> degenerate hull
+ expect(() =>
+ createColliderFromOptions(
+ { shape: "convexHull", args: [new Float32Array(12)] } as any,
+ world,
+ new Vector3(1, 1, 1)
+ )
+ ).toThrow(/convex hull/i);
+ });
+});
+
+describe("scaleColliderArgs", () => {
+ it("scales heightfield x/y/z independently without mutating the args (#564)", () => {
+ const heightfieldScale = { x: 2, y: 3, z: 4 };
+ const args = [1, 1, [0, 0, 0, 0], heightfieldScale];
+ const scale = new Vector3(10, 100, 1000);
+
+ const first = scaleColliderArgs("heightfield", args, scale);
+ expect(first[3]).toEqual({ x: 20, y: 300, z: 4000 });
+ expect(heightfieldScale).toEqual({ x: 2, y: 3, z: 4 });
+
+ const second = scaleColliderArgs("heightfield", args, scale);
+ expect(second[3]).toEqual({ x: 20, y: 300, z: 4000 });
+ });
+});
+
+describe("heightfield args validation (#730)", () => {
+ const make = async (heights: number[]) => {
+ await RAPIER.init();
+ const world = new RAPIER.World({ x: 0, y: 0, z: 0 });
+ createColliderFromOptions(
+ {
+ shape: "heightfield",
+ args: [1, 1, heights, { x: 1, y: 1, z: 1 }]
+ } as any,
+ world,
+ new Vector3(1, 1, 1)
+ );
+ };
+
+ it("warns when heights length is not (rows+1)*(cols+1)", async () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ try {
+ // rapier may also panic on the invalid heights, which we wrap in an error
+ await make([0, 0, 0]).catch(() => {});
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("(rows + 1)"));
+ } finally {
+ warn.mockRestore();
+ }
+ });
+
+ it("does not warn for a correct heights length", async () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ try {
+ await make([0, 0, 0, 0]);
+ expect(warn).not.toHaveBeenCalled();
+ } finally {
+ warn.mockRestore();
+ }
+ });
+});
diff --git a/packages/react-three-rapier/tests/instanced.test.tsx b/packages/react-three-rapier/tests/instanced.test.tsx
new file mode 100644
index 00000000..40353bdc
--- /dev/null
+++ b/packages/react-three-rapier/tests/instanced.test.tsx
@@ -0,0 +1,88 @@
+import ReactThreeTestRenderer from "@react-three/test-renderer";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { InstancedRigidBodies, Physics } from "../src";
+import { pause } from "./test-utils";
+
+describe("InstancedRigidBodies", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("does not spread `key` into JSX (React 19 warning) (#691)", async () => {
+ const spy = vi.spyOn(console, "error").mockImplementation(() => {});
+
+ await ReactThreeTestRenderer.create(
+
+
+
+
+
+ );
+ await pause(300);
+
+ const keyWarnings = spy.mock.calls.filter((call) =>
+ String(call[0]).includes('"key" prop is being spread into JSX')
+ );
+ expect(keyWarnings).toHaveLength(0);
+ });
+
+ it("disables frustum culling on the instanced mesh, since bounds go stale (#683)", async () => {
+ const renderer = await ReactThreeTestRenderer.create(
+
+
+
+
+
+ );
+ await pause(300);
+
+ const meshes = renderer.scene.findAll(
+ (node) => "isInstancedMesh" in node.instance
+ );
+ expect(meshes).toHaveLength(1);
+ expect(meshes[0].instance.frustumCulled).toBe(false);
+ });
+
+ it("lets users opt back in to frustum culling", async () => {
+ const renderer = await ReactThreeTestRenderer.create(
+
+
+
+
+
+ );
+ await pause(300);
+
+ const meshes = renderer.scene.findAll(
+ (node) => "isInstancedMesh" in node.instance
+ );
+ expect(meshes[0].instance.frustumCulled).toBe(true);
+ });
+
+ it("keeps frustum culling disabled when the instanced mesh is re-created (#683)", async () => {
+ const tree = (count: number) => (
+
+ ({ key: i }))}
+ >
+
+
+
+ );
+ const renderer = await ReactThreeTestRenderer.create(tree(1));
+ await pause(300);
+ const first = renderer.scene.findAll(
+ (node) => "isInstancedMesh" in node.instance
+ )[0].instance;
+
+ await ReactThreeTestRenderer.act(async () => {
+ await renderer.update(tree(3));
+ });
+ const meshes = renderer.scene.findAll(
+ (node) => "isInstancedMesh" in node.instance
+ );
+ expect(meshes).toHaveLength(1);
+ expect(meshes[0].instance).not.toBe(first);
+ expect(meshes[0].instance.frustumCulled).toBe(false);
+ });
+});
diff --git a/packages/react-three-rapier/tests/physics.test.tsx b/packages/react-three-rapier/tests/physics.test.tsx
index d3050cea..cfdcc751 100644
--- a/packages/react-three-rapier/tests/physics.test.tsx
+++ b/packages/react-three-rapier/tests/physics.test.tsx
@@ -25,31 +25,28 @@ describe("physics", () => {
describe("useRapier exposed things", () => {
it("exposes a manual step function", async () => {
const rigidBody = React.createRef();
- const rapierContext = await new Promise(
- async (resolve, reject) => {
- try {
- await ReactThreeTestRenderer.create(
-
-
-
-
-
-
-
-
-
-
- );
- } catch (e) {
- reject(e);
- }
- }
+ let resolveContext!: (context: RapierContext) => void;
+ const ready = new Promise((resolve) => {
+ resolveContext = resolve;
+ });
+ await ReactThreeTestRenderer.create(
+
+
+
+
+
+
+
+
+
+
);
+ const rapierContext = await ready;
const translation1 = vec3(rigidBody.current?.translation()).toArray();
expect(translation1).to.deep.eq([0, 0, 0]);
@@ -108,23 +105,25 @@ describe("physics", () => {
const beforeStepCallback = vi.fn();
const frameCallback = vi.fn();
- let renderer: Awaited>;
-
- await new Promise(async (resolve) => {
- renderer = await ReactThreeTestRenderer.create(
-
-
-
-
-
-
-
- );
- });
+ let onReady!: (value: unknown) => void;
+ const ready = new Promise((resolve) => (onReady = resolve));
+
+ // `create` resolves after the tree has mounted, so await it directly rather than
+ // racing it against `onReady` (with r3f v10 `onReady` fires before `create` resolves).
+ const renderer = await ReactThreeTestRenderer.create(
+
+
+
+
+
+
+
+ );
+ await ready;
await ReactThreeTestRenderer.act(async () => {});
@@ -135,38 +134,43 @@ describe("physics", () => {
await renderer.advanceFrames(1, 1 / 60);
});
+ // r3f v10's test renderer primes the scheduler with a zero-delta frame before the
+ // requested frames, so useFrame runs n + 1 times while physics steps n times.
expect(beforeStepCallback).toBeCalledTimes(1);
- expect(frameCallback).toBeCalledTimes(1);
+ expect(frameCallback).toBeCalledTimes(2);
await ReactThreeTestRenderer.act(async () => {
await renderer.advanceFrames(1, 1 / 60);
});
expect(beforeStepCallback).toBeCalledTimes(2);
- expect(frameCallback).toBeCalledTimes(2);
+ expect(frameCallback).toBeCalledTimes(4);
});
it("updates when using the 'independent' strategy", async () => {
const beforeStepCallback = vi.fn();
const frameCallback = vi.fn();
- await new Promise(async (resolve) => {
- await ReactThreeTestRenderer.create(
-
-
-
-
-
- ,
- { frameloop: "demand" }
- );
+ let onReady!: () => void;
+ const ready = new Promise((resolve) => {
+ onReady = resolve;
});
+ await ReactThreeTestRenderer.create(
+
+
+
+
+
+ ,
+ { frameloop: "demand" }
+ );
+ await ready;
- await pause(50);
+ await pause(150);
expect(beforeStepCallback).toHaveBeenCalled();
expect(frameCallback).toHaveBeenCalled();
@@ -174,7 +178,7 @@ describe("physics", () => {
beforeStepCallback.mockClear();
frameCallback.mockClear();
- await pause(50);
+ await pause(150);
expect(beforeStepCallback).toHaveBeenCalled();
expect(frameCallback).toHaveBeenCalled();
@@ -182,7 +186,7 @@ describe("physics", () => {
beforeStepCallback.mockClear();
frameCallback.mockClear();
- await pause(50);
+ await pause(150);
expect(beforeStepCallback).toHaveBeenCalled();
expect(frameCallback).toHaveBeenCalled();
diff --git a/packages/react-three-rapier/tests/refs.test.tsx b/packages/react-three-rapier/tests/refs.test.tsx
new file mode 100644
index 00000000..91e4f98b
--- /dev/null
+++ b/packages/react-three-rapier/tests/refs.test.tsx
@@ -0,0 +1,110 @@
+import ReactThreeTestRenderer from "@react-three/test-renderer";
+import { createRef, StrictMode } from "react";
+import { pause } from "./test-utils";
+import { describe, expect, it } from "vitest";
+import {
+ CuboidCollider,
+ InstancedRigidBodies,
+ Physics,
+ RapierCollider,
+ RapierRigidBody,
+ RigidBody
+} from "../src";
+
+describe("forwarded refs", () => {
+ it("nulls RigidBody and collider object refs on unmount", async () => {
+ const bodyRef = createRef();
+ const colliderRef = createRef();
+
+ const renderer = await ReactThreeTestRenderer.create(
+
+
+
+
+
+ );
+ await pause(300);
+
+ expect(bodyRef.current).toBeTruthy();
+ expect(colliderRef.current).toBeTruthy();
+
+ await ReactThreeTestRenderer.act(async () => {
+ await renderer.unmount();
+ });
+
+ expect(bodyRef.current).toBeNull();
+ expect(colliderRef.current).toBeNull();
+ });
+
+ it("calls callback refs with null on unmount", async () => {
+ const bodyCalls: unknown[] = [];
+ const colliderCalls: unknown[] = [];
+
+ const renderer = await ReactThreeTestRenderer.create(
+
+ void bodyCalls.push(b)}>
+ void colliderCalls.push(c)}
+ args={[1, 1, 1]}
+ />
+
+
+ );
+ await pause(300);
+ await ReactThreeTestRenderer.act(async () => {
+ await renderer.unmount();
+ });
+
+ expect(bodyCalls[bodyCalls.length - 1]).toBeNull();
+ expect(colliderCalls[colliderCalls.length - 1]).toBeNull();
+ expect(bodyCalls.some((b) => b !== null)).toBe(true);
+ });
+
+ it("keeps refs populated under StrictMode and nulls them on unmount", async () => {
+ const bodyRef = createRef();
+
+ const renderer = await ReactThreeTestRenderer.create(
+
+
+
+
+
+
+
+ );
+ await pause(300);
+
+ expect(bodyRef.current).toBeTruthy();
+ // must not be a freed body
+ expect(() => bodyRef.current!.translation()).not.toThrow();
+
+ await ReactThreeTestRenderer.act(async () => {
+ await renderer.unmount();
+ });
+ expect(bodyRef.current).toBeNull();
+ });
+
+ it("nulls InstancedRigidBodies per-instance refs when instances are removed", async () => {
+ const ref = createRef<(RapierRigidBody | null)[]>();
+ const tree = (n: number) => (
+
+ ({ key: i }))}
+ >
+
+
+
+ );
+ const renderer = await ReactThreeTestRenderer.create(tree(3));
+ await pause(300);
+ expect(ref.current!.filter(Boolean).length).toBe(3);
+
+ await ReactThreeTestRenderer.act(async () => {
+ await renderer.update(tree(1));
+ });
+ expect(ref.current![1]).toBeNull();
+ expect(ref.current![2]).toBeNull();
+ expect(ref.current![0]).toBeTruthy();
+ });
+});
diff --git a/packages/react-three-rapier/tests/rigidbody-type.test.tsx b/packages/react-three-rapier/tests/rigidbody-type.test.tsx
new file mode 100644
index 00000000..fdc11a4f
--- /dev/null
+++ b/packages/react-three-rapier/tests/rigidbody-type.test.tsx
@@ -0,0 +1,42 @@
+import { RigidBodyType } from "@dimforge/rapier3d-compat";
+import ReactThreeTestRenderer from "@react-three/test-renderer";
+import { createRef } from "react";
+import { describe, expect, it } from "vitest";
+import { CuboidCollider, Physics, RapierRigidBody, RigidBody } from "../src";
+import { pause } from "./test-utils";
+
+describe("RigidBody type changes (#314)", () => {
+ it("applies a changed `type` prop after mount", async () => {
+ const ref = createRef();
+ const tree = (type: "dynamic" | "fixed" | "kinematicPosition") => (
+
+
+
+
+
+ );
+
+ const renderer = await ReactThreeTestRenderer.create(tree("dynamic"));
+ await pause(300);
+ expect(ref.current!.bodyType()).toBe(RigidBodyType.Dynamic);
+
+ await ReactThreeTestRenderer.act(async () => {
+ await renderer.update(tree("fixed"));
+ });
+ expect(ref.current!.bodyType()).toBe(RigidBodyType.Fixed);
+ expect(ref.current!.isFixed()).toBe(true);
+ // colliders must follow the new body
+ expect(ref.current!.numColliders()).toBe(1);
+
+ await ReactThreeTestRenderer.act(async () => {
+ await renderer.update(tree("kinematicPosition"));
+ });
+ expect(ref.current!.bodyType()).toBe(RigidBodyType.KinematicPositionBased);
+ expect(ref.current!.numColliders()).toBe(1);
+
+ await ReactThreeTestRenderer.act(async () => {
+ await renderer.update(tree("dynamic"));
+ });
+ expect(ref.current!.bodyType()).toBe(RigidBodyType.Dynamic);
+ });
+});
diff --git a/packages/react-three-rapier/tests/setup.ts b/packages/react-three-rapier/tests/setup.ts
index 13301255..74f6f6e5 100644
--- a/packages/react-three-rapier/tests/setup.ts
+++ b/packages/react-three-rapier/tests/setup.ts
@@ -1,7 +1,6 @@
// vitest.setup.ts
import "vitest-canvas-mock";
-// @ts-ignore
global.IS_REACT_ACT_ENVIRONMENT = true;
// Ignore all warnings that are repetitive and not helpful
diff --git a/packages/react-three-rapier/tests/soft-bodies.test.tsx b/packages/react-three-rapier/tests/soft-bodies.test.tsx
new file mode 100644
index 00000000..a93855fd
--- /dev/null
+++ b/packages/react-three-rapier/tests/soft-bodies.test.tsx
@@ -0,0 +1,432 @@
+import ReactThreeTestRenderer from "@react-three/test-renderer";
+import { getScheduler } from "@react-three/fiber";
+import React, { StrictMode } from "react";
+import { describe, expect, it } from "vitest";
+import { Physics, RapierContext, RapierRigidBody, RigidBody } from "../src";
+import { SoftBodyMaterial } from "@dimforge/rapier3d-compat";
+import { SoftBody, SoftBodyState, weldGeometry } from "../src/soft-bodies";
+import { BufferGeometry, Mesh, SphereGeometry, Vector3 } from "three";
+import { pause, UseRapierMounter } from "./test-utils";
+
+const setup = async (
+ children: React.ReactNode,
+ { strict = false, paused = true }: { strict?: boolean; paused?: boolean } = {}
+) => {
+ // Await `create` itself (not just the mount callback): racing them leaves
+ // act() scopes overlapping, which doesn't flush reliably on r3f v10.
+ let resolveContext!: (context: RapierContext) => void;
+ const ready = new Promise((resolve) => {
+ resolveContext = resolve;
+ });
+ const tree = (
+
+
+ {children}
+
+ );
+ let renderer!: Awaited>;
+ await ReactThreeTestRenderer.act(async () => {
+ renderer = await ReactThreeTestRenderer.create(
+ strict ? {tree} : tree
+ );
+ });
+ const context = await ready;
+ // let the effects of the children settle
+ await ReactThreeTestRenderer.act(async () => {
+ await pause(20);
+ });
+ return { renderer, context };
+};
+
+const stepFor = (context: RapierContext, seconds: number) => {
+ const steps = Math.round(seconds * 60);
+ for (let i = 0; i < steps; i++) context.step(1 / 60);
+};
+
+const clothMesh = (
+
+
+
+
+);
+
+const positions = (mesh: Mesh) =>
+ Array.from(mesh.geometry.attributes.position.array as Float32Array);
+
+describe("soft bodies", () => {
+ it("creates a soft body from a mesh and removes it on unmount", async () => {
+ const ref = React.createRef();
+ const { renderer, context } = await setup(
+
+ {clothMesh}
+
+ );
+
+ expect(context.world.softBodies.len()).toBe(1);
+ expect(ref.current).toBeInstanceOf(SoftBodyState);
+ expect(ref.current!.body.numParticles()).toBe(25);
+
+ await ReactThreeTestRenderer.act(async () => {
+ await renderer.unmount();
+ });
+ expect(context.world.softBodies.len()).toBe(0);
+ });
+
+ it("is safe under StrictMode (no leaked or duplicated bodies)", async () => {
+ const { renderer, context } = await setup(
+ {clothMesh} ,
+ { strict: true }
+ );
+ expect(context.world.softBodies.len()).toBe(1);
+ await ReactThreeTestRenderer.act(async () => {
+ await renderer.unmount();
+ });
+ expect(context.world.softBodies.len()).toBe(0);
+ });
+
+ it("moves the mesh vertices under gravity", async () => {
+ const ref = React.createRef();
+ const { context } = await setup(
+
+ {clothMesh}
+
+ );
+ const mesh = ref.current!.object as Mesh;
+ const before = positions(mesh);
+ // vertices are in the parent's space, with the mesh position baked in
+ expect(Math.max(...before.filter((_, i) => i % 3 === 1))).toBeCloseTo(3.5);
+ expect(mesh.position.y).toBe(0);
+
+ stepFor(context, 0.5);
+
+ const after = positions(mesh);
+ const meanY = (p: number[]) =>
+ p.filter((_, i) => i % 3 === 1).reduce((a, b) => a + b, 0) /
+ (p.length / 3);
+ expect(meanY(after)).toBeLessThan(meanY(before) - 0.2);
+ // normals and bounds are recomputed
+ expect(mesh.geometry.boundingSphere!.center.y).toBeLessThan(3);
+ expect(mesh.geometry.attributes.normal).toBeDefined();
+ });
+
+ it("keeps pinned vertices in place", async () => {
+ const ref = React.createRef();
+ const { context } = await setup(
+ // a horizontal cloth, pinned along one edge (local y = +0.5)
+ p.y > 0.49}>
+
+
+
+
+
+ );
+ const mesh = ref.current!.object as Mesh;
+ const before = positions(mesh);
+ stepFor(context, 1);
+ const after = positions(mesh);
+
+ let pinned = 0;
+ let falling = 0;
+ for (let i = 0; i < before.length / 3; i++) {
+ const moved = Math.abs(after[i * 3 + 1] - before[i * 3 + 1]);
+ if (moved < 1e-4) pinned++;
+ else if (moved > 0.005) falling++;
+ }
+ expect(pinned).toBe(5);
+ expect(falling).toBeGreaterThan(0);
+ });
+
+ it("writes vertices in the parent's space", async () => {
+ const ref = React.createRef();
+ const { context } = await setup(
+
+
+ {clothMesh}
+
+
+ );
+ const mesh = ref.current!.object as Mesh;
+ stepFor(context, 0.3);
+ mesh.updateWorldMatrix(true, false);
+
+ // the simulated particles are in world space...
+ const world = ref.current!.body.particlePositions();
+ const p = mesh.geometry.attributes.position;
+ const v = new Vector3();
+ for (let i = 0; i < p.count; i++) {
+ v.fromBufferAttribute(p, i).applyMatrix4(mesh.matrixWorld);
+ expect(v.x).toBeCloseTo(world[i * 3], 3);
+ expect(v.y).toBeCloseTo(world[i * 3 + 1], 3);
+ expect(v.z).toBeCloseTo(world[i * 3 + 2], 3);
+ }
+ // ...and the local ones are not (the parent is offset by 10 on x)
+ expect(Math.abs(p.getX(0))).toBeLessThan(2);
+ });
+
+ describe.each([1, 3])(
+ "material driven tears (%i substeps per frame)",
+ (n) => {
+ it("never exposes NaN vertices or bounds", async () => {
+ const ref = React.createRef();
+ let tears = 0;
+ const { context } = await setup(
+ p.y > 0.49}
+ material={() => {
+ const material = SoftBodyMaterial.uniform(8, 0.4);
+ material.tearStrain = 0.0005;
+ return material;
+ }}
+ onTear={() => tears++}
+ >
+ {clothMesh}
+
+ );
+ const mesh = ref.current!.object as Mesh;
+ for (let frame = 0; frame < 90; frame++) {
+ context.step(n / 60);
+ const p = positions(mesh);
+ expect(p.every((v) => Number.isFinite(v))).toBe(true);
+ expect(mesh.geometry.boundingSphere!.radius).not.toBeNaN();
+ }
+ expect(tears).toBeGreaterThan(0);
+ });
+ }
+ );
+
+ it("keeps the original UVs and hard normals of the render mesh", async () => {
+ const ref = React.createRef();
+ const { context } = await setup(
+
+
+
+
+
+
+ );
+ const geometry = ref.current!.object.geometry;
+ // unwelded: 24 render vertices, each face keeps its own uv
+ expect(geometry.attributes.position.count).toBe(24);
+ expect(geometry.attributes.uv.count).toBe(24);
+ stepFor(context, 0.2);
+ expect(geometry.attributes.position.count).toBe(24);
+ });
+
+ it("welds vertices on position only (UV seams)", () => {
+ const sphere = new SphereGeometry(1, 8, 6);
+ const welded = weldGeometry(sphere);
+ const unique = new Set();
+ const p = sphere.attributes.position;
+ for (let i = 0; i < p.count; i++) {
+ unique.add(
+ [p.getX(i), p.getY(i), p.getZ(i)]
+ .map((v) => (Math.round(v * 1e4) / 1e4 + 0).toFixed(4))
+ .join(",")
+ );
+ }
+ expect(welded.attributes.position.count).toBe(unique.size);
+ expect(welded.attributes.uv.count).toBe(unique.size);
+ expect(sphere.attributes.position.count).toBeGreaterThan(unique.size);
+ });
+
+ it("builds ropes from a line, and the free end swings", async () => {
+ const ref = React.createRef();
+ const geometry = new BufferGeometry().setFromPoints(
+ Array.from({ length: 6 }, (_, i) => ({ x: i * 0.4, y: 4, z: 0 }) as any)
+ );
+ const { context } = await setup(
+
+
+
+
+
+
+ );
+ const line = ref.current!.object;
+ expect(ref.current!.body.numParticles()).toBe(6);
+ stepFor(context, 1);
+ const p = positions(line as unknown as Mesh);
+ expect(p[0]).toBeCloseTo(0);
+ expect(p[1]).toBeCloseTo(4);
+ // last point dropped well below the start
+ expect(p[5 * 3 + 1]).toBeLessThan(3);
+ });
+
+ it("fills a closed mesh with a volume and deforms the skin", async () => {
+ const ref = React.createRef();
+ const { context } = await setup(
+
+
+
+
+
+
+ );
+ const mesh = ref.current!.object as Mesh;
+ expect(ref.current!.body.numCells()).toBeGreaterThan(0);
+ const before = positions(mesh);
+ stepFor(context, 0.4);
+ const after = positions(mesh);
+ expect(after.some((v, i) => Math.abs(v - before[i]) > 0.05)).toBe(true);
+ });
+
+ it("collides with a fixed rigid body", async () => {
+ const ref = React.createRef();
+ const { context } = await setup(
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+ stepFor(context, 2);
+ const mesh = ref.current!.object as Mesh;
+ const minY = Math.min(...positions(mesh).filter((_, i) => i % 3 === 1));
+ // rests on the floor top (y = 0.25) instead of falling through
+ expect(minY).toBeGreaterThan(0.1);
+ });
+
+ it("attaches particles to a rigid body", async () => {
+ const body = React.createRef();
+ const ref = React.createRef();
+ const { context } = await setup(
+ <>
+
+
+
+ p.y > 0.49, body }]}
+ >
+ {clothMesh}
+
+ >
+ );
+ expect(ref.current!.body.numAttachments()).toBe(5);
+ body.current!.setNextKinematicTranslation({ x: 3, y: 3.5, z: 0 });
+ stepFor(context, 1);
+ const p = positions(ref.current!.object as Mesh);
+ const topX = p.filter((_, i) => i % 3 === 0 && p[i + 1] > 3.3);
+ expect(Math.max(...topX)).toBeGreaterThan(1);
+ });
+
+ it("keeps attached vertices on their interpolated rigid body between steps", async () => {
+ const body = React.createRef();
+ const anchor = React.createRef();
+ const ref = React.createRef();
+ await setup(
+ <>
+
+
+
+ p.y > 0.49, body }]}
+ >
+ {clothMesh}
+
+ >,
+ { paused: false }
+ );
+ const cloth = ref.current!.object as Mesh;
+ expect(ref.current!.body.numAttachments()).toBe(5);
+
+ // Rendered (local) x offsets of the attached top row from the body.
+ const topRow = (bodyX: number) => {
+ const p = positions(cloth);
+ const out: number[] = [];
+ for (let i = 0; i < p.length; i += 3) {
+ const world = cloth.localToWorld(new Vector3(p[i], p[i + 1], p[i + 2]));
+ if (world.y > 3.45) out.push(world.x - bodyX);
+ }
+ return out.sort((a, b) => a - b);
+ };
+
+ // Drive real frames: 10 whole steps, then half a step, so the frame
+ // renders with an interpolation alpha of 0.5.
+ const scheduler = getScheduler();
+ let time = 0;
+ scheduler.resetTiming();
+ scheduler.step(time);
+ for (let i = 0; i < 10; i++) {
+ time += 1000 / 60;
+ scheduler.step(time);
+ }
+ time += 500 / 60;
+ scheduler.step(time);
+
+ const rendered = anchor.current!.getWorldPosition(new Vector3());
+ const simulated = body.current!.translation();
+ // The body itself renders half a step (6 m/s / 60 / 2) behind the simulation
+ expect(simulated.x - rendered.x).toBeCloseTo(0.05, 3);
+
+ // The attached vertices keep the offsets the simulation has between them
+ // and the body (the engine's attachment trails a moving body slightly),
+ // measured from the rendered body: they are not half a step ahead of it.
+ const offsets = topRow(rendered.x);
+ const particles = ref.current!.body.particlePositions();
+ const expected: number[] = [];
+ for (let i = 0; i < particles.length; i += 3) {
+ if (particles[i + 1] > 3.45) expected.push(particles[i] - simulated.x);
+ }
+ expected.sort((a, b) => a - b);
+ expect(offsets.length).toBe(5);
+ expect(expected.length).toBe(5);
+ expected.forEach((x, i) => expect(offsets[i]).toBeCloseTo(x, 3));
+ });
+
+ it("reports tears and gives the torn seam its own vertices", async () => {
+ const ref = React.createRef();
+ const tears: number[] = [];
+ const { context } = await setup(
+ p.y > 0.49}
+ onTear={(e) => tears.push(e.tornEdges.length)}
+ >
+ {clothMesh}
+
+ );
+ const geometry = ref.current!.object.geometry;
+ const before = geometry.index!.count;
+ stepFor(context, 0.2);
+ // tear every edge (bend edges included) that crosses the plane x = 0.01
+ const body = ref.current!.body;
+ const edges = body.edges();
+ const crossing: number[] = [];
+ for (let e = 0; e < edges.length / 2; e++) {
+ const a = body.particlePosition(edges[e * 2]).x;
+ const b = body.particlePosition(edges[e * 2 + 1]).x;
+ if ((a - 0.01) * (b - 0.01) < 0) crossing.push(e);
+ }
+ expect(ref.current!.tear(crossing)).toBe(true);
+ expect(tears.length).toBe(1);
+ // the torn seam got its own vertices
+ expect(ref.current!.vertexCount).toBeGreaterThan(25);
+ expect(tears[0]).toBeGreaterThanOrEqual(crossing.length);
+ expect(ref.current!.object.geometry.index!.count % 3).toBe(0);
+
+ stepFor(context, 0.2);
+ const p = positions(ref.current!.object as Mesh);
+ expect(p.every((v) => Number.isFinite(v))).toBe(true);
+ });
+});
diff --git a/packages/react-three-rapier/tests/stepping.test.tsx b/packages/react-three-rapier/tests/stepping.test.tsx
new file mode 100644
index 00000000..e7f11197
--- /dev/null
+++ b/packages/react-three-rapier/tests/stepping.test.tsx
@@ -0,0 +1,734 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import React, { useEffect } from "react";
+import ReactThreeTestRenderer from "@react-three/test-renderer";
+import { getScheduler, useFrame } from "@react-three/fiber";
+import {
+ BufferGeometry,
+ Group,
+ LineSegments,
+ Object3D,
+ Quaternion,
+ Vector3
+} from "three";
+import {
+ CuboidCollider,
+ Physics,
+ RapierContext,
+ RapierRigidBody,
+ RigidBody,
+ RigidBodyProps,
+ useAfterPhysicsStep,
+ useBeforePhysicsStep,
+ useRapier
+} from "../src";
+import { pause } from "./test-utils";
+
+type Renderer = Awaited>;
+
+const scheduler = getScheduler();
+
+let renderers: Renderer[] = [];
+const create = async (element: React.ReactNode, options?: any) => {
+ let renderer!: Renderer;
+ await ReactThreeTestRenderer.act(async () => {
+ renderer = await ReactThreeTestRenderer.create(
+ element as React.ReactElement,
+ options
+ );
+ });
+ renderers.push(renderer);
+
+ // suspends while Rapier's wasm loads (first test only)
+ for (let i = 0; i < 200 && renderer.scene.children.length === 0; i++) {
+ await ReactThreeTestRenderer.act(async () => {
+ await pause(10);
+ });
+ }
+ return renderer;
+};
+
+afterEach(async () => {
+ for (const renderer of renderers) {
+ await ReactThreeTestRenderer.act(async () => {
+ await renderer.unmount();
+ });
+ }
+ renderers = [];
+});
+
+/**
+ * Drives the scheduler like real rAF frames, without resetting the clocks
+ * between calls (unlike `advanceFrames`), so banked time carries over.
+ */
+const createClock = () => {
+ let time = 0;
+ scheduler.resetTiming();
+ scheduler.step(time);
+ return (deltas: number | number[], count = 1) => {
+ const list = Array.isArray(deltas) ? deltas : Array(count).fill(deltas);
+ for (const delta of list) {
+ time += delta * 1000;
+ scheduler.step(time);
+ }
+ };
+};
+
+/** Records every before/after step call with its dt. */
+const createRecorder = () => {
+ const before: number[] = [];
+ const after: number[] = [];
+ const Recorder = () => {
+ useBeforePhysicsStep((world, dt) => {
+ expect(world).toBeDefined();
+ before.push(dt);
+ });
+ useAfterPhysicsStep((_world, dt) => {
+ after.push(dt);
+ });
+ return null;
+ };
+ return { before, after, Recorder };
+};
+
+const ContextCatcher = ({ onContext }: { onContext: (c: any) => void }) => {
+ const ctx = useRapier();
+ useEffect(() => {
+ onContext(ctx);
+ }, [ctx]);
+ return null;
+};
+
+const Body = ({
+ bodyRef,
+ objectRef,
+ ...props
+}: RigidBodyProps & {
+ bodyRef?: React.Ref;
+ objectRef?: React.Ref;
+}) => (
+
+
+
+
+
+);
+
+const FALLBACK = "accumulator fallback (no scheduler fixed-step)";
+const FIXED = "scheduler fixed phase";
+
+/** Hide the scheduler 0.3 fixed-step API to exercise the fallback path. */
+const useFallback = () => {
+ beforeEach(() => {
+ (scheduler as any).getOverstep = undefined;
+ });
+ afterEach(() => {
+ delete (scheduler as any).getOverstep;
+ });
+};
+
+describe.each([FIXED, FALLBACK])("stepping: %s", (mode) => {
+ if (mode === FALLBACK) useFallback();
+
+ it("runs a deterministic number of fixed steps for varying frame deltas", async () => {
+ const { before, after, Recorder } = createRecorder();
+ await create(
+
+
+
+
+ );
+
+ const frames = createClock();
+
+ // 1/120 + 1/40 = 1/30 -> two steps every pair of frames
+ frames([1 / 120, 1 / 40], 1);
+ for (let i = 0; i < 9; i++) frames([1 / 120, 1 / 40]);
+ expect(before.length).toBe(20);
+
+ // 0.3s in 30 uneven-ish frames of 10ms -> exactly 18 steps of 1/60
+ frames(0.01, 30);
+ expect(before.length).toBe(38);
+
+ expect(new Set(before)).toEqual(new Set([1 / 60]));
+ expect(after).toEqual(before);
+ });
+
+ it("caps catch-up at maxSubSteps and drops the surplus", async () => {
+ const { before, Recorder } = createRecorder();
+ await create(
+
+
+
+
+ );
+
+ const frames = createClock();
+ frames(10.5 / 60);
+ expect(before.length).toBe(3);
+
+ // The dropped 7 steps are not owed later; only the half step carries over
+ frames(1 / 60);
+ expect(before.length).toBe(4);
+ });
+
+ it("does not step while paused, and does not burst on resume", async () => {
+ const { before, Recorder } = createRecorder();
+ const scene = (paused: boolean) => (
+
+
+
+
+ );
+ const renderer = await create(scene(true));
+
+ const frames = createClock();
+ frames(1 / 60, 30);
+ expect(before.length).toBe(0);
+
+ await ReactThreeTestRenderer.act(async () => {
+ await renderer.update(scene(false));
+ });
+
+ frames(1 / 60);
+ expect(before.length).toBe(1);
+ frames(1 / 60);
+ expect(before.length).toBe(2);
+ });
+
+ it("interpolates rendered transforms by the overstep", async () => {
+ const object = React.createRef();
+ const bodyObject = () => object.current!.parent!;
+ await create(
+
+
+
+ );
+
+ const frames = createClock();
+ // One step of 1/60 moves the body 0.1. Half a step remains banked.
+ frames(1.5 / 60);
+ expect(bodyObject().position.x).toBeCloseTo(0.05, 4);
+
+ // 2.25 steps banked: two steps run (x = 0.2), a quarter of the next banked,
+ // so the visual sits a quarter of the way from step 1 to step 2
+ frames(0.75 / 60);
+ expect(bodyObject().position.x).toBeCloseTo(0.125, 4);
+
+ // 3.25 steps: a quarter of the way from step 2 to step 3
+ frames(1 / 60);
+ expect(bodyObject().position.x).toBeCloseTo(0.225, 4);
+ });
+
+ it("renders the latest step when interpolate is false", async () => {
+ const object = React.createRef();
+ await create(
+
+
+
+ );
+
+ const frames = createClock();
+ frames(1.5 / 60);
+ expect(object.current!.parent!.position.x).toBeCloseTo(0.1, 4);
+ });
+});
+
+describe("stepping", () => {
+ it("passes dt to useBeforePhysicsStep/useAfterPhysicsStep and still supports single-argument callbacks", async () => {
+ const legacy = vi.fn((world: any) => world);
+ const { before, after, Recorder } = createRecorder();
+ const Legacy = () => {
+ useBeforePhysicsStep(legacy);
+ return null;
+ };
+
+ await create(
+
+
+
+
+
+ );
+
+ const frames = createClock();
+ frames(1 / 30, 2);
+ expect(before).toEqual([1 / 30, 1 / 30]);
+ expect(after).toEqual([1 / 30, 1 / 30]);
+ expect(legacy).toHaveBeenCalledTimes(2);
+ });
+
+ it("steps with the clamped frame delta in vary mode", async () => {
+ const { before, Recorder } = createRecorder();
+ await create(
+
+
+
+
+ );
+
+ const frames = createClock();
+ frames([1 / 30, 1 / 45, 0.75]);
+ expect(before.length).toBe(3);
+ expect(before[0]).toBeCloseTo(1 / 30, 9);
+ expect(before[1]).toBeCloseTo(1 / 45, 9);
+ expect(before[2]).toBe(0.5);
+ });
+
+ it("manual step() works while paused and syncs meshes", async () => {
+ const object = React.createRef();
+ let context!: RapierContext;
+ await create(
+
+ (context = c)} />
+
+
+ );
+
+ const frames = createClock();
+ frames(1 / 60, 10);
+ expect(object.current!.parent!.position.x).toBe(0);
+
+ context.step(1 / 60);
+ // Synced immediately, without waiting for a frame
+ expect(object.current!.parent!.position.x).toBeCloseTo(0.1, 4);
+
+ // Paused frames keep showing the stepped pose (alpha 1, no jitter)
+ frames(0.7 / 60);
+ expect(object.current!.parent!.position.x).toBeCloseTo(0.1, 4);
+ });
+
+ it("runs multiple worlds with different timeSteps in one canvas, without retiming the shared physics phase", async () => {
+ const a = createRecorder();
+ const b = createRecorder();
+
+ await create(
+ <>
+
+
+
+
+
+
+
+
+ >
+ );
+
+ const frames = createClock();
+ frames(1 / 60, 12);
+
+ expect(a.before.length).toBe(12);
+ expect(b.before.length).toBe(6);
+ expect(new Set(b.before)).toEqual(new Set([1 / 30]));
+ expect(scheduler.getPhaseTimestep("physics")).toBe(1 / 60);
+ });
+
+ it("invalidates the canvas while bodies are awake (frameloop='demand')", async () => {
+ const invalidateRoot = vi.spyOn(scheduler, "invalidateRoot");
+ const { before, Recorder } = createRecorder();
+
+ await create(
+
+
+
+ ,
+ { frameloop: "demand" }
+ );
+
+ // The scheduler's own rAF driver keeps ticking the demand root only
+ // because every synced frame with awake bodies invalidates it.
+ await pause(200);
+ expect(before.length).toBeGreaterThan(3);
+ expect(invalidateRoot).toHaveBeenCalled();
+ invalidateRoot.mockRestore();
+ });
+
+ it("does not invalidate when nothing is awake", async () => {
+ const { Recorder } = createRecorder();
+ await create(
+
+
+
+ ,
+ { frameloop: "demand" }
+ );
+
+ const frames = createClock();
+ frames(1 / 60, 2);
+ const invalidateRoot = vi.spyOn(scheduler, "invalidateRoot");
+ frames(1 / 60, 5);
+ expect(invalidateRoot).not.toHaveBeenCalled();
+ invalidateRoot.mockRestore();
+ });
+
+ it("steps in its own loop with updateLoop='independent'", async () => {
+ const { before, Recorder } = createRecorder();
+ await create(
+
+
+
+ ,
+ { frameloop: "never" }
+ );
+
+ await pause(150);
+ expect(before.length).toBeGreaterThan(1);
+ });
+
+ it("keeps body visuals on their bodies when an ancestor moves after mount (#36)", async () => {
+ const group = React.createRef();
+ const object = React.createRef();
+ const body = React.createRef();
+
+ await create(
+
+
+
+
+
+ );
+
+ const frames = createClock();
+ frames(1 / 60, 2);
+
+ const expectInSync = () => {
+ const visual = object.current!.parent!;
+ visual.updateWorldMatrix(true, false);
+ const position = new Vector3();
+ const quaternion = new Quaternion();
+ visual.matrixWorld.decompose(position, quaternion, new Vector3());
+
+ const t = body.current!.translation();
+ const r = body.current!.rotation();
+ expect(position.x).toBeCloseTo(t.x, 4);
+ expect(position.y).toBeCloseTo(t.y, 4);
+ expect(position.z).toBeCloseTo(t.z, 4);
+ expect(
+ Math.abs(quaternion.dot(new Quaternion(r.x, r.y, r.z, r.w)))
+ ).toBeCloseTo(1, 4);
+ };
+
+ expect(body.current!.translation()).toMatchObject({ x: 1, y: 2, z: 0 });
+ expectInSync();
+
+ // Rotate and move the parent after mount
+ group.current!.rotation.set(0.3, Math.PI / 2, 0);
+ group.current!.position.set(5, -1, 3);
+ frames(1 / 60, 2);
+ expectInSync();
+
+ // ...also when the body is asleep
+ body.current!.sleep();
+ group.current!.rotation.set(0, -Math.PI / 3, 0.2);
+ group.current!.position.set(-4, 0, 0);
+ frames(1 / 60, 2);
+ expect(body.current!.isSleeping()).toBe(true);
+ expectInSync();
+ });
+
+ it("debug renders into one persistent geometry and disposes it on toggle (#666)", async () => {
+ const scene = (debug: boolean) => (
+
+
+