在 React Three Fiber 中实现 3D 模型点击扩散波效果
本文介绍如何在 React Three Fiber(R3F)框架中,为 3D 模型添加 “点击扩散波” 交互效果 —— 当用户点击模型表面时,从点击位置向外扩散多层彩色光圈,增强场景的交互反馈和视觉吸引力。我们将基于 CityModel 组件的实现,拆解点击检测、3D 坐标获取、扩散动画等核心技术点,用通俗易懂的方式讲解实现思路。
本文基于前文介绍的如何生成光波基础上,在这个博客中我介绍了如何生成光波
用 React Three Fiber 实现 3D 城市模型的扩散光圈特效-CSDN博客
一、效果概述
在 3D 城市模型(CityModel 组件)中,实现以下交互效果:
- 用户用鼠标左键点击模型任意位置时,触发扩散波动画
- 扩散波从点击位置出发,向外围呈环形扩散
- 包含多层不同颜色的光圈(暖红→橙黄→浅蓝),每层光圈有不同的扩散速度和范围
- 光圈随扩散逐渐变淡,最终消失
这种效果可用于:
- 3D 场景中的交互反馈(如点击选中、位置标记)
- 模拟信号传播、能量扩散等业务场景
- 增强用户操作的视觉引导
二、核心实现步骤
1. 点击检测:判断用户是否点击了 3D 模型
要实现点击交互,需使用 Three.js 的
Raycaster
(射线检测器),从相机发射 “射线”,检测是否与模型相交。
核心代码片段:
// CityModel 组件中处理点击事件
const handleClick = (event: MouseEvent) => {// 仅响应左键点击if (event.button !== 0) return;// 1. 将屏幕坐标转换为 Three.js 标准化设备坐标(NDC)pointer.current.x = (event.clientX / window.innerWidth) * 2 - 1;pointer.current.y = -(event.clientY / window.innerHeight) * 2 + 1;// 2. 从相机发射射线,检测与模型的交点raycaster.current.setFromCamera(pointer.current, camera);const intersects = raycaster.current.intersectObject(modelRef.current, true);// 3. 如果点击到模型,触发扩散波if (intersects.length > 0) {// 获取点击位置的 3D 坐标const clickPosition = intersects[0].point.clone();setClickPosition(clickPosition);// 激活扩散波setIsApertureActive(true);// 1秒后关闭,允许再次触发setTimeout(() => setIsApertureActive(false), 1000);}
};
技术解析:
- 屏幕坐标(像素)需转换为标准化设备坐标(范围 -1 到 1),才能被 Three.js 识别
Raycaster
模拟 “视线”,intersectObject
方法检测射线是否与模型相交intersects[0].point
是射线与模型表面的交点(即点击的 3D 位置),通过setClickPosition
保存
2. 扩散波载体:用圆柱几何体模拟光圈
扩散波的视觉载体是
DiffuseAperture
组件,本质是一个 “空心圆柱”:
- 用
CylinderGeometry
创建圆柱,设置openEnded: true
隐藏上下底面,仅保留侧面- 通过动态调整圆柱的半径(大小)和透明度,实现 “扩散消失” 效果
核心原理:
// DiffuseAperture 组件的几何体定义(简化版)
<cylinderGeometryargs={[initialRadius, // 初始半径initialRadius, // 底部半径(与顶部相同,保证是正圆)height, // 圆柱高度(光圈厚度)64, // 径向分段数(数值越大,光圈边缘越平滑)1, // 高度分段数true, // 开口(无上下底,仅保留侧面)]}
/>
通俗解释:就像用一张纸条卷成空心圆环,剪掉上下两个圆形底面,只剩中间的环形侧面 —— 这个侧面就是我们看到的 “光圈”。
3. 扩散动画:让光圈动起来
扩散波的 “动” 包含两个核心变化:
- 半径增大:从初始大小逐渐变大(扩散)
- 透明度降低:从清晰逐渐变淡(消失)
动画逻辑代码(DiffuseAperture 组件内部):
// 每帧更新动画状态
useFrame((_, delta) => {if (!isActive) return;// 1. 半径随时间增大(扩散)currentRadius += expandSpeed * delta;meshRef.current.scale.set(currentRadius, 1, currentRadius);// 2. 透明度随时间降低(消失)currentOpacity -= fadeSpeed * delta;materialRef.current.opacity = Math.max(currentOpacity, 0);// 3. 超出最大范围后重置if (currentRadius > maxRadius || currentOpacity <= 0) {resetAperture(); // 重置为初始状态}
});
参数作用:
expandSpeed
:控制扩散速度(值越大,扩散越快)fadeSpeed
:控制淡出速度(值越大,消失越快)maxRadius
:控制最大扩散范围
4. 多层叠加:增强视觉层次感
通过同时渲染多个参数不同的 DiffuseAperture
组件,形成多层扩散波:
// CityModel 组件中渲染多层扩散波
{isApertureActive && (<>{/* 内层:暖红色,扩散慢,范围小 */}<DiffuseAperturecolor="#ff6b3b"initialRadius={0.1}maxRadius={15}expandSpeed={2}fadeSpeed={0.1}position={clickPosition} // 定位到点击位置/>{/* 中层:橙黄色,速度中等 */}<DiffuseAperturecolor="#ffc154"initialRadius={0.2}maxRadius={18}expandSpeed={2.5}fadeSpeed={0.7}position={clickPosition}/>{/* 外层:浅蓝色,扩散快,范围大 */}<DiffuseAperturecolor="#609bdf"initialRadius={0.3}maxRadius={22}expandSpeed={3}fadeSpeed={0.8}position={clickPosition}/></>
)}
层次感设计:
- 颜色:从暖色调(内)到冷色调(外),视觉上有区分度
- 速度:外层比内层扩散快,避免重叠
- 范围:外层比内层扩散得更远,形成 “波纹” 效果
5. 位置同步:让光圈从点击处出发
通过
position
属性,将扩散波定位到用户点击的 3D 位置:
// 在 CityModel 中渲染扩散波时传递位置
<DiffuseApertureposition={clickPosition} // 点击位置的 3D 坐标// 其他参数...
/>
关键点:
clickPosition
是通过第一步的射线检测获取的 3D 坐标,确保光圈 “从点击处冒出”。
6.光波完整代码
核心通过isActive控制是否扩散,通过position动态更新光波的位置。
import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'
import { useRef, useMemo, useState, useEffect } from 'react'// 扩散光圈组件
export const DiffuseAperture = ({color = ' 0x4C8BF5', // 光圈颜色initialRadius = 0.5, // 初始半径maxRadius = 10, // 最大扩散半径expandSpeed = 2, // 扩散速度(半径增长速率)fadeSpeed = 0.8, // 淡出速度(透明度降低速率)textureUrl, // 侧面纹理贴图URL(可选)height = 1.5, // 从 0.1 增大到 1.5(根据场景比例调整)isActive = false, // 控制是否激活扩散position = new THREE.Vector3(0, 0, 0), // 初始位置
}: {color?: stringinitialRadius?: numbermaxRadius?: numberexpandSpeed?: numberfadeSpeed?: numberheight?: numbertextureUrl?: stringisActive?: boolean;position?: THREE.Vector3;
}) => {const apertureRef = useRef<THREE.Mesh>(null)const radiusRef = useRef(initialRadius) // 跟踪当前半径const opacityRef = useRef(1) // 跟踪当前透明度const [isExpanding, setIsExpanding] = useState(isActive);// 监听 isActive 变化,控制扩散状态useEffect(() => {if (isActive) {setIsExpanding(true);radiusRef.current = initialRadius;opacityRef.current = 1;}}, [isActive]);// 创建圆柱侧面材质(带纹理支持)const material = useMemo(() => {const textureLoader = new THREE.TextureLoader()const materialParams: THREE.MeshBasicMaterialParameters = {color: new THREE.Color(color),transparent: true, // 启用透明度side: THREE.DoubleSide, // 确保侧面可见}// 若提供纹理URL,加载纹理并应用if (textureUrl) {const texture = textureLoader.load(textureUrl)materialParams.map = texture}return new THREE.MeshBasicMaterial(materialParams)}, [color, textureUrl])// 每帧更新圆柱状态(半径增大+透明度降低)useFrame(() => {if (!apertureRef.current || !isExpanding) return;// 1. 更新半径(逐渐增大)radiusRef.current += expandSpeed * 0.016 // 基于帧率的平滑增长apertureRef.current.scale.set(radiusRef.current, // X轴缩放(控制半径)1, // Y轴不缩放(保持高度)radiusRef.current, // Z轴缩放(控制半径))// 2. 更新透明度(逐渐降低)opacityRef.current -= fadeSpeed * 0.016material.opacity = Math.max(opacityRef.current, 0) // 不小于0// 3. 当完全透明或超出最大半径时,重置状态(循环扩散)if (radiusRef.current > maxRadius || opacityRef.current <= 0) {setIsExpanding(false);}})return (<mesh ref={apertureRef} position={position}>{/* 圆柱几何体:顶面和底面隐藏,仅保留侧面 */}<cylinderGeometryargs={[initialRadius, // 顶部半径initialRadius, // 底部半径(与顶部相同,确保是正圆柱)height, // 圆柱高度(厚度)64, // 径向分段数(越高越平滑)1, // 高度分段数true, // 开口(无顶面和底面)]}/><primitive object={material} /></mesh>)
}
三、在 CityModel 中集成的完整逻辑
- 初始化模型:加载 3D 模型,计算包围盒并居中,设置相机位置
- 绑定事件:在
useEffect
中绑定鼠标点击事件handleClick
- 状态管理:用
isApertureActive
控制扩散波的显示 / 隐藏,clickPosition
存储点击位置 - 条件渲染:当
isApertureActive
为true
时,渲染三层 DiffuseAperture 组件,位置设为clickPosition
通过setTimeout让光波持续1秒,并设置状态未非活跃,让光波消失。
import { useGLTF } from '@react-three/drei'
import { useThree } from '@react-three/fiber'
import { useEffect, useRef, useState } from 'react'
import * as THREE from 'three'
import { useModelManager } from '../../../utils/viewHelper/viewContext'
import { DiffuseAperture } from '../../WaveEffect'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 [isApertureActive, setIsApertureActive] = useState(false)const [clickPosition, setClickPosition] = useState<THREE.Vector3>(new THREE.Vector3(),)// 存储所有创建的边缘线对象const edgeLines = useRef<Map<string, THREE.LineSegments>>(new Map())// 绑定点击事件useEffect(() => {window.addEventListener('click', handleClick)return () => window.removeEventListener('click', handleClick)}, [])// 添加边缘高亮效果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 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// 执行射线检测raycaster.current.setFromCamera(pointer.current, camera)const intersects = raycaster.current.intersectObject(modelRef.current, true)// 如果点击到模型,触发扩散效果if (intersects.length > 0) {// 记录点击位置(这里简化为模型中心,也可以用 intersects[0].point)setIsApertureActive(true)const clickPosition = intersects[0].point.clone()setClickPosition(clickPosition)// 300ms后重置激活状态,允许再次触发setTimeout(() => setIsApertureActive(false), 1000)}}// 模型加载后初始化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)// 2. 将模型中心移到世界原点(居中)modelRef.current.position.sub(new THREE.Vector3(center.x, 0, center.z)) // 反向移动模型,使其中心对齐原点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.3, cameraZ * 1)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 = truechild.material.color.setStyle('#040912')addHighlight(child)// 保存原始材质(用于后续恢复或高亮逻辑)if (!child.userData.baseMaterial) {child.userData.baseMaterial = child.material // 存储原始材质}}})}, [modelRef.current])// 添加模型到管理器const addModel = () => {if (modelRef.current) {helper.addModel({id: '模型1',name: '模型1',url: url,model: modelRef.current,})}}return (<><primitive object={scene} ref={modelRef} />{/* 扩散光圈:位于模型中心,与模型平面平行 */}{/* 内层光圈:暖红色系,扩散范围最小,亮度最高 */}{ isApertureActive &&<><DiffuseAperturecolor="#ff6b3b" // 内层暖红(鲜艳)initialRadius={0.1}maxRadius={15} // 最小扩散范围expandSpeed={2} // 中等扩散速度fadeSpeed={0.1} // 较慢淡出(停留更久)height={0.01}isActive={isApertureActive}position={clickPosition}/>{/* 中层光圈:橙黄色系,衔接内外层 */}<DiffuseAperturecolor="#ffc154" // 中层橙黄(过渡色)initialRadius={0.2}maxRadius={18} // 中等扩散范围expandSpeed={2.5} // 稍快于内层fadeSpeed={0.7} // 中等淡出速度height={0.01}isActive={isApertureActive}position={clickPosition}/>{/* 外层光圈:蓝紫色系,扩散范围最大,亮度最低 */}<DiffuseAperturecolor="#609bdf" // 外层浅蓝(冷色)initialRadius={0.3}maxRadius={22} // 最大扩散范围expandSpeed={3} // 最快扩散速度fadeSpeed={0.8} // 最快淡出(快速消失)height={0.01}isActive={isApertureActive}position={clickPosition}/></>}</>)
}
总结
实现 3D 模型点击扩散波效果的核心步骤:
- 用
Raycaster
检测点击,获取 3D 位置- 用空心圆柱(
CylinderGeometry
)作为光圈载体- 通过
useFrame
逐帧更新半径和透明度,实现扩散消失动画- 叠加多层不同参数的光圈,增强视觉层次
这种效果充分利用了 Three.js 的几何变换和着色器能力,结合 React 的状态管理,实现了流畅的 3D 交互体验。掌握这些技巧后,可扩展出更复杂的交互效果,如路径动画、区域高亮等。