解析 ‘React-Three-Fiber’ 的事件系统:如何在 3D WebGL 环境中模拟 DOM 的冒泡与捕获逻辑?

React-Three-Fiber 事件系统深度解析:在 3D WebGL 环境中模拟 DOM 冒泡与捕获逻辑

在现代 Web 开发中,构建沉浸式 3D 体验已不再是遥不可及的梦想。React-Three-Fiber (R3F) 作为 Three.js 的一个 React 渲染器,极大地简化了这一过程,它将 Three.js 的强大功能与 React 的声明式组件模型相结合,让开发者能够以熟悉的方式构建复杂的 3D 场景。然而,当我们从传统的 2D DOM 环境转向 3D WebGL 画布时,一个核心挑战便浮现出来:如何处理用户交互?DOM 事件系统是为 2D 元素设计的,它依赖于浏览器渲染引擎对元素位置和堆叠顺序的理解。在 3D 空间中,这些概念被完全颠覆。

R3F 的事件系统正是为了解决这一根本性矛盾而生。它不是简单地将 DOM 事件传递给 3D 对象,而是在 WebGL 画布上模拟了一套完整的 DOM 事件冒泡与捕获逻辑,使得 3D 对象能够像 2D HTML 元素一样响应 onClickonPointerOver 等事件。本讲座将深入探讨 R3F 事件系统的内部机制、工作原理,并通过丰富的代码示例,揭示其如何巧妙地在 3D 世界中重塑 2D 交互范式。

1. 3D 交互的基石:光线投射 (Raycasting)

在 2D DOM 中,浏览器知道鼠标指针下方是哪个 HTML 元素。但在 3D 场景中,鼠标指针只是屏幕上的一个 2D 坐标。我们需要一种机制来确定这个 2D 屏幕点对应 3D 空间中的哪个或哪些对象。这就是光线投射(Raycasting)的核心作用。

1.1. 光线投射原理

光线投射是一种从特定点(通常是相机位置)沿着特定方向(由鼠标点击的 2D 屏幕坐标决定)发射一条虚拟射线,并检测这条射线与场景中哪些 3D 对象相交的技术。

当用户在 R3F 渲染的 <Canvas> 上进行交互(例如鼠标移动、点击)时,R3F 会执行以下步骤:

  1. 获取 2D 屏幕坐标: 记录鼠标事件(mousemoveclick 等)提供的 clientXclientY
  2. 标准化设备坐标 (NDC) 转换: 将屏幕坐标转换为 NDC 范围 [-1, 1]。对于 clientX(x / width) * 2 - 1;对于 clientY-(y / height) * 2 + 1(Y 轴通常需要反转,因为屏幕坐标系 Y 轴向下,WebGL Y 轴向上)。
  3. 创建光线: 使用 Three.js 的 THREE.Raycaster 对象,结合当前相机和 NDC 坐标,生成一条从相机穿过该 NDC 点并延伸到 3D 场景深处的光线。
  4. 检测交点: THREE.Raycaster 会遍历场景中的所有(或指定)3D 对象,计算这条光线与它们之间的所有交点。
  5. 返回交点列表: 返回一个包含所有相交对象的数组,通常按距离相机由近到远排序。每个交点对象包含被撞击的 3D 对象、交点在世界坐标系中的位置、交点处的法线、撞击的几何体面索引等信息。

1.2. THREE.Raycaster 的运用

R3F 在内部维护了一个 THREE.Raycaster 实例,并在每次相关的指针事件发生时使用它。我们可以通过 useThree 钩子访问到 Three.js 的核心对象,包括 gl (WebGLRenderer)、scene (Scene)、camera (Camera) 以及 raycaster

import { useThree } from '@react-three/fiber';
import { useEffect } from 'react';

function RaycasterDebugger() {
  const { gl, scene, camera, raycaster } = useThree();

  useEffect(() => {
    // 可以在这里观察或修改 raycaster 的属性
    console.log('Raycaster instance:', raycaster);
    // 例如,修改 raycaster 的精度
    // raycaster.params.Points.threshold = 0.1;
  }, [raycaster]);

  return null; // 这个组件不渲染任何东西,只用于获取 Three.js 状态
}

