Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/kind-icons-arrive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@react-three/rapier": minor
---

feat: add 'useFilterContactPair', 'useFilterIntersectionPair' hooks (@driescroons, @isaac-mason)
6 changes: 4 additions & 2 deletions demo/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
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";
Expand All @@ -35,6 +36,7 @@ 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";
Expand All @@ -43,7 +45,6 @@ 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 { ActiveCollisionTypesExample } from "./examples/active-collision-types/ActiveCollisionTypesExample";
import { OrbitControls as OrbitControlsImpl } from "three-stdlib";
import { useResetOrbitControls } from "./hooks/use-reset-orbit-controls";

Expand Down Expand Up @@ -108,7 +109,8 @@ const routes: Record<string, ReactNode> = {
spring: <SpringExample />,
"rope-joint": <RopeJointExample />,
"active-collision-types": <ActiveCollisionTypesExample />,
"contact-skin": <ContactSkinExample />
"contact-skin": <ContactSkinExample />,
"one-way-platform": <OneWayPlatform />
};

export const App = () => {
Expand Down
163 changes: 163 additions & 0 deletions demo/src/examples/one-way-platform/OneWayPlatform.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { Sphere } from "@react-three/drei";
import { useThree } from "@react-three/fiber";
import {
CuboidCollider,
RapierCollider,
RapierRigidBody,
RigidBody,
useBeforePhysicsStep,
useFilterContactPair,
useRapier
} from "@react-three/rapier";
import { useEffect, useRef } from "react";
import { Vector3 } from "three";
import { Demo } from "../../App";
import { useControls } from "leva";

export const OneWayPlatform: Demo = () => {
const platformRef = useRef<RapierRigidBody>(null);
const colliderRef = useRef<RapierCollider>(null);
const ballRef = useRef<RapierRigidBody>(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<number, { position: Vector3; velocity: Vector3 }>
>(new Map());

useEffect(() => {
camera.position.set(0, 10, 20);
camera.lookAt(0, 0, 0);
camera.updateProjectionMatrix();

window.addEventListener("click", () => {
ballRef.current?.applyImpulse(new Vector3(0, 50, 0), true);
});
}, []);

const { rapier } = 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();

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);
}
});

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);

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 rapier.SolverFlags.COMPUTE_IMPULSE; // Process the collision
}

return rapier.SolverFlags.EMPTY; // Ignore the collision (pass through)
} catch (error) {
console.error(error);
return null;
}
});

useEffect(() => {
if (colliderRef.current) {
colliderRef.current.setActiveHooks(
filteringEnabled ? rapier.ActiveHooks.FILTER_CONTACT_PAIRS : 0
);
}
}, [filteringEnabled, rapier]);

return (
<group>
<RigidBody
ref={ballRef}
colliders="ball"
position={[0, -5, 0]}
userData={{ type: "ball" }}
>
<Sphere castShadow receiveShadow>
<meshPhysicalMaterial color="red" />
</Sphere>
</RigidBody>
<mesh>
<boxGeometry args={[10, 0.1, 10]} />
<meshStandardMaterial
color={filteringEnabled ? "orange" : "grey"}
opacity={0.5}
transparent={true}
/>
</mesh>
<RigidBody type="fixed" userData={{ type: "platform" }} ref={platformRef}>
<CuboidCollider args={[10, 0.1, 10]} ref={colliderRef} />
</RigidBody>
</group>
);
};
90 changes: 90 additions & 0 deletions packages/react-three-rapier/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -886,6 +887,95 @@ 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.

`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<RapierRigidBody>(null);
const ballRef = useRef<RapierRigidBody>(null);
const colliderRef = useRef<RapierCollider>(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 (
<>
<RigidBody ref={platformRef} type="fixed">
<CuboidCollider ref={colliderRef} args={[5, 0.1, 5]} />
</RigidBody>
<RigidBody ref={ballRef} position={[0, 3, 0]}>
<CuboidCollider args={[1, 1, 1]} />
</RigidBody>
</>
);
};
```

### Manual stepping

You can manually step the physics simulation by calling the `step` method from the `useRapier` hook.
Expand Down
Loading