在 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 交互体验。掌握这些技巧后,可扩展出更复杂的交互效果,如路径动画、区域高亮等。

相关推荐
一点一木11 分钟前
🚀 2025 年 06 月 GitHub 十大热门项目排行榜 🔥
前端·人工智能·github
杨进军13 分钟前
实现 React 函数组件渲染
前端·react.js·前端框架
归于尽17 分钟前
被 50px 到 200px 的闪烁整破防了?useLayoutEffect 和 useEffect 的区别原来在这
前端·react.js
杨进军23 分钟前
实现 React Fragment 节点渲染
前端·react.js·前端框架
杨进军25 分钟前
实现 React 类组件渲染
前端·react.js·前端框架
小山不高26 分钟前
react封装横向滚动组件
前端
拾光拾趣录28 分钟前
油猴插件开发学习:从零编写你的第一个浏览器增强脚本
前端·浏览器
国家不保护废物28 分钟前
深入浅出JavaScript事件循环(event loop):宏任务与微任务的奇幻之旅
前端·javascript·面试
FogLetter29 分钟前
React组件开发之Todos基础:从零打造一个优雅的待办事项应用
前端·javascript·react.js
刘羡阳30 分钟前
使用d3js实现了一个组织架构树形图(拖拽,展开收起)
前端