当前位置: 首页 > ai >正文

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 对象的管理

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](最近的交点),符合真实世界的交互逻辑

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 - 1pointer.current.y = -(event.clientY / window.innerHeight) * 2 + 1detectClickedMesh()
}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)实现高亮效果的同时不遮挡其他重要信息

// 高亮材质(半透明效果)
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.materialmesh.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 管理线框对象,确保每个模型对应唯一的线框,方便添加 / 移除
// 存储所有创建的边缘线对象
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 对象如果不手动清理,即使组件卸载也可能残留在内存中
  • 状态重置:确保组件卸载后所有引用和状态都回到初始状态 
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

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 - 1pointer.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// 仅处理标记为可交互的Meshif (clickedObject instanceof THREE.Mesh &&clickedObject.userData.interactive) {// 切换高亮状态:点击已高亮的Mesh则取消,否则高亮新Meshif (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)// 高亮当前点击的MeshsaveOriginalAndApplyHighlight(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.materialmesh.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) returnaddModel()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 = 100const 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 = truechild.receiveShadow = truechild.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

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} /><directionalLightposition={[100, 200, 100]}intensity={3}castShadowcolor="#ffffff"shadow-mapSize-width={2048}shadow-mapSize-height={2048}/>{/* 环境贴图 */}{/* 纯色背景替代环境贴图 */}<color attach="background" args={['#0a1a3a']} /></>)
}

 CityView.tsx

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 

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

 操作面板有需要的话自取

OperationPanel.tsx

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.visiblehelper.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) => (<divkey={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博客

暗黑模式是怎么做的?

添加这个材质即可

    child.material.color.setStyle('#040912')

 

总结与扩展

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

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

扩展方向

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

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

http://www.xdnf.cn/news/15036.html

相关文章:

  • Microsoft Word 中 .doc 和 .docx 的区别
  • mongodb 开源同步工具介绍
  • 项目开发日记
  • 锁的艺术:从Mutex到ReentrantLock,掌握并发编程的脉搏
  • java多线程环境下资源隔离机制ThreadLocal详解
  • 《PyQt6-3D:开启Python 3D编程新世界 2》
  • 多线程学习
  • 处理Web请求路径参数
  • 【笔记】使用 html 创建网址快捷方式
  • 计算机学科专业基础综合(408)四门核心课程的知识点总结
  • RabbitMQ 幂等性
  • 在vscode中和obsidian中使用Mermaid
  • esp32在vscode中仿真调试
  • 蓝桥云课 矩形切割-Java
  • ZW3D 二次开发-创建球体
  • [架构之美]虚拟机Ubuntu密码重置
  • C++ Lambda 表达式详解
  • 建造者模式
  • Linux驱动06 --- UDP
  • 8.2.3希尔排序
  • CTI-CRYOGENICS Cryo-Torr®高真空泵安装、操作和维护说明
  • 国内如何考取Oracle大师
  • [2025CVPR]CCFS:高IPC数据集蒸馏的课程式粗细筛选技术解析
  • scp发送文件忽悠密码
  • Vue+Element Plus 中按回车刷新页面问题排查与解决
  • Linux中的命令连接符
  • php中array($this, ‘loadClass‘)表示啥意思?
  • 设计模式的六大设计原则
  • 《数据库》 MySQL库表操作
  • 判断当前是否为钉钉环境