React Three Fiber 实现 3D 模型点击高亮交互的核心技巧

在 WebGL 3D 开发中,模型交互是提升用户体验的关键功能之一。本文将基于 React Three Fiber(R3F)和 Three.js,总结 3D 模型点击高亮(包括模型本身和边框)的核心技术技巧,帮助开发者快速掌握复杂 3D 交互的实现思路。本文主要围着以下功能进行讲述

  • 加载 GLB 格式的 3D 城市模型
  • 通过鼠标点击实现模型的选中 / 取消选中状态切换
  • 选中时同时显示两种高亮效果:
    • 模型本身的半透明材质覆盖
    • 模型边缘的线框高亮
  • 完善的资源管理和内存清理机制
    操作面板和模型导入功能参考这篇博客

Three.js 如何控制 GLB 模型的内置属性实现精准显示-CSDN博客

3D 模型加载与引用管理

使用 useGLTF 钩子简化 GLTF 模型加载,这是 R3F 生态中处理 3D 资源的标准方式,内部已处理加载状态、资源缓存和内存管理;通过 ref 引用 Three.js 原生 Group 对象,使我们能直接操作 3D 场景对象;使用 <primitive> 组件将 Three.js 原生对象挂载到 React 虚拟 DOM 中,实现 React 对 Three.js 对象的管理

javascript 复制代码
import { useGLTF } from '@react-three/drei'

export const CityModel = ({ url }: { url: string }) => {
  const { scene } = useGLTF(url)
  const modelRef = useRef<THREE.Group>(null)
  
  // 组件渲染
  return <primitive object={scene} ref={modelRef} />
}

射线检测实现模型点击交互

这是 3D 交互的核心机制,通过 Raycaster 模拟一条从相机发射到点击位置的射线。坐标转换是关键:将屏幕像素坐标(0~window.innerWidth)转换为 Three.js 标准化设备坐标(-1~1)。intersectObject 方法第二个参数设为 true 表示递归检测所有子对象,确保能选中复杂模型的子网格。优先处理 intersects[0](最近的交点),符合真实世界的交互逻辑

javascript 复制代码
const raycaster = useRef(new THREE.Raycaster())
const pointer = useRef(new THREE.Vector2())

const handleClick = (event: MouseEvent) => {
  // 仅响应左键点击
  if (event.button !== 0) return
  
  // 屏幕坐标转换为 Three.js 标准化设备坐标
  pointer.current.x = (event.clientX / window.innerWidth) * 2 - 1
  pointer.current.y = -(event.clientY / window.innerHeight) * 2 + 1
  
  detectClickedMesh()
}

const detectClickedMesh = () => {
  if (!modelRef.current) return
  // 更新射线(从相机到点击位置)
  raycaster.current.setFromCamera(pointer.current, camera)
  
  // 检测与模型的交点(递归检测所有子Mesh)
  const intersects = raycaster.current.intersectObject(modelRef.current, true)
  
  if (intersects.length > 0) {
    const clickedObject = intersects[0].object as THREE.Mesh
    // 处理点击逻辑...
  }
}

材质切换实现模型高亮

利用 Three.js 的 userData 存储原始材质,这是一种安全的扩展对象属性的方式;同时支持单材质和多材质(MeshFaceMaterial)的场景,处理更全面

使用 clone() 方法创建材质副本,避免多个对象共享同一材质实例导致的样式冲突

半透明材质(transparent: true)实现高亮效果的同时不遮挡其他重要信息

javascript 复制代码
// 高亮材质(半透明效果)
const highlightMaterial = useRef(
  new THREE.MeshBasicMaterial({ 
    color: '#040912', 
    transparent: true, 
    opacity: 0.4 
  })
)

// 保存原始材质并应用高亮材质
const saveOriginalAndApplyHighlight = (mesh: THREE.Mesh) => {
  // 保存原始材质(处理单材质和多材质情况)
  if (!mesh.userData.originalMaterial) {
    if (Array.isArray(mesh.material)) {
      mesh.userData.originalMaterial = [...mesh.material]
      mesh.material = mesh.material.map(() => highlightMaterial.current.clone())
    } else {
      mesh.userData.originalMaterial = mesh.material
      mesh.material = highlightMaterial.current.clone()
    }
  }
}

