ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Cesium粒子系统实战:打造逼真下雪天气效果

Cesium粒子系统实战:打造逼真下雪天气效果 提到Cesium天气效果很多人第一反应是阳光、云层、光影这类大场景视觉其实真正能让人眼前一亮的往往是下雪这种带动态粒子的天气模拟。我之前在一个智慧园区项目里临时被要求加一个“下雪天”的展示模式甲方原话是“要像电影里那种雪花飘下来有氛围感但别糊成一团”当时Cesium官方文档里粒子系统的例子只有火焰和烟雾跟雪花差距还挺大。折腾了大概两天把粒子发射器、纹理、生命周期、动态回调这些东西逐个摸了一遍最终效果连我们自己产品经理都发了朋友圈。这篇就把整个实现思路、参数配置、踩坑记录都整理出来给正在跟Cesium粒子系统较劲的同学一个参考。1. 先拆清楚下雪天效果到底由哪几部分组成1.1 所谓“下雪”不是一个特效是三件事的叠加我在一开始做的时候总想着“找个下雪例子抄一抄”后来发现根本没法抄。因为雪天效果在三维场景里其实是三个独立问题的集合雪花本身长什么样纹理、雪花怎么飘下来粒子运动、飘下来之后场景怎么变环境氛围。雪花本身长什么样。真实雪花是六角形的但在WebGL场景里如果你真的生成六角形纹理远距离看就是一堆密密麻麻的小白点反而难看。我最后用的是圆形模糊雪花纹理或者极简的“星星形状”远处看就是柔和的白色颗粒。这个后面细说。雪花怎么飘下来。这是核心中的核心Cesium里自带ParticleSystem但它默认的粒子行为是“从某一点向外喷射”比如火焰、喷泉。雪花需要的是“从一个大范围顶面向下飘落”并且带一点左右摆动、随机速度、随机大小、随机透明度。官方文档没给这个场景的例子得自己写updateCallback来逐帧控制每个粒子的状态。场景环境氛围。只有粒子没有环境就像在一张白天截图上下雨很割裂。真正让用户感觉到“下雪天”的还包括天空盒变灰白、场景雾浓度增加、地表出现积雪色。Cesium的地表雪效果可以简单用雾天空盒模拟稍微进阶一点可以通过地形shader做积雪算法不过很多业务需求到雾粒子灰白天空就够用了。1.2 技术选型为什么选粒子系统而不是贴图或顶点动画有段时间我在想能不能直接用一张带雪花图案的透明贴图盖在场景上或者写一个全屏Shader来做下雪。这两种方案我都试过。全屏Shader方案的优点是非常流畅因为只是在一个平面纹理上采样叠加雪花噪声贴图性能消耗极小但缺点是雪花是“贴在屏幕上的”视角转动时雪花不会跟着场景纵深变化透视关系是错的用户很容易识别出这是“滤镜”而不是“下雪”。贴图方案则是简单的“静止”效果只能模拟“雪停了”的画面想表现雪花飘落几乎不可用。所以最终选择Cesium原生ParticleSystem。一方面它天然具备三维空间位置坐标粒子在世界空间里生成、运动、消亡另一方面Cesium在场景渲染层面会对粒子进行深度测试和透明度合成雪花会自然被建筑物、地形遮挡真实感强很多。代价是粒子系统的性能调教需要一些功夫后面我在第4章会专门讲。2. 下雪粒子的核心参数和逐项调优方案2.1 发射器Emitter怎么选BoxEmitter是首选Cesium提供多种粒子发射器CircleEmitter、ConeEmitter、BoxEmitter、SphereEmitter。火焰烟雾通常用ConeEmitter因为它自带一个锥形扩散夹角。但雪花是从天上大面积往下飘的发射器应该覆盖整个视野上方所以BoxEmitter最合适它能够在立方体范围内均匀生成粒子。实际项目中我在摄像机位置上方设置了一个范围较大的BoxEmitterconst emitterBox new Cesium.BoxEmitter(new Cesium.Cartesian3(800, 800, 400));这里三个参数分别是长、宽、高我一般把发射范围设为长宽各800米、高度400米的一个立方体。简单理解在这个范围内粒子会随机出现在任意位置然后从高处落下来。范围越大雪越“密”高度越高雪花下落过程越有纵深。但要注意BoxEmitter的发射中心和粒子系统的模型矩阵是绑定的如果直接把它固定在世界坐标里当用户旋转视角或飞行到其他地方雪花范围可能就不在视野内了。我的做法是让发射器跟随摄像机移动。这个在3.3节给具体代码。2.2 粒子生命周期与发射速率数学上算清楚才是好效果Formulas看上去很基础但参数之间的关联性很容易被忽略。我一开始想当然地把粒子生命设为10秒、发射速率设为2000以为雪会很大结果场景里像在下暴雨又密又长完全不是雪。这里的关键逻辑是粒子系统的“存量”和“流量”存量粒子数 ≈ 粒子生命周期秒 × 发射速率个/秒如果你希望场景里同时存在约1000朵雪花粒子生命周期是20秒那么发射速率只需要50个/秒即可因为每朵雪花要活20秒每秒补50朵才能维持1000朵的总量。我的最终配置是const snowParticleSystem new Cesium.ParticleSystem({ emitter: emitterBox, emissionRate: 80, particleLife: 20, lifetime: Infinity, speed: 30, });80×201600场景里同时会有约1600个粒子。这个数量在普通设备上完全跑得动同时视觉密度也不错适合大范围俯瞰场景。注意speed参数速度越快雪花越像“流星”速度越慢越像“柳絮”。下雪的温柔感速度就是要慢我settings到20~30之间。2.3 雪花纹理用Canvas生成别用图片文件一开始图省事直接找了一张雪花PNG图片结果项目发布时图片路径没处理好加载不出来。后来学乖了用Canvas动态生成纹理代码里直接创建根本不需要网络请求。function createSnowflakeTexture() { const canvas document.createElement(canvas); canvas.width 32; canvas.height 32; const ctx canvas.getContext(2d); ctx.clearRect(0, 0, canvas.width, canvas.height); // 画一个柔和的圆形雪花颗粒 const gradient ctx.createRadialGradient(16, 16, 2, 16, 16, 14); gradient.addColorStop(0, rgba(255,255,255,1)); gradient.addColorStop(0.5, rgba(255,255,255,0.8)); gradient.addColorStop(1, rgba(255,255,255,0)); ctx.fillStyle gradient; ctx.beginPath(); ctx.arc(16, 16, 14, 0, 2 * Math.PI); ctx.fill(); return canvas; }这种径向渐变圆的好处是雪花边缘柔和不会出现“颗粒感”。如果你想要更精致的六角雪花也可以在Canvas里画几个交叉三角形或弧线但实测下来在粒子小尺寸显示时效果差别不大反而增加Canvas的计算压力和纹理采样成本。2.4 粒子大小并不是一个固定值用scaleByDistance做景深雪花在不同距离下应该有不同大小。Cesium的ParticleSystem没有像billboard那样直接的scaleByDistance属性但可以通过startScale和endScale在生命周期内改变尺寸。更细致一点还可以利用粒子系统的nearFarScale来实现按距离缩放不过这个参数我建议在室内小场景不要用会影响远处的密度分布。我的方案是用生命周期缩放startScale: 1.2, endScale: 0.3,一个雪花从生成到落到地面会从最初1.2倍缩放逐渐缩小到0.3倍。为什么“越落越小”实际物理上雪花不一定会变小但在视觉艺术里近处大、远处小以及下落过程中由清晰到模糊的过渡会让画面更有层次感。这里的核心是让大小变化不突兀我配合了后面的alpha透明度参数。2.5 透明度变化雪花快落地时会“消失”而不是突然没了不管雪花最终被场景里的建筑物遮挡还是落在地面上粒子系统都会在生命周期结束时把它移除。如果粒子在生命周期结束时透明度还是1.0那就会出现“粒子到半空中凭空消失”的穿帮画面。所以一定要在updateCallback里控制alpha的衰减updateCallback: (particle, dt) { const lifeRatio particle.age / particle.life; particle.alpha Math.sin(lifeRatio * Math.PI) * 0.9; }这里用sin函数的好处是粒子刚生成时alpha从0慢慢变亮到生命中期最亮快结束时再渐隐到0整个过程是平滑自然的。如果不做这个处理雪花在远视角下不明显在近视角下又闪烁刺眼。2.6 给雪花加“后劲”随机摆动模拟风真实雪花不是直线下落的而是受空气阻力影响带一点左右摇晃。这个效果可以通过修改粒子的velocity向量来实现。updateCallback: (particle, dt) { const lifeRatio particle.age / particle.life; particle.alpha Math.sin(lifeRatio * Math.PI) * 0.9; particle.velocity new Cesium.Cartesian3( Math.sin(particle.age * 0.8 particle.id) * 0.5, Math.cos(particle.age * 0.6 particle.id) * 0.5, -20 ); }velocity的x和y分量用sin/cos做正弦摆动幅度大概0.5。这里的两个0.8和0.6是摆动频率我试过几组值频率太低雪花像在原地漂浮频率太高像蜜蜂干扰。0.5~1.0之间相对自然。如果某个场景需要大风天气还可以把x方向加上一个固定偏置比如让所有雪花整体往一个方向偏移particle.velocity.x windStrength;后来我把“风力”抽象成了外部变量可以通过UI滑块实时调节项目演示效果很加分。3. 完整实现从零跑通一个可复用的Cesium下雪效果3.1 基础环境引入Cesium并初始化Viewer这个demo我基于Cesium 1.95版本测试Cesium ion的token需要自己申请。如果你不用ion可以加载本地3D Tiles影像数据方式不影响粒子系统。const viewer new Cesium.Viewer(cesiumContainer, { animation: false, timeline: false, baseLayerPicker: false, sceneMode: Cesium.SceneMode.SCENE3D, infoBox: false, selectionIndicator: false, }); viewer.scene.globe.enableLighting true;enableLighting开灯光的目的是让后面天空盒和雾效果更明显。3.2 创建雪花纹理和粒子系统纹理就用上一节那个createSnowflakeTexture函数然后组装成ParticleSystemconst snowSystem new Cesium.ParticleSystem({ image: createSnowflakeTexture(), imageSize: new Cesium.Cartesian2(24, 24), startScale: 1.2, endScale: 0.3, particleLife: 20, speed: 30, emissionRate: 80, lifetime: Infinity, emitter: new Cesium.BoxEmitter(new Cesium.Cartesian3(800, 800, 400)), updateCallback: function(particle, dt) { const lifeRatio particle.age / particle.life; particle.alpha Math.sin(lifeRatio * Math.PI) * 0.9; particle.velocity new Cesium.Cartesian3( Math.sin(particle.age * 0.8 particle.id) * 0.5, Math.cos(particle.age * 0.6 particle.id) * 0.5, -20 ); } }); viewer.scene.primitives.add(snowSystem);3.3 让雪花发射范围跟着视角走这一步是最容易被忽略的。如果不做你会看到雪花只在初始摄像头位置那一块下镜头一拉动就露馅了。解决方案很简单每一帧把粒子系统整体移到一个以摄像机为参考的偏移位置。具体来说我设置了两个矩阵const particleOffset new Cesium.Cartesian3(0, 0, 200); let emitterMatrix Cesium.Matrix4.fromTranslation(particleOffset); viewer.scene.preUpdate.addEventListener((scene, time) { const cameraPosition viewer.camera.positionWC; const offsetPosition Cesium.Cartesian3.add( cameraPosition, particleOffset, new Cesium.Cartesian3() ); emitterMatrix Cesium.Matrix4.fromTranslation( Cesium.Cartesian3.subtract( offsetPosition, Cesium.Cartesian3.ZERO, new Cesium.Cartesian3() ), new Cesium.Matrix4() ); snowSystem.emitterModelMatrix emitterMatrix; });这段代码的意思是把发射器位置对准摄像机位置再往上偏移200米这样不管用户飞到哪、转到哪头顶上方始终有一个约800×800×400米范围的雪花发射区。我觉得200米这个高度比较合理太高雪花还没飘到视线内就被删掉太低距离建筑太近透视感错乱。3.4 环境氛围雾、天空盒和基调色一个冷白偏灰的天空盒能让粒子系统立刻融入场景。如果你的项目有自己的3D Tiles建筑模型可以调skyBox属性。我这里用Cesium内置的颜色渐变天空加scene.fogviewer.scene.fog.enabled true; viewer.scene.fog.density 0.0008; viewer.scene.skyAtmosphere.hueShift -0.2; viewer.scene.skyAtmosphere.saturationShift -0.8; viewer.scene.skyAtmosphere.brightnessShift -0.1;hueShift偏蓝紫saturationShift降低饱和度brightnessShift变暗。这样天空会呈灰白色跟白色雪花粒子对比柔和不刺眼。如果希望地型也有雪感一个很简单的做法是给Globe套一层高亮偏白的影像滤镜或者把一个半透明白色的多边形覆盖在地面上viewer.entities.add({ rectangle: { coordinates: Cesium.Rectangle.fromDegrees(-180, -85, 180, 85), material: Cesium.Color.WHITE.withAlpha(0.15), } });这个方法只能远处看近处会糊。更精细的工程做法要在地形材质上做积雪shader属于另一个话题我在第5章讲一点替代思路。3.5 控制雨雪开关把天气系统封装成类项目里不可能只下雪所以我直接把下雪效果封装成了一个SnowWeather类包含启动和销毁两方法。class SnowWeather { constructor(viewer, options {}) { this.viewer viewer; this.options Object.assign({ emissionRate: 80, particleLife: 20, speed: 30, snowSize: 24, }, options); this._particleSystem null; } start() { this._createSnow(); this._setFog(); } _createSnow() { /* 上面那段粒子系统代码 */ } _setFog() { this.viewer.scene.fog.enabled true; this.viewer.scene.fog.density this.options.fogDensity || 0.0008; } destroy() { if (this._particleSystem) { this.viewer.scene.primitives.remove(this._particleSystem); this._particleSystem null; } this.viewer.scene.fog.enabled false; this.viewer.scene.fog.density 0; } }这样在业务代码里调用就很干净const snow new SnowWeather(viewer); snow.start(); // 切换天气 snow.destroy();4. 实战中的问题和排查记录4.1 雪花突然消失且位置离摄像机超远现象执行几秒后粒子系统不再生成雪花或者雪花出现在视野外。排查这种问题80%出在emitterMatrix的计算上。我最初把emitterModelMatrix写成了相对世界坐标的偏移导致镜头移动后发射器还在初始位置。另外BoxEmitter的参数是相对粒子系统坐标的局部尺寸如果你设置了emitterModelMatrix为一个大偏移矩阵粒子会在偏移后的范围生成可能超出视野。解决方法每次在preUpdate里重新计算emitterModelMatrix用摄像机positionWC加上局部偏移。这部分代码我上面已经给了。4.2 雪花看着像“白噪点”或者“像素点”原因多数是粒子尺寸太小或者纹理采样出现了马赛克。Cesium在粒子尺寸较小时像素对齐效果不好边缘会闪烁。我试过两种方案一种是把imageSize调大比如从16调到24闪烁缓解另一种是把粒子纹理的canvas从32×32改成64×64让边缘渐变更宽更平滑。但64×64纹理占内存大粒子多了性能会降。我的最终平衡点是32×32纹理24×24显示尺寸配合startScale 1.2。4.3 场景很卡帧率掉到30以下粒子系统性能瓶颈主要在粒子数量、纹理采样和updateCallback里的对象创建。我给一个实测数据GTX 1660显卡1600个粒子32×32纹理帧率稳定在50以上如果把粒子数提到5000帧率掉到30以下画面开始卡顿。我做的优化有这几步第一把emissionRate从200降到100以内控制存量粒子数量。第二在updateCallback里避免频繁创建新对象。比如上面那段代码里每次回调都new Cesium.Cartesian3这个在粒子数少时还好粒子多时会增加GC压力。可以缓存一个临时向量每次复用const tempVelocity new Cesium.Cartesian3(); // 在updateCallback里 tempVelocity.x Math.sin(particle.age * 0.8 particle.id) * 0.5; tempVelocity.y Math.cos(particle.age * 0.6 particle.id) * 0.5; tempVelocity.z -20; particle.velocity Cesium.Cartesian3.clone(tempVelocity, particle.velocity);第三把texture的采样方式改成LINEAR不要用NEAREST否则粒子锯齿感很强调参也很费劲。在ParticleSystem里没有直接暴露采样方式但可以用image的canvas让浏览器生成平滑纹理默认就是线性。4.4 雪花被建筑“穿模”或者显示在建筑内部这个问题本质是深度测试关系不对。Cesium粒子系统默认在渲染时参与场景深度测试但粒子是半透明的所以建筑和粒子之间的前后遮挡需要看渲染顺序。如果你的建筑模型使用了自定义Primitive或者半透明材质渲染顺序可能被抽到粒子之后雪花就会“穿过”建筑显示在前面。解决方案有两种。一种是给粒子系统设置translucent false强制它在不透明渲染队列中渲染。但这样粒子会变成一个不透明白点很不自然。另一种是给建筑模型设置半透明排序优先级。如果你的建筑也是Cesium entity加载的可以通过设置entity.rectangle.material的alpha来微调我们项目里建筑都用3D Tiles可以给tileset设置style的半透明或禁用深度测试。最稳妥的还是在视觉上规避控制雪花发射范围不要覆盖建筑太密集的底部区域。雪花只从高空飘下在地面附近就渐隐能大幅减少穿模感知。4.5 下雨和下雪切换时画面闪烁天气系统切换时旧粒子系统和新粒子系统同时存在会导致短暂的重叠。解决时我习惯先在destroy()里把旧系统移除再延迟几毫秒创建新系统snow.destroy(); setTimeout(() { rain.start(); }, 50);或者更简单把粒子透明度在destroy时先渐变到0再移除优雅很多但代码复杂度上去了看业务需求取舍。4.6 粒子“叠加”材质导致雪花发亮Cesium粒子系统的默认混合模式是Additive加法混合适合火焰、发光效果。但雪花是白色不发光物体用Additive会让雪花在亮色建筑背景下特别刺眼失去雪花的柔和感。此时需要把混合模式改成AlphaBlend雪花就会像普通广告牌一样自然融合到背景里。修改方式如下snowSystem.blendMode Cesium.BlendOption.OPAQUE_AND_TRANSLUCENT;我试过将一个纯透明背景的雪花纹理放到Additive里效果是雪花颗粒周围一圈发白整体画面发灰效果并不好。改成Alpha后雪花颗粒干净很多。5. 让雪景效果再进阶的几个方向5.1 给雪花加入“粒子旋转”雪花真实是旋转着下落的。Cesium粒子有一个particle.rotation参数可以在updateCallback里设置particle.rotation particle.age * 0.5;这个旋转效果在近景、大雪花时特别明显六角雪花会一边旋转一边下落很有细节感。但远景小雪花时旋转会造成锯齿和闪烁所以要控制好只在近距离时生效或者直接用噪声控制旋转速度。5.2 把降雪做成“天气蓝图”而不是单独一个效果我后来在项目里把类似上面的下雪代码做成了天气系统包含晴天、多云、下雨、下雪四种模式。每个模式其实就是一个Plugin类统一提供start()和destroy()互相注册到WeatherManager中。如果要切换天气只需调用weatherManager.switch(snow);这是很多数字孪生项目很自然的需求我们项目里还有根据业务时间轴自动切换天气的模式。这样一套代码复用性高也方便产品经理看效果。5.3 地形积雪和建筑积雪的SVG图层思路有网友问“为什么我的雪粒子飘到地上就没了地面上一点雪都没有”。粒子系统确实不会在地面上形成积雪。一个简单的低成本方案在地表上叠加一个半透明白色图层配合一个细微的噪声纹理实现雪斑驳不均的感觉。不过这个东西要做得精细得说清楚如果想做雪后场景需要地形高度、坡度和光照信息如果想做动态降雪累积那得考虑网格更新和shader的uniform传值。我们项目里这种效果不常用一般用3D Tiles里的替换纹理。如果要快速看一个“雪山”效果有一个土办法把globe的imageryProvider换成一张白色的瓦片服务再叠加半透明alpha蒙版。但会遮盖所有建筑只能用于宏观展示。真要在近景有真实的雪还是得用Shader对地形高程做处理。5.4 雪花的环境音反馈这个看起来是加分项但对于氛围感极其重要。下雪天气配合环境风声、踩雪声能够有效拉高沉浸感。不过Cesium自身没有音频能力我用的是HTML的audio标签在外层播放声音与粒子系统无关。Cesium地图应用大多在Web端不存在虚拟现实那一套复杂音效系统所以这就是个小事。5.5 关于动态光照与雪花之间的“联动”有次同事问“晚上的下雪效果怎么黑乎乎一片”。其实是因为雪花的粒子颜色是白色而晚上场景背景很暗眼睛没看到足够的白雪反射。你可以调整粒子颜色给白色雪花加一点冷蓝倾向particle.color Cesium.Color.fromElements(0.8, 0.9, 1.0, particle.alpha);或者把场景的动态光照调亮。Cesium默认的自然光照在晚上暗得快如果是城市夜景比较建议直接用假光照禁用globe.enableLighting改用固定高光。如果要追求真实感可以用Cesium的太阳方向计算自定义光照但那是另一篇文章了。结尾之前再聊几句实战心得做Cesium天气效果参数微调是最耗时的没有哪个数值是“标准答案”。我的调参策略一般是先固定一个观看视角把粒子数量从低到高加把速度从快到慢减找到视觉平衡点再整体切到自由视角检查是否有穿模或闪烁。你会发现最终参数往往跟场景的尺度、摄像机高度、建筑密度强相关——一个给“整个城市俯瞰”用的雪天参数放在园区里做步巡视角雪会大得像暴风雪。我个人实际调出来比较顺手的参数组合是发射范围800×800×400米、粒子生命20秒、发射速率80个/秒、粒子速度30、透明度用Sin曲线。如果你用的是近景园林或校园场景建议把发射范围缩到300×300×200发射速率降到30粒子尺寸减小到15左右更柔和。最后再分享一个小技巧调雪天效果时把Cesium的Scene.debugShowFramesPerSecond打开实时盯帧率变化。你要的“电影感”不是靠无限堆粒子堆出来的而是靠合适的密度和靠谱的透明度曲线让你就算把画面切到全局视角也依然觉得“哦是在下雪”。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进