在 React Three Fiber 中实现 3D 模型点击扩散波效果

本文介绍如何在 React Three Fiber(R3F)框架中,为 3D 模型添加 "点击扩散波" 交互效果 ------ 当用户点击模型表面时,从点击位置向外扩散多层彩色光圈,增强场景的交互反馈和视觉吸引力。我们将基于 CityModel 组件的实现,拆解点击检测、3D 坐标获取、扩散动画等核心技术点,用通俗易懂的方式讲解实现思路。

本文基于前文介绍的如何生成光波基础上,在这个博客中我介绍了如何生成光波

用 React Three Fiber 实现 3D 城市模型的扩散光圈特效-CSDN博客

一、效果概述

在 3D 城市模型(CityModel 组件)中,实现以下交互效果:

  • 用户用鼠标左键点击模型任意位置时,触发扩散波动画
  • 扩散波从点击位置出发,向外围呈环形扩散
  • 包含多层不同颜色的光圈(暖红→橙黄→浅蓝),每层光圈有不同的扩散速度和范围
  • 光圈随扩散逐渐变淡,最终消失

这种效果可用于:

  • 3D 场景中的交互反馈(如点击选中、位置标记)
  • 模拟信号传播、能量扩散等业务场景
  • 增强用户操作的视觉引导

二、核心实现步骤

1. 点击检测:判断用户是否点击了 3D 模型

要实现点击交互,需使用 Three.js 的 Raycaster(射线检测器),从相机发射 "射线",检测是否与模型相交。

核心代码片段

javascript 复制代码
// 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 隐藏上下底面,仅保留侧面
  • 通过动态调整圆柱的半径(大小)和透明度,实现 "扩散消失" 效果

核心原理

html 复制代码
// DiffuseAperture 组件的几何体定义(简化版)
<cylinderGeometry
  args={[
    initialRadius, // 初始半径
    initialRadius, // 底部半径(与顶部相同,保证是正圆)
    height, // 圆柱高度(光圈厚度)
    64, // 径向分段数(数值越大,光圈边缘越平滑)
    1, // 高度分段数
    true, // 开口(无上下底,仅保留侧面)
  ]}
/>

通俗解释:就像用一张纸条卷成空心圆环,剪掉上下两个圆形底面,只剩中间的环形侧面 ------ 这个侧面就是我们看到的 "光圈"。

3. 扩散动画:让光圈动起来

扩散波的 "动" 包含两个核心变化:

  • 半径增大:从初始大小逐渐变大(扩散)
  • 透明度降低:从清晰逐渐变淡(消失)

动画逻辑代码(DiffuseAperture 组件内部):

javascript 复制代码
// 每帧更新动画状态
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 组件,形成多层扩散波:

javascript 复制代码
// 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 位置:

javascript 复制代码
// 在 CityModel 中渲染扩散波时传递位置
<DiffuseAperture
  position={clickPosition} // 点击位置的 3D 坐标
  // 其他参数...
/>

关键点clickPosition 是通过第一步的射线检测获取的 3D 坐标,确保光圈 "从点击处冒出"。

6.光波完整代码

核心通过isActive控制是否扩散,通过position动态更新光波的位置。

javascript 复制代码
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 中集成的完整逻辑

  1. 初始化模型:加载 3D 模型,计算包围盒并居中,设置相机位置
  2. 绑定事件 :在 useEffect 中绑定鼠标点击事件 handleClick
  3. 状态管理 :用 isApertureActive 控制扩散波的显示 / 隐藏,clickPosition 存储点击位置
  4. 条件渲染 :当 isApertureActivetrue 时,渲染三层 DiffuseAperture 组件,位置设为 clickPosition

通过setTimeout让光波持续1秒,并设置状态未非活跃,让光波消失。

javascript 复制代码
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 模型点击扩散波效果的核心步骤:

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

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

相关推荐
加班是不可能的,除非双倍日工资1 小时前
css预编译器实现星空背景图
前端·css·vue3
wyiyiyi2 小时前
【Web后端】Django、flask及其场景——以构建系统原型为例
前端·数据库·后端·python·django·flask
gnip2 小时前
vite和webpack打包结构控制
前端·javascript
excel3 小时前
在二维 Canvas 中模拟三角形绕 X、Y 轴旋转
前端
阿华的代码王国3 小时前
【Android】RecyclerView复用CheckBox的异常状态
android·xml·java·前端·后端
一条上岸小咸鱼3 小时前
Kotlin 基本数据类型(三):Booleans、Characters
android·前端·kotlin
Jimmy3 小时前
AI 代理是什么,其有助于我们实现更智能编程
前端·后端·ai编程
ZXT3 小时前
promise & async await总结
前端
Jerry说前后端3 小时前
RecyclerView 性能优化:从原理到实践的深度优化方案
android·前端·性能优化
画个太阳作晴天4 小时前
A12预装app
linux·服务器·前端