// 在 Canvas 内部使用
// <Canvas>
//   <RaycasterDebugger />
//   {/* ... 其他 3D 对象 */}
// </Canvas>

1.3. 场景图与对象拾取

Three.js 的场景是一个层级结构,被称为场景图(Scene Graph)。所有 3D 对象(MeshGroupLight 等)都作为 THREE.Object3D 的子类存在,并可以相互嵌套。光线投射的交点列表会包含所有被射线穿透的对象,无论它们是否被其他对象遮挡(除非明确配置了 visible = falserenderOrder)。

R3F 的挑战在于:从这个交点列表中,它不仅要识别出“最前面”的对象,还要理解这些对象之间的父子关系,才能模拟 DOM 的事件传播。

2. R3F 事件系统架构:连接 2D DOM 与 3D WebGL

R3F 的事件系统核心思想是:将 2D DOM 事件作为触发器,然后通过光线投射在 3D 场景中模拟一套等价的事件处理流程。

2.1. <Canvas> 作为事件源

在 R3F 应用中,所有的 3D 渲染都发生在 <Canvas> 组件内部。这个 <Canvas> 组件实际上是一个原生的 HTML <canvas> 元素。因此,它能够监听所有标准的浏览器 DOM 事件,如 clickmousemovepointerdownpointerupwheel 等。

import { Canvas } from '@react-three/fiber';

function App() {
  return (
    <Canvas
      // 可以直接在 Canvas 上监听原生 DOM 事件
      // R3F 也会在内部监听这些事件
      onPointerMove={(event) => {
        // 这是原生的 DOM PointerEvent,event.target 是 canvas 元素
        // R3F 会基于这个事件进行 3D 光线投射
        // console.log("Native Canvas PointerMove", event.clientX, event.clientY);
      }}
      // eventSource 属性可以指定哪个 DOM 元素作为事件源
      // 默认为 canvas 元素自身。如果你的 canvas 被其他元素覆盖,
      // 或者你想将事件委托给父元素,这会很有用。
      // eventSource={document.getElementById('root')}
    >
      {/* 3D 场景内容 */}
    </Canvas>
  );
}

R3F 内部会订阅这些原生 DOM 事件,并在事件发生时:

  1. 获取原生事件数据: clientX, clientY, buttons, ctrlKey, metaKey 等。
  2. 执行光线投射: 利用获取的 2D 坐标和当前场景/相机状态,执行 THREE.Raycaster.intersectObjects()
  3. 处理交点: 基于交点列表,R3F 内部的事件调度器开始模拟 DOM 事件的捕获、目标和冒泡阶段。

2.2. R3F 内部状态与调度

R3F 维护了一个复杂的内部状态来管理交互。这包括:

  • hovered 对象集合: 一个 Map,存储当前鼠标悬停在其上的所有 3D 对象,以及它们对应的事件信息。这对于实现 onPointerOveronPointerOut 至关重要,因为它需要跟踪鼠标何时进入和离开一个对象。
  • active 对象: 通常指被按下或拖拽的对象。
  • 事件队列: 用于处理和分发模拟的 3D 事件。

每次指针事件发生时,R3F 的事件调度器会:

  1. 清空旧的悬停状态: 遍历 hovered 集合,对于那些不再被光线投射命中的对象,触发 onPointerOut 事件。
  2. 识别新的悬停对象: 遍历当前光线投射命中的对象,对于那些新进入悬停状态的对象,触发 onPointerOver 事件。
  3. 确定事件目标: 从光线投射结果中选择最符合条件的 3D 对象作为事件的“目标”(通常是最近的、且绑定了事件处理器的对象)。
  4. 构建事件路径: 从场景根节点到目标对象,构建一个包含所有祖先节点的路径。
  5. 调度事件: 按照 DOM 事件模型的捕获、目标和冒泡阶段,依次调用路径上每个对象的事件处理器。

3. 模拟 DOM 事件阶段:捕获、目标、冒泡

