做三维地球项目的同行大概都接过这种需求:地图上标一个点,要求"啪"地炸开一团火,再配上持续燃烧的火焰特效。听起来像是十分钟能交差的活,真上手才发现 Cesium 的粒子系统参数之间互相牵扯,发射器坐标系一搞错,粒子就朝着地心钻;贴图没处理好,一团黑方块糊在屏幕上;粒子数一多,帧率立刻掉到个位数。我这几年做过几套应急指挥和数据可视化的大屏,Cesium 粒子爆炸和火焰这块前前后后改过七八版,从最初硬编码几组参数到后来封装成可复用的特效池,中间踩的坑足够写一篇长文。
下面这份内容围绕Cesium 粒子爆炸特效与动态火焰特效两条线展开:先讲清楚粒子系统那四个构件各自管什么,再把一次爆炸从零配到能跑通的完整参数表列出来,然后对比火焰和爆炸在参数上的根本差异,最后把我实际调试中遇到的"看不见、闪一下就没、卡成幻灯片"三类故障的排查顺序讲透。代码全部可直接粘贴运行,不依赖任何外部图片资源。不管你是刚接触 Cesium 粒子系统的新手,还是已经在调参但效果总觉得"假"的老手,应该都能从里面捞到点东西。
1. 爆炸特效在三维地球里为什么"看着简单、做着难受"
1.1 一次爆炸在视觉上到底由哪几层构成
很多人第一次做爆炸,直觉是"一个球往外膨胀"。真做出来就是个大号发光球,完全没有爆炸的味道。问题在于真实爆炸在视觉上是分层叠加的,至少包含四层信息:核心闪光(极短、极亮、白色偏黄)、火球主体(橙红色,快速膨胀后迅速黯淡)、碎屑飞溅(小颗粒、高速、有方向性、拖尾)、烟尘扩散(大颗粒、低速、灰黑色、持续时间长)。
这四层的时间尺度完全不同:核心闪光只有零点几秒,火球主体一秒上下,烟尘能飘三五秒。如果你用一个 ParticleSystem 硬扛全部,参数一定会互相打架——想让烟尘慢,火球就显得拖沓;想让碎屑快,整体就糊成一片。我后来的做法是一次爆炸拆成两到三个粒子系统:一个短促高亮的"闪光+火球"、一个"碎屑"、一个可选的"烟尘"。它们共用同一个modelMatrix,只是时间和速度参数不同。这个拆法听起来麻烦,实际调起来反而轻松,因为每套参数只需要管一件事。
如果性能预算紧张,至少也要拆成"核心"和"碎屑"两层。单系统出来的爆炸,不管参数怎么调,总有股说不出的廉价感,原因就在这里。
1.2 Cesium 粒子系统和游戏引擎粒子系统的能力边界
从游戏引擎转过来的人容易犯一个错:默认 Cesium 的粒子系统有重力场、有碰撞、有混合模式开关。实际上这些都没有。Cesium 的ParticleSystem是一个相当精简的实现,粒子之间没有交互,不受场景光照影响,不参与物理模拟,生命周期内位置完全靠速度积分,而速度只能靠updateCallback手动改。
这个限制听着吓人,但换个角度想反而简单:你不需要去理解一堆物理参数,只要在updateCallback里对每个粒子手动施加你想施加的力,效果完全可控。比如给爆炸粒子加空气阻力就是一行乘法,给火焰加上浮就是一个velocity.z += ...。
但有两件事必须自己兜底。一是混合模式,Cesium 没暴露这个开关,粒子按固定方式混合,想让火焰"发光"只能靠贴图本身的 alpha 渐变和颜色叠加来营造。二是发射器朝向的灵活性,Cesium 的ConeEmitter固定朝局部坐标的 +Z,想让它朝别的方向,只能靠旋转emitterModelMatrix实现,没有"方向向量"参数。这两点在后文都会具体处理。
2. 粒子系统的四个骨架件:发射器、系统矩阵、生命周期、贴图
调 Cesium 粒子,如果只盯着startColor、endColor这些"效果参数"看,永远调不明白。真正决定粒子在哪、往哪飞、活多久的是四个"骨架件",这四个没配好,效果参数怎么调都是歪的。
2.1 emitter 和 emitterModelMatrix 的分工
这里是最容易混淆的地方。Cesium 粒子系统里有两个矩阵:modelMatrix和emitterModelMatrix,外加一个emitter对象。三者的关系是:
modelMatrix:把整个粒子系统的局部坐标系变换到世界坐标。通常用Cesium.Transforms.eastNorthUpToFixedFrame(世界坐标)生成,得到的是"东-北-天"局部坐标系。emitter:在这个局部坐标系里决定粒子从哪个形状的区域内往哪个方向出生。SphereEmitter在球体内随机、ConeEmitter在锥体内、CircleEmitter在圆盘内、BoxEmitter在盒体内。emitterModelMatrix:发射器在局部坐标系里的偏移和旋转。默认是单位矩阵,也就是发射器就在局部原点。
理解方式:假设你要在地球上某个经纬度炸一下。modelMatrix把这个点变成局部原点,并且让局部坐标系的正 Z 轴垂直地表向上、正 Y 轴朝北、正 X 轴朝东。然后SphereEmitter(0.5)表示粒子从半径 0.5 米的球内随机出生;ConeEmitter(Math.PI / 8)表示粒子在一个约 22.5 度半角的锥体内朝正 Z(也就是垂直向上)飞出。
这里有个硬核细节,很多人调火焰时会栽跟头:updateCallback里拿到的particle.velocity是局部坐标系下的向量,不是世界坐标。想让火焰往上飘,写particle.velocity.z += xxx就对了——因为局部 Z 就是垂直向上。如果你直接把世界坐标里的重力方向或某个世界方向向量塞进去,粒子会朝莫名其妙的方向跑,而且换一个经纬度,方向又变了,极难排查。
// 正确:局部坐标系下 Z 轴垂直向上 updateCallback: function (particle, dt) { particle.velocity.z += 2.4 * dt; } // 错误:这里塞进去的是世界方向,换个地点方向就乱了 updateCallback: function (particle, dt) { var up = Cesium.Cartesian3.normalize(position, new Cesium.Cartesian3()); particle.velocity = Cesium.Cartesian3.add( particle.velocity, Cesium.Cartesian3.multiplyByScalar(up, 2.4 * dt, up), particle.velocity ); }要让爆炸点有偏移(比如从建筑顶部的某个位置炸开),不要动modelMatrix,而是动emitterModelMatrix:
var emitterModelMatrix = new Cesium.Matrix4(); Cesium.Matrix4.multiplyByTranslation( Cesium.Matrix4.IDENTITY, new Cesium.Cartesian3(0, 0, 12), // 局部系里向上偏 12 米 emitterModelMatrix );2.2 modelMatrix 从经纬度算出来的完整写法
modelMatrix必须是Cesium.Matrix4,直接传Cartesian3是最常见的低级错误。标准写法只有一句:
var worldPosition = Cesium.Cartesian3.fromDegrees(116.39, 39.91, 50); // 最后一个参数是离地高度 var modelMatrix = Cesium.Transforms.eastNorthUpToFixedFrame(worldPosition);要注意第三个参数。如果只写经纬度,高度默认 0,粒子系统就贴在地表,如果你同时开了地形深度检测,一半粒子会被地面切掉,看起来像"炸了半个"。实际项目里我一般会显式加 20~50 米的高度,或者干脆用viewer.scene.globe.getHeight()取地形高度再加偏移。
另一个隐蔽的坑:eastNorthUpToFixedFrame在极点附近的行为会退化,虽然实际业务很少遇到,但如果你的场景里允许飞到极地,建议加个保护,把位置钳制在 ±85 度纬度以内。
2.3 没有美术资源怎么办:用 Canvas 现场画一张粒子贴图
大量教程里image直接写一个 png 路径,于是新手的第一步就卡在"我上哪找这张图"。其实完全不用外部资源,Cesium 的image参数接受 dataURL,用 Canvas 现画一张径向渐变圆就行,而且画出来的东西完全可控。
function createRadialTexture(stops, size) { size = size || 128; var canvas = document.createElement('canvas'); canvas.width = canvas.height = size; var ctx = canvas.getContext('2d'); var half = size / 2; var g = ctx.createRadialGradient(half, half, 0, half, half, half); for (var i = 0; i < stops.length; i++) { g.addColorStop(stops[i][0], stops[i][1]); } ctx.fillStyle = g; ctx.fillRect(0, 0, size, size); return canvas.toDataURL(); } // 爆炸用:白心 → 黄 → 橙 → 全透明 var EXPLOSION_TEX = createRadialTexture([ [0.00, 'rgba(255,255,255,1.0)'], [0.22, 'rgba(255,238,170,0.95)'], [0.55, 'rgba(255,130,40,0.55)'], [1.00, 'rgba(200,40,0,0.0)'] ], 128); // 火焰用:偏亮黄,边缘更快透明,便于叠加 var FIRE_TEX = createRadialTexture([ [0.00, 'rgba(255,246,200,0.95)'], [0.35, 'rgba(255,170,60,0.70)'], [0.70, 'rgba(220,60,10,0.28)'], [1.00, 'rgba(120,10,0,0.0)'] ], 128);这里有几个实操要点。第一,渐变的最后一档一定要alpha = 0,否则粒子边缘会出现一个明显的硬圆边界,像贴了一堆小圆片。第二,贴图尺寸不用太大,128×128 足够,粒子本身在屏幕上一般不到 50 像素,256 以上纯属浪费显存;但如果你的粒子需要贴得很近看(比如第一人称视角的火焰),可以提到 256。第三,千万不要用 jpg,没有透明通道,渲染出来是个黑方块。我见过不止一个人卡在这,以为是颜色参数设置问题,查了一下午。
如果确实要用外部图片,注意同源限制:跨域图片在没有正确 CORS 头的情况下可能加载失败,粒子直接不显示,控制台还不一定报错。
2.4 lifetime、loop、emissionRate 三者的真实关系
这三个参数是决定"粒子什么时候消失、什么时候停止发射"的核心,但文档写得比较散,我这里给个统一的理解框架。
emissionRate是每秒发射粒子数。minimumParticleLife/maximumParticleLife是单个粒子存活秒数,系统会在两者间随机取值。lifetime是整个粒子系统的存在时间,默认Number.MAX_VALUE相当于永久。loop决定lifetime到期后是否重来。
由此可以直接推出一个关键公式,这也是性能估算的基础:
峰值粒子数 ≈ emissionRate × maximumParticleLife
比如emissionRate = 100、maximumParticleLife = 1.3,那么稳定运行时场内大约有 130 个粒子。这个数字是可以拿来核预算的:一般桌面端能扛几千个粒子,但移动端或者低配集显机器,同屏超过 1500 个粒子就开始明显掉帧了。同屏有十个火堆,每个 130 个,一共 1300,已经很接近红线。
爆炸场景还有个额外的坑:loop必须设成false。默认是true,如果不改,爆炸会每隔lifetime秒自己重播一次,页面上就出现一个诡异的"循环爆炸点"。这个现象我第一次遇到时还以为是浏览器缓存的旧代码,查了半小时才反应过来。
3. 从零敲出一个能跑的爆炸:参数逐项落地
这一节给完整可运行的代码,不是伪代码片段。你可以直接存成 html 打开。
3.1 场景初始化和几个该关的开关
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8" /> <style> html, body, #cesiumContainer { width: 100%; height: 100%; margin: 0; padding: 0; overflow: hidden; } </style> <!-- 按你项目里实际的部署路径引入,这里用相对路径示意 --> <link rel="stylesheet" href="./Cesium/Widgets/widgets.css" /> <script src="./Cesium/Cesium.js"></script> </head> <body> <div id="cesiumContainer"></div> <script> var viewer = new Cesium.Viewer('cesiumContainer', { animation: false, timeline: false, baseLayerPicker: false, geocoder: false, homeButton: false, sceneModePicker: false, navigationHelpButton: false, fullscreenButton: false, infoBox: false, selectionIndicator: false, // 关掉默认影像,用纯色地表,便于看清粒子颜色 baseLayer: Cesium.ImageryLayer.fromProviderAsync( Cesium.TileMapServiceImageryProvider.fromUrl( Cesium.buildModuleUrl('Assets/Textures/NaturalEarthII') ) ) }); // 性能相关:关掉 FXAA 之外的冗余效果,低配机器上很关键 viewer.scene.fxaa = true; viewer.scene.postProcessStages.fxaa.enabled = true; // 如果开了 requestRenderMode,任何一次粒子更新都要手动 requestRender viewer.scene.requestRenderMode = false; viewer.camera.setView({ destination: Cesium.Cartesian3.fromDegrees(116.39, 39.91, 900), orientation: { heading: Cesium.Math.toRadians(0), pitch: Cesium.Math.toRadians(-35), roll: 0 } }); </script> </body> </html>requestRenderMode这个开关要单独说一句。它在省电和低功耗场景下很有用,但一旦打开,Cesium 只在你显式调用viewer.scene.requestRender()时才重绘。粒子系统的更新不会自动触发重绘,表现出来就是"爆炸只闪了一帧就定格"。如果你的项目必须开这个模式,那就在粒子系统存活期间挂一个preRender监听,里面调一次requestRender()。
3.2 一次爆炸的完整参数表和逐项理由
下面这张表是我实际项目里用的配置,每一项后面写了为什么这么设。
| 参数 | 取值 | 设置理由 |
|---|---|---|
image | Canvas 径向渐变 dataURL | 免外部资源,透明通道可控 |
imageSize | Cartesian2(6, 6) | 配合sizeInMeters=true,单粒子最大 6 米 |
sizeInMeters | true | 让粒子随距离自然缩放,远近都合理 |
modelMatrix | eastNorthUpToFixedFrame(pos) | 把爆炸点定位到真实经纬度 |
emitter | SphereEmitter(0.35) | 球内随机出生,避免所有粒子同点爆发 |
startColor | 白色 alpha 1.0 | 爆炸核心必须是过曝白 |
endColor | 橙红 alpha 0.0 | 末端完全透明,避免硬边 |
startScale | 0.25 | 起点小,才有"膨胀"的观感 |
endScale | 1.0 | 终点达到imageSize的满尺寸 |
minimumParticleLife | 0.9 | 短命,爆炸就该转瞬即逝 |
maximumParticleLife | 1.6 | 与最小值的差值制造层次 |
minimumSpeed | 6 | 低速粒子撑起火球体积 |
maximumSpeed | 24 | 高速粒子形成飞溅感 |
emissionRate | 0 | 关键:不连续发射,全靠 burst |
bursts | [{time:0, min:110, max:220}] | 一次爆开 110~220 个粒子 |
lifetime | 1.8 | 系统比最长粒子寿命长一点,保证全部自然消亡 |
loop | false | 绝不能循环 |
startScale和endScale这两个容易被忽略。imageSize是粒子的基准尺寸,startScale/endScale是在此基础上的缩放倍数。如果两个都设 1.0,粒子从头到尾一样大,爆炸会像"一堆气球飞出去",毫无张力。设成 0.25 → 1.0,配合endColor的 alpha 衰减,才有那种"炸开后迅速消散"的感觉。
3.3 用 burst 做"一击即散"而不是持续喷射
bursts是 Cesium 粒子系统里一个很好用但不太被提及的能力。ParticleBurst的time是相对系统启动的秒数,minimum/maximum是这一次爆发的粒子数范围。配合emissionRate: 0,整个系统就只在指定时刻喷一次,非常适合爆炸。
function spawnExplosion(viewer, worldPosition, options) { var opts = Object.assign({ radius: 6, count: 200, duration: 1.6, coreColor: Cesium.Color.WHITE, edgeColor: Cesium.Color.fromCssColorString('#FF4A00') }, options || {}); var modelMatrix = Cesium.Transforms.eastNorthUpToFixedFrame(worldPosition); var ps = new Cesium.ParticleSystem({ image: EXPLOSION_TEX, imageSize: new Cesium.Cartesian2(opts.radius, opts.radius), sizeInMeters: true, modelMatrix: modelMatrix, emitter: new Cesium.SphereEmitter(0.35), startColor: opts.coreColor.withAlpha(1.0), endColor: opts.edgeColor.withAlpha(0.0), startScale: 0.25, endScale: 1.0, minimumParticleLife: opts.duration * 0.55, maximumParticleLife: opts.duration, minimumSpeed: 6.0, maximumSpeed: 24.0, emissionRate: 0.0, bursts: [ new Cesium.ParticleBurst({ time: 0.0, minimum: opts.count * 0.55, maximum: opts.count }) ], lifetime: opts.duration + 0.2, loop: false, updateCallback: function (particle, dt) { // 空气阻力:每帧按比例衰减速度 var drag = Math.max(0, 1.0 - 2.2 * dt); particle.velocity.x *= drag; particle.velocity.y *= drag; particle.velocity.z *= drag; // 轻微上浮,模拟热气上升 particle.velocity.z += 1.2 * dt; } }); viewer.scene.primitives.add(ps); return ps; }updateCallback里的阻力写成1 - k * dt的形式是有讲究的:必须跟dt挂钩,否则高帧率机器上粒子减速快、低帧率机器上减速慢,同一个特效在 30fps 和 144fps 下表现完全不同。我第一次写的时候用了固定系数0.97,在自己机器上看着完美,到客户现场的低配机上变成"炸开就不动了",排查了很久才发现是帧率差异。
另外drag要Math.max(0, ...)兜底,防止dt异常大(比如切标签页回来)时出现负速度把粒子往回拽。
3.4 回收:怎么判断这次爆炸已经放完了
粒子系统加到primitives之后不会自己消失(除非isComplete为真且引擎清理),不清的话每炸一次就在内存里多留一个对象,连续点几十次就卡了。
ParticleSystem有个只读属性isComplete,条件是loop === false且所有粒子已经消亡。用它做回收:
function autoRemove(viewer, particleSystem) { var removeListener = viewer.scene.preRender.addEventListener(function () { if (particleSystem.isComplete) { viewer.scene.primitives.remove(particleSystem); removeListener(); // Event.addEventListener 返回的就是移除函数 } }); }我一般会再加一层保险:用一个时间戳兜底,超过 5 秒还没isComplete(某些异常情况下会出现)也强制移除。这个兜底在长时间运行的大屏上救过我一次,当时因为某个异常导致一个粒子系统卡在未完成状态,内存缓慢增长,页面开一整夜就崩了。
4. 火焰特效和爆炸不是一回事:从"一次性"改成"持续燃烧"
爆炸是"一次性爆发",火焰是"持续输出"。这个差别决定了几乎全部参数都要换一套逻辑。把爆炸的参数直接拿来改火焰,最常见的结果是一团"抽搐的橙色团块"。
4.1 ConeEmitter 的口径和朝向决定火焰形态
火焰必须用ConeEmitter,这一点几乎没有争议。CircleEmitter只在一个平面上撒粒子,侧面看就是一条线;SphereEmitter四散,像雾不像火。
ConeEmitter(angle)的angle是锥体的半角,单位弧度。这个值的直观含义:
0到Math.PI / 18(10 度):极细的火线,像喷灯;Math.PI / 12到Math.PI / 8(15~22.5 度):正常的火把、篝火;Math.PI / 4(45 度):散开的火焰,像烧着的油池;- 超过
Math.PI / 2:基本就是乱喷了,不太像火。
锥体默认朝局部坐标的 +Z,而eastNorthUpToFixedFrame生成的正 Z 正好是垂直地表向上,所以什么都不用调,火焰天然朝上。这也是为什么前面强调必须用这个函数生成modelMatrix:它顺手把"向上"这件事解决了。
如果火焰要斜着烧(比如喷火器横着喷),就得旋转emitterModelMatrix。旋转轴用局部 Y 轴,角度按需要给:
var emitterModelMatrix = new Cesium.Matrix4(); var rotation = Cesium.Matrix3.fromRotationY(Cesium.Math.toRadians(-60)); Cesium.Matrix4.fromRotationTranslation(rotation, Cesium.Cartesian3.ZERO, emitterModelMatrix);这里toRadians(-60)是让它朝东偏 60 度。正负号的方向感第一次用一定会搞反,建议先给 ±60 各跑一次看效果,记下来。
4.2 updateCallback 里给粒子加上浮和抖动
Cesium 粒子没有内置的浮力,火焰那股"越往上越快"的感觉全靠updateCallback手动加。
updateCallback: function (particle, dt) { // 1. 浮力:越往上速度越快,模拟热气膨胀 particle.velocity.z += 3.2 * dt * (0.5 + particle.percentComplete); // 2. 横向抖动:用随机游走让火苗不直挺挺 particle.velocity.x += (Math.random() - 0.5) * 4.0 * dt; particle.velocity.y += (Math.random() - 0.5) * 4.0 * dt; // 3. 横向速度阻尼,防止抖动累积成"散架" particle.velocity.x *= (1.0 - 1.5 * dt); particle.velocity.y *= (1.0 - 1.5 * dt); }这里用了particle.percentComplete,它是粒子生命周期进度(0 到 1)。乘以这个系数后,粒子刚出生时浮力小、快到寿命时浮力大,火焰顶端会自然散开收窄,比一条笔直的火柱真实得多。
横向抖动的两个系数要配合调:加得太多是"爆炸式乱飞",加得太少是"僵硬直线"。经验值是加力系数 3~5、阻尼系数 1.2~1.8,按你的火焰高度微调。另外抖动一定要乘dt,不然又是帧率相关的老问题。
还有一点:Math.random()在updateCallback里每帧对每个粒子都调两次,粒子多的时候这是实实在在的开销。一百来个粒子无所谓,上千个粒子的火焰阵列就要考虑改成预生成随机表或者用更便宜的伪随机,我一般在粒子数超过 800 的火焰场景会换成一维噪声查表。
4.3 颜色梯度:为什么火焰的 endColor 不能太红
火焰的颜色变化和爆炸不同。爆炸是"白 → 橙红 → 透明",火焰是"亮黄白核心 → 橙 → 暗红 → 透明"。但你如果直接把endColor设成深红,会发现火焰顶端有一圈"脏脏的褐色",特别是叠加在地形上时很难看。
原因是粒子的颜色是线性插值的,而 alpha 也在同时衰减。当粒子接近寿命末端时,颜色已经变暗但还没完全透明,叠加多个粒子就变成一坨棕褐色。解法是让 alpha 衰减得快于颜色变暗,或者干脆把endColor的色相保持在橙红区间,别往深红走:
// 推荐 startColor: Cesium.Color.fromCssColorString('#FFF6C8').withAlpha(0.95), endColor: Cesium.Color.fromCssColorString('#D93A00').withAlpha(0.0), // 不推荐,末端会发脏 startColor: Cesium.Color.WHITE, endColor: Cesium.Color.fromCssColorString('#4A0000').withAlpha(0.1),另外火焰的startScale/endScale和爆炸相反:起点大、终点小。火焰粒子往上飘的过程中应该收窄,像火苗的尖。所以startScale: 1.0、endScale: 0.3这种方向。这一点和爆炸是完全颠倒的,很多人没意识到。
4.4 让火焰不"整齐":分层与随机化
单层火焰参数调得再好,近看也能看出规律。真实的火焰是有层次的,我通常这么处理:
第一层,核心火柱:ConeEmitter半角很小(约 8 度),速度低(1~2),颜色最亮,粒子尺寸小,寿命短。它的作用是撑起火焰的骨架。
第二层,外焰:半角大一些(约 18 度),速度中等(2~4),颜色偏橙红,粒子尺寸大,寿命长。它让火焰有体积感。
第三层(可选),火星与烟:粒子极少、速度高、寿命长、颜色暗。这一层只在需要表现"燃烧物"时加。
三层共用同一个modelMatrix,发射器稍微错开就行。三个系统的粒子总数控制在 300 以内,一般的火把足够用了。如果你有几十个火把同屏,建议只保留第一层和第二层,火星层改成按需触发。
5. 参数调优的实数:几组可以直接抄的配置
光讲道理没用,这节给出我实际项目里验证过的几组配置,拿来改改就能用。
5.1 三档爆炸的差异化参数
| 参数 | 小型(近距、单点) | 中型(建筑、标绘点) | 大型(远景、区域) |
|---|---|---|---|
radius(imageSize) | 3 | 6 | 14 |
| 粒子总数(burst max) | 80 | 220 | 500 |
minimumParticleLife | 0.5 | 0.9 | 1.4 |
maximumParticleLife | 0.9 | 1.6 | 2.6 |
minimumSpeed | 4 | 6 | 12 |
maximumSpeed | 14 | 24 | 48 |
startScale | 0.3 | 0.25 | 0.2 |
endScale | 1.0 | 1.0 | 1.0 |
lifetime | 1.1 | 1.8 | 2.9 |
三档之所以差异这么大,核心是观看距离。远距离的爆炸如果粒子速度只有 6 米/秒,在屏幕上几乎静止;反过来近距离用 48 米/秒,粒子一帧就飞出视野。所以在实际项目里,我会根据相机到爆点的距离自动选档:
function pickExplosionPreset(viewer, worldPosition) { var distance = Cesium.Cartesian3.distance(viewer.camera.positionWC, worldPosition); if (distance < 1500) return PRESET_SMALL; if (distance < 8000) return PRESET_MEDIUM; return PRESET_LARGE; }这个自动选档的思路非常值得抄,因为用户在大屏上缩放是常事,固定参数的结果就是"拉近了看不见、拉远了糊一团"。
5.2 火把和篝火,差别比想象中大
| 参数 | 火把(高瘦) | 篝火(矮胖) |
|---|---|---|
ConeEmitter半角 | 8 度 | 22 度 |
imageSize | 1.6 | 3.2 |
emissionRate | 45 | 110 |
minimumParticleLife | 0.5 | 0.9 |
maximumParticleLife | 1.0 | 1.8 |
minimumSpeed | 1.2 | 1.0 |
maximumSpeed | 2.8 | 2.6 |
startScale | 1.0 | 1.0 |
endScale | 0.25 | 0.35 |
| 浮力系数 | 3.2 | 2.0 |
火把的关键是高而窄,所以锥角小、速度偏大、粒子小。篝火是宽而散,锥角大、发射率高、粒子大,浮力反而要小一点(火焰本身已经够高了,再加大浮力就飘没了)。
篝火的发射率 110、最长寿命 1.8,峰值粒子数大约 200。这已经是个不小的数了,同屏五个篝火就是 1000 粒子,得开始看帧率了。
5.3 峰值粒子数怎么在场内算出来
前面给了公式emissionRate × maximumParticleLife,实战里我还会用运行时的方法验证一遍。viewer.scene.primitives是个集合,可以遍历:
function countParticles(viewer) { var total = 0; var prims = viewer.scene.primitives; for (var i = 0; i < prims.length; i++) { var p = prims.get(i); if (p instanceof Cesium.ParticleSystem) { // 内部数组,仅用于调试观测,不建议在业务代码里依赖 total += (p._particles && p._particles.length) || 0; } } return total; }这个统计只建议在调试面板里用,因为_particles是下划线开头的内部字段,不同版本可能变。但它能非常直观地告诉你"我到底在渲染多少粒子",比拍脑袋估算靠谱。
6. 不显示、闪一下就没、掉帧:三类故障的排查链路
这节是我花时间最多、也最愿意分享的部分。Cesium 粒子的故障表现就那么几种,但原因五花八门。我按症状分类,给出固定的排查顺序,照着走基本都能定位。
6.1 完全看不到粒子的六步排查
按这个顺序走,不要跳步:
第一步,确认系统加进去了。viewer.scene.primitives.add(ps)有没有执行?打印viewer.scene.primitives.length看数字变没变。我遇到过在异步回调里创建完忘了 add 的情况。
第二步,确认modelMatrix是 Matrix4。传了Cartesian3不会报错,但系统会静默失效。用instanceof Cesium.Matrix4断言一次。
第三步,确认位置在视野内。临时把相机飞过去:viewer.camera.flyTo({destination: worldPosition})。有时粒子确实在,只是飞到地球背面了。
第四步,确认emissionRate不是 0 且bursts有内容。如果你用了 burst 模式,emissionRate必须是 0,但如果忘记配bursts,那就是真的一个粒子都不发。反过来,如果你用了 burst 又保留了emissionRate,会出现"爆发一次 + 持续小股喷发"的怪现象。
第五步,确认贴图加载成功。dataURL 一般没问题;外部图片检查网络面板和 CORS。也可以在浏览器控制台里document.createElement('img').src = 你的dataURL看看能不能显示。
第六步,确认sizeInMeters和imageSize单位匹配。这是最隐蔽的一个。sizeInMeters: false(默认)时imageSize的单位是像素,如果你按"米"的直觉填了new Cartesian2(6, 6),粒子就只有 6 像素大,远看等于没有。反过来sizeInMeters: true时imageSize是米,填new Cartesian2(200, 200)就会变成 200 米的巨型粒子糊满屏幕。
我实际踩过最久的一次是第六步的变种:sizeInMeters: true但imageSize填了new Cartesian2(0.02, 0.02)——想的是"2 厘米的小火星",结果相机在 900 米高空,2 厘米的粒子不到一个像素,完全不可见。后来改成 2 米才对。在地球尺度上做特效,粒子尺寸要按米算,而且起点就是米级,厘米级是没意义的。
6.2 只闪一帧或几帧就没了的三种成因
成因一:requestRenderMode开着。前面说过,粒子更新不触发重绘。要么关掉这个模式,要么在粒子存活期间挂监听强制requestRender()。判断方法很简单:鼠标拖一下地图,如果粒子"复活"了一帧,就是这个问题。
成因二:lifetime设得太短。比如lifetime: 0.1而粒子寿命是 1.6 秒,系统 0.1 秒后就结束了,粒子都被截断。lifetime必须大于maximumParticleLife,我一般给maximumParticleLife + 0.2。
成因三:loop是默认的true。这个不是"消失",是"循环",但用户看到的就是"每隔一会炸一次"。检查构造参数里有没有显式写loop: false。
还有一种偶发情况:updateCallback里把速度改成了NaN。比如写了particle.velocity.x /= someVariable,而someVariable在某帧是 0。速度变 NaN 后位置也是 NaN,粒子从渲染队列里直接消失,而且不报错。排查方法是在updateCallback里加一个临时断言:
if (!isFinite(particle.velocity.x)) { console.warn('velocity NaN', particle); }这个坑在我某个项目里排查了整整一个下午,因为现象是"大部分时候正常,偶尔整批粒子消失",非常难复现。
6.3 粒子一多就卡:从三个方向压缩
方向一,减数量。峰值粒子数是最直接的成本。爆炸从 500 减到 220,视觉上的差别远小于帧率上的差别。特别是远景爆炸,粒子在屏幕上本来就几个像素,多给 300 个毫无意义。
方向二,减尺寸。大尺寸粒子的填充率(overdraw)成本很高,尤其是多个粒子叠在一起的时候。imageSize和endScale一起控制,能把屏幕上的总像素填充量降下来。
方向三,去掉updateCallback里不必要的运算。每帧对每个粒子执行一遍,里面有Math.random()、有开方、有多次对象访问,加起来很可观。我做过一个测试:1000 个粒子的火焰,updateCallback里做三次随机数运算,在中端笔记本上能吃掉约 3~5 帧。把随机数换成查表、把Math.sqrt换成近似值,能回收一半。
另外还有两个工程层面的优化值得做:特效池化——同一时刻只会有一个小型爆炸在播放,那就复用同一个ParticleSystem,通过重置modelMatrix和重新触发 burst 来实现,避免频繁创建销毁。以及距离剔除——距离相机超过某个阈值的火焰系统直接ps.show = false,省下渲染开销,这个在大场景里效果非常明显。
7. 几个我踩过之后才记住的细节
写到这里,把那些散落在各处、但确实让我返工过的细节集中说一下。
关于emitterModelMatrix的默认值。不传的话 Cesium 内部会用单位矩阵,发射器就在局部原点。这一点文档没强调,导致我一开始以为必须显式传。
关于多个粒子系统的modelMatrix复用。同一次爆炸拆成三层时,三层可以共用同一个Matrix4对象,不需要重复计算eastNorthUpToFixedFrame。这个函数每次调用要算一堆三角函数,批量生成特效时能省不少。
关于ParticleBurst的版本差异。这个类不是所有版本都有,老版本上只能靠调emissionRate和lifetime来模拟一次性爆发。如果你要在老项目里用,先console.log(Cesium.ParticleBurst)确认一下存在。没有的话,替代方案是设emissionRate为一个较高值、lifetime设成极短(比如 0.1 秒),效果接近但不完全一样,粒子会有一个明显的时间窗口而不是瞬间全出。
关于夜景下的效果。白天场景里,爆炸的白色核心很容易被地表亮色"压住",显得没什么存在感。夜景下就完全反过来了,同样的参数显得非常刺眼。如果项目要同时支持昼夜切换,建议把startColor的亮度和endScale跟当前场景亮度做个联动,夜景下调低一点。我一般按经验给夜景版的startColoralpha 打 0.8 折。
关于地形吸附。粒子系统不会自动贴地。如果你的爆点在山区,fromDegrees只给了经纬度、没给高度,爆点会落在地形内部,一半粒子被地面挡住,看起来像"从地里冒出来的半截爆炸"。用viewer.scene.globe.getHeight(cartographic)取地形高度后加 2~5 米偏移,能解决大部分情况。
关于和后期效果的配合。如果你的场景里开了泛光(Bloom),爆炸的白色核心会被明显放大,视觉冲击力强很多。但泛光的阈值要调好,否则会连带把地表亮部也一起爆掉,整个画面发白。
最后分享一个我在封装特效组件时的做法:把爆炸和火焰都做成"参数对象 + 生成函数"的形式,所有魔法数字集中在一个PRESETS常量里,业务代码只调spawnExplosion(viewer, position, 'medium')。这么做的好处是后期调参不用满项目找数字,而且做特效分级(高/中/低画质)时,直接换一套 preset 就够了,不用改任何逻辑代码。这套结构在我手上迭代过三个项目,基本没再动过骨架,只换里面的数字。