18 七种粒子特效配方:从烟火到星光与魔法

前置知识:第 09—12 章的生命周期、随机与运动,第 13—17 章的点云、混合、着色器与软粒子。
本章示例:七种效果画廊 · 源码。
预期画面:切换烟、火、雨、雪、爆炸、星光和魔法光环。烟较厚,火与魔法重叠处发亮,雨是窄竖线,雪缓慢飘落,爆炸一次后消失,星光基本不移动而闪烁。

先看现象:一种数据结构,七种运动与合成

第 16 章已经把初始位置、速度、寿命、出生偏移、大小和颜色交给 GPU。特效画廊仍只维护这六组属性,切换预设时重新取样,普通动画帧只推进 uTime 并更新风等 uniform。一个 THREE.Points 对象绘制当前预设的所有点。同一种渲染容器不等于同一种效果:分布、运动、生命曲线、片元形状、颜色与混合方式共同构成配方。

这里用七种预设覆盖原始需求中的七个视觉对象;雨/雪共享“从上方出生”的大类,星光/魔法共享亮点与加法混合,但各自的时间规则不同。它们是教学近似,不模拟烟的流体、雨滴碰撞、火的热传导或真实星体。

效果 初始分布与运动 生命周期与形状 混合与主要限制
烟雾 窄发射区,缓慢上升和横向摆动 逐渐变大、变淡的柔圆 普通 Alpha;一个点云内部无逐点排序;触地软交界需第 17 章的深度纹理
火焰 窄发射区,较快上升 短寿命、收缩、暖色亮点 加法混合;易过曝,仍不是真实火舌形状
雨 宽范围上方出生、快速下落 片元圆点压成窄竖线 普通 Alpha;没有地面飞溅与碰撞
雪 宽范围上方出生、慢速下落与轻摆 小柔圆、较长寿命 普通 Alpha;单点不是六角雪花贴图
爆炸 同一中心,随机角度与速度径向发射 一次性寿命,到期隐藏 加法混合;隐藏的顶点仍提交给 GPU
星光 全屏随机位置,基本静止 正弦闪烁亮度 加法混合;不需要移动才能称为粒子效果
魔法光环 初始相位随机,绕轴旋转并上升 循环短寿命、紫色柔圆 加法混合;螺旋只是规则轨迹,不含相互作用

1. 先选状态模型,再选渲染方法

配方首先回答三个问题:粒子从哪里来、在什么时候可见、在每个年龄怎样运动。示例把预设写成对象,mode 是着色器分支,count 是基础绘制数,blend 是材质混合方式:

const presets = {
  smoke: { mode: 0, count: 1500, blend: THREE.NormalBlending },
  fire: { mode: 1, count: 1200, blend: THREE.AdditiveBlending }
};

geometry.setDrawRange( 0, Math.round( preset.count * density ) );
material.uniforms.uMode.value = preset.mode;
material.blending = preset.blend;

setDrawRange() 决定参与绘制的顶点数量,改变它不重建属性,也不让一个点云变成多个绘制调用。切换预设或点击重播时 sample() 改写预分配 TypedArray,并把六个 BufferAttribute.needsUpdate 置为 true;这是一次整组属性上传。每帧只改 uniform 的说法只适用于同一预设持续播放期间。数量倍率只改绘制范围;如果改变倍数后新增的点原先已取样,它们立即按同一时间规则出现。

烟、火、雨、雪、魔法用 mod(max(uTime-aBirth,0),aLife) 循环年龄。负的 aBirth 让初始画面已有不同年龄的点。爆炸改用不取模的 age=max(uTime-aBirth,0),超过 aLife 时 vAlpha=0;星光保持位置不动,用 sin(uTime*频率+相位) 改亮度。星光模式把原有 aLife 借作闪烁频率、aBirth 借作相位,不再把它们当真实寿命和出生时间。这些规则不能互换:爆炸若取模会反复重放,雨若不取模会很快只剩空画面。

2. 位置公式决定流向;片元形状决定质感

烟、火、雨、雪使用 p=p₀+v₀·age 作为基础,再按模式加入水平风或摆动。雨的 vy 为负,火和烟的 vy 为正;改变速度分布,就改变运动范围。魔法光环不需要积分上一帧,它直接按年龄求轨迹:

