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

本文介绍了如何使用 React Three Fiber(R3F)和 Three.js 实现一个从中心向外扩散的光圈特效(DiffuseAperture 组件),并将其集成到城市 3D 模型(CityModel 组件)中。该特效通过动态调整圆柱几何体的大小和透明度,模拟出类似水波扩散的视觉效果,可用于增强 3D 场景的交互反馈或氛围营造。我们将从功能原理、实现细节和集成方式三个方面,用通俗易懂的语言讲解技术要点。
更多该系列更新请参考

Three.js 如何控制 GLB 模型的内置属性实现精准显示-CSDN博客

React Three Fiber 实现 3D 模型点击高亮交互的核心技巧-CSDN博客

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

什么是 "扩散光圈"?

想象一下,当你往平静的湖面扔一颗石子,水波会从落点向四周一圈圈扩散,逐渐变大并消失 ------ 我们实现的 "扩散光圈" 就是这个效果的 3D 版本。在 CityModel 城市模型中,这个特效表现为:从城市中心(原点)向外扩散的环形光圈,随着时间推移,光圈半径不断增大,同时逐渐变得透明,最终消失;随后又从中心重新开始扩散,形成循环动画。

这个特效可以为 3D 城市模型增添动态感,比如:

  • 作为场景加载完成的 "开场动画"
  • 作为用户点击城市中心的交互反馈
  • 模拟信号覆盖、能量扩散等业务场景

核心原理:用 "空心圆柱" 模拟光圈

实现这个特效的核心思路很简单:用一个 "没有上下底的空心圆柱" 作为光圈的载体,通过动态改变它的大小和透明度,让它看起来像在 "扩散消失"。

为什么用圆柱?

  • 圆柱的侧面是环形,天然适合模拟 "光圈"
  • 可以通过缩放控制半径(大小),通过透明度控制可见度
  • 隐藏上下底面后,只剩侧面,视觉上更像 "一圈光"

实现细节:让光圈 "动" 起来的关键

1. 基础结构:构建空心圆柱

html 复制代码
// 核心代码片段:创建圆柱几何体
<cylinderGeometry
  args={[
    initialRadius, // 顶部半径(初始大小)
    initialRadius, // 底部半径(和顶部相同,保证是正圆)
    height, // 圆柱高度(光圈的"厚度",越薄越像2D光圈)
    64, // 径向分段数(数值越大,光圈边缘越平滑)
    1, // 高度分段数(固定为1即可)
    true, // 开口(关键!让圆柱没有上下底,只剩侧面)
  ]}
/>

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

2. 动态变大:让光圈 "扩散"

光圈的 "扩散" 效果,本质是让圆柱的半径随时间不断增大:

javascript 复制代码
// 核心代码片段:每帧更新半径
useFrame(() => {
  // 1. 让半径随时间增大(expandSpeed 控制扩散快慢)
  radiusRef.current += expandSpeed * 0.016;
  // 用缩放控制大小(x和z轴同时放大,保证是正圆)
  apertureRef.current.scale.set(
    radiusRef.current, // x轴缩放
    1, // y轴不缩放(保持厚度不变)
    radiusRef.current, // z轴缩放
  );
});

通俗解释 :想象圆筒被吹气球一样慢慢变大,而且是均匀地向四周扩张,就像水波越来越大。这里的 0.016 是为了适配屏幕刷新率(约 60 帧 / 秒),让不同设备上的扩散速度一致。

3. 逐渐消失:让光圈 "淡化"

光扩散的同时会慢慢变淡,通过降低材质的透明度实现:

javascript 复制代码
// 核心代码片段:控制透明度
useFrame(() => {
  // 2. 让透明度随时间降低(fadeSpeed 控制消失快慢)
  opacityRef.current -= fadeSpeed * 0.016;
  material.opacity = Math.max(opacityRef.current, 0); // 透明度不小于0
});

通俗解释:就像用半透明的纸做的圆筒,随着扩散,纸张越来越薄,直到完全看不见。

4. 循环动画:让光圈 "重复扩散"

当光圈扩散到最大范围或完全消失后,需要重置状态重新开始:

javascript 复制代码
// 核心代码片段:重置条件
if (radiusRef.current > maxRadius || opacityRef.current <= 0) {
  radiusRef.current = initialRadius; // 半径变回初始大小
  opacityRef.current = 1; // 透明度重置为完全可见
}

通俗解释:这就像设置了一个循环播放的 "水波" 动画,一波消失后,新的一波从中心重新开始扩散。

5. 纹理增强:让光圈更生动(可选)

