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/App.tsx b/demo/src/App.tsx index b2b937d6..403e858e 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -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"; @@ -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"; @@ -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"; @@ -108,7 +109,8 @@ const routes: Record = { spring: , "rope-joint": , "active-collision-types": , - "contact-skin": + "contact-skin": , + "one-way-platform": }; export const App = () => { 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..0914d251 --- /dev/null +++ b/demo/src/examples/one-way-platform/OneWayPlatform.tsx @@ -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(null); + const colliderRef = useRef(null); + 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 + >(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 ( + + + + + + + + + + + + + + + ); +}; diff --git a/packages/react-three-rapier/readme.md b/packages/react-three-rapier/readme.md index 9f92e90d..9056337f 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,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(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. diff --git a/packages/react-three-rapier/src/components/Physics.tsx b/packages/react-three-rapier/src/components/Physics.tsx index 0d44ee8d..c681b1e6 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"; @@ -65,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; @@ -122,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 */ @@ -422,6 +458,29 @@ export const Physics: FC = (props) => { const rigidBodyEvents = useConst(() => new Map()); const colliderEvents = useConst(() => new Map()); const eventQueue = useConst(() => new EventQueue(false)); + + const filterContactPairHooks = useConst( + () => new Set() + ); + const filterIntersectionPairHooks = + useConst(() => new Set()); + + const hooks = useConst(() => ({ + filterContactPair: (...args) => { + for (const hook of filterContactPairHooks) { + const result = hook.current(...args); + if (result !== null) return result; + } + return null; + }, + filterIntersectionPair: (...args) => { + for (const hook of filterIntersectionPairHooks) { + const result = hook.current(...args); + if (result === false) return false; + } + return true; + } + })); const beforeStepCallbacks = useConst(() => new Set()); const afterStepCallbacks = useConst(() => new Set()); @@ -541,7 +600,12 @@ export const Physics: FC = (props) => { }); world.timestep = delta; - world.step(eventQueue); + + const hasHooks = + filterContactPairHooks.size > 0 || + filterIntersectionPairHooks.size > 0; + + world.step(eventQueue, hasHooks ? hooks : undefined); // Trigger afterStep callbacks afterStepCallbacks.forEach((callback) => { @@ -800,7 +864,9 @@ export const Physics: FC = (props) => { afterStepCallbacks, isPaused: paused, isDebug: debug, - step + step, + filterContactPairHooks, + filterIntersectionPairHooks }), [paused, step, debug, colliders, gravity] ); 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 new file mode 100644 index 00000000..de0a00e3 --- /dev/null +++ b/packages/react-three-rapier/tests/physics-hooks.test.tsx @@ -0,0 +1,335 @@ +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, + useFilterContactPair, + useFilterIntersectionPair, + RapierRigidBody, + RapierCollider +} from "../src"; +import { SolverFlags, ActiveHooks } from "@dimforge/rapier3d-compat"; +import { awaitReady } from "./test-utils"; + +describe("physics hooks", () => { + describe("useFilterContactPair", () => { + it("should register and call contact pair filter hooks", async () => { + const filterHook = vi.fn(() => null); + + const TestComponent = () => { + const colliderRef = useRef(null); + + useFilterContactPair(filterHook); + + useEffect(() => { + if (colliderRef.current) { + colliderRef.current.setActiveHooks( + ActiveHooks.FILTER_CONTACT_PAIRS + ); + } + }, []); + + 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 call hook with correct parameters when collision occurs", async () => { + const filterHook = vi.fn(() => SolverFlags.COMPUTE_IMPULSE); + + const TestComponent = () => { + const colliderRef = useRef(null); + + useFilterContactPair(filterHook); + + useEffect(() => { + if (colliderRef.current) { + colliderRef.current.setActiveHooks( + ActiveHooks.FILTER_CONTACT_PAIRS + ); + } + }, []); + + return ( + + + + ); + }; + + const step = await awaitReady( + <> + + + + + + ); + + await ReactThreeTestRenderer.act(async () => { + for (let i = 0; i < 30; i++) { + step(1 / 60); + } + }); + + // 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 SolverFlags.COMPUTE_IMPULSE", async () => { + const onCollisionEnter = vi.fn(); + + const TestComponent = () => { + const colliderRef = useRef(null); + + useFilterContactPair( + useCallback(() => { + return SolverFlags.COMPUTE_IMPULSE; // Allow collisions + }, []) + ); + + useEffect(() => { + if (colliderRef.current) { + colliderRef.current.setActiveHooks( + ActiveHooks.FILTER_CONTACT_PAIRS + ); + } + }, []); + + 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 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); + + const OneWayPlatform = () => { + 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 + }); + } + }); + + useFilterContactPair( + 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 SolverFlags.COMPUTE_IMPULSE; // Allow collision + }, []) + ); + + useEffect(() => { + if (colliderRef.current) { + colliderRef.current.setActiveHooks( + ActiveHooks.FILTER_CONTACT_PAIRS + ); + } + }, []); + + 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("useFilterIntersectionPair", () => { + it("should register and call intersection pair filter hooks", async () => { + const filterHook = vi.fn(() => true); + + const TestComponent = () => { + const colliderRef = useRef(null); + + useFilterIntersectionPair(filterHook); + + useEffect(() => { + if (colliderRef.current) { + colliderRef.current.setActiveHooks( + ActiveHooks.FILTER_INTERSECTION_PAIRS + ); + } + }, []); + + return ( + + + + ); + }; + + const step = await awaitReady( + <> + + + + + + ); + + await ReactThreeTestRenderer.act(async () => { + for (let i = 0; i < 30; i++) { + step(1 / 60); + } + }); + + expect(filterHook).toHaveBeenCalled(); + }); + }); +});