// 恢复原始材质
const restoreOriginalMaterial = (mesh: THREE.Mesh[]) => {
  mesh.forEach(m => {
    if (m.userData.originalMaterial) {
      m.material = m.userData.originalMaterial
    }
  })
}

边缘线框高亮效果实现

  • 使用 EdgesGeometry 从网格几何体生成边缘线,自动计算模型的轮廓边缘
  • 通过 LineSegments 创建线框对象,比 Line 更适合表现闭合轮廓
  • 将线框作为模型的子对象,确保线框能跟随模型一起变换(移动、旋转、缩放)
  • 使用 Map + uuid 管理线框对象,确保每个模型对应唯一的线框,方便添加 / 移除
javascript 复制代码
// 存储所有创建的边缘线对象
const edgeLines = useRef<Map<string, THREE.LineSegments>>(new Map())

// 添加边缘高亮效果
const addHighlight = (object: THREE.Mesh) => {
  if (!object.geometry) return
  
  // 创建边缘几何体
  const geometry = new THREE.EdgesGeometry(object.geometry)
  
  // 创建边缘线材质
  const material = new THREE.LineBasicMaterial({
    color: 0x4C8BF5, // 蓝色边缘
    linewidth: 2,
  })
  
  // 创建边缘线对象
  const line = new THREE.LineSegments(geometry, material)
  
  // 继承原始模型的变换属性
  line.position.copy(object.position)
  line.rotation.copy(object.rotation)
  line.scale.copy(object.scale)
  
  // 添加到模型并记录
  object.add(line)
  edgeLines.current.set(object.uuid, line)
}

// 移除边缘高亮效果
const removeHighlight = (object: THREE.Mesh) => {
  const line = edgeLines.current.get(object.uuid)
  if (line && object.children.includes(line)) {
    object.remove(line)
  }
  edgeLines.current.delete(object.uuid)
}

组件生命周期与资源管理

  • useEffect 中管理事件监听,确保只在模型加载完成后绑定事件
  • 卸载时执行完整的清理工作:移除事件监听、清理线框对象、恢复材质状态
  • 避免内存泄漏:Three.js 对象如果不手动清理,即使组件卸载也可能残留在内存中
  • 状态重置:确保组件卸载后所有引用和状态都回到初始状态
javascript 复制代码
useEffect(() => {
  if (!modelRef.current) return
  
  // 模型初始化逻辑...
  
  // 绑定点击事件
  window.addEventListener('click', handleClick)
  
  // 组件卸载时清理
  return () => {
    window.removeEventListener('click', handleClick)
    // 移除所有高亮边缘线
    highlightedMeshRef.current.forEach(mesh => {
      removeHighlight(mesh)
    })
    edgeLines.current.clear()
    // 清理高亮状态
    if (highlightedMeshRef.current) {
      restoreOriginalMaterial(highlightedMeshRef.current)
    }
    highlightedMeshRef.current = []
  }
}, [modelRef.current])

完整代码

其中 const helper = useModelManager()是我用于操作面板管理时获取模型数据,不用可以移除

CityModel.tsx

javascript 复制代码
import { useGLTF } from '@react-three/drei'
import { useThree } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
import * as THREE from 'three'
import { useModelManager } from '../../../utils/viewHelper/viewContext'