DOM 事件模型定义了事件在元素层级结构中传播的三个阶段:

  1. 捕获阶段 (Capturing Phase): 事件从 window 对象开始,向下传播到目标元素。在此阶段,事件监听器会按照从父到子的顺序被触发。
  2. 目标阶段 (Target Phase): 事件到达目标元素本身,目标元素上的监听器被触发。
  3. 冒泡阶段 (Bubbling Phase): 事件从目标元素开始,向上冒泡到 window 对象。在此阶段,事件监听器会按照从子到父的顺序被触发。

R3F 巧妙地在 3D 场景中复刻了这一机制。

3.1. R3F 如何识别“目标”对象

在 2D DOM 中,浏览器会精确知道鼠标点击了哪个元素。在 3D 中,光线投射可能返回多个交点。R3F 的策略是:

  1. 筛选可交互对象: 只考虑那些在 R3F 组件中绑定了事件处理器的 Three.js 对象。
  2. 最近原则: 从筛选后的交点中,选择距离相机最近的对象作为主要候选。
  3. 层级优先: 如果一个父对象和它的子对象都被命中,且子对象有事件处理器,那么子对象通常是更精确的目标。

event.object vs event.eventObject:一个重要的区别

R3F 提供的事件对象 (ThreeEvent) 有两个关键属性:

  • event.object: 这是实际被光线投射命中的 Three.js 对象。它可能是场景中的任何 MeshLine 等,即使它没有直接绑定 R3F 事件处理器。
  • event.eventObject: 这是在 R3F 声明式组件中绑定了事件处理器的那个 Three.js 对象。它通常是事件的“目标”或其祖先,也就是你写 onClick 的那个组件所代表的 3D 对象。

这个区别至关重要。例如,你有一个 ParentBox 包含一个 ChildSphere。如果你点击 ChildSphereevent.object 将是 ChildSphereMesh 实例。而如果 ChildSphere 没有 onClick 处理器,事件冒泡到 ParentBox,那么在 ParentBoxonClick 处理器中,event.object 仍然是 ChildSphere,但 event.eventObject 将是 ParentBoxMesh 实例。

3.2. 构建事件路径

一旦确定了 event.eventObject(即事件的“目标”R3F 组件对应的 Three.js 对象),R3F 会沿着 Three.js 场景图的父子链向上遍历,直到场景根节点,从而构建出一个完整的事件路径。这个路径与 DOM 中的 event.composedPath() 概念相似。

3.3. 模拟捕获、目标和冒泡

R3F 模拟的事件传播流程如下:

  1. 捕获阶段(onPointer[Event]Capture):

    • R3F 从事件路径的根(通常是场景对象)开始,向下遍历到目标对象的父级
    • 对于路径上的每个对象,如果它定义了 onPointer[Event]Capture 处理器(例如 onClickCaptureonPointerDownCapture),则会按从父到子的顺序触发。
    • 如果在任何捕获处理器中调用了 event.stopPropagation(),则事件传播会立即停止,后续的捕获、目标和冒泡阶段都不会发生。
  2. 目标阶段(onPointer[Event] on target):

    • 事件到达 event.eventObject(事件目标)。
    • 触发目标对象上定义的 onPointer[Event] 处理器。
    • event.stopPropagation() 在此阶段也能阻止冒泡。
  3. 冒泡阶段(onPointer[Event] on ancestors):

    • 事件从目标对象的父级开始,向上冒泡到场景根。
    • 对于路径上的每个祖先对象,如果它定义了 onPointer[Event] 处理器,则会按从子到父的顺序触发。
    • 如果在任何冒泡处理器中调用了 event.stopPropagation(),则事件会停止向上冒泡。

3.4. ThreeEvent 对象属性

R3F 提供的 ThreeEvent 对象封装了大量有用的信息,它继承自原生的 DOM PointerEvent,并添加了 3D 相关的属性:

