1. 先想清楚:爆炸效果到底该不该交给粒子系统
做 Cesium 场景做了几年,只要涉及到“炸一下”“烧起来”这类需求,我基本都会被问到同一个问题:这东西到底用粒子系统做,还是搞个视频/序列帧贴上去糊弄过去。答案是看你要什么。如果只是背景里远处一团火光闪一下,一个带动画贴图的 Billboard 就能骗过 90% 的眼睛,成本极低。但只要镜头会推近、会环绕、会从火里穿过去,贴图立刻露馅——因为它的轮廓永远平行于屏幕,你绕着走一圈,火焰是“贴”在空中的一张纸。Cesium 粒子爆炸效果实战这个话题之所以值得写,就是因为Cesium 的 ParticleSystem 是原生三维的:每颗粒子在世界坐标里有自己的位置、速度、寿命和颜色曲线,摄像机怎么转,火苗的立体感都在。
这篇内容我打算把两套最常用的特效从头到尾拆一遍:一个是持续性火焰(火堆、火把、着火点、油井火炬),一个是瞬发爆炸(爆炸点、炮弹落点、设备爆燃)。中间会穿插我做项目时踩过的坑:粒子不显示、位置飘到地心去、爆炸完不消失、帧率掉一半、以及那个让无数人困惑的“寿命到底是系统的还是粒子的”问题。代码全部给完整版,可以直接贴进项目里改。读者对象不限,只要你写过new Cesium.Viewer(...),能看懂Cartesian3就够;对已经用过粒子系统的朋友,第 2 章和第 6 章的坐标账和排查表应该最有价值。
先说清楚边界:粒子系统不是万能的。它擅长“大量小的、无序的、有生命周期的点状或面片状物体”的群体表现——火花、烟、尘、火焰、水雾、碎屑。它不擅长“形状明确的刚体”——飞出去的弹片、崩裂的墙皮、翻滚的车辆,这些得靠自定义模型 + 物理,粒子只能做辅助。我在一个模拟项目里见过有人用粒子拼爆炸碎片,拼到最后一颗粒子飞出去是方的,那画面没法看。所以本篇聚焦的始终是“火”和“光”本身,形变类的东西另开一篇讲。
爆炸效果在业务里通常不是孤立出现的。真实场景中一次爆炸包含 4 层:核心强光、向外扩散的火球、被抛射并带拖尾的火星、以及升腾的烟尘。这四层的尺度、速度、颜色和寿命完全不同,如果只用一个粒子系统硬怼,一定是一坨均匀的、没有层次的糊状物。所以第 4 章的核心思路就是“分层组合”,每一层用一个独立配置的 ParticleSystem 或者图元,各自跑各自的曲线,最后叠出人眼觉得“真实”的效果。这也是我在实际项目里唯一稳定复用的做法。
提醒:Cesium 的粒子系统内部一帧只做一次批量绘制,粒子数量本身对 draw call 压力不大,真正吃性能的是填充率——粒子尺寸乘以数量。后面第 6 章会给出具体的估算方法。
2. 粒子系统的骨架:五个概念弄明白了再动手
2.1 发射器、粒子、更新回调,三者是什么关系
Cesium 的ParticleSystem本质是个“粒子工厂”。Cesium.CircleEmitter、SphereEmitter、ConeEmitter、BoxEmitter这些发射器决定了“新粒子从哪儿、朝哪个方向出生”。发射器只负责出生瞬间的位置和速度方向,出生之后的事情它一概不管——这一点特别重要,因为很多人以为设了发射器粒子就会自己往上飘,结果火焰在地上摊成一张饼。
每颗粒子出生时会从系统配置里“继承”一组随机化后的属性:速度大小在minimumSpeed到maximumSpeed之间随机,寿命在minimumParticleLife到maximumParticleLife之间随机,质量在minimumMass到maximumMass之间随机,尺寸在minimumImageSize到maximumImageSize之间随机(没设这两个就用imageSize固定值)。这些随机是粒子系统“看起来不机械”的基础,别都设成同一个值,一设就死板。
出生之后,粒子的运动就是匀速直线加系统级的重力/风力。Cesium 从 1.52 左右开始支持在ParticleSystem上挂updateCallback,签名是(particle, dt),每颗粒子每帧调一次,dt是距上一帧的秒数。这是你唯一的“力学入口”:想加浮力、加湍流、加阻尼、加跟随,全在这里写。我个人的习惯是能写在updateCallback里的就不去动modelMatrix,因为modelMatrix是整系统共享的,动它等于动全体,代价和效果都不好控制。
2.2 modelMatrix 和 emitterModelMatrix 的坐标账
这是新手翻车率最高的一处。它们的关系是:最终位置 = modelMatrix × emitterModelMatrix × 粒子局部位置。
modelMatrix干的事情是“把整个粒子系统的本地坐标系搬到世界坐标系的某个位置”。所以它绝不该是一个平移矩阵,而应该是一个带旋转的坐标系矩阵,标准写法:
const modelMatrix = Cesium.Transforms.eastNorthUpToFixedFrame( Cesium.Cartesian3.fromDegrees(116.39, 39.90, 0) );eastNorthUpToFixedFrame给的是“东-北-天”局部坐标系,它的 +Z 指向天空。好处是:CircleEmitter是在局部 XY 平面上撒点的,粒子出生后沿 +Z 飞就是向上,符合直觉;你写Cesium.Cartesian3.UNIT_Z加力就是垂直升力,不用换算。
emitterModelMatrix则是“在这个局部坐标系里再挪一下”,通常是平移(火堆离地 0.5 米)或者绕某个轴倾斜(火焰被风吹斜)。我见过的错误是把世界坐标Cartesian3.fromDegrees(...)直接塞给emitterModelMatrix——那是几百万米量级的数值,粒子直接飞到地心去了,屏幕上什么都没有,然后开始怀疑人生。
实操心得:调位置的时候先只设
modelMatrix,把emitterModelMatrix留空,确认火苗出现在正确的地面上,再加上局部偏移。分两步走能省半小时。
2.3 寿命:lifetime 和 particleLife 不是一回事
ParticleSystem上的lifetime是这个粒子系统本身存活多少秒,单位秒;minimumParticleLife/maximumParticleLife才是单颗粒子的寿命。两个名字挨得近,坑很深。
典型症状:配了个火焰,lifetime默认 5 秒,结果 5 秒后火灭了,你以为参数没生效。其实系统寿终正寝了。火焰这类持续特效必须lifetime: Number.MAX_VALUE(或者一个大数),再配合loop: true。爆炸这类瞬发特效则相反,系统寿命可以设短,但要保证它比最长粒子的寿命长,否则系统被移除时粒子还在半空中,会硬生生消失。
安全做法:系统 lifetime ≥ maximumParticleLife + 发射窗口 + 一点余量。比如爆炸粒子最长活 1.5 秒,发射窗口 0.06 秒,那 lifetime 设 2 秒比较稳。
2.4 颜色、混合模式,以及那层“发光”
粒子颜色的设置有两套:color+colorBlendMode,或者startColor/endColor做生命周期渐变。后者优先级更高,一旦设了startColor/endColor,color就不起作用了。做火焰我永远用后者,因为火焰必须从亮到暗、从饱和到透明地衰减。
colorBlendMode有三个值:MIX(粒子颜色与贴图颜色混合)、HIGHLIGHT(粒子颜色作为高亮色叠加)、REPLACE(完全用粒子颜色替换贴图)。贴图是白灰径向渐变的时候,MIX表现最自然;贴图自带彩色(比如已经画了橙红色的火焰图)就用REPLACE,不然颜色会拧巴。
真正决定“发光感”的是blendOption:
| 混合模式 | 效果 | 适用 | 代价 |
|---|---|---|---|
BlendingState.ALPHA_BLEND(默认) | 按 alpha 正常遮盖 | 烟尘、水雾、灰烬 | 重叠区会显脏、发灰 |
BlendingState.ADDITIVE_BLEND | 颜色相加,越叠越亮 | 火焰、火星、强光、冲击波 | 黑色不再是黑,暗背景容易过曝 |
BlendingState.MULTIPLY_BLEND | 相乘,压暗 | 阴影、烟幕压暗层 | 用得少,慎用 |
ADDITIVE_BLEND是火焰的命门。不用它,火苗看起来像一堆半透明的橙色纸片;用了它,粒子重叠的地方自然变白变亮,人眼立刻读成“高温”。但代价也真实:白天亮色地形上叠加大量加法混合的粒子,会出现一片惨白的过曝区。我的处理是白天场景把 startColor 的 alpha 降到 0.5 上下,尺寸缩小 20%,或者干脆给火焰外面套一个距离判断,远了才启用加法混合。
3. 火焰特效:从一簇火苗到一片火墙
3.1 贴图自己造:一张 64×64 的径向渐变图
粒子效果好不好,七成看贴图。火焰贴图的要求很朴素:中心不透明、边缘完全透明、过渡是柔和径向的、没有硬边。网上找的火焰图往往带背景、有黑边、色彩过于具体,直接扔进去就是一堆小方块——这是“粒子显示成黑方块”最常见的原因。
最稳的办法是运行时用 Canvas 现画一张,toDataURL()出来的就是ParticleSystem.image能吃的字符串,还顺带绕开了外部图片的跨域和 404 问题:
function createGlowTexture(size = 64) { const canvas = document.createElement('canvas'); canvas.width = canvas.height = size; const ctx = canvas.getContext('2d'); const r = size / 2; const grad = ctx.createRadialGradient(r, r, 0, r, r, r); grad.addColorStop(0.0, 'rgba(255,255,255,1.0)'); grad.addColorStop(0.35, 'rgba(255,255,255,0.85)'); grad.addColorStop(0.7, 'rgba(255,255,255,0.25)'); grad.addColorStop(1.0, 'rgba(255,255,255,0.0)'); ctx.fillStyle = grad; ctx.fillRect(0, 0, size, size); return canvas.toDataURL(); }这张图是纯白灰度,颜色完全交给startColor/endColor去染。好处是一张图能复用到火焰、火星、烟、冲击波、爆炸核心光所有地方,只是颜色和尺寸不同。我一般还会再画一张“带点噪声”的版本给烟尘用,做法是在径向渐变上叠几十个随机低透明度的圆,让烟有絮状感,不然烟会像一团均匀的雾。
注意:
imageSize是贴图在屏幕上的基础像素尺寸,startScale/endScale是乘在它上面的缩放因子。如果你同时设了imageSize和minimumImageSize/maximumImageSize,随机尺寸的优先级更高。
3.2 参数怎么算:稳态粒子数的公式
很多人调粒子系统是“试出来的”,改一个数看一眼。其实有个简单公式能让调参变成算术题:
稳态粒子数 ≈ emissionRate × 平均粒子寿命
火焰举例:emissionRate: 80,minimumParticleLife: 0.9,maximumParticleLife: 1.8,平均 1.35 秒,那么同一时刻场上有 80 × 1.35 ≈ 108 颗粒子。一簇小火苗 100 粒上下是合理的,看起来浓而不糊;想做成火墙,就并行放 5~8 个系统,或者把发射器半径拉大、发射率提到 300 以上(约 400 粒),画面才撑得起来。
反过来算也行:你想要大概 250 颗粒子,寿命平均 1.5 秒,那emissionRate = 250 / 1.5 ≈ 167。这个反向算法在性能受限的移动端特别有用——预算就那么多,先定粒子数上限,再倒推发射率。
尺寸上有个经验值:贴图像素占屏幕高度的 3%~8% 看起来最舒服。1080p 下火焰粒子 60~90 像素,火星 8~16 像素,烟 150~250 像素。手机上全部砍半,否则很快就会被填充率拖死。
3.3 让火苗往上蹿:升力、湍流与阻尼
发射器只给初速度,不给你浮力。真实的火焰之所以是“上宽下窄的锥形”,是因为热空气持续向上加速。所以updateCallback里必须给 +Z 方向的持续加速,并且越快熄灭的粒子加速越猛(因为它处于火焰最亮的最核心部分):
updateCallback: (particle, dt) => { const t = particle.normalizedAge; // 0 出生 → 1 消亡 // 浮力:新生粒子加速更强,老粒子趋于平缓 const lift = 3.2 * (1.0 - t) + 0.6; particle.velocity.z += lift * dt; // 湍流:用不同频率的正弦叠加,避免整齐划一 const phase = particle.age * 7.0 + particle.mass * 11.0; particle.velocity.x += Math.sin(phase) * 0.9 * dt; particle.velocity.y += Math.cos(phase * 1.37) * 0.9 * dt; // 水平阻尼:让火苗收拢成锥形而不是无限扩散 const damp = Math.max(0, 1 - 1.6 * dt); particle.velocity.x *= damp; particle.velocity.y *= damp; }particle.normalizedAge是 0 到 1 的归一化年龄,用它做曲线比用绝对年龄方便得多,换寿命不用改逻辑。particle.mass只在这里当随机种子用——每个粒子的质量是随机出来的,天然互不相同,正好拿来打散相位,避免所有粒子朝同一方向抖。
湍流强度这条曲线我是试出来的:太小(0.3 以下)火苗像一根硬邦邦的柱子;太猛(1.5 以上)会散成一团橙色雾。0.8~1.0 之间配阻尼,火苗会有自然的“舔”的感觉。
3.4 呼吸感和风偏:让静态火焰活起来
持续火焰还有个毛病——它是一个稳定系统,粒子随机但整体统计量恒定,看久了像 GIF 循环。两个小手段解决:
一是发射率呼吸。用一个周期性变化去改emissionRate,幅度 ±15%,周期 2~4 秒,不规则一点:
this.system.emissionRate = this.baseEmissionRate * (1 + 0.15 * Math.sin(performance.now() / 900));二是整体风偏。别去改modelMatrix(那是世界坐标,一动火堆就跑),改emitterModelMatrix,给它一个绕北向轴的旋转,让发射平面微微倾斜,粒子出生时就带着偏向:
const tilt = Cesium.Matrix4.fromRotationTranslation( Cesium.Matrix3.fromRotationY(0.18), // 约 10 度 new Cesium.Cartesian3(0, 0, 0.6) ); this.system.emitterModelMatrix = tilt;再配合updateCallback里给一个恒定的水平加速度,就是自然的“被风吹斜”。这个细节在户外场景里作用巨大,尤其火堆旁边有建筑或树的时候,直立火焰看着特别假。
4. 爆炸特效:一次球面爆发的分层设计
4.1 核心火光:球面发射器 + 高速短命
爆炸的第一层是“那一下闪光”。它的特征是:极亮、极大、极短。参数上是这样的——SphereEmitter,速度 12~30 米/秒,粒子寿命 0.25~0.5 秒,尺寸从 60 快速涨到 180,颜色从接近白的黄(Color(1, 0.95, 0.7, 1))衰减到暗红全透明。
为什么用球面发射器而不是圆?因为爆炸是各向同性的,圆发射器只在水平面撒点,从侧面看会变成一张圆盘。SphereEmitter的radius建议给一个很小值(0.1~0.3),太大反而会让爆炸中心出现一个空心球。
核心火光必须用ADDITIVE_BLEND,这是它“亮”的唯一来源。另外很关键的一点:这层粒子的endColor的 alpha 必须严格到 0,否则爆炸结束后会残留一圈暗红色的雾,看着像场景被污染了。
4.2 火星、烟尘:三套不同尺度的粒子叠加
第二层是火星:尺寸 8~14 像素,速度 20~45 米/秒,寿命 0.6~1.5 秒,受重力影响明显(updateCallback里velocity.z -= 9.8 * dt),颜色从亮黄到橙红再到暗淡,飞行轨迹应该是一条抛物线。这层的粒子数量不用多,200~400 颗就够,多了反而看不出“一颗一颗”的颗粒感。
第三层是烟尘:尺寸 150~260 像素,速度 2~6 米/秒,寿命 2~4 秒,几乎不受重力,反而要往上飘。颜色从深灰棕(Color(0.25, 0.22, 0.2, 0.7))到全透明,混合模式用默认的ALPHA_BLEND,千万别用加法——烟用加法会变成一片发光的雾,物理上完全错误。
层与层之间要有时间差。真实爆炸里,闪光和火球是同时的,火星晚 50~100 毫秒,烟尘晚 150~300 毫秒才起来。这个时间差靠setTimeout或者一个帧循环里的计时器来做,能让效果立刻上一档。我最初所有层同时发射,做出来的东西像个球;加了 100 毫秒的错峰之后,明显就有了“爆开然后蔓延”的层次。
4.3 冲击波:换个思路,用图元缩放代替粒子
冲击波是纯视觉符号,用粒子做效果很差(一堆点拼不出一个圆环)。我采用的做法是:一个Billboard贴环形渐变图,scale和color都用CallbackProperty按时间计算,0.5 秒内从 0.1 倍缩放到 6 倍,alpha 从 0.9 衰减到 0,同时加rotation缓慢自转。
const startTime = performance.now(); const life = 620; // 毫秒 const entity = viewer.entities.add({ position: center, billboard: { image: createRingTexture(), scale: new Cesium.CallbackProperty(() => { const p = Math.min(1, (performance.now() - startTime) / life); return 0.15 + p * 6.0; // 扩散 }, false), color: new Cesium.CallbackProperty(() => { const p = Math.min(1, (performance.now() - startTime) / life); return new Cesium.Color(1, 0.85, 0.55, 0.9 * (1 - p) * (1 - p)); // 二次衰减 }, false), disableDepthTestDistance: Number.POSITIVE_INFINITY } });(1-p)*(1-p)这个二次衰减是我特意用的:线性衰减看起来像“慢慢淡出”,二次衰减看起来像“啪一下没了”,更接近冲击波的心理预期。disableDepthTestDistance = Number.POSITIVE_INFINITY让冲击波不会被地形挡住——代价是它会穿透建筑物,如果场景里有建筑遮挡需求,就得去掉这行或者换个方式。
环形贴图同样可以用 Canvas 画:一个径向渐变做成“中间透明、边缘一圈亮”,然后在addColorStop上做点手脚,让亮圈偏外。
4.4 一次性爆发:发射率开关法
持续火焰靠emissionRate恒定输出,爆炸却必须“只喷一下”。Cesium 的粒子系统没有原生的 emit 一次接口,所以主流做法是开高发射率 + 计时关闭。
关键在于算清楚发射窗口。爆炸想要 1200 颗粒子,如果发射窗口是 0.06 秒,那emissionRate = 1200 / 0.06 = 20000。一帧 16.7 毫秒的话,每帧发射约 333 颗,60 毫秒大约 4 帧,正好 1200 颗左右。注意这里的前提是发射率足够大,让窗口内能凑够粒子,但窗口又必须够短,不然就变成“持续喷火”了。
关闭的方式:
const startMs = performance.now(); const windowMs = 60; const listener = viewer.scene.preUpdate.addEventListener(() => { if (performance.now() - startMs >= windowMs) { this.system.emissionRate = 0; // 停止新粒子出生 listener(); // 注销监听 } });emissionRate在 Cesium 里是可读写的,改成 0 之后已出生的粒子会继续按寿命走完,不会凭空消失,这正是我们要的。如果你的版本里这个属性和预期不一致(不同版本的属性可写性确实会有差异),兜底方案是:爆炸时临时 new 一个粒子系统塞进 primitives,1.5 秒后primitives.remove()掉——效果一样,只是创建销毁的开销大一点。
重要提醒:停发之后不要立刻 remove 系统。粒子还在飞,remove 会让它们集体消失,画面会有明显断裂感。正确做法是等
maximumParticleLife + 0.5秒再回收。
5. 完整代码:把两套效果封装成可复用模块
5.1 页面骨架与 Viewer 初始化
先把最小可运行环境搭起来。为了特效调起来直观,关掉一堆默认控件,把抗锯齿和背景调好:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8" /> <title>粒子特效联调</title> <link rel="stylesheet" href="./Cesium/Widgets/widgets.css" /> <script src="./Cesium/Cesium.js"></script> <style> html, body, #cesiumContainer { width: 100%; height: 100%; margin: 0; overflow: hidden; } #cesiumContainer { background: #0a0d12; } </style> </head> <body> <div id="cesiumContainer"></div> <script src="./main.js"></script> </body> </html>const viewer = new Cesium.Viewer('cesiumContainer', { terrainProvider: new Cesium.EllipsoidTerrainProvider(), animation: false, timeline: false, baseLayerPicker: false, geocoder: false, homeButton: false, sceneModePicker: false, navigationHelpButton: false, infoBox: false, selectionIndicator: false, contextOptions: { requestWebgl1: false } }); viewer.scene.globe.enableLighting = false; viewer.scene.skyAtmosphere.show = false; viewer.scene.backgroundColor = Cesium.Color.fromCssColorString('#0a0d12'); // 加点草地色底,方便对比火焰颜色 viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees(116.39, 39.90), ellipse: { semiMajorAxis: 60, semiMinorAxis: 60, material: Cesium.Color.fromCssColorString('#2c2a26').withAlpha(0.9) } }); // 镜头先摆好,省得每次都手动飞 viewer.camera.setView({ destination: Cesium.Cartesian3.fromDegrees(116.39, 39.8994, 90), orientation: { heading: 0, pitch: Cesium.Math.toRadians(-28), roll: 0 } });enableLighting关掉是刻意的:开了光照,黎明和夜晚时段的粒子颜色会被底色影响,调参时会误以为是自己参数写错了。
5.2 FireEffect 完整实现
class FireEffect { constructor(viewer, position, options = {}) { this.viewer = viewer; this.baseRate = options.emissionRate ?? 90; this._alive = true; this.system = viewer.scene.primitives.add(new Cesium.ParticleSystem({ image: FireEffect.glowTexture(), imageSize: new Cesium.Cartesian2( options.size ?? 78, options.size ?? 78 ), startScale: 0.45, endScale: 1.55, minimumParticleLife: 0.85, maximumParticleLife: 1.75, minimumSpeed: 0.6, maximumSpeed: 2.0, minimumMass: 0.6, maximumMass: 1.0, emitter: new Cesium.CircleEmitter(options.radius ?? 0.9), emissionRate: this.baseRate, lifetime: Number.MAX_VALUE, loop: true, modelMatrix: Cesium.Transforms.eastNorthUpToFixedFrame(position), emitterModelMatrix: Cesium.Matrix4.fromTranslation( new Cesium.Cartesian3(0, 0, options.lift ?? 0.5) ), startColor: new Cesium.Color(1.0, 0.86, 0.32, 0.92), endColor: new Cesium.Color(0.72, 0.11, 0.02, 0.0), colorBlendMode: Cesium.ColorBlendMode.MIX, blendOption: Cesium.BlendingState.ADDITIVE_BLEND, updateCallback: (particle, dt) => { const t = particle.normalizedAge; const lift = 3.2 * (1.0 - t) + 0.6; particle.velocity.z += lift * dt; const phase = particle.age * 7.0 + particle.mass * 11.0; particle.velocity.x += Math.sin(phase) * 0.9 * dt; particle.velocity.y += Math.cos(phase * 1.37) * 0.9 * dt; const damp = Math.max(0, 1 - 1.6 * dt); particle.velocity.x *= damp; particle.velocity.y *= damp; } })); this._tick = this._tick.bind(this); this._removeTick = viewer.scene.preUpdate.addEventListener(this._tick); } _tick() { if (!this._alive) return; const now = performance.now(); this.system.emissionRate = this.baseRate * (1 + 0.15 * Math.sin(now / 900)); } setVisible(v) { this.system.show = v; } destroy() { this._alive = false; this._removeTick(); this.viewer.scene.primitives.remove(this.system); this.system = null; } static glowTexture() { if (FireEffect._tex) return FireEffect._tex; const size = 64; const canvas = document.createElement('canvas'); canvas.width = canvas.height = size; const ctx = canvas.getContext('2d'); const r = size / 2; const grad = ctx.createRadialGradient(r, r, 0, r, r, r); grad.addColorStop(0.0, 'rgba(255,255,255,1.0)'); grad.addColorStop(0.35, 'rgba(255,255,255,0.85)'); grad.addColorStop(0.70, 'rgba(255,255,255,0.25)'); grad.addColorStop(1.0, 'rgba(255,255,255,0.0)'); ctx.fillStyle = grad; ctx.fillRect(0, 0, size, size); FireEffect._tex = canvas.toDataURL(); return FireEffect._tex; } }贴图做了静态缓存,第二次创建火焰就不用重画 Canvas 了。多火点场景下这个细节能省下不少初始化时间。
5.3 ExplosionEffect 完整实现
class ExplosionEffect { constructor(viewer, center, options = {}) { this.viewer = viewer; this.center = center; const count = options.count ?? 1200; const windowSec = options.window ?? 0.06; const colorLife = options.colorLife ?? 0.42; // 核心火光寿命 const sparkLife = options.sparkLife ?? 1.2; // 火星寿命 const smokeLife = options.smokeLife ?? 2.8; // 烟尘寿命 // ---- 第一层:核心火光 ---- this.core = viewer.scene.primitives.add(new Cesium.ParticleSystem({ image: FireEffect.glowTexture(), imageSize: new Cesium.Cartesian2(88, 88), startScale: 0.5, endScale: 2.4, minimumParticleLife: colorLife * 0.8, maximumParticleLife: colorLife, minimumSpeed: 10, maximumSpeed: 26, emitter: new Cesium.SphereEmitter(0.25), emissionRate: Math.round(count / windowSec), lifetime: colorLife + 1.0, loop: false, modelMatrix: Cesium.Transforms.eastNorthUpToFixedFrame(center), emitterModelMatrix: Cesium.Matrix4.fromTranslation( new Cesium.Cartesian3(0, 0, 1.2) ), startColor: new Cesium.Color(1.0, 0.96, 0.76, 1.0), endColor: new Cesium.Color(0.85, 0.18, 0.02, 0.0), colorBlendMode: Cesium.ColorBlendMode.MIX, blendOption: Cesium.BlendingState.ADDITIVE_BLEND })); // ---- 第二层:火星(延后 60ms 起)---- setTimeout(() => { this.sparks = viewer.scene.primitives.add(new Cesium.ParticleSystem({ image: FireEffect.glowTexture(), imageSize: new Cesium.Cartesian2(12, 12), startScale: 1.0, endScale: 0.35, minimumParticleLife: sparkLife * 0.6, maximumParticleLife: sparkLife, minimumSpeed: 22, maximumSpeed: 46, emitter: new Cesium.SphereEmitter(0.1), emissionRate: Math.round(count * 0.28 / windowSec), lifetime: sparkLife + 1.0, loop: false, modelMatrix: Cesium.Transforms.eastNorthUpToFixedFrame(center), emitterModelMatrix: Cesium.Matrix4.fromTranslation( new Cesium.Cartesian3(0, 0, 1.2) ), startColor: new Cesium.Color(1.0, 0.9, 0.42, 1.0), endColor: new Cesium.Color(1.0, 0.28, 0.02, 0.0), colorBlendMode: Cesium.ColorBlendMode.MIX, blendOption: Cesium.BlendingState.ADDITIVE_BLEND, updateCallback: (particle, dt) => { particle.velocity.z -= 9.8 * dt; // 重力 const damp = Math.max(0, 1 - 0.35 * dt); particle.velocity.x *= damp; particle.velocity.y *= damp; } })); this._closeEmission(this.sparks, windowSec * 0.8); }, 60); // ---- 第三层:烟尘(延后 180ms 起)---- setTimeout(() => { this.smoke = viewer.scene.primitives.add(new Cesium.ParticleSystem({ image: FireEffect.glowTexture(), imageSize: new Cesium.Cartesian2(210, 210), startScale: 0.35, endScale: 2.2, minimumParticleLife: smokeLife * 0.7, maximumParticleLife: smokeLife, minimumSpeed: 1.5, maximumSpeed: 5.0, emitter: new Cesium.SphereEmitter(1.2), emissionRate: Math.round(count * 0.16 / (windowSec * 3)), lifetime: smokeLife + 1.5, loop: false, modelMatrix: Cesium.Transforms.eastNorthUpToFixedFrame(center), emitterModelMatrix: Cesium.Matrix4.fromTranslation( new Cesium.Cartesian3(0, 0, 2.2) ), startColor: new Cesium.Color(0.24, 0.21, 0.19, 0.72), endColor: new Cesium.Color(0.45, 0.44, 0.42, 0.0), colorBlendMode: Cesium.ColorBlendMode.MIX, blendOption: Cesium.BlendingState.ALPHA_BLEND, updateCallback: (particle, dt) => { particle.velocity.z += 1.4 * dt; // 上浮 const damp = Math.max(0, 1 - 0.6 * dt); particle.velocity.x *= damp; particle.velocity.y *= damp; } })); this._closeEmission(this.smoke, windowSec * 3); }, 180); this._closeEmission(this.core, windowSec); this.shockwave(startTimeSafe(this)); } _closeEmission(system, windowSec) { const t0 = performance.now(); const remove = this.viewer.scene.preUpdate.addEventListener(() => { if (performance.now() - t0 >= windowSec * 1000) { if (system) system.emissionRate = 0; remove(); } }); } shockwave(startMs) { const life = 620; const entity = this.viewer.entities.add({ position: this.center, billboard: { image: ExplosionEffect.ringTexture(), scale: new Cesium.CallbackProperty(() => { const p = Math.min(1, (performance.now() - startMs) / life); return 0.15 + p * 6.0; }, false), color: new Cesium.CallbackProperty(() => { const p = Math.min(1, (performance.now() - startMs) / life); return new Cesium.Color(1, 0.85, 0.55, 0.9 * (1 - p) * (1 - p)); }, false), disableDepthTestDistance: Number.POSITIVE_INFINITY } }); setTimeout(() => this.viewer.entities.remove(entity), life + 120); } static ringTexture() { const size = 128; const canvas = document.createElement('canvas'); canvas.width = canvas.height = size; const ctx = canvas.getContext('2d'); const r = size / 2; const grad = ctx.createRadialGradient(r, r, r * 0.2, r, r, r); grad.addColorStop(0.00, 'rgba(255,255,255,0)'); grad.addColorStop(0.62, 'rgba(255,255,255,0)'); grad.addColorStop(0.80, 'rgba(255,255,255,0.85)'); grad.addColorStop(0.94, 'rgba(255,255,255,0.35)'); grad.addColorStop(1.00, 'rgba(255,255,255,0)'); ctx.fillStyle = grad; ctx.fillRect(0, 0, size, size); return canvas.toDataURL(); } }代码里那个startTimeSafe(this)是占位写法,实际用的时候直接传performance.now()即可,我保留在注释说明里是为了强调冲击波的起始时间必须和爆炸同一时刻取,如果等三层粒子都创建完再取时间,冲击波会晚 200 毫秒,明显不搭。
烟尘那层的emissionRate我除了windowSec * 3,因为烟需要持续久一点往上冒,除以 3 是让发射窗口拉长到三倍,粒子出生更分散,不然烟会像一根柱子瞬间顶上去。
5.4 触发、回收与销毁
调用起来很直接:
// 一团常驻火焰 const fire = new FireEffect(viewer, Cesium.Cartesian3.fromDegrees(116.3902, 39.9002)); // 点一下屏幕就近处炸一下 const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); let explosion = null; handler.setInputAction((click) => { const ray = viewer.camera.getPickRay(click.position); const pos = viewer.scene.globe.pick(ray, viewer.scene); if (!pos) return; if (explosion) explosion.dispose(); explosion = new ExplosionEffect(viewer, pos, { count: 1400 }); }, Cesium.ScreenSpaceEventType.LEFT_CLICK);ExplosionEffect还应该补一个dispose(),把三层粒子系统从 primitives 里移除,并清掉所有定时器和监听:
dispose() { [this.core, this.sparks, this.smoke].forEach((s) => { if (s) this.viewer.scene.primitives.remove(s); }); this.core = this.sparks = this.smoke = null; }回收这件事必须认真写。我见过一个连续爆炸的项目,每次爆炸 new 一套粒子系统,从来不移除,跑了半小时场景里堆了两百多个粒子系统,帧率从 60 掉到 8。粒子系统不是免费的,每个系统虽然 draw call 少,但每帧都要遍历粒子做 update,数量一多就线性增长。凡是“瞬发”的特效,一定要有一个明确的回收时机。
6. 排查实录:不显示、位置飘、帧率掉这些坑
6.1 常见现象速查表
| 现象 | 最可能的原因 | 处理方式 |
|---|---|---|
| 完全看不到粒子 | image路径 404 或跨域被拦 | 打开控制台看 Network,或改 Canvas 生成 |
| 完全看不到粒子 | emissionRate为 0 或show: false | 打日志确认运行时的值 |
| 完全看不到粒子 | imageSize太小(如 1×1) | 先调到 100 看有没有 |
| 显示成一堆黑方块 | PNG 没有 alpha 通道 | 换带透明的图或 Canvas 生成 |
| 显示成白色方块 | 贴图全白且未设colorBlendMode | 加ColorBlendMode.MIX或REPLACE |
| 粒子在地下/地心 | 把经纬度矩阵塞进emitterModelMatrix | 世界坐标放modelMatrix |
| 火焰是扁的、贴地 | modelMatrix用了纯平移矩阵 | 换eastNorthUpToFixedFrame |
| 火苗不上升 | 没写updateCallback的浮力 | 加 +Z 方向加速度 |
| 火焰 5 秒后自动熄灭 | lifetime用了默认值 | 设大或Number.MAX_VALUE |
| 爆炸后残留暗红色雾 | endColor的 alpha 不为 0 | 严格设 0 |
| 爆炸粒子集体消失 | 系统 remove 太早 | 等最长粒子寿命结束再回收 |
| 粒子看着像纸片 | 用了默认ALPHA_BLEND | 火焰换ADDITIVE_BLEND |
| 白天过曝一片白 | 加法混合叠加过多 | 降 alpha、缩尺寸、白天关加法 |