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 体验。

相关推荐
天空之城--1 小时前
Claude Code 高效开发 Web 2D/3D 完全指南:心法、自定义 Skill 体系与社区技能包实战
前端·3d
清风笑烟语20 小时前
rustfs 集群部署
3d
还是大剑师兰特1 天前
Unity 从零搭建简易3D场景(新手分步教程)
3d·unity·游戏引擎
EQ-雪梨蛋花汤1 天前
Android 3D 开发教程(三):Sceneform-EQR 配置 PBR 材质、光照、相机与实时阴影
android·3d·材质
弈语道破AI1 天前
3D渲染不再熬时间!即梦 Seedance 2.5 具备3D白模渲染功能的AI视频生成工具
人工智能·3d·音视频
答案—answer2 天前
VibeCoding 能做到什么程度?我用它做了一座 3D 数字博物馆
3d·ai编程·threejs·vibecoding
玖玥拾2 天前
Unity3D RPG 入门项目(四)背包拖拽、物品悬浮提示、项目开发思维
3d·unity·游戏引擎
cyadyx3 天前
【Three.js】Three.js 中加载 3D 模型
前端·javascript·3d
AI_AGENT_DEV_AI3 天前
数字孪生项目的开发
webgl
chaoyuanl3 天前
悬空玻璃剧场源头厂家选型全解析|270°裸眼3D悬空玻璃剧场投资避坑指南
大数据·人工智能·3d·xr·娱乐·mr