Skip to content
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
130 changes: 130 additions & 0 deletions demo/src/examples/one-way-platform/OneWayPlatform.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { Sphere } from "@react-three/drei";
import { useThree } from "@react-three/fiber";
import {
CuboidCollider,
RapierCollider,
RapierRigidBody,
RigidBody,
useBeforePhysicsStep,
useRapier
} from "@react-three/rapier";
import { useCallback, useEffect, useRef } from "react";
import { Vector3 } from "three";
import { Demo } from "../../App";

export const OneWayPlatform: Demo = () => {
const platformRef = useRef<RapierRigidBody>(null);
const colliderRef = useRef<RapierCollider>(null);
const ballRef = useRef<RapierRigidBody>(null);
const { camera } = useThree();

// 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 { 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 {
// 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);
filterContactPairHooks.push(hook);
}, []);

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={"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>
);
};
63 changes: 63 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,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<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 { 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 (
<RigidBody ref={platformRef}>
<CuboidCollider ref={colliderRef} args={[5, 0.1, 5]} />
</RigidBody>
);
};
```

### Manual stepping

You can manually step the physics simulation by calling the `step` method from the `useRapier` hook.
Expand Down
49 changes: 47 additions & 2 deletions packages/react-three-rapier/src/components/Physics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import {
Collider,
ColliderHandle,
EventQueue,
PhysicsHooks,
RigidBody,
RigidBodyHandle,
SolverFlags,
World
} from "@dimforge/rapier3d-compat";
import { useThree } from "@react-three/fiber";
Expand Down Expand Up @@ -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<RapierContext | undefined>(
Expand Down Expand Up @@ -432,6 +447,34 @@ export const Physics: FC<PhysicsProps> = (props) => {
const rigidBodyEvents = useConst<EventMap>(() => new Map());
const colliderEvents = useConst<EventMap>(() => 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<PhysicsHooks>(() => ({
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<WorldStepCallbackSet>(() => new Set());
const afterStepCallbacks = useConst<WorldStepCallbackSet>(() => new Set());

Expand Down Expand Up @@ -554,7 +597,7 @@ export const Physics: FC<PhysicsProps> = (props) => {
});

world.timestep = delta;
world.step(eventQueue);
world.step(eventQueue, hooks);

// Trigger afterStep callbacks
afterStepCallbacks.forEach((callback) => {
Expand Down Expand Up @@ -813,7 +856,9 @@ export const Physics: FC<PhysicsProps> = (props) => {
afterStepCallbacks,
isPaused: paused,
isDebug: debug,
step
step,
filterContactPairHooks,
filterIntersectionPairHooks
}),
[paused, step, debug, colliders, gravity]
);
Expand Down
Loading