本文介绍如何在 React Three Fiber(R3F)框架中,为 3D 模型添加 “点击扩散波” 交互效果 —— 当用户点击模型表面时,从点击位置向外扩散多层彩色光圈,增强场景的交互反馈和视觉吸引力。我们将基于 CityModel 组件的实现,拆解点击检测、3D 坐标获取、扩散动画等核心技术点,用通俗易懂的方式讲解实现思路。
本文基于前文介绍的如何生成光波基础上,在这个博客中我介绍了如何生成光波
一、效果概述
在 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 组件的几何体定义(简化版)
<cylinderGeometry
args={[
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 && (
<>
{/* 内层:暖红色,扩散慢,范围小 */}
<DiffuseAperture
color="#ff6b3b"
initialRadius={0.1}
maxRadius={15}
expandSpeed={2}
fadeSpeed={0.1}
position={clickPosition} // 定位到点击位置
/>
{/* 中层:橙黄色,速度中等 */}
<DiffuseAperture
color="#ffc154"
initialRadius={0.2}
maxRadius={18}
expandSpeed={2.5}
fadeSpeed={0.7}
position={clickPosition}
/>
{/* 外层:浅蓝色,扩散快,范围大 */}
<DiffuseAperture
color="#609bdf"
initialRadius={0.3}
maxRadius={22}
expandSpeed={3}
fadeSpeed={0.8}
position={clickPosition}
/>
</>
)}
层次感设计:
- 颜色:从暖色调(内)到冷色调(外),视觉上有区分度
- 速度:外层比内层扩散快,避免重叠
- 范围:外层比内层扩散得更远,形成 “波纹” 效果
5. 位置同步:让光圈从点击处出发
通过
position
属性,将扩散波定位到用户点击的 3D 位置:
// 在 CityModel 中渲染扩散波时传递位置
<DiffuseAperture
position={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?: string
initialRadius?: number
maxRadius?: number
expandSpeed?: number
fadeSpeed?: number
height?: number
textureUrl?: string
isActive?: 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.016
material.opacity = Math.max(opacityRef.current, 0) // 不小于0
// 3. 当完全透明或超出最大半径时,重置状态(循环扩散)
if (radiusRef.current > maxRadius || opacityRef.current <= 0) {
setIsExpanding(false);
}
})
return (
<mesh ref={apertureRef} position={position}>
{/* 圆柱几何体:顶面和底面隐藏,仅保留侧面 */}
<cylinderGeometry
args={[
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 - 1
pointer.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) 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)
// 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 = 100
const 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 = true
child.receiveShadow = true
child.material.transparent = true
// 标记为可交互(后续可通过此属性过滤)
child.userData.interactive = true
child.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 &&
<>
<DiffuseAperture
color="#ff6b3b" // 内层暖红(鲜艳)
initialRadius={0.1}
maxRadius={15} // 最小扩散范围
expandSpeed={2} // 中等扩散速度
fadeSpeed={0.1} // 较慢淡出(停留更久)
height={0.01}
isActive={isApertureActive}
position={clickPosition}
/>
{/* 中层光圈:橙黄色系,衔接内外层 */}
<DiffuseAperture
color="#ffc154" // 中层橙黄(过渡色)
initialRadius={0.2}
maxRadius={18} // 中等扩散范围
expandSpeed={2.5} // 稍快于内层
fadeSpeed={0.7} // 中等淡出速度
height={0.01}
isActive={isApertureActive}
position={clickPosition}
/>
{/* 外层光圈:蓝紫色系,扩散范围最大,亮度最低 */}
<DiffuseAperture
color="#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 交互体验。掌握这些技巧后,可扩展出更复杂的交互效果,如路径动画、区域高亮等。