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

在 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 中集成的完整逻辑

  1. 初始化模型:加载 3D 模型,计算包围盒并居中,设置相机位置
  2. 绑定事件:在 useEffect 中绑定鼠标点击事件 handleClick
  3. 状态管理:用 isApertureActive 控制扩散波的显示 / 隐藏,clickPosition 存储点击位置
  4. 条件渲染:当 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 模型点击扩散波效果的核心步骤:

  1. 用 Raycaster 检测点击,获取 3D 位置
  2. 用空心圆柱(CylinderGeometry)作为光圈载体
  3. 通过 useFrame 逐帧更新半径和透明度,实现扩散消失动画
  4. 叠加多层不同参数的光圈,增强视觉层次

这种效果充分利用了 Three.js 的几何变换和着色器能力,结合 React 的状态管理,实现了流畅的 3D 交互体验。掌握这些技巧后,可扩展出更复杂的交互效果,如路径动画、区域高亮等。

http://www.lryc.cn/news/584797.html

相关文章:

  • CSS和CSS3区别对比
  • 【深度学习新浪潮】什么是AI个性化医疗?
  • 黑马点评系列问题之P55优惠券秒杀 快捷键问题 Ctrl+D显示不出来老师给的界面
  • 【数据结构】8. 二叉树
  • FastAPI + SQLAlchemy (异步版)连接数据库时,对数据进行加密
  • React Three Fiber 实现 3D 模型点击高亮交互的核心技巧
  • Gin 中常见参数解析方法
  • 用TensorFlow进行逻辑回归(二)
  • 闲庭信步使用图像验证平台加速FPGA的开发:第九课——图像插值的FPGA实现
  • 硬件加速(FPGA)
  • BigFoot Decursive 2.7.28 2025.07.11
  • MyBatis插件机制揭秘:从拦截器开发到分页插件实战
  • 深入剖析 ADL:C++ 中的依赖查找机制及其编译错误案例分析
  • Linux面试问题-软件测试
  • RISC-V:开源芯浪潮下的技术突围与职业新赛道 (二) RISC-V架构深度解剖(上)
  • idea如何打开extract surround
  • 【C++】——类和对象(上)
  • Linux指令与权限
  • Navicat实现MySQL数据传输与同步完整指南
  • python正则表达式(小白五分钟从入门到精通)
  • Vue 中监测路由变化时,通常不需要开启深度监听(deep: true)
  • Spring事务管理深度解析:原理、实践与陷阱
  • STM32-ADC
  • squash压缩合并
  • 计算机视觉速成 之 概述
  • 【学习笔记】机器学习(Machine Learning) | 第七章|神经网络(2)
  • Linux:库的原理
  • (C++)任务管理系统(文件存储)(正式版)(迭代器)(list列表基础教程)(STL基础知识)
  • 【算法笔记 day three】滑动窗口(其他类型)
  • 使用球体模型模拟相机成像:地面与天空的可见性判断与纹理映射