如果想让光圈有纹路(比如光斑、条纹),可以添加纹理贴图:

javascript 复制代码
// 核心代码片段:添加纹理
if (textureUrl) {
  const texture = textureLoader.load(textureUrl); // 加载纹理图片
  materialParams.map = texture; // 应用到材质上
}

通俗解释:就像给圆筒的侧面贴上带花纹的贴纸,让光圈看起来更有细节(比如模拟雷达扫描的波纹)。

光波组件代码(完整)

javascript 复制代码
import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'
import { useRef, useMemo } 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(根据场景比例调整)
  radialSegments = 128, // 径向分段数(从64增至128,边缘更平滑)
  heightSegments = 3, // 高度分段数(从1增至3,厚度方向有细节)
}: {
  color?: string
  initialRadius?: number
  maxRadius?: number
  expandSpeed?: number
  fadeSpeed?: number
  height?: number
  textureUrl?: string
}) => {
  const apertureRef = useRef<THREE.Mesh>(null)
  const radiusRef = useRef(initialRadius) // 跟踪当前半径
  const opacityRef = useRef(1) // 跟踪当前透明度

  // 创建圆柱侧面材质(带纹理支持)
  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) 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) {
      radiusRef.current = initialRadius
      opacityRef.current = 1
    }
  })

  return (
    <mesh ref={apertureRef}>
      {/* 圆柱几何体:顶面和底面隐藏,仅保留侧面 */}
      <cylinderGeometry
        args={[
          initialRadius, // 顶部半径
          initialRadius, // 底部半径(与顶部相同,确保是正圆柱)
          height, // 圆柱高度(厚度)
          64, // 径向分段数(越高越平滑)
          1, // 高度分段数
          true, // 开口(无顶面和底面)
        ]}
      />
      <primitive object={material} />
    </mesh>
  )
}

集成到城市模型:让特效 "服务于场景"

我们的扩散光圈不是孤立存在的,需要和城市模型(CityModel 组件)配合使用,才能发挥最大效果:

1. 位置对齐:让光圈从城市中心扩散

javascript 复制代码
// 在 CityModel 组件中使用 DiffuseAperture
return (
  <>
    {/* 城市模型(已居中到原点) */}
    <primitive object={scene} ref={modelRef} />
    {/* 扩散光圈:放在城市中心 */}
    <DiffuseAperture
      color="#4C8BF5"
      initialRadius={0.5}
      maxRadius={20} // 扩散范围适配城市大小
      rotation={[Math.PI/2, 0, 0]} // 旋转90度,让光圈与地面平行
    />
  </>
);

关键细节 :城市模型通过 modelRef.current.position.sub(center) 已居中到原点(0,0,0),光圈默认也在原点,因此能从城市中心开始扩散。rotation 是为了让光圈 "躺平" 在地面上(默认是直立的)。

2. 参数适配:让特效和场景协调

参数 作用 适配城市模型的建议值
maxRadius 光圈最大扩散范围 设为城市宽度的 1.5 倍
height 光圈厚度 0.05-0.1(薄一点更自然)
expandSpeed 扩散速度 2-3(太快看不清,太慢拖沓)
color 光圈颜色 与城市主色调对比(如蓝色)

3. 增强体验:多光圈叠加

通过同时渲染多个参数不同的光圈,可以形成更丰富的效果:

javascript 复制代码
// 多光圈叠加示例
<>
  <DiffuseAperture color="#ff6b3b" maxRadius={15} />
  <DiffuseAperture color="#ffc154" maxRadius={25} expandSpeed={2.5} />
  <DiffuseAperture color="#609bdf" maxRadius={35} expandSpeed={3} />
</>

效果:就像同时往水里扔三颗石子,形成的水波一圈套一圈,颜色从内到外渐变(红→黄→蓝),增强层次感。

完整组件引用代码

注释掉的部分是模型的点击高亮,现在为了演示效果,默认是初始化高亮的

