4、粒子动画基础:更新粒子位置、使用requestAnimationFrame、简单的运动轨迹

好,咱们进入粒子系统的核心环节——让粒子动起来。

前面几章我们搭建了粒子、配置了材质,但那些粒子都是静止的。说白了,就像一张照片。真正的粒子特效,得是活的、会呼吸的。这一章,我们就来给粒子注入生命力。

4.1 为什么需要requestAnimationFrame?

你可能听说过setInterval也能做动画。没错,技术上可行。但我个人习惯,从来不用它做3D动画。为什么?

setInterval有几个硬伤:

  • 不跟显示器刷新率同步——60Hz的屏幕你设16ms,结果可能掉帧或跳帧
  • 后台标签页还在跑——浪费CPU,用户切走了还在算
  • 精度不够——JavaScript单线程,setInterval会被其他任务阻塞

requestAnimationFrame就聪明多了。它会等浏览器准备好绘制下一帧时再调用你。用户切到其他标签页?自动暂停。刷新率是120Hz?它自动适配。省心又高效。

核心原则:Three.js项目里,所有连续动画都用requestAnimationFrame驱动。这是行业标准,没有例外。

4.2 最简单的动画循环

先写一个最基础的循环。你想想看,其实就三件事:更新数据、渲染画面、请求下一帧。

// 动画循环
function animate() {
  requestAnimationFrame(animate);
  
  // 更新粒子位置(后面会写)
  updateParticles();
  
  // 渲染场景
  renderer.render(scene, camera);
}

// 启动
animate();

嗯,就这么简单。但这里有个细节——requestAnimationFrame(animate)必须写在函数体最前面。我曾经见过有人把它写在最后,结果第一帧执行时还没注册下一帧,偶尔会卡一下。虽然概率低,但做特效的人,得有点强迫症。

4.3 更新粒子位置:直接操作BufferAttribute

粒子位置存在哪里?存在geometry.attributes.position里。它是一个BufferAttribute,底层是个Float32Array。

要更新粒子位置,我们直接改这个数组的值,然后告诉Three.js:「数据变了,重新用一下」。

// 假设我们有1000个粒子
const count = 1000;
const positions = new Float32Array(count * 3);

// 初始化随机位置
for (let i = 0; i < count * 3; i++) {
  positions[i] = (Math.random() - 0.5) * 10;
}

geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));

// 动画更新函数
function updateParticles() {
  const positionAttr = geometry.attributes.position;
  const array = positionAttr.array;
  
  for (let i = 0; i < array.length; i += 3) {
    // 让粒子沿Y轴缓慢上升
    array[i + 1] += 0.01;
    
    // 超出顶部就回到底部
    if (array[i + 1] > 5) {
      array[i + 1] = -5;
    }
  }
  
  // 重要!告诉Three.js属性已更新
  positionAttr.needsUpdate = true;
}

千万注意:needsUpdate = true这行不能漏。我刚开始学Three.js时,调了半天粒子不动,最后发现就是忘了设这个标志位。BufferAttribute不会自动检测数组变化,你得手动通知它。

4.4 加上时间因子,让运动更平滑

直接加0.01有个问题——不同刷新率的电脑,粒子速度不一样。60Hz和144Hz的屏幕,粒子飘的速度差了一倍多。

解决办法:用clock.getDelta()获取两帧之间的时间差,乘以速度系数。

const clock = new THREE.Clock();

function animate() {
  requestAnimationFrame(animate);
  
  const delta = clock.getDelta();
  // delta的单位是秒,一般0.016左右(60fps)
  
  updateParticles(delta);
  renderer.render(scene, camera);
}

function updateParticles(delta) {
  const array = geometry.attributes.position.array;
  const speed = 2.0; // 单位:单位/秒
  
  for (let i = 0; i < array.length; i += 3) {
    array[i + 1] += speed * delta;
    
    if (array[i + 1] > 5) {
      array[i + 1] = -5;
    }
  }
  
  geometry.attributes.position.needsUpdate = true;
}

这样不管用户是什么显示器,粒子都以每秒2个单位的速度上升。我在做移动端项目时特别依赖这个技巧——手机屏幕刷新率五花八门,不用delta根本没法统一体验。

4.5 简单的运动轨迹:正弦波与圆周运动

直线运动太单调了。咱们来点好看的。

4.5.1 正弦波运动

让粒子在上升的同时左右摆动,形成波浪效果。

function updateParticles(delta, elapsedTime) {
  const array = geometry.attributes.position.array;
  
  for (let i = 0; i < array.length; i += 3) {
    // 保存初始X位置(需要额外存一份)
    // 这里简化:直接用i作为相位偏移
    const phase = i * 0.01;
    const amplitude = 1.5;
    
    // Y轴匀速上升
    array[i + 1] += 2.0 * delta;
    
    // X轴正弦摆动
    array[i] = Math.sin(elapsedTime + phase) * amplitude;
    
    // 重置逻辑
    if (array[i + 1] > 5) {
      array[i + 1] = -5;
    }
  }
  
  geometry.attributes.position.needsUpdate = true;
}