属性名称 类型 描述
object THREE.Object3D 实际被光线投射命中的 Three.js 对象(例如 Mesh 实例)。
eventObject THREE.Object3D R3F 事件处理器的绑定对象。它可能是 object 本身,也可能是 object 的某个祖先,取决于哪个 R3F 组件声明了事件处理器。
point THREE.Vector3 光线与 object 的交点在世界坐标系中的位置。
distance number 从相机到 point 的距离。
intersections Intersection[] 光线投射返回的所有交点数组,按距离排序。每个 Intersection 对象包含 objectpointfacefaceIndexuv 等详细信息。
uv THREE.Vector2 交点在 object 纹理 UV 坐标系中的位置(如果有)。可用于纹理拾取。
delta number 鼠标滚轮事件的滚动量。
stopPropagation () => void 阻止事件进一步传播(包括捕获、目标、冒泡阶段)。
preventDefault () => void 阻止浏览器对原生事件的默认行为(例如右键菜单、拖拽图像)。
nativeEvent PointerEvent | WheelEvent 触发此 3D 事件的原始 DOM 事件对象。你可以访问所有原生事件属性,如 clientXclientYbuttonsctrlKey 等。
ray THREE.Ray 用于生成此次交点的 Three.js 光线对象。
camera THREE.Camera 当前场景使用的相机实例。
gl THREE.WebGLRenderer Three.js 渲染器实例。
timestamp number 事件发生的时间戳。

4. 详细代码示例与演示

我们将通过一系列代码示例来演示 R3F 事件系统的捕获、目标和冒泡行为。

4.1. 基础点击与悬停

首先,让我们创建一个可点击和悬停的盒子。

import React, { useRef, useState } from 'react';
import { Canvas, useFrame } from '@react-three/fiber';
import { OrbitControls } from '@react-three/drei';

// 一个简单的可交互盒子组件
function InteractiveBox(props) {
  const meshRef = useRef();
  const [hovered, setHover] = useState(false);
  const [active, setActive] = useState(false);

  // 动画:每帧旋转盒子
  useFrame(() => {
    if (meshRef.current) {
      meshRef.current.rotation.x += 0.01;
      meshRef.current.rotation.y += 0.005;
    }
  });

  return (
    <mesh
      {...props}
      ref={meshRef}
      scale={active ? 1.5 : 1} // 点击时放大
      onClick={(event) => {
        setActive(!active);
        console.log('Box Clicked!', event.object.name, event.eventObject.name);
        // event.stopPropagation(); // 如果取消注释,将阻止事件冒泡到父级或 Canvas
      }}
      onPointerOver={(event) => {
        setHover(true);
        console.log('Box Hovered Over!', event.object.name, event.eventObject.name);
      }}
      onPointerOut={(event) => {
        setHover(false);
        console.log('Box Hovered Out!', event.object.name, event.eventObject.name);
      }}
      name="MyInteractiveBox" // 给 Three.js 对象一个名字,方便调试
    >
      <boxGeometry args={[1, 1, 1]} />
      <meshStandardMaterial color={hovered ? 'hotpink' : 'orange'} />
    </mesh>
  );
}

// 主应用组件
function BasicInteractionApp() {
  return (
    <Canvas camera={{ position: [0, 0, 5], fov: 75 }}>
      <ambientLight intensity={0.5} />
      <spotLight position={[10, 10, 10]} angle={0.15} penumbra={1} />
      <pointLight position={[-10, -10, -10]} />
      <InteractiveBox position={[-1.2, 0, 0]} />
      <InteractiveBox position={[1.2, 0, 0]} />
      <OrbitControls />
    </Canvas>
  );
}

在这个例子中,InteractiveBox 组件直接绑定了 onClickonPointerOveronPointerOut 事件。当鼠标与盒子交互时,对应的处理器会被触发。event.object.nameevent.eventObject.name 在这里是相同的,因为事件直接发生在绑定了处理器的 mesh 上。

4.2. 嵌套对象与冒泡机制

现在,我们创建一个父级盒子,内部包含一个子级球体,并观察事件的冒泡行为。

import React, { useRef, useState } from 'react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls } from '@react-three/drei';