export const CityModel = ({ url }: { url: string }) => {
  const { scene } = useGLTF(url)
  const modelRef = useRef<THREE.Group>(null)
  const helper = useModelManager()
  const { camera } = useThree()

  const raycaster = useRef(new THREE.Raycaster())
  const pointer = useRef(new THREE.Vector2())
  const highlightedMeshRef = useRef<THREE.Mesh[]>([])
  // 存储所有创建的边缘线对象
  const edgeLines = useRef<Map<string, THREE.LineSegments>>(new Map())

  // 高亮材质(半透明青色)
  const highlightMaterial = useRef(
    new THREE.MeshBasicMaterial({
      color: '#5a6f85',
      transparent: true,
      opacity: 0.7,
    }),
  )

  // 添加边缘高亮效果
  const addHighlight = (object: THREE.Mesh) => {
    if (!object.geometry) return

    // 创建边缘几何体
    const geometry = new THREE.EdgesGeometry(object.geometry)

    // 创建边缘线材质
    const material = new THREE.LineBasicMaterial({
      color: 0x4c8bf5, // 蓝色边缘
      linewidth: 2, // 线宽
    })

    // 创建边缘线对象
    const line = new THREE.LineSegments(geometry, material)
    line.name = 'surroundLine'

    // 复制原始网格的变换
    line.position.copy(object.position)
    line.rotation.copy(object.rotation)
    line.scale.copy(object.scale)

    // 设置为模型的子对象,确保跟随模型变换
    object.add(line)
    edgeLines.current.set(object.uuid, line)
  }

  // 移除边缘高亮效果
  const removeHighlight = (object: THREE.Mesh) => {
    const line = edgeLines.current.get(object.uuid)
    if (line && object.children.includes(line)) {
      object.remove(line)
    }
    edgeLines.current.delete(object.uuid)
  }

  // 处理左键点击事件
  const handleClick = (event: MouseEvent) => {
    // 仅响应左键点击(排除右键/中键/滚轮)
    if (event.button !== 0) return

    // 计算点击位置的标准化设备坐标
    pointer.current.x = (event.clientX / window.innerWidth) * 2 - 1
    pointer.current.y = -(event.clientY / window.innerHeight) * 2 + 1

    // 执行射线检测,判断点击目标
    detectClickedMesh()
  }

  // 检测点击的Mesh并切换高亮状态
  const detectClickedMesh = () => {
    if (!modelRef.current) return
    // 更新射线(从相机到点击位置)
    raycaster.current.setFromCamera(pointer.current, camera)

    // 检测与模型的交点(递归检测所有子Mesh)
    const intersects = raycaster.current.intersectObject(modelRef.current, true)

    if (intersects.length > 0) {
      const clickedObject = intersects[0].object as THREE.Mesh

      // 仅处理标记为可交互的Mesh
      if (
        clickedObject instanceof THREE.Mesh &&
        clickedObject.userData.interactive
      ) {
        // 切换高亮状态:点击已高亮的Mesh则取消,否则高亮新Mesh
        if (highlightedMeshRef.current?.includes(clickedObject)) {
          console.log('取消高亮', clickedObject.name)
          // 取消高亮:恢复原始材质
          restoreOriginalMaterial([clickedObject])
          // 移除边框高亮
          removeHighlight(clickedObject)
          const newHighlighted = highlightedMeshRef.current.filter(
            (m) => m.name !== clickedObject.name,
          )
          highlightedMeshRef.current = [...newHighlighted]
        } else {
          console.log('高亮', clickedObject.name)
          // 高亮当前点击的Mesh
          saveOriginalAndApplyHighlight(clickedObject)
          // 添加边框高亮
          addHighlight(clickedObject)
          highlightedMeshRef.current = [
            ...highlightedMeshRef.current,
            clickedObject,
          ]
        }
      }
    }
  }

  // 工具函数:恢复原始材质
  const restoreOriginalMaterial = (mesh: THREE.Mesh[]) => {
    mesh.forEach((m) => {
      if (m.userData.originalMaterial) {
        m.material = m.userData.originalMaterial
      }
    })
  }

  // 工具函数:保存原始材质并应用高亮材质
  const saveOriginalAndApplyHighlight = (mesh: THREE.Mesh) => {
    // 保存原始材质(处理单材质和多材质情况)
    if (!mesh.userData.originalMaterial) {
      if (Array.isArray(mesh.material)) {
        mesh.userData.originalMaterial = [...mesh.material]
        mesh.material = mesh.material.map(() =>
          highlightMaterial.current.clone(),
        )
      } else {
        mesh.userData.originalMaterial = mesh.material
        mesh.material = highlightMaterial.current.clone()
      }
    } else {
      // 已保存过原始材质,直接应用高亮
      if (Array.isArray(mesh.material)) {
        mesh.material = mesh.material.map(() =>
          highlightMaterial.current.clone(),
        )
      } else {
        mesh.material = highlightMaterial.current.clone()
      }
    }
  }

  // 模型加载后初始化
  useEffect(() => {
    if (!modelRef.current) return
    addModel()

    const box = new THREE.Box3().setFromObject(modelRef.current)
    const center = new THREE.Vector3()
    box.getCenter(center)
    const size = new THREE.Vector3()
    box.getSize(size)

    const maxDim = Math.max(size.x, size.y, size.z)
    const fov = 100
    const cameraZ = Math.abs(maxDim / 2 / Math.tan((Math.PI * fov) / 360))
    camera.position.set(0, maxDim * 0.8, cameraZ * 1.2)
    camera.lookAt(0, 0, 0)

    // 遍历模型设置通用属性并标记可交互
    modelRef.current.traverse((child) => {
      if (child instanceof THREE.Mesh) {
        child.castShadow = true
        child.receiveShadow = true
        child.material.transparent = true
        // 标记为可交互(后续可通过此属性过滤)
        child.userData.interactive = true
      }
    })

    // 绑定点击事件监听
    window.addEventListener('click', handleClick)

    // 组件卸载时清理
    return () => {
      window.removeEventListener('click', handleClick)
      // 移除所有高亮边缘线
      highlightedMeshRef.current.forEach((mesh) => {
        removeHighlight(mesh)
      })
      edgeLines.current.clear()
      // 清理高亮状态
      if (highlightedMeshRef.current) {
        restoreOriginalMaterial(highlightedMeshRef.current)
      }
      highlightedMeshRef.current = []
    }
  }, [modelRef.current])

  // 添加模型到管理器
  const addModel = () => {
    if (modelRef.current) {
      helper.addModel({
        id: '模型1',
        name: '模型1',
        url: url,
        model: modelRef.current,
      })
    }
  }

  return <primitive object={scene} ref={modelRef} />
}