这里elapsedTime是从动画开始累计的时间。我习惯用clock.getElapsedTime()获取。每个粒子的相位不同,所以看起来像波浪一样依次起伏。

4.5.2 圆周运动

让粒子围绕中心旋转,适合做星系、旋涡效果。

function updateParticles(delta, elapsedTime) {
  const array = geometry.attributes.position.array;
  
  for (let i = 0; i < array.length; i += 3) {
    // 每个粒子有自己的半径和初始角度
    const radius = 2.0 + (i / array.length) * 3.0;
    const speed = 0.5 + (i / array.length) * 0.3;
    const angle = elapsedTime * speed + i * 0.1;
    
    // 圆周位置
    array[i] = Math.cos(angle) * radius;
    array[i + 2] = Math.sin(angle) * radius;
    
    // Y轴轻微上下浮动
    array[i + 1] = Math.sin(elapsedTime * 0.8 + i * 0.05) * 0.5;
  }
  
  geometry.attributes.position.needsUpdate = true;
}

我的经验:做圆周运动时,半径和速度最好给每个粒子一点随机差异。全一样的半径和速度,转起来像个铁环,不好看。稍微加点变化,就有星云那种层次感了。

4.6 性能注意事项

更新粒子位置本质上是操作大量浮点数。1000个粒子还好,如果是10万个,就得注意了:

粒子数 每帧操作数 建议
< 10,000 3万次浮点运算 直接操作BufferAttribute,没问题
10,000 ~ 100,000 30万次 注意避免在循环里做复杂数学运算
> 100,000 30万次以上 考虑用Shader实现(后面章节会讲)

另外,每次更新都设needsUpdate = true会触发GPU重新上传数据。如果粒子位置变化不大,可以考虑合并更新或者用自定义着色器绕过这个开销。不过那是进阶内容了,咱们先把基础跑通。

4.7 完整示例:飞舞的粒子群

把上面所有知识点串起来,写一个完整的粒子动画。粒子从底部升起,左右摆动,到顶部后重置。

// 场景、相机、渲染器(省略,前面章节已写)
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.z = 10;

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// 粒子几何体
const count = 2000;
const positions = new Float32Array(count * 3);
const colors = new Float32Array(count * 3);

for (let i = 0; i < count; i++) {
  const i3 = i * 3;
  positions[i3] = (Math.random() - 0.5) * 8;
  positions[i3 + 1] = (Math.random() - 0.5) * 10;
  positions[i3 + 2] = (Math.random() - 0.5) * 5;
  
  // 颜色渐变:从蓝到紫
  colors[i3] = 0.2 + Math.random() * 0.3;
  colors[i3 + 1] = 0.1 + Math.random() * 0.2;
  colors[i3 + 2] = 0.6 + Math.random() * 0.4;
}

const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));

// 材质
const material = new THREE.PointsMaterial({
  size: 0.08,
  vertexColors: true,
  transparent: true,
  opacity: 0.8,
  blending: THREE.AdditiveBlending,
  depthWrite: false
});

const particles = new THREE.Points(geometry, material);
scene.add(particles);

// 时钟
const clock = new THREE.Clock();

// 动画循环
function animate() {
  requestAnimationFrame(animate);
  
  const delta = clock.getDelta();
  const elapsedTime = clock.getElapsedTime();
  
  const positionAttr = geometry.attributes.position;
  const array = positionAttr.array;
  
  for (let i = 0; i < array.length; i += 3) {
    // Y轴上升
    array[i + 1] += 1.5 * delta;
    
    // X轴正弦摆动(幅度随时间微变)
    const phase = i * 0.02;
    const amplitude = 1.2 + Math.sin(elapsedTime * 0.3 + i * 0.01) * 0.3;
    array[i] = Math.sin(elapsedTime * 0.5 + phase) * amplitude;
    
    // 重置
    if (array[i + 1] > 6) {
      array[i + 1] = -6;
      array[i] = (Math.random() - 0.5) * 4;
    }
  }
  
  positionAttr.needsUpdate = true;
  
  // 相机缓慢旋转
  camera.position.x = Math.sin(elapsedTime * 0.1) * 12;
  camera.position.z = Math.cos(elapsedTime * 0.1) * 12;
  camera.lookAt(0, 0, 0);
  
  renderer.render(scene, camera);
}

animate();

这段代码跑起来,你会看到蓝紫色的粒子像萤火虫一样从底部升起,左右摇曳,到顶部消失。相机还在缓慢环绕,很有沉浸感。

本章小结:

  • 用requestAnimationFrame驱动动画循环
  • 直接操作BufferAttribute.array更新粒子位置
  • 别忘了设needsUpdate = true
  • 用clock.getDelta()保证不同帧率下的速度一致性
  • 正弦波和圆周运动是两种最常用的轨迹模式

下一章,我们会给粒子加上大小变化和透明度渐变,让动画更有层次感。嗯,到时候见。