// 父级盒子组件
function ParentContainer({ children, ...props }) {
  const [hovered, setHovered] = useState(false);

  return (
    <mesh
      {...props}
      onPointerOver={(e) => {
        setHovered(true);
        console.log('ParentContainer: onPointerOver (bubble)', e.object.name, '->', e.eventObject.name);
        // e.stopPropagation(); // 如果在这里停止,子组件的 onPointerOver 也会被阻止
      }}
      onPointerOut={(e) => {
        setHovered(false);
        console.log('ParentContainer: onPointerOut (bubble)', e.object.name, '->', e.eventObject.name);
      }}
      onClick={(e) => {
        console.log('ParentContainer: onClick (bubble)', e.object.name, '->', e.eventObject.name);
        // e.stopPropagation(); // 如果在这里停止,将阻止事件冒泡到 Canvas 或更高层级
      }}
      name="ParentContainer"
    >
      <boxGeometry args={[3, 3, 3]} />
      <meshStandardMaterial color={hovered ? 'lightblue' : 'blue'} transparent opacity={0.5} />
      {children}
    </mesh>
  );
}

// 子级球体组件
function ChildSphere(props) {
  const [hovered, setHovered] = useState(false);

  return (
    <mesh
      {...props}
      onPointerOver={(e) => {
        setHovered(true);
        console.log('ChildSphere: onPointerOver (target)', e.object.name, '->', e.eventObject.name);
        // e.stopPropagation(); // 如果在这里停止,事件将不会冒泡到 ParentContainer
      }}
      onPointerOut={(e) => {
        setHovered(false);
        console.log('ChildSphere: onPointerOut (target)', e.object.name, '->', e.eventObject.name);
      }}
      onClick={(e) => {
        console.log('ChildSphere: onClick (target)', e.object.name, '->', e.eventObject.name);
        e.stopPropagation(); // 阻止点击事件冒泡到 ParentContainer
      }}
      name="ChildSphere"
    >
      <sphereGeometry args={[0.8, 32, 32]} />
      <meshStandardMaterial color={hovered ? 'lightgreen' : 'green'} />
    </mesh>
  );
}

// 嵌套事件演示应用
function NestedEventsApp() {
  return (
    <Canvas camera={{ position: [0, 0, 10] }}>
      <ambientLight intensity={0.5} />
      <pointLight position={[10, 10, 10]} />
      <ParentContainer position={[0, 0, 0]}>
        <ChildSphere position={[0, 0, 0]} />
      </ParentContainer>
      <OrbitControls />
    </Canvas>
  );
}

运行此代码并观察控制台输出:

  1. 点击 ParentContainer 的空白区域: 只会打印 ParentContainer: onClick (bubble)。此时 e.object.namee.eventObject.name 都是 "ParentContainer"。
  2. 点击 ChildSphere
    • 首先打印 ChildSphere: onClick (target)
    • 由于 ChildSphereonClick 中调用了 e.stopPropagation()ParentContaineronClick 不会被触发。
    • 此时 ChildSphereonClick 处理器中,e.object.namee.eventObject.name 都是 "ChildSphere"。
  3. 移除 ChildSpheree.stopPropagation() 再次点击 ChildSphere,你会看到:
    • ChildSphere: onClick (target)
    • ParentContainer: onClick (bubble)
    • 此时在 ParentContaineronClick 处理器中,e.object.name 是 "ChildSphere",而 e.eventObject.name 是 "ParentContainer"。这清晰地展示了 object (实际命中) 和 eventObject (绑定处理器) 的区别,以及事件冒泡的路径。

4.3. 捕获阶段的演示

现在,我们引入捕获阶段的事件处理器 (onPointer[Event]Capture)。

import React, { useRef, useState } from 'react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls } from '@react-three/drei';