float angle = position.x + age * 3.7; // position.x 存初始相位
float radius = 0.15 + age * 0.48;
vec3 p = vec3( cos( angle ) * radius + uWind * age * 0.35,
               position.y + age * 0.95,
               sin( angle ) * 0.25 );

示例在魔法模式把 position.x 借作相位,而不是世界 x 坐标;这种复用节省属性,但必须写清语义,否则调试时会误以为初始位置错误。爆炸的径向速度用随机角度 θ 和速度 s 生成 (s cosθ,s sinθ);若直接分别随机 x/y 速度,方向会偏向方形区域。第 12 章详细解释了这些分布。

gl_PointSize 由基础 CSS 像素大小、寿命尺寸曲线及 renderer.getPixelRatio() 相乘。自定义着色器必须自行处理像素比;实际点大小仍受 ALIASED_POINT_SIZE_RANGE 的硬件上限限制。片元着色器默认用 gl_PointCoord 画柔圆,雨模式把横向坐标放大,产生窄竖线:

vec2 coord = gl_PointCoord - vec2( 0.5 );
float r = rain ? length( coord * vec2( 7.0, 2.0 ) )
               : length( coord * 2.0 );
float mask = 1.0 - smoothstep( 0.35, 1.0, r );

这条雨线始终竖直。风会改变其运动路径,但不会自动旋转图形;若要随风倾斜,还需修改片元形状或用可旋转的 Billboard。大而柔的烟点覆盖许多屏幕像素,可能受 overdraw 限制;点数相同也不代表它比小星点便宜。

3. 混合、深度和排序要一起选

烟、雨、雪使用 NormalBlending,透明度决定背景透出程度。火、爆炸、星光、魔法使用 AdditiveBlending,叠加处更亮,适合发光感,但不能表现真正的黑烟遮挡。所有预设设置 transparent:true, depthTest:true, depthWrite:false:已有不透明场景可以遮住粒子;粒子不把硬深度写入缓冲,避免相互裁切。这个画廊没有不透明几何,所以 depthTest 在当前画面里并无可见对照;把效果放进场景时它才显出作用。

普通 Alpha 的烟在一个 Points 内部不能逐点排序,renderOrder 只能移动整个对象的提交位置。把烟拆成多个有序层、采用近似顺序或改用加法混合,各有视觉成本;不要把加法混合当作透明排序的通用修复。烟雾与地面相交的软边也不会由 depthWrite:false 自动产生,应复用第 17 章的场景深度差。若要让雨滴在地面形成飞溅,需要碰撞检测和另一种发射事件;当前解析式画廊没有这类状态反馈。

4. 怎样把配方用于真实项目

先固定相机与画布,只改变一个参数:数量倍率主要影响重叠密度和 GPU 工作量,时间倍率改变年龄推进速度,风是世界单位/秒的横向位移系数。风在星光模式无效;它对爆炸和魔法的作用系数与烟不同,这属于配方选择。更换颜色纹理时,颜色图应设置正确的 texture.colorSpace,而 Alpha 不应用颜色空间变换来“修边”。复杂贴图可用第 14 章图集;需要真实场景软交界可用第 17 章深度预通道。

性能比较至少记录活跃点数、画布 CSS 尺寸、像素比、浏览器/设备和点大小。画廊的“1 次绘制调用”只意味着一个 Points 对象;GPU 仍处理每个活跃顶点和大量透明片元。切换预设要上传属性,普通帧不上传属性;这与第 15 章的 CPU 更新成本不同。解析式运动也不能在运行时连续响应碰撞或邻居作用,第 19 章的鸟群会因此采用有状态更新。

常见问题

爆炸消失后绘制调用为何仍是 1? 片元被丢弃,不等于顶点从 drawRange 中移除。若需要释放提交成本,可在整场爆炸结束后把绘制范围设为 0,或管理有状态活跃池。

烟看起来太亮、太厚怎么办? 先区分 Alpha 混合与加法混合,再检查单点 Alpha、尺寸、点数和覆盖面积。增加点数不一定改善烟的层次。

雨滴斜着移动却仍是竖线? 速度改变的是中心轨迹,gl_PointCoord 的形状没有旋转。见第 18 章练习的倾斜雨线。

完成第 18 章练习,继续第 19 章:鸟群与独立粒子。参考:Points、ShaderMaterial、Material.blending。