From 770e7fad0df9a737728fd71c4755e8b309c34294 Mon Sep 17 00:00:00 2001 From: Dries Croons Date: Mon, 22 Jan 2024 01:00:08 +0100 Subject: [PATCH 01/13] test: one way platform issue --- demo/src/App.tsx | 7 +- .../one-way-platform/OneWayPlatform.tsx | 101 ++++++++++++++++++ .../src/components/Physics.tsx | 49 ++++++++- 3 files changed, 152 insertions(+), 5 deletions(-) create mode 100644 demo/src/examples/one-way-platform/OneWayPlatform.tsx diff --git a/demo/src/App.tsx b/demo/src/App.tsx index e4699111..4169db8f 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -38,7 +38,8 @@ import { PerformanceExample } from "./examples/performance/PeformanceExample"; import { DynamicTypeChangeExample } from "./examples/dynamic-type-change/DynamicTypeChangeExample"; import { StutteringExample } from "./examples/stuttering/StutteringExample"; import { ImmutablePropsExample } from "./examples/immutable-props/ImmutablePropsExample"; -import { SnapshotExample } from './examples/snapshot/SnapshotExample'; +import { SnapshotExample } from "./examples/snapshot/SnapshotExample"; +import { OneWayPlatform } from "./examples/one-way-platform/OneWayPlatform"; const demoContext = createContext<{ setDebug?(f: boolean): void; @@ -90,7 +91,7 @@ const Floor = () => { }; const routes: Record = { - "": , + "": , joints: , components: , cradle: , @@ -200,7 +201,7 @@ export const App = () => { > {Object.keys(routes).map((key) => ( - {key.replace(/-/g, " ") || "Plinko"} + {key.replace(/-/g, " ") || "One Way Platform"} ))} diff --git a/demo/src/examples/one-way-platform/OneWayPlatform.tsx b/demo/src/examples/one-way-platform/OneWayPlatform.tsx new file mode 100644 index 00000000..171e549b --- /dev/null +++ b/demo/src/examples/one-way-platform/OneWayPlatform.tsx @@ -0,0 +1,101 @@ +import { Sphere } from "@react-three/drei"; +import { useThree } from "@react-three/fiber"; +import { + CuboidCollider, + RapierCollider, + RapierRigidBody, + RigidBody, + useRapier +} from "@react-three/rapier"; +import { useCallback, useEffect, useRef } from "react"; +import { Vector3 } from "three"; +import { Demo } from "../../App"; + +export const OneWayPlatform: Demo = () => { + const ref = useRef(null); + const collider = useRef(null); + + const ball = useRef(null); + const { camera } = useThree(); + + useEffect(() => { + camera.position.set(0, 10, 20); + camera.lookAt(0, 0, 0); + camera.updateProjectionMatrix(); + + window.addEventListener("click", () => { + ball.current?.applyImpulse(new Vector3(0, 50, 0), true); + }); + }, []); + + const { filterContactPairHooks, world } = useRapier(); + + const hook = useCallback( + (c1: number, c2: number, b1: number, b2: number) => { + try { + const collider1 = world.getCollider(c1); + const collider2 = world.getCollider(c2); + + const body1 = world.getRigidBody(b1); + const body2 = world.getRigidBody(b2); + + if ( + (body1.userData as any)?.type && + (body1.userData as any).type === "platform" && + (body2.userData as any)?.type && + (body2.userData as any)?.type === "ball" + ) { + // Once we get try to get access to the ball and platform, the "hook" that we pass to filterContactPairHooks crashes + + // why does this crash here? what's wrong with the below setup? + const platformPosition = body1.translation(); + const ballVelocity = body2.linvel(); + const ballPosition = body2.translation(); + + // also doesn't work + // const platformPosition = ref.current!.translation(); + // const ballVelocity = ball.current!.linvel(); + // const ballPosition = ref.current!.translation(); + + // Allow collision if the ball is moving downwards and above the platform + if (ballVelocity.y < 0 && ballPosition.y > platformPosition.y) { + return 1; // Process the collision + } + } + + return 0; // Ignore the collision + } catch (error) { + console.log(error); + return null; + } + }, + [world] + ); + + useEffect(() => { + collider.current?.setActiveHooks(1); + filterContactPairHooks.push(hook); + }, []); + + return ( + + + + + + + + + + + + + + + ); +}; diff --git a/packages/react-three-rapier/src/components/Physics.tsx b/packages/react-three-rapier/src/components/Physics.tsx index 3efae2f1..c394e7cf 100644 --- a/packages/react-three-rapier/src/components/Physics.tsx +++ b/packages/react-three-rapier/src/components/Physics.tsx @@ -3,8 +3,10 @@ import { Collider, ColliderHandle, EventQueue, + PhysicsHooks, RigidBody, RigidBodyHandle, + SolverFlags, World } from "@dimforge/rapier3d-compat"; import { useThree } from "@react-three/fiber"; @@ -187,6 +189,19 @@ export interface RapierContext { * Is debug mode enabled */ isDebug: boolean; + + filterContactPairHooks: (( + collider1: ColliderHandle, + collider2: ColliderHandle, + body1: RigidBodyHandle, + body2: RigidBodyHandle + ) => SolverFlags | null)[]; + filterIntersectionPairHooks: (( + collider1: ColliderHandle, + collider2: ColliderHandle, + body1: RigidBodyHandle, + body2: RigidBodyHandle + ) => boolean)[]; } export const rapierContext = createContext( @@ -394,6 +409,34 @@ export const Physics: FC = (props) => { const rigidBodyEvents = useConst(() => new Map()); const colliderEvents = useConst(() => new Map()); const eventQueue = useConst(() => new EventQueue(false)); + + const filterContactPairHooks = useConst< + (( + collider1: ColliderHandle, + collider2: ColliderHandle, + body1: RigidBodyHandle, + body2: RigidBodyHandle + ) => SolverFlags | null)[] + >(() => []); + const filterIntersectionPairHooks = useConst< + (( + collider1: ColliderHandle, + collider2: ColliderHandle, + body1: RigidBodyHandle, + body2: RigidBodyHandle + ) => boolean)[] + >(() => []); + + const hooks = useConst(() => ({ + filterContactPair: (...args) => { + const hook = filterContactPairHooks.find((hook) => hook(...args)); + return hook ? hook(...args) : null; + }, + filterIntersectionPair: (...args) => { + const hook = filterIntersectionPairHooks.find((hook) => hook(...args)); + return hook ? hook(...args) : false; + } + })); const beforeStepCallbacks = useConst(() => new Set()); const afterStepCallbacks = useConst(() => new Set()); @@ -504,7 +547,7 @@ export const Physics: FC = (props) => { }); world.timestep = delta; - world.step(eventQueue); + world.step(eventQueue, hooks); // Trigger afterStep callbacks afterStepCallbacks.forEach((callback) => { @@ -763,7 +806,9 @@ export const Physics: FC = (props) => { afterStepCallbacks, isPaused: paused, isDebug: debug, - step + step, + filterContactPairHooks, + filterIntersectionPairHooks }), [paused, step, debug, colliders, gravity] ); From df95128b9bced98bae86d1600893d6acbef6eb1e Mon Sep 17 00:00:00 2001 From: Dries Croons Date: Sat, 25 Oct 2025 13:02:13 +0200 Subject: [PATCH 02/13] fix: cache rigid body data --- demo/src/App.tsx | 2 +- .../one-way-platform/OneWayPlatform.tsx | 101 +++++++++++------- 2 files changed, 62 insertions(+), 41 deletions(-) diff --git a/demo/src/App.tsx b/demo/src/App.tsx index 7eeee019..ddbce9b8 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -80,7 +80,7 @@ const Floor = () => { const routes: Record = { "": , - shapes: Shapes, + shapes: , joints: , components: , cradle: , diff --git a/demo/src/examples/one-way-platform/OneWayPlatform.tsx b/demo/src/examples/one-way-platform/OneWayPlatform.tsx index 171e549b..ba672746 100644 --- a/demo/src/examples/one-way-platform/OneWayPlatform.tsx +++ b/demo/src/examples/one-way-platform/OneWayPlatform.tsx @@ -5,6 +5,7 @@ import { RapierCollider, RapierRigidBody, RigidBody, + useBeforePhysicsStep, useRapier } from "@react-three/rapier"; import { useCallback, useEffect, useRef } from "react"; @@ -12,75 +13,95 @@ import { Vector3 } from "three"; import { Demo } from "../../App"; export const OneWayPlatform: Demo = () => { - const ref = useRef(null); - const collider = useRef(null); - - const ball = useRef(null); + const platformRef = useRef(null); + const colliderRef = useRef(null); + const ballRef = useRef(null); const { camera } = useThree(); + // Cache for storing body states before physics step + const bodyStateCache = useRef>(new Map()); + useEffect(() => { camera.position.set(0, 10, 20); camera.lookAt(0, 0, 0); camera.updateProjectionMatrix(); window.addEventListener("click", () => { - ball.current?.applyImpulse(new Vector3(0, 50, 0), true); + ballRef.current?.applyImpulse(new Vector3(0, 100, 0), true); }); }, []); - const { filterContactPairHooks, world } = useRapier(); + const { filterContactPairHooks } = useRapier(); + + // Cache body states BEFORE the physics step + useBeforePhysicsStep(() => { + if (platformRef.current && ballRef.current) { + const platformHandle = platformRef.current.handle; + const ballHandle = ballRef.current.handle; + + const platformPos = platformRef.current.translation(); + const ballPos = ballRef.current.translation(); + const ballVel = ballRef.current.linvel(); + + bodyStateCache.current.set(platformHandle, { + position: new Vector3(platformPos.x, platformPos.y, platformPos.z), + velocity: new Vector3(0, 0, 0) + }); + + bodyStateCache.current.set(ballHandle, { + position: new Vector3(ballPos.x, ballPos.y, ballPos.z), + velocity: new Vector3(ballVel.x, ballVel.y, ballVel.z) + }); + } + }); const hook = useCallback( (c1: number, c2: number, b1: number, b2: number) => { try { - const collider1 = world.getCollider(c1); - const collider2 = world.getCollider(c2); - - const body1 = world.getRigidBody(b1); - const body2 = world.getRigidBody(b2); - - if ( - (body1.userData as any)?.type && - (body1.userData as any).type === "platform" && - (body2.userData as any)?.type && - (body2.userData as any)?.type === "ball" - ) { - // Once we get try to get access to the ball and platform, the "hook" that we pass to filterContactPairHooks crashes - - // why does this crash here? what's wrong with the below setup? - const platformPosition = body1.translation(); - const ballVelocity = body2.linvel(); - const ballPosition = body2.translation(); - - // also doesn't work - // const platformPosition = ref.current!.translation(); - // const ballVelocity = ball.current!.linvel(); - // const ballPosition = ref.current!.translation(); - - // Allow collision if the ball is moving downwards and above the platform - if (ballVelocity.y < 0 && ballPosition.y > platformPosition.y) { - return 1; // Process the collision - } + // Use cached states instead of querying the world + const state1 = bodyStateCache.current.get(b1); + const state2 = bodyStateCache.current.get(b2); + + if (!state1 || !state2) { + return null; // Let default behavior happen + } + + // Determine which is platform and which is ball + let platformState, ballState; + + if (platformRef.current?.handle === b1 && ballRef.current?.handle === b2) { + platformState = state1; + ballState = state2; + } else if (platformRef.current?.handle === b2 && ballRef.current?.handle === b1) { + platformState = state2; + ballState = state1; + } else { + return null; // Not our platform/ball pair + } + + // Allow collision only if the ball is moving downwards and above the platform + if (ballState.velocity.y < 0 && ballState.position.y > platformState.position.y) { + return 1; // Process the collision (SolverFlags::COMPUTE_IMPULSES) } return 0; // Ignore the collision } catch (error) { - console.log(error); + console.error(error); return null; } }, - [world] + [] ); useEffect(() => { - collider.current?.setActiveHooks(1); + colliderRef.current?.setActiveHooks(1); filterContactPairHooks.push(hook); }, []); return ( { - - + + ); From c6f6994bc351ca9f5297dbef91c2dfacbca9a2d0 Mon Sep 17 00:00:00 2001 From: Dries Croons Date: Sat, 25 Oct 2025 13:07:40 +0200 Subject: [PATCH 03/13] fix: change impulse strength --- demo/src/examples/one-way-platform/OneWayPlatform.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demo/src/examples/one-way-platform/OneWayPlatform.tsx b/demo/src/examples/one-way-platform/OneWayPlatform.tsx index ba672746..14a8279b 100644 --- a/demo/src/examples/one-way-platform/OneWayPlatform.tsx +++ b/demo/src/examples/one-way-platform/OneWayPlatform.tsx @@ -27,7 +27,7 @@ export const OneWayPlatform: Demo = () => { camera.updateProjectionMatrix(); window.addEventListener("click", () => { - ballRef.current?.applyImpulse(new Vector3(0, 100, 0), true); + ballRef.current?.applyImpulse(new Vector3(0, 50, 0), true); }); }, []); From 8f329f0a904ccb1972e683bc275b6bc2070e7269 Mon Sep 17 00:00:00 2001 From: Dries Croons Date: Sat, 25 Oct 2025 13:07:54 +0200 Subject: [PATCH 04/13] fix: change order of demo apps --- demo/src/App.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/demo/src/App.tsx b/demo/src/App.tsx index ddbce9b8..68ff3889 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -79,7 +79,6 @@ const Floor = () => { }; const routes: Record = { - "": , shapes: , joints: , components: , @@ -110,7 +109,8 @@ const routes: Record = { spring: , "rope-joint": , "active-collision-types": , - "contact-skin": + "contact-skin": , + "one-way-platform": }; export const App = () => { From 29e9527191c3481a42090f062ecab1ee43afe67d Mon Sep 17 00:00:00 2001 From: Dries Croons Date: Sat, 25 Oct 2025 13:10:52 +0200 Subject: [PATCH 05/13] feat: add readme change --- packages/react-three-rapier/readme.md | 63 +++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/packages/react-three-rapier/readme.md b/packages/react-three-rapier/readme.md index 9f92e90d..a737c07c 100644 --- a/packages/react-three-rapier/readme.md +++ b/packages/react-three-rapier/readme.md @@ -85,6 +85,7 @@ For full API outline and documentation, see 🧩 [API Docs](https://pmndrs.githu - [Spring Joint](#spring-joint) - [🖼 Joints Example](#-joints-example) - [Advanced hooks usage](#advanced-hooks-usage) + - [Physics Hooks (Collision Filtering)](#physics-hooks-collision-filtering) - [Manual stepping](#manual-stepping) - [On-demand rendering](#on-demand-rendering) - [Snapshots](#snapshots) @@ -886,6 +887,68 @@ Advanced users might need granular access to the physics loop and direct access 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. + +The `useRapier` hook provides access to two arrays: +- `filterContactPairHooks` - Filter collision pairs and control solver behavior +- `filterIntersectionPairHooks` - Filter intersection pairs for sensors + +**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`. + +```tsx +import { useRapier, useBeforePhysicsStep } 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 { filterContactPairHooks } = 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 hook using cached data + const hook = useCallback((c1, c2, b1, b2) => { + const ballState = bodyStateCache.current.get(b1); + if (!ballState) return null; + + // Allow collision only if ball is moving down and above platform + if (ballState.velocity.y < 0 && ballState.position.y > 0) { + return 1; // Process collision + } + return 0; // Ignore collision + }, []); + + useEffect(() => { + // Enable active hooks on the collider + colliderRef.current?.setActiveHooks(1); + filterContactPairHooks.push(hook); + }, []); + + return ( + + + + ); +}; +``` + ### Manual stepping You can manually step the physics simulation by calling the `step` method from the `useRapier` hook. From 8dac9852d7b905f1227a4dffc005616d7d8af392 Mon Sep 17 00:00:00 2001 From: Dries Croons Date: Sat, 25 Oct 2025 13:13:29 +0200 Subject: [PATCH 06/13] feat: physics hook tests --- .../tests/physics-hooks.test.tsx | 263 ++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 packages/react-three-rapier/tests/physics-hooks.test.tsx diff --git a/packages/react-three-rapier/tests/physics-hooks.test.tsx b/packages/react-three-rapier/tests/physics-hooks.test.tsx new file mode 100644 index 00000000..acb4dc00 --- /dev/null +++ b/packages/react-three-rapier/tests/physics-hooks.test.tsx @@ -0,0 +1,263 @@ +import React, { useCallback, useEffect, useRef } from "react"; +import ReactThreeTestRenderer from "@react-three/test-renderer"; +import { describe, expect, it, vi } from "vitest"; +import { + Physics, + RigidBody, + CuboidCollider, + useRapier, + useBeforePhysicsStep, + RapierRigidBody, + RapierCollider +} from "../src"; +import { awaitReady } from "./test-utils"; + +describe("physics hooks", () => { + describe("filterContactPairHooks", () => { + it("should register and call contact pair filter hooks", async () => { + const filterHook = vi.fn(() => null); + let hookRegistered = false; + + const TestComponent = () => { + const { filterContactPairHooks } = useRapier(); + const colliderRef = useRef(null); + + useEffect(() => { + if (colliderRef.current && !hookRegistered) { + colliderRef.current.setActiveHooks(1); + filterContactPairHooks.push(filterHook); + hookRegistered = true; + } + }, []); + + return ( + + + + ); + }; + + const step = await awaitReady( + <> + + + + + + ); + + await ReactThreeTestRenderer.act(async () => { + // Step multiple times to allow bodies to collide + for (let i = 0; i < 30; i++) { + step(1 / 60); + } + }); + + expect(filterHook).toHaveBeenCalled(); + }); + + it("should allow blocking collisions when hook returns 0", async () => { + const onCollisionEnter = vi.fn(); + let hookRegistered = false; + + const TestComponent = () => { + const { filterContactPairHooks } = useRapier(); + const colliderRef = useRef(null); + + const filterHook = useCallback(() => { + return 0; // Block all collisions + }, []); + + useEffect(() => { + if (colliderRef.current && !hookRegistered) { + colliderRef.current.setActiveHooks(1); + filterContactPairHooks.push(filterHook); + hookRegistered = true; + } + }, []); + + return ( + + + + ); + }; + + const step = await awaitReady( + <> + + + + + + ); + + await ReactThreeTestRenderer.act(async () => { + for (let i = 0; i < 30; i++) { + step(1 / 60); + } + }); + + // Collision should be blocked + expect(onCollisionEnter).not.toHaveBeenCalled(); + }); + + it("should allow collisions when hook returns 1", async () => { + const onCollisionEnter = vi.fn(); + let hookRegistered = false; + + const TestComponent = () => { + const { filterContactPairHooks } = useRapier(); + const colliderRef = useRef(null); + + const filterHook = useCallback(() => { + return 1; // Allow collisions + }, []); + + useEffect(() => { + if (colliderRef.current && !hookRegistered) { + colliderRef.current.setActiveHooks(1); + filterContactPairHooks.push(filterHook); + hookRegistered = true; + } + }, []); + + return ( + + + + ); + }; + + const step = await awaitReady( + <> + + + + + + ); + + await ReactThreeTestRenderer.act(async () => { + for (let i = 0; i < 30; i++) { + step(1 / 60); + } + }); + + // Collision should occur + expect(onCollisionEnter).toHaveBeenCalled(); + }); + + it("should work with cached body state from useBeforePhysicsStep", async () => { + const filterHook = vi.fn(() => 1); + let hookRegistered = false; + + const OneWayPlatform = () => { + const { filterContactPairHooks } = useRapier(); + const platformRef = useRef(null); + const ballRef = useRef(null); + const colliderRef = useRef(null); + const bodyStateCache = useRef(new Map()); + + // Cache body states before 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 + }); + } + }); + + const hook = useCallback((c1: number, c2: number, b1: number, b2: number) => { + const state = bodyStateCache.current.get(b1) || + bodyStateCache.current.get(b2); + + // If we have cached state, the test is successful + if (state) { + filterHook(); + } + + return 1; // Allow collision + }, []); + + useEffect(() => { + if (colliderRef.current && !hookRegistered) { + colliderRef.current.setActiveHooks(1); + filterContactPairHooks.push(hook); + hookRegistered = true; + } + }, []); + + return ( + <> + + + + + + + + ); + }; + + const step = await awaitReady(); + + await ReactThreeTestRenderer.act(async () => { + for (let i = 0; i < 60; i++) { + step(1 / 60); + } + }); + + // Verify the filter hook was called with cached state + expect(filterHook).toHaveBeenCalled(); + }); + }); + + describe("filterIntersectionPairHooks", () => { + it("should register and call intersection pair filter hooks", async () => { + const filterHook = vi.fn(() => true); + let hookRegistered = false; + + const TestComponent = () => { + const { filterIntersectionPairHooks } = useRapier(); + const colliderRef = useRef(null); + + useEffect(() => { + if (colliderRef.current && !hookRegistered) { + colliderRef.current.setActiveHooks(2); // Active hooks for intersection + filterIntersectionPairHooks.push(filterHook); + hookRegistered = true; + } + }, []); + + return ( + + + + ); + }; + + const step = await awaitReady( + <> + + + + + + ); + + await ReactThreeTestRenderer.act(async () => { + for (let i = 0; i < 30; i++) { + step(1 / 60); + } + }); + + expect(filterHook).toHaveBeenCalled(); + }); + }); +}); + From f7dea9bc0c42b33e97ac3816088946398ea044df Mon Sep 17 00:00:00 2001 From: Dries Croons Date: Sat, 25 Oct 2025 13:26:42 +0200 Subject: [PATCH 07/13] fix: prettier --- .../one-way-platform/OneWayPlatform.tsx | 82 ++++++++++--------- 1 file changed, 45 insertions(+), 37 deletions(-) diff --git a/demo/src/examples/one-way-platform/OneWayPlatform.tsx b/demo/src/examples/one-way-platform/OneWayPlatform.tsx index 14a8279b..61507df1 100644 --- a/demo/src/examples/one-way-platform/OneWayPlatform.tsx +++ b/demo/src/examples/one-way-platform/OneWayPlatform.tsx @@ -19,7 +19,9 @@ export const OneWayPlatform: Demo = () => { const { camera } = useThree(); // Cache for storing body states before physics step - const bodyStateCache = useRef>(new Map()); + const bodyStateCache = useRef< + Map + >(new Map()); useEffect(() => { camera.position.set(0, 10, 20); @@ -55,43 +57,49 @@ export const OneWayPlatform: Demo = () => { } }); - const hook = useCallback( - (c1: number, c2: number, b1: number, b2: number) => { - try { - // Use cached states instead of querying the world - const state1 = bodyStateCache.current.get(b1); - const state2 = bodyStateCache.current.get(b2); - - if (!state1 || !state2) { - return null; // Let default behavior happen - } - - // Determine which is platform and which is ball - let platformState, ballState; - - if (platformRef.current?.handle === b1 && ballRef.current?.handle === b2) { - platformState = state1; - ballState = state2; - } else if (platformRef.current?.handle === b2 && ballRef.current?.handle === b1) { - platformState = state2; - ballState = state1; - } else { - return null; // Not our platform/ball pair - } - - // Allow collision only if the ball is moving downwards and above the platform - if (ballState.velocity.y < 0 && ballState.position.y > platformState.position.y) { - return 1; // Process the collision (SolverFlags::COMPUTE_IMPULSES) - } - - return 0; // Ignore the collision - } catch (error) { - console.error(error); - return null; + const hook = useCallback((c1: number, c2: number, b1: number, b2: number) => { + try { + // Use cached states instead of querying the world + const state1 = bodyStateCache.current.get(b1); + const state2 = bodyStateCache.current.get(b2); + + if (!state1 || !state2) { + return null; // Let default behavior happen } - }, - [] - ); + + // Determine which is platform and which is ball + let platformState, ballState; + + if ( + platformRef.current?.handle === b1 && + ballRef.current?.handle === b2 + ) { + platformState = state1; + ballState = state2; + } else if ( + platformRef.current?.handle === b2 && + ballRef.current?.handle === b1 + ) { + platformState = state2; + ballState = state1; + } else { + return null; // Not our platform/ball pair + } + + // Allow collision only if the ball is moving downwards and above the platform + if ( + ballState.velocity.y < 0 && + ballState.position.y > platformState.position.y + ) { + return 1; // Process the collision (SolverFlags::COMPUTE_IMPULSES) + } + + return 0; // Ignore the collision + } catch (error) { + console.error(error); + return null; + } + }, []); useEffect(() => { colliderRef.current?.setActiveHooks(1); From 65dc8d29f2e87ab6bfecb16b9495a9e0cc232029 Mon Sep 17 00:00:00 2001 From: Dries Croons Date: Sat, 25 Oct 2025 13:27:13 +0200 Subject: [PATCH 08/13] fix: test linter --- .../tests/physics-hooks.test.tsx | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/react-three-rapier/tests/physics-hooks.test.tsx b/packages/react-three-rapier/tests/physics-hooks.test.tsx index acb4dc00..a39c1d66 100644 --- a/packages/react-three-rapier/tests/physics-hooks.test.tsx +++ b/packages/react-three-rapier/tests/physics-hooks.test.tsx @@ -172,17 +172,20 @@ describe("physics hooks", () => { } }); - const hook = useCallback((c1: number, c2: number, b1: number, b2: number) => { - const state = bodyStateCache.current.get(b1) || - bodyStateCache.current.get(b2); - - // If we have cached state, the test is successful - if (state) { - filterHook(); - } - - return 1; // Allow collision - }, []); + const hook = useCallback( + (c1: number, c2: number, b1: number, b2: number) => { + const state = + bodyStateCache.current.get(b1) || bodyStateCache.current.get(b2); + + // If we have cached state, the test is successful + if (state) { + filterHook(); + } + + return 1; // Allow collision + }, + [] + ); useEffect(() => { if (colliderRef.current && !hookRegistered) { @@ -260,4 +263,3 @@ describe("physics hooks", () => { }); }); }); - From 86154f42ccefd8e8ab372cfebe21e537739bed86 Mon Sep 17 00:00:00 2001 From: Dries Croons Date: Sat, 25 Oct 2025 13:33:19 +0200 Subject: [PATCH 09/13] fix: basic route for shapes --- demo/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demo/src/App.tsx b/demo/src/App.tsx index 68ff3889..7c099f7e 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -79,7 +79,7 @@ const Floor = () => { }; const routes: Record = { - shapes: , + "": , joints: , components: , cradle: , From 094cda8cba7f11f4eacc6e865b491fc9ab405253 Mon Sep 17 00:00:00 2001 From: Dries Croons Date: Sat, 25 Oct 2025 13:34:27 +0200 Subject: [PATCH 10/13] fix: revert default string --- demo/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demo/src/App.tsx b/demo/src/App.tsx index 7c099f7e..403e858e 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -246,7 +246,7 @@ export const App = () => { {Object.keys(routes).map((key) => ( - {key.replace(/-/g, " ") || "One Way Platform"} + {key.replace(/-/g, " ") || "Plinko"} ))} From 463c981e8d403267ef716dcd0d926c5c1c465f6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CIsaac?= Date: Mon, 3 Nov 2025 10:10:21 +1000 Subject: [PATCH 11/13] feat: add 'useFilterContactPair', 'useFilterIntersectionPair' hooks --- .changeset/kind-icons-arrive.md | 5 + .../one-way-platform/OneWayPlatform.tsx | 41 +++++-- packages/react-three-rapier/readme.md | 110 +++++++++++++++--- .../src/components/Physics.tsx | 87 ++++++++------ .../react-three-rapier/src/hooks/hooks.ts | 108 +++++++++++++++++ packages/react-three-rapier/src/index.ts | 8 +- .../tests/physics-hooks.test.tsx | 102 ++++++++-------- 7 files changed, 353 insertions(+), 108 deletions(-) create mode 100644 .changeset/kind-icons-arrive.md diff --git a/.changeset/kind-icons-arrive.md b/.changeset/kind-icons-arrive.md new file mode 100644 index 00000000..48cdce21 --- /dev/null +++ b/.changeset/kind-icons-arrive.md @@ -0,0 +1,5 @@ +--- +"@react-three/rapier": minor +--- + +feat: add 'useFilterContactPair', 'useFilterIntersectionPair' hooks (@driescroons, @isaac-mason) diff --git a/demo/src/examples/one-way-platform/OneWayPlatform.tsx b/demo/src/examples/one-way-platform/OneWayPlatform.tsx index 61507df1..77d9f5df 100644 --- a/demo/src/examples/one-way-platform/OneWayPlatform.tsx +++ b/demo/src/examples/one-way-platform/OneWayPlatform.tsx @@ -6,11 +6,13 @@ import { RapierRigidBody, RigidBody, useBeforePhysicsStep, + useFilterContactPair, useRapier } from "@react-three/rapier"; -import { useCallback, useEffect, useRef } from "react"; +import { useEffect, useRef } from "react"; import { Vector3 } from "three"; import { Demo } from "../../App"; +import { useControls } from "leva"; export const OneWayPlatform: Demo = () => { const platformRef = useRef(null); @@ -18,6 +20,13 @@ export const OneWayPlatform: Demo = () => { const ballRef = useRef(null); const { camera } = useThree(); + const { filteringEnabled } = useControls("One-Way Platform", { + filteringEnabled: { + value: true, + label: "Enable Filtering" + } + }); + // Cache for storing body states before physics step const bodyStateCache = useRef< Map @@ -33,7 +42,7 @@ export const OneWayPlatform: Demo = () => { }); }, []); - const { filterContactPairHooks } = useRapier(); + const { rapier } = useRapier(); // Cache body states BEFORE the physics step useBeforePhysicsStep(() => { @@ -57,8 +66,13 @@ export const OneWayPlatform: Demo = () => { } }); - const hook = useCallback((c1: number, c2: number, b1: number, b2: number) => { + useFilterContactPair((c1: number, c2: number, b1: number, b2: number) => { try { + // If filtering is disabled, let default collision behavior happen + if (!filteringEnabled) { + return null; + } + // Use cached states instead of querying the world const state1 = bodyStateCache.current.get(b1); const state2 = bodyStateCache.current.get(b2); @@ -91,20 +105,23 @@ export const OneWayPlatform: Demo = () => { ballState.velocity.y < 0 && ballState.position.y > platformState.position.y ) { - return 1; // Process the collision (SolverFlags::COMPUTE_IMPULSES) + return rapier.SolverFlags.COMPUTE_IMPULSE; // Process the collision } - return 0; // Ignore the collision + return rapier.SolverFlags.EMPTY; // Ignore the collision (pass through) } catch (error) { console.error(error); return null; } - }, []); + }); useEffect(() => { - colliderRef.current?.setActiveHooks(1); - filterContactPairHooks.push(hook); - }, []); + if (colliderRef.current) { + colliderRef.current.setActiveHooks( + filteringEnabled ? rapier.ActiveHooks.FILTER_CONTACT_PAIRS : 0 + ); + } + }, [filteringEnabled, rapier]); return ( @@ -120,7 +137,11 @@ export const OneWayPlatform: Demo = () => { - + diff --git a/packages/react-three-rapier/readme.md b/packages/react-three-rapier/readme.md index a737c07c..bed47f45 100644 --- a/packages/react-three-rapier/readme.md +++ b/packages/react-three-rapier/readme.md @@ -891,14 +891,20 @@ Advanced users might need granular access to the physics loop and direct access 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. -The `useRapier` hook provides access to two arrays: -- `filterContactPairHooks` - Filter collision pairs and control solver behavior -- `filterIntersectionPairHooks` - Filter intersection pairs for sensors +`r3/rapier` provides two hooks for collision filtering: +- `useFilterContactPair` - Filter collision pairs and control solver behavior +- `useFilterIntersectionPair` - Filter intersection pairs for sensors **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 } from "@react-three/rapier"; +import { + useRapier, + useBeforePhysicsStep, + useFilterContactPair +} from "@react-three/rapier"; const OneWayPlatform = () => { const platformRef = useRef(null); @@ -908,14 +914,14 @@ const OneWayPlatform = () => { // Cache for storing body states before physics step const bodyStateCache = useRef(new Map()); - const { filterContactPairHooks } = useRapier(); + 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 @@ -923,22 +929,81 @@ const OneWayPlatform = () => { } }); - // Filter hook using cached data - const hook = useCallback((c1, c2, b1, b2) => { - const ballState = bodyStateCache.current.get(b1); - if (!ballState) return null; + // 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 1; // Process collision + return rapier.SolverFlags.COMPUTE_IMPULSE; // Process collision } - return 0; // Ignore 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 ( + <> + + + + + + + + ); +}; +``` + +```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 + // Enable active hooks on the collider (required for filtering) colliderRef.current?.setActiveHooks(1); - filterContactPairHooks.push(hook); }, []); return ( @@ -949,6 +1014,23 @@ const OneWayPlatform = () => { }; ``` +#### 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 + ### Manual stepping You can manually step the physics simulation by calling the `step` method from the `useRapier` hook. diff --git a/packages/react-three-rapier/src/components/Physics.tsx b/packages/react-three-rapier/src/components/Physics.tsx index f1e55e9e..5922be5b 100644 --- a/packages/react-three-rapier/src/components/Physics.tsx +++ b/packages/react-three-rapier/src/components/Physics.tsx @@ -67,6 +67,28 @@ export type WorldStepCallback = (world: World) => void; export type WorldStepCallbackSet = Set<{ current: WorldStepCallback }>; +export type FilterContactPairCallback = ( + collider1: ColliderHandle, + collider2: ColliderHandle, + body1: RigidBodyHandle, + body2: RigidBodyHandle +) => SolverFlags | null; + +export type FilterIntersectionPairCallback = ( + collider1: ColliderHandle, + collider2: ColliderHandle, + body1: RigidBodyHandle, + body2: RigidBodyHandle +) => boolean; + +export type FilterContactPairCallbackSet = Set<{ + current: FilterContactPairCallback; +}>; + +export type FilterIntersectionPairCallbackSet = Set<{ + current: FilterIntersectionPairCallback; +}>; + export interface ColliderState { collider: Collider; object: Object3D; @@ -190,18 +212,17 @@ export interface RapierContext { */ isDebug: boolean; - filterContactPairHooks: (( - collider1: ColliderHandle, - collider2: ColliderHandle, - body1: RigidBodyHandle, - body2: RigidBodyHandle - ) => SolverFlags | null)[]; - filterIntersectionPairHooks: (( - collider1: ColliderHandle, - collider2: ColliderHandle, - body1: RigidBodyHandle, - body2: RigidBodyHandle - ) => boolean)[]; + /** + * Hooks to filter contact pairs + * @internal + */ + filterContactPairHooks: FilterContactPairCallbackSet; + + /** + * Hooks to filter intersection pairs + * @internal + */ + filterIntersectionPairHooks: FilterIntersectionPairCallbackSet; } export const rapierContext = createContext( @@ -438,31 +459,26 @@ export const Physics: FC = (props) => { const colliderEvents = useConst(() => new Map()); const eventQueue = useConst(() => new EventQueue(false)); - const filterContactPairHooks = useConst< - (( - collider1: ColliderHandle, - collider2: ColliderHandle, - body1: RigidBodyHandle, - body2: RigidBodyHandle - ) => SolverFlags | null)[] - >(() => []); - const filterIntersectionPairHooks = useConst< - (( - collider1: ColliderHandle, - collider2: ColliderHandle, - body1: RigidBodyHandle, - body2: RigidBodyHandle - ) => boolean)[] - >(() => []); + const filterContactPairHooks = useConst( + () => new Set() + ); + const filterIntersectionPairHooks = + useConst(() => new Set()); const hooks = useConst(() => ({ filterContactPair: (...args) => { - const hook = filterContactPairHooks.find((hook) => hook(...args)); - return hook ? hook(...args) : null; + for (const hook of filterContactPairHooks) { + const result = hook.current(...args); + if (result !== null) return result; + } + return null; }, filterIntersectionPair: (...args) => { - const hook = filterIntersectionPairHooks.find((hook) => hook(...args)); - return hook ? hook(...args) : false; + for (const hook of filterIntersectionPairHooks) { + const result = hook.current(...args); + if (result === false) return false; + } + return true; } })); const beforeStepCallbacks = useConst(() => new Set()); @@ -584,7 +600,12 @@ export const Physics: FC = (props) => { }); world.timestep = delta; - world.step(eventQueue, hooks); + + const hasHooks = + filterContactPairHooks.size > 0 || + filterIntersectionPairHooks.size > 0; + + world.step(eventQueue, hasHooks ? hooks : undefined); // Trigger afterStep callbacks afterStepCallbacks.forEach((callback) => { diff --git a/packages/react-three-rapier/src/hooks/hooks.ts b/packages/react-three-rapier/src/hooks/hooks.ts index ffacfa4d..309c60e1 100644 --- a/packages/react-three-rapier/src/hooks/hooks.ts +++ b/packages/react-three-rapier/src/hooks/hooks.ts @@ -74,6 +74,114 @@ export const useAfterPhysicsStep = (callback: WorldStepCallback) => { }, []); }; +/** + * Registers a callback to filter contact pairs. + * + * The callback determines if contact computation should happen between two colliders, + * and how the constraints solver should behave for these contacts. + * + * This will only be executed if at least one of the involved colliders contains the + * `ActiveHooks.FILTER_CONTACT_PAIR` flag in its active hooks. + * + * @param callback - Function that returns: + * - `SolverFlags.COMPUTE_IMPULSE` (1) - Process the collision normally (compute impulses and resolve penetration) + * - `SolverFlags.EMPTY` (0) - Skip computing impulses for this collision pair (colliders pass through each other) + * - `null` - Skip this hook; let the next registered hook decide, or use Rapier's default behavior if no hook handles it + * + * When multiple hooks are registered, they are called in order until one returns a non-null value. + * That value is then passed to Rapier's physics engine. + * + * @category Hooks + * + * @example + * ```tsx + * import { useFilterContactPair } from '@react-three/rapier'; + * import { SolverFlags } from '@dimforge/rapier3d-compat'; + * + * useFilterContactPair((collider1, collider2, body1, body2) => { + * // Only process collisions for specific bodies + * if (body1 === myBodyHandle) { + * return SolverFlags.COMPUTE_IMPULSE; + * } + * // Let other hooks or default behavior handle it + * return null; + * }); + * ``` + */ +export const useFilterContactPair = ( + callback: ( + collider1: number, + collider2: number, + body1: number, + body2: number + ) => number | null +) => { + const { filterContactPairHooks } = useRapier(); + + const ref = useMutableCallback(callback); + + useEffect(() => { + filterContactPairHooks.add(ref); + + return () => { + filterContactPairHooks.delete(ref); + }; + }, []); +}; + +/** + * Registers a callback to filter intersection pairs. + * + * The callback determines if intersection computation should happen between two colliders + * (where at least one is a sensor). + * + * This will only be executed if at least one of the involved colliders contains the + * `ActiveHooks.FILTER_INTERSECTION_PAIR` flag in its active hooks. + * + * @param callback - Function that returns: + * - `true` - Allow the intersection to be detected (trigger intersection events) + * - `false` - Block the intersection (no intersection events will fire) + * + * When multiple hooks are registered, the **first hook that returns `false` blocks** the intersection. + * If all hooks return `true`, the intersection is allowed. + * + * @category Hooks + * + * @example + * ```tsx + * import { useFilterIntersectionPair } from '@react-three/rapier'; + * + * useFilterIntersectionPair((collider1, collider2, body1, body2) => { + * // Block intersections for specific body pairs + * if (body1 === myBodyHandle && body2 === otherBodyHandle) { + * return false; + * } + * // Allow all other intersections + * return true; + * }); + * ``` + */ +export const useFilterIntersectionPair = ( + callback: ( + collider1: number, + collider2: number, + body1: number, + body2: number + ) => boolean +) => { + const { filterIntersectionPairHooks } = useRapier(); + + const ref = useMutableCallback(callback); + + useEffect(() => { + filterIntersectionPairHooks.add(ref); + + return () => { + filterIntersectionPairHooks.delete(ref); + }; + }, []); +}; + // Internal hooks /** * @internal diff --git a/packages/react-three-rapier/src/index.ts b/packages/react-three-rapier/src/index.ts index 00d47534..2f696b6e 100644 --- a/packages/react-three-rapier/src/index.ts +++ b/packages/react-three-rapier/src/index.ts @@ -20,7 +20,9 @@ export type { export type { PhysicsProps, RapierContext, - WorldStepCallback + WorldStepCallback, + FilterContactPairCallback, + FilterIntersectionPairCallback } from "./components/Physics"; export type { MeshColliderProps } from "./components/MeshCollider"; @@ -34,7 +36,9 @@ export * from "./hooks/joints"; export { useRapier, useBeforePhysicsStep, - useAfterPhysicsStep + useAfterPhysicsStep, + useFilterContactPair, + useFilterIntersectionPair } from "./hooks/hooks"; export * from "./utils/interaction-groups"; export * from "./utils/three-object-helpers"; diff --git a/packages/react-three-rapier/tests/physics-hooks.test.tsx b/packages/react-three-rapier/tests/physics-hooks.test.tsx index a39c1d66..edefa39a 100644 --- a/packages/react-three-rapier/tests/physics-hooks.test.tsx +++ b/packages/react-three-rapier/tests/physics-hooks.test.tsx @@ -7,26 +7,29 @@ import { CuboidCollider, useRapier, useBeforePhysicsStep, + useFilterContactPair, + useFilterIntersectionPair, RapierRigidBody, RapierCollider } from "../src"; +import { SolverFlags, ActiveHooks } from "@dimforge/rapier3d-compat"; import { awaitReady } from "./test-utils"; describe("physics hooks", () => { - describe("filterContactPairHooks", () => { + describe("useFilterContactPair", () => { it("should register and call contact pair filter hooks", async () => { const filterHook = vi.fn(() => null); - let hookRegistered = false; const TestComponent = () => { - const { filterContactPairHooks } = useRapier(); const colliderRef = useRef(null); + useFilterContactPair(filterHook); + useEffect(() => { - if (colliderRef.current && !hookRegistered) { - colliderRef.current.setActiveHooks(1); - filterContactPairHooks.push(filterHook); - hookRegistered = true; + if (colliderRef.current) { + colliderRef.current.setActiveHooks( + ActiveHooks.FILTER_CONTACT_PAIRS + ); } }, []); @@ -56,28 +59,24 @@ describe("physics hooks", () => { expect(filterHook).toHaveBeenCalled(); }); - it("should allow blocking collisions when hook returns 0", async () => { - const onCollisionEnter = vi.fn(); - let hookRegistered = false; + it("should call hook with correct parameters when collision occurs", async () => { + const filterHook = vi.fn(() => SolverFlags.COMPUTE_IMPULSE); const TestComponent = () => { - const { filterContactPairHooks } = useRapier(); const colliderRef = useRef(null); - const filterHook = useCallback(() => { - return 0; // Block all collisions - }, []); + useFilterContactPair(filterHook); useEffect(() => { - if (colliderRef.current && !hookRegistered) { - colliderRef.current.setActiveHooks(1); - filterContactPairHooks.push(filterHook); - hookRegistered = true; + if (colliderRef.current) { + colliderRef.current.setActiveHooks( + ActiveHooks.FILTER_CONTACT_PAIRS + ); } }, []); return ( - + ); @@ -98,27 +97,35 @@ describe("physics hooks", () => { } }); - // Collision should be blocked - expect(onCollisionEnter).not.toHaveBeenCalled(); + // Hook should be called with 4 numeric parameters (collider1, collider2, body1, body2) + expect(filterHook).toHaveBeenCalled(); + expect(filterHook.mock.calls.length).toBeGreaterThan(0); + + const callArgs: any[] = filterHook.mock.calls[0]; + expect(callArgs.length).toBe(4); + expect(typeof callArgs[0]).toBe("number"); // collider1 + expect(typeof callArgs[1]).toBe("number"); // collider2 + expect(typeof callArgs[2]).toBe("number"); // body1 + expect(typeof callArgs[3]).toBe("number"); // body2 }); - it("should allow collisions when hook returns 1", async () => { + it("should allow collisions when hook returns SolverFlags.COMPUTE_IMPULSE", async () => { const onCollisionEnter = vi.fn(); - let hookRegistered = false; const TestComponent = () => { - const { filterContactPairHooks } = useRapier(); const colliderRef = useRef(null); - const filterHook = useCallback(() => { - return 1; // Allow collisions - }, []); + useFilterContactPair( + useCallback(() => { + return SolverFlags.COMPUTE_IMPULSE; // Allow collisions + }, []) + ); useEffect(() => { - if (colliderRef.current && !hookRegistered) { - colliderRef.current.setActiveHooks(1); - filterContactPairHooks.push(filterHook); - hookRegistered = true; + if (colliderRef.current) { + colliderRef.current.setActiveHooks( + ActiveHooks.FILTER_CONTACT_PAIRS + ); } }, []); @@ -150,10 +157,8 @@ describe("physics hooks", () => { it("should work with cached body state from useBeforePhysicsStep", async () => { const filterHook = vi.fn(() => 1); - let hookRegistered = false; const OneWayPlatform = () => { - const { filterContactPairHooks } = useRapier(); const platformRef = useRef(null); const ballRef = useRef(null); const colliderRef = useRef(null); @@ -172,8 +177,8 @@ describe("physics hooks", () => { } }); - const hook = useCallback( - (c1: number, c2: number, b1: number, b2: number) => { + useFilterContactPair( + useCallback((c1: number, c2: number, b1: number, b2: number) => { const state = bodyStateCache.current.get(b1) || bodyStateCache.current.get(b2); @@ -182,16 +187,15 @@ describe("physics hooks", () => { filterHook(); } - return 1; // Allow collision - }, - [] + return SolverFlags.COMPUTE_IMPULSE; // Allow collision + }, []) ); useEffect(() => { - if (colliderRef.current && !hookRegistered) { - colliderRef.current.setActiveHooks(1); - filterContactPairHooks.push(hook); - hookRegistered = true; + if (colliderRef.current) { + colliderRef.current.setActiveHooks( + ActiveHooks.FILTER_CONTACT_PAIRS + ); } }, []); @@ -220,20 +224,20 @@ describe("physics hooks", () => { }); }); - describe("filterIntersectionPairHooks", () => { + describe("useFilterIntersectionPair", () => { it("should register and call intersection pair filter hooks", async () => { const filterHook = vi.fn(() => true); - let hookRegistered = false; const TestComponent = () => { - const { filterIntersectionPairHooks } = useRapier(); const colliderRef = useRef(null); + useFilterIntersectionPair(filterHook); + useEffect(() => { - if (colliderRef.current && !hookRegistered) { - colliderRef.current.setActiveHooks(2); // Active hooks for intersection - filterIntersectionPairHooks.push(filterHook); - hookRegistered = true; + if (colliderRef.current) { + colliderRef.current.setActiveHooks( + ActiveHooks.FILTER_INTERSECTION_PAIRS + ); } }, []); From 4cd93b74236e90ea1288be89db5b760c90f30f5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CIsaac?= Date: Mon, 3 Nov 2025 10:18:42 +1000 Subject: [PATCH 12/13] feat: physics hooks readme and examples cleanups --- .../one-way-platform/OneWayPlatform.tsx | 30 +++++-- packages/react-three-rapier/readme.md | 89 ++++--------------- .../src/components/Physics.tsx | 24 ++--- 3 files changed, 50 insertions(+), 93 deletions(-) diff --git a/demo/src/examples/one-way-platform/OneWayPlatform.tsx b/demo/src/examples/one-way-platform/OneWayPlatform.tsx index 77d9f5df..0914d251 100644 --- a/demo/src/examples/one-way-platform/OneWayPlatform.tsx +++ b/demo/src/examples/one-way-platform/OneWayPlatform.tsx @@ -54,15 +54,27 @@ export const OneWayPlatform: Demo = () => { const ballPos = ballRef.current.translation(); const ballVel = ballRef.current.linvel(); - bodyStateCache.current.set(platformHandle, { - position: new Vector3(platformPos.x, platformPos.y, platformPos.z), - velocity: new Vector3(0, 0, 0) - }); - - bodyStateCache.current.set(ballHandle, { - position: new Vector3(ballPos.x, ballPos.y, ballPos.z), - velocity: new Vector3(ballVel.x, ballVel.y, ballVel.z) - }); + let platformState = bodyStateCache.current.get(platformHandle); + if (!platformState) { + platformState = { + position: new Vector3(), + velocity: new Vector3() + }; + bodyStateCache.current.set(platformHandle, platformState); + } + platformState.position.set(platformPos.x, platformPos.y, platformPos.z); + platformState.velocity.set(0, 0, 0); + + let ballState = bodyStateCache.current.get(ballHandle); + if (!ballState) { + ballState = { + position: new Vector3(), + velocity: new Vector3() + }; + bodyStateCache.current.set(ballHandle, ballState); + } + ballState.position.set(ballPos.x, ballPos.y, ballPos.z); + ballState.velocity.set(ballVel.x, ballVel.y, ballVel.z); } }); diff --git a/packages/react-three-rapier/readme.md b/packages/react-three-rapier/readme.md index bed47f45..9056337f 100644 --- a/packages/react-three-rapier/readme.md +++ b/packages/react-three-rapier/readme.md @@ -895,6 +895,23 @@ You can implement advanced collision behaviors like one-way platforms by using p - `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. @@ -959,78 +976,6 @@ const OneWayPlatform = () => { }; ``` -```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(1); - }, []); - - return ( - - - - ); -}; -``` - -#### 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 - ### Manual stepping You can manually step the physics simulation by calling the `step` method from the `useRapier` hook. diff --git a/packages/react-three-rapier/src/components/Physics.tsx b/packages/react-three-rapier/src/components/Physics.tsx index 5922be5b..c681b1e6 100644 --- a/packages/react-three-rapier/src/components/Physics.tsx +++ b/packages/react-three-rapier/src/components/Physics.tsx @@ -146,6 +146,18 @@ export interface RapierContext { */ afterStepCallbacks: WorldStepCallbackSet; + /** + * Hooks to filter contact pairs + * @internal + */ + filterContactPairHooks: FilterContactPairCallbackSet; + + /** + * Hooks to filter intersection pairs + * @internal + */ + filterIntersectionPairHooks: FilterIntersectionPairCallbackSet; + /** * Direct access to the Rapier instance */ @@ -211,18 +223,6 @@ export interface RapierContext { * Is debug mode enabled */ isDebug: boolean; - - /** - * Hooks to filter contact pairs - * @internal - */ - filterContactPairHooks: FilterContactPairCallbackSet; - - /** - * Hooks to filter intersection pairs - * @internal - */ - filterIntersectionPairHooks: FilterIntersectionPairCallbackSet; } export const rapierContext = createContext( From 7935428a6c65c68d6568c6c504c9dc093164bf42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CIsaac?= Date: Mon, 3 Nov 2025 10:23:21 +1000 Subject: [PATCH 13/13] feat: add useFilterContactPair SolverFlags.EMPTY test --- .../tests/physics-hooks.test.tsx | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/packages/react-three-rapier/tests/physics-hooks.test.tsx b/packages/react-three-rapier/tests/physics-hooks.test.tsx index edefa39a..de0a00e3 100644 --- a/packages/react-three-rapier/tests/physics-hooks.test.tsx +++ b/packages/react-three-rapier/tests/physics-hooks.test.tsx @@ -155,6 +155,72 @@ describe("physics hooks", () => { expect(onCollisionEnter).toHaveBeenCalled(); }); + it("should block collisions when hook returns SolverFlags.EMPTY", async () => { + let ballRef: RapierRigidBody | null = null; + let platformRef: RapierRigidBody | null = null; + + const TestComponent = () => { + const colliderRef = useRef(null); + const platformBodyRef = useRef(null); + + useEffect(() => { + platformRef = platformBodyRef.current; + }, []); + + useFilterContactPair( + useCallback(() => { + return SolverFlags.EMPTY; // Block collision resolution + }, []) + ); + + useEffect(() => { + if (colliderRef.current) { + colliderRef.current.setActiveHooks( + ActiveHooks.FILTER_CONTACT_PAIRS + ); + } + }, []); + + return ( + + + + ); + }; + + const FallingBall = () => { + const ref = useRef(null); + + useEffect(() => { + ballRef = ref.current; + }, []); + + return ( + + + + ); + }; + + const step = await awaitReady( + <> + + + + ); + + await ReactThreeTestRenderer.act(async () => { + // Step enough times for ball to fall through platform + for (let i = 0; i < 120; i++) { + step(1 / 60); + } + }); + + // Ball should have fallen through the platform (y position below platform) + // If collision was resolved, ball would be resting on top of platform at y ~1 + expect(ballRef!.translation().y).toBeLessThan(-2); + }); + it("should work with cached body state from useBeforePhysicsStep", async () => { const filterHook = vi.fn(() => 1);