// 父级容器,现在包含捕获阶段处理器
function ParentContainerCapture({ children, ...props }) {
  const [hovered, setHovered] = useState(false);

  return (
    <mesh
      {...props}
      onPointerOverCapture={(e) => {
        console.log('ParentContainerCapture: onPointerOverCapture!', e.object.name, '->', e.eventObject.name);
        // e.stopPropagation(); // 如果在此处停止,ChildSphere 的事件将永远不会被触发
      }}
      onPointerOver={(e) => {
        setHovered(true);
        console.log('ParentContainerCapture: onPointerOver (bubble)', e.object.name, '->', e.eventObject.name);
      }}
      onPointerOut={(e) => {
        setHovered(false);
        console.log('ParentContainerCapture: onPointerOut (bubble)', e.object.name, '->', e.eventObject.name);
      }}
      onClickCapture={(e) => {
        console.log('ParentContainerCapture: onClickCapture!', e.object.name, '->', e.eventObject.name);
        // e.stopPropagation(); // 如果在此处停止,ChildSphere 的 onClick 将永远不会被触发
      }}
      onClick={(e) => {
        console.log('ParentContainerCapture: onClick (bubble)', e.object.name, '->', e.eventObject.name);
      }}
      name="ParentContainerCapture"
    >
      <boxGeometry args={[3, 3, 3]} />
      <meshStandardMaterial color={hovered ? 'lightblue' : 'blue'} transparent opacity={0.5} />
      {children}
    </mesh>
  );
}

// 子级球体组件(同上)
function ChildSphere(props) {
  const [hovered, setHovered] = useState(false);

  return (
    <mesh
      {...props}
      onPointerOver={(e) => {
        setHovered(true);
        console.log('ChildSphere: onPointerOver (target)', e.object.name, '->', e.eventObject.name);
        // e.stopPropagation();
      }}
      onPointerOut={(e) => {
        setHovered(false);
        console.log('ChildSphere: onPointerOut (target)', e.object.name, '->', e.eventObject.name);
      }}
      onClick={(e) => {
        console.log('ChildSphere: onClick (target)', e.object.name, '->', e.eventObject.name);
        // e.stopPropagation();
      }}
      name="ChildSphere"
    >
      <sphereGeometry args={[0.8, 32, 32]} />
      <meshStandardMaterial color={hovered ? 'lightgreen' : 'green'} />
    </mesh>
  );
}

// 捕获阶段演示应用
function CapturePhaseApp() {
  return (
    <Canvas camera={{ position: [0, 0, 10] }}>
      <ambientLight intensity={0.5} />
      <pointLight position={[10, 10, 10]} />
      <ParentContainerCapture position={[0, 0, 0]}>
        <ChildSphere position={[0, 0, 0]} />
      </ParentContainerCapture>
      <OrbitControls />
    </Canvas>
  );
}

运行此代码并观察控制台输出:

  1. 鼠标移入 ChildSphere
    • ParentContainerCapture: onPointerOverCapture! (捕获阶段,先触发父级)
    • ChildSphere: onPointerOver (target) (目标阶段)
    • ParentContainerCapture: onPointerOver (bubble) (冒泡阶段,再触发父级)
  2. 点击 ChildSphere
    • ParentContainerCapture: onClickCapture! (捕获阶段)
    • ChildSphere: onClick (target) (目标阶段)
    • ParentContainerCapture: onClick (bubble) (冒泡阶段)
  3. 尝试在 ParentContainerCaptureonClickCapture 中取消注释 e.stopPropagation()
    • 再次点击 ChildSphere,你会发现只有 ParentContainerCapture: onClickCapture! 被打印。ChildSphereonClickParentContainerCaptureonClick (冒泡) 都不会被触发。这证明了捕获阶段的 stopPropagation 能够阻止事件的进一步传播。

5. 高级考量与定制

R3F 事件系统不仅功能强大,还提供了足够的灵活性来处理复杂的场景和性能需求。

5.1. 性能优化