javascript 复制代码
import { useGLTF } from '@react-three/drei'
import { useThree } from '@react-three/fiber'
import { useEffect, useRef } 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 highlightedMeshRef = useRef<THREE.Mesh[]>([])
  // 存储所有创建的边缘线对象
  const edgeLines = useRef<Map<string, THREE.LineSegments>>(new Map())

  // 添加边缘高亮效果
  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 removeHighlight = (object: THREE.Mesh) => {
    const line = edgeLines.current.get(object.uuid)
    if (line && object.children.includes(line)) {
      object.remove(line)
    }
    edgeLines.current.delete(object.uuid)
  }

  // 处理左键点击事件
  // 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

  //   // 执行射线检测,判断点击目标
  //   detectClickedMesh()
  // }

  // 检测点击的Mesh并切换高亮状态
  // const detectClickedMesh = () => {
  //   if (!modelRef.current) return
  //   // 更新射线(从相机到点击位置)
  //   raycaster.current.setFromCamera(pointer.current, camera)

  //   // 检测与模型的交点(递归检测所有子Mesh)
  //   const intersects = raycaster.current.intersectObject(modelRef.current, true)

  //   if (intersects.length > 0) {
  //     const clickedObject = intersects[0].object as THREE.Mesh

  //     // 仅处理标记为可交互的Mesh
  //     if (
  //       clickedObject instanceof THREE.Mesh &&
  //       clickedObject.userData.interactive
  //     ) {
  //       // 切换高亮状态:点击已高亮的Mesh则取消,否则高亮新Mesh
  //       if (highlightedMeshRef.current?.includes(clickedObject)) {
  //         console.log('取消高亮', clickedObject.name)

  //         // 移除边框高亮
  //         removeHighlight(clickedObject)
  //         const newHighlighted = highlightedMeshRef.current.filter(
  //           (m) => m.name !== clickedObject.name,
  //         )
  //         highlightedMeshRef.current = [...newHighlighted]
  //       } else {
  //         console.log('高亮', clickedObject.name)
  //         // 添加边框高亮
  //         addHighlight(clickedObject)
  //         highlightedMeshRef.current = [
  //           ...highlightedMeshRef.current,
  //           clickedObject,
  //         ]
  //       }
  //     }
  //   }
  // }
  // 模型加载后初始化
  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 // 存储原始材质
        }
      }
    })

    // 绑定点击事件监听
    // window.addEventListener('click', handleClick)

    // 组件卸载时清理
    return () => {
      // window.removeEventListener('click', handleClick)
      // 移除所有高亮边缘线
      highlightedMeshRef.current.forEach((mesh) => {
        removeHighlight(mesh)
      })
      edgeLines.current.clear()
      highlightedMeshRef.current = []
    }
  }, [modelRef.current])

  // 添加模型到管理器
  const addModel = () => {
    if (modelRef.current) {
      helper.addModel({
        id: '模型1',
        name: '模型1',
        url: url,
        model: modelRef.current,
      })
    }
  }

  return (
    <>
      <primitive object={scene} ref={modelRef} />
      {/* 扩散光圈:位于模型中心,与模型平面平行 */}
      {/* 内层光圈:暖红色系,扩散范围最小,亮度最高 */}
      <DiffuseAperture
        color="#ff6b3b" // 内层暖红(鲜艳)
        initialRadius={0.1}
        maxRadius={15} // 最小扩散范围
        expandSpeed={2} // 中等扩散速度
        fadeSpeed={0.6} // 较慢淡出(停留更久)
        height={0.08}
      />

      {/* 中层光圈:橙黄色系,衔接内外层 */}
      <DiffuseAperture
        color="#ffc154" // 中层橙黄(过渡色)
        initialRadius={0.2}
        maxRadius={20} // 中等扩散范围
        expandSpeed={2.5} // 稍快于内层
        fadeSpeed={0.7} // 中等淡出速度
        height={0.06}
      />

      {/* 外层光圈:蓝紫色系,扩散范围最大,亮度最低 */}
      <DiffuseAperture
        color="#609bdf" // 外层浅蓝(冷色)
        initialRadius={0.3}
        maxRadius={25} // 最大扩散范围
        expandSpeed={3} // 最快扩散速度
        fadeSpeed={0.8} // 最快淡出(快速消失)
        height={0.04}
      />
    </>
  )
}

总结:技术点与应用场景

这个扩散光圈特效的核心是 "动态变化"------ 通过 React Three Fiber 的 useFrame 实现每一帧的更新,用 Three.js 的圆柱几何体和材质属性控制视觉表现。它的优势在于:

  1. 性能友好:只用一个简单的圆柱几何体,避免复杂计算
  2. 易于控制:通过参数可轻松调整大小、速度、颜色
  3. 场景适配:旋转和位置调整可适配任何 3D 模型

在 CityModel 中,这个特效可用于:

  • 城市加载完成的 "开场动画"
  • 用户点击城市中心的 "交互反馈"
  • 模拟信号覆盖、能量扩散等业务场景
    通过这个小功能,我们可以感受到 3D 特效的魅力 ------ 看似复杂的视觉效果,往往是由简单的几何变换和动态更新组合而成。