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()保证不同帧率下的速度一致性
- 正弦波和圆周运动是两种最常用的轨迹模式
下一章,我们会给粒子加上大小变化和透明度渐变,让动画更有层次感。嗯,到时候见。