光线投射操作在每次指针事件(尤其是 onPointerMove)发生时都会执行。对于包含成千上万个复杂 3D 对象的场景,这可能成为性能瓶颈。

  • eventSource 优化: 默认情况下,R3F 的 <Canvas> 元素会监听事件。如果你有一个全屏的 R3F 场景,并且希望将事件监听委托给一个更高层级的 DOM 元素(例如 document.getElementById('root')),可以使用 eventSource 属性。这在某些情况下可以简化事件处理逻辑,但对光线投射本身的性能影响不大。
  • raycaster 属性: <Canvas> 组件接受一个 raycaster 属性,允许你定制 THREE.Raycaster 实例的行为。
    • params: 可以调整光线投射参数,例如对 LinePoints 对象的 threshold(阈值),以控制它们被拾取的灵敏度。
    • filter: 一个函数,在光线投射返回交点后,但 R3F 内部处理它们之前执行。你可以用它来过滤掉不需要交互的对象,从而减少 R3F 事件调度器的负担。
    • computeOffsets: 一个函数,用于自定义如何将屏幕坐标转换为 UV 坐标,这在进行精确纹理拾取时很有用。
  • 选择性交互: 并非所有对象都需要交互。只在那些需要响应事件的组件上添加事件处理器,R3F 会自动忽略那些没有事件处理器的对象,除非它们是某个交互对象的父级。
  • useMemoinstancedMesh 对于大量重复对象,使用 useMemo 缓存几何体和材质,或使用 InstancedMesh 进行批处理渲染,可以显著提升渲染性能,间接改善事件处理的响应速度。
// 定制 Raycaster 示例
import { Canvas } from '@react-three/fiber';
import { OrbitControls } from '@react-three/drei';
import * as THREE from 'three';

function CustomRaycasterApp() {
  return (
    <Canvas
      camera={{ position: [0, 0, 5] }}
      raycaster={{
        // 调整点云的拾取阈值
        params: { Points: { threshold: 0.2 } },
        // 过滤掉所有名称包含 "Ignore" 的对象
        filter: (intersects, state) => intersects.filter(i => !i.object.name?.includes('Ignore')),
        // 自定义 UV 计算(高级用法,通常不需要)
        computeOffsets: (event, state) => {
          // 默认实现通常是 event.uv = new THREE.Vector2(event.offsetX, event.offsetY) / canvasSize
          // 你可以根据需求进行修改
          return { offsetX: event.offsetX, offsetY: event.offsetY };
        }
      }}
    >
      <ambientLight intensity={0.5} />
      <pointLight position={[10, 10, 10]} />
      {/* 一个普通盒子 */}
      <mesh position={[-1, 0, 0]} onClick={() => console.log('Clickable Box clicked!')} name="ClickableBox">
        <boxGeometry />
        <meshStandardMaterial color="green" />
      </mesh>
      {/* 一个会被 raycaster.filter 忽略的盒子 */}
      <mesh position={[1, 0, 0]} onClick={() => console.log('This will not be logged if filtered!')} name="IgnoreThisBox">
        <boxGeometry />
        <meshStandardMaterial color="red" />
      </mesh>
      <OrbitControls />
    </Canvas>
  );
}

CustomRaycasterApp 中,如果你点击 IgnoreThisBox,控制台不会有任何输出,因为 filter 函数将其排除了。

5.2. 穿透与透明度

Three.js 的光线投射会返回所有被射线穿透的对象,即使它们是透明的或被其他对象遮挡。R3F 的事件系统会默认选择距离相机最近的那个有事件处理器的对象作为目标。这意味着如果一个透明的父容器和一个不透明的子对象重叠,点击子对象时,R3F 会优先将事件路由到子对象,然后才冒泡到父对象。

如果你需要一个对象能够被点击,但其视觉上是透明的,这正是预期的行为。如果你希望透明对象不被点击,你需要手动在 raycaster.filter 中或组件内部逻辑中处理。

5.3. 事件委托

R3F 也支持事件委托的概念。你可以在一个父级 GroupMesh 组件上放置一个事件处理器,然后通过检查 event.object 来判断实际被点击的是哪个子对象。这与 DOM 中的事件委托非常相似。

function EventDelegationGroup() {
  const handleClick = (e) => {
    console.log('Group Clicked:', e.eventObject.name);
    console.log('Actual Object Clicked:', e.object.name);
    if (e.object.name === 'DelegatedSphere') {
      console.log('Specifically handled click on DelegatedSphere!');
    }
  };

  return (
    <group onClick={handleClick} name="DelegationGroup">
      <mesh position={[-1, 0, 0]} name="DelegatedBox">
        <boxGeometry />
        <meshStandardMaterial color="purple" />
      </mesh>
      <mesh position={[1, 0, 0]} name="DelegatedSphere">
        <sphereGeometry />
        <meshStandardMaterial color="cyan" />
      </mesh>
    </group>
  );
}