CityScene.tsx

javascript 复制代码
import { Suspense, useRef } from 'react'
import { CityModel } from '../model/CityModal'
import { OrbitControls } from '@react-three/drei'

export const CityScene = ({ modelUrl }: { modelUrl: string }) => {
  const controlsRef = useRef<any>(null)

  return (
    <>
      <Suspense>
        <CityModel url={modelUrl} />
        {/* 控制器 */}
        <OrbitControls ref={controlsRef} />
      </Suspense>
      {/* 环境光和方向光 */}
      <ambientLight intensity={0.5} color={0xffffff} />
      <directionalLight
        position={[100, 200, 100]}
        intensity={3}
        castShadow
        color="#ffffff"
        shadow-mapSize-width={2048}
        shadow-mapSize-height={2048}
      />

      {/* 环境贴图 */}

      {/* 纯色背景替代环境贴图 */}
      <color attach="background" args={['#0a1a3a']} />
    </>
  )
}

CityView.tsx

javascript 复制代码
import { Canvas } from '@react-three/fiber'
import { CityScene } from './scene/CityScene'

export const CityView = () => {
  const cityModelUrl = '/models/city-_shanghai-sandboxie.glb'
  return (
    <div className="w-[100vw] h-full absolute">
      <Canvas style={{ width: '100vw', height: '100vh' }}  shadows={true}   >
        <ambientLight />
        <CityScene modelUrl={cityModelUrl} />
      </Canvas>
    </div>
  )
}

Home.tsx

javascript 复制代码
import { CityView } from './CityView'
import './index.less'
import { OperationPanel } from './OperationPanel'
export const Home = () => {
  return (
    <div className="screen-container">

         <CityView />
         <OperationPanel />
    </div>
  )
}

操作面板有需要的话自取

OperationPanel.tsx

javascript 复制代码
import { Space, Tag } from 'antd'
import { useModelManager, useModels } from '../../utils/viewHelper/viewContext'
import './index.less'
import * as THREE from 'three'

