在 WebGL 3D 开发中,模型交互是提升用户体验的关键功能之一。本文将基于 React Three Fiber(R3F)和 Three.js,总结 3D 模型点击高亮(包括模型本身和边框)的核心技术技巧,帮助开发者快速掌握复杂 3D 交互的实现思路。本文主要围着以下功能进行讲述
- 加载 GLB 格式的 3D 城市模型
- 通过鼠标点击实现模型的选中 / 取消选中状态切换
- 选中时同时显示两种高亮效果:
- 模型本身的半透明材质覆盖
- 模型边缘的线框高亮
- 完善的资源管理和内存清理机制
操作面板和模型导入功能参考这篇博客


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>
)
}
模型管理用到的类和方法在参考这篇博客,不在赘述
暗黑模式是怎么做的?
添加这个材质即可
csschild.material.color.setStyle('#040912')

总结与扩展
本文通过 CityModel 组件的实现,详细讲解了 3D 模型交互中的核心技术点:
- 射线检测是 3D 交互的基础,掌握坐标转换和交点检测是关键
- 材质管理需考虑原始状态保存和多材质场景处理
- 线框高亮通过几何处理和父子关系实现,增强视觉反馈
- 资源清理在 Three.js 开发中尤为重要,直接影响应用性能
扩展方向:
- 支持框选多个模型(通过
RectAreaLight
或鼠标拖拽区域检测)- 添加高亮动画过渡(使用
gsap
等动画库实现材质属性平滑过渡)- 结合后期处理(如
OutlinePass
)实现更复杂的高亮效果- 优化大规模场景性能(使用 LOD、实例化、视锥体剔除)
掌握这些技巧后,你可以轻松实现更复杂的 3D 交互功能,为用户带来沉浸式的 Web 3D 体验。