function DelegationApp() {
  return (
    <Canvas camera={{ position: [0, 0, 5] }}>
      <ambientLight intensity={0.5} />
      <pointLight position={[10, 10, 10]} />
      <EventDelegationGroup />
      <OrbitControls />
    </Canvas>
  );
}

点击 DelegatedBoxDelegatedSphere 时,EventDelegationGrouphandleClick 都会被触发,并且你可以通过 e.object.name 精确识别是哪个子对象触发了事件。

5.4. 结合外部交互库

虽然 R3F 提供了基础的指针事件,但对于更复杂的交互模式(如拖拽、缩放、旋转、手势识别),通常会结合专门的库。例如,@use-gesture 是一个流行的 React 钩子库,它能够将底层的 pointer 事件抽象为高级手势。R3F 与 @use-gesture 结合得非常好,@use-gesture 可以直接在 R3F 元素的事件处理器中使用,因为它能够接收 R3F 提供的 ThreeEvent

import React, { useRef } from 'react';
import { Canvas, useFrame } from '@react-three/fiber';
import { OrbitControls } from '@react-three/drei';
import { useSpring, a } from '@react-spring/three';
import { useDrag } from '@use-gesture/react';

function DraggableBox() {
  const meshRef = useRef();
  const [springProps, api] = useSpring(() => ({
    position: [0, 0, 0],
    scale: 1,
    rotation: [0, 0, 0],
    config: { mass: 1, tension: 300, friction: 20 }
  }));

  const bind = useDrag(({ active, offset: [x, y], event }) => {
    // event 是 R3F 的 ThreeEvent,包含了 3D 交互信息
    // 我们可以使用 event.unprojectedPoint 来获取拖拽的 3D 坐标
    // 或者更简单地,直接使用 useDrag 提供的 offset 映射到 3D 空间
    api.start({
      position: [x / 100, -y / 100, 0], // 将 2D 屏幕偏移映射到 3D 空间
      scale: active ? 1.2 : 1,
      rotation: [y / 100, x / 100, 0],
    });
  }, {
    // 在 R3F 中,useDrag 自动处理事件传播,
    // 默认会调用 event.stopPropagation() 来阻止父级元素接收拖拽事件。
    // 你可以根据需要调整。
  });

  return (
    <a.mesh {...springProps} {...bind()} ref={meshRef} name="DraggableBox">
      <boxGeometry args={[1, 1, 1]} />
      <meshStandardMaterial color="purple" />
    </a.mesh>
  );
}

function DragApp() {
  return (
    <Canvas camera={{ position: [0, 0, 5] }}>
      <ambientLight intensity={0.5} />
      <pointLight position={[10, 10, 10]} />
      <DraggableBox />
      <OrbitControls />
    </Canvas>
  );
}

在这个例子中,useDrag 钩子可以直接绑定到 R3F 的 <a.mesh> 组件上(a 是 react-spring 提供的动画化组件)。当拖拽 DraggableBox 时,useDrag 会处理底层的 pointerdown, pointermove, pointerup 事件,并将它们转换为 offset 等手势参数,从而驱动盒子的位置和旋转动画。

6. 总结

React-Three-Fiber 的事件系统是其核心亮点之一,它通过光线投射在 3D WebGL 环境中巧妙地模拟了 DOM 事件的捕获、目标和冒泡机制。这使得开发者能够以熟悉的 React 事件处理模式来构建复杂的 3D 交互,极大地降低了 3D Web 开发的门槛。理解 event.objectevent.eventObject 的区别,以及如何利用 stopPropagation 控制事件流,是有效利用 R3F 事件系统的关键。通过定制 raycaster 和结合外部库,我们可以进一步优化性能并实现更丰富的交互体验。R3F 的这一设计哲学,完美地将 WebGL 的强大能力与 React 的开发范式融为一体,为沉浸式 Web 体验的未来铺平了道路。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注