export const OperationPanel = () => {
  const helper = useModelManager()
  const models = useModels()

  // 收集模型子对象
  const getMeshChildren = (model: THREE.Group | undefined): THREE.Mesh[] => {
    const meshes: THREE.Mesh[] = []
    model?.traverse((child) => {
      if (child instanceof THREE.Mesh) {
        meshes.push(child)
      }
    })
    return meshes
  }

  // 切换可见性
  const toggleMeshVisibility = (mesh: THREE.Mesh) => {
    mesh.visible = !mesh.visible
    helper.updateModelVisibility(mesh.uuid, mesh.visible) // 可选:通知管理器保存状态
  }

  return (
    <div className="screen-operation absolute top-[10px] right-[30px] w-[20vw] h-[60vh] text-white z-10 ">
      {/* 面板标题栏 */}
      <div className=" px-[12px] py-3 shadow-lg">
        <span className="text-lg font-semibold tracking-wide">操作面板</span>
      </div>

      {/* 模型列表 */}
      <div className="overflow-y-auto h-[calc(100%-30px)]">
        {models.map((model) => {
          const meshes = getMeshChildren(model.model)

          return (
            <div key={model.id} className="mt-[10px]">
              {/* 子对象列表 */}
              <div className="space-y-1 pl-[3px] pb-[6px]">
                {meshes.length > 0 ? (
                  meshes.map((mesh) => (
                    <div
                      key={mesh.uuid}
                      // 核心样式:状态颜色 + 悬浮效果
                      className={`
                        px-[6px] my-[6px] rounded-md cursor-pointer transition-all duration-300 ease-out
                       
                        ${
                          /* 基础样式 */ 'shadow-md transform border border-transparent'
                        }
                        ${
                          /* 悬浮效果 */ 'hover:shadow-lg hover:shadow-blue-900/20'
                        }
                        ${
                          /* 显示状态 */ mesh.visible
                            ? 'bg-gradient-to-r from-[#47718b] to-[#3a5a70] text-[#fff] hover:border-[#8ec5fc]'
                            : 'bg-gradient-to-r from-[#2d3b45] to-[#1f2930] text-[#a0a0a0] hover:border-[#555]'
                        }
                      `}
                      title={mesh.name}
                      onClick={() => toggleMeshVisibility(mesh)}
                    >
                      <div className="flex items-center justify-between">
                        <span className="font-medium w-[14vw] overflow-hidden whitespace-nowrap text-ellipsis">
                          {mesh.name}
                        </span>
                        <Space>
                          <Tag>{mesh.type}</Tag>
                          <Tag color={mesh.visible ? 'success' : 'error'}>
                            {' '}
                            {mesh.visible ? '显示中' : '已隐藏'}
                          </Tag>
                        </Space>
                      </div>
                    </div>
                  ))
                ) : (
                  <div className="px-3 py-4 text-center text-gray-400 italic">
                    无可用模型数据
                  </div>
                )}
              </div>
            </div>
          )
        })}
      </div>
    </div>
  )
}

模型管理用到的类和方法在参考这篇博客,不在赘述

Three.js 如何控制 GLB 模型的内置属性实现精准显示-CSDN博客

暗黑模式是怎么做的?

添加这个材质即可

css 复制代码
    child.material.color.setStyle('#040912')

总结与扩展

本文通过 CityModel 组件的实现,详细讲解了 3D 模型交互中的核心技术点:

  1. 射线检测是 3D 交互的基础,掌握坐标转换和交点检测是关键
  2. 材质管理需考虑原始状态保存和多材质场景处理
  3. 线框高亮通过几何处理和父子关系实现,增强视觉反馈
  4. 资源清理在 Three.js 开发中尤为重要,直接影响应用性能

扩展方向

  • 支持框选多个模型(通过 RectAreaLight 或鼠标拖拽区域检测)
  • 添加高亮动画过渡(使用 gsap 等动画库实现材质属性平滑过渡)
  • 结合后期处理(如 OutlinePass)实现更复杂的高亮效果
  • 优化大规模场景性能(使用 LOD、实例化、视锥体剔除)

掌握这些技巧后,你可以轻松实现更复杂的 3D 交互功能,为用户带来沉浸式的 Web 3D 体验。

相关推荐
心前阳光5 小时前
Unity WebGL文本输入
unity·游戏引擎·webgl
ykjhr_3d5 小时前
3D 演示动画在汽车培训与教育领域中的应用
3d·汽车
康康的幸福生活9 小时前
webgl2 方法解析: bufferSubData()
webgl
千鼎数字孪生-可视化1 天前
Web技术栈重塑HMI开发:HTML5+WebGL的轻量化实践路径
前端·html5·webgl
云空1 天前
《PyQt6-3D应用开发技术文档》
3d·pyqt
鹧鸪云光伏1 天前
光伏无人机3D建模:毫秒级精度设计
3d·无人机
杀生丸学AI1 天前
【三维生成】FlashDreamer:基于扩散模型的单目图像到3D场景
人工智能·3d·大模型·aigc·蒸馏与迁移学习·扩散模型与生成模型
gis分享者2 天前
学习threejs,使用自定义GLSL 着色器,生成漂流的3D能量球
3d·threejs·着色器·glsl·shadermaterial·能量球
m0_743106462 天前
【论文笔记】BlockGaussian:巧妙解决大规模场景重建中的伪影问题
论文阅读·计算机视觉·3d·aigc·几何学