基于Vue3与Cesium的雷雨天气特效实现:粒子系统与闪电模拟
2026/9/19 14:43:29 网站建设 项目流程

做智慧园区大屏的时候,甲方临时提了个需求:晴天场景要能切到雷雨天气,既要能看到雨丝下落,又要能模拟闪电瞬间照亮云层和地面的氛围。基于Vue3和Cesium这套组合,我折腾了一套还说得过去的天气特效方案,这篇文章就把实现过程、踩过的坑和最终沉淀下来的代码结构都整理出来,想自己动手在Cesium里做雷雨、暴雨、甚至暴风雪效果的朋友可以直接参考。

先说结论:Cesium原生的粒子系统完全能撑起“雨”的效果,闪电则需要结合entity的polyline和广告牌做分层叠加,配合相机视角和透明度控制,才能做出那种“咔嚓一下劈下来,云层被照亮”的感觉。整个方案不依赖额外贴图文件,所有纹理都可以用canvas动态生成,部署的时候少了很多静态资源烦恼。

1. 项目整体设计与技术选型思路

天气可视化在数字孪生、智慧城市、园区大屏里其实是刚需,但网上能找到的Cesium下雨教程大多只是简单抛一个粒子系统出来,雨是下了,可画面很假:雨丝没有方向感、没有远近层次、闪电只是画几道线然后闪一下,完全没有氛围。这次做雷雨效果,我在设计上定了几个硬指标:

  • 雨要有层次,近处的雨丝清晰且下落快,远处的雨丝模糊且下落缓慢,模拟真实视野中的景深关系。
  • 闪电不能只是贴图闪白,要有“云层被照亮”的间接光感,并且闪电形状要随机、模拟真实的放电分支。
  • 整体性能要可控,粒子数量能根据场景相机高度动态调整,避免大屏上卡顿。
  • 全部资源内联生成,不引入外部贴图,方便项目打包和离线部署。

1.1 为什么选Vue3而不是Vue2

很多教程还在用Vue2写Cesium,但Vue3的Composition API在管理Cesium这种“拥有大量独立状态的对象”时真的舒服太多。Cesium的viewer、entities集合、粒子系统这些都是强状态对象,如果用Vue2的Options API,data里摆一堆非响应式大对象,要么把性能拖垮,要么就得满地写this.$set,极其痛苦。

Vue3里我直接把viewer实例、雨粒子集合、闪电管理器全部放在setup作用域里管理,配合shallowRef和markRaw,既保证了响应式状态(比如雨量档位、雷暴开关)能驱动视图更新,又不让Cesium内部庞大的对象进入Vue的深度响应式系统,性能开销小很多。

import { shallowRef, markRaw, onBeforeUnmount } from 'vue'; const cesiumViewer = shallowRef(null); function initViewer(container) { const viewer = new Cesium.Viewer(container, { ... }); // markRaw告诉Vue:别碰这个对象,别做响应式代理 cesiumViewer.value = markRaw(viewer); }

这个markRaw是很多人容易忽略的细节,Cesium的viewer对象本身包含大量WebGL上下文、渲染循环回调,如果Vue3默认用Proxy包裹它,光是访问属性时的代理开销就能让帧率掉几个点,极端情况下还会引发内存泄漏。

1.2 Cesium版本选择与引入方式

Cesium目前主流是1.9x系列,1.100之后的版本对WebGL2的要求更严格,但换来的是更好的渲染性能和更清晰的材质系统。我用的是1.111,稳定性和文档都比较完善。

引入方式上不建议直接用CDN,大屏项目经常离线部署,最好npm安装后打包。不过直接import * as Cesium from 'cesium'会遇到一个老问题:Cesium的静态资源(workers、wasm、图片)不能被webpack/vite自动拷贝。

在Vite项目里我这样配置:

// vite.config.js import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; import cesium from 'vite-plugin-cesium'; export default defineConfig({ plugins: [ vue(), cesium() ] });

vite-plugin-cesium这个插件会帮我把Cesium的静态资源和global变量都处理好,省去手动把node_modules/cesium/Build/Cesium拷贝到public目录的麻烦。如果不用插件,手动配置cesium的buildModuleUrl也是一条路,但每次升级Cesium版本都要检查一遍,挺烦的。

1.3 为什么加入云层实体是个加分项

雷雨效果的氛围感很大程度来自天空的变化。Cesium默认的星空天空盒太“晴空万里”了,直接在下头下雨会感觉很违和。我加了一个半透明的椭球体云层叠加在场景里,本质是一个entity的ellipsoid,用canvas画了一张带噪点的云雾纹理,透明度调低后叠在原有地形上,颜色偏深灰色,看起来就是雷雨前那种压顶的乌云。

这个思路比加载3D Tiles云层数据轻量得多,一个entity就能搞定,而且可以跟随相机半径动态调整透明度。阴天下雨的场景不能太亮,云层把天然光遮住,大屏上环境光暗下来,闪电亮起来的时候才会有“瞬间被照亮”的反差效果。

2. Vue3工程集成Cesium的环境搭建

这一步看起来简单,实际有一堆小细节决定后面顺不顺畅。我先把完整流程过一遍,再提几个优化点。

2.1 创建Vue3项目与安装依赖

项目初始化用的Vite,相比webpack在开发冷启动和热更新上都快了一个量级:

npm create vite@latest storm-visual -- --template vue cd storm-visual npm install npm install cesium vite-plugin-cesium

注意几个依赖的版本问题:

  • Node版本建议16以上,Vite4以上对Node版本要求比较严格,版本太低会直接报错。
  • 如果公司内网镜像源同步不及时,install cesium后立刻跑起来可能会碰到Failed to fetch extension之类的报错,重启一下vite或者删掉node_modules/.vite缓存基本能解。
  • 如果项目里已经装了vue-router、pinia这类库,不会和Cesium冲突,可以把天气控件放到任意独立组件,组件内部只通过props接收viewer实例,解耦很干净。

2.2 初始化Viewer的配置细节

初始化Cesium.Viewer时,很多人图省事直接new Cesium.Viewer('container'),雷雨效果会出几个问题:左上角默认的widgets投影到场景里很丑,而且左下角那个地球图标和底部的版权信息有时候甲方不喜欢。我在init时特意关掉了不需要的组件:

const viewer = new Cesium.Viewer('cesiumContainer', { animation: false, // 动画控件 timeline: false, // 时间线 baseLayerPicker: false, // 图层选择器 geocoder: false, // 搜索框 homeButton: false, // 首页按钮 sceneModePicker: false, // 3D/2D切换 navigationHelpButton: false, // 帮助按钮 fullscreenButton: false, // 全屏按钮 infoBox: false, // 点击实体后弹出的信息框 selectionIndicator: false, // 选中指示器 shouldAnimate: true, // 开启动画,粒子系统需要时间轴推进 terrainProvider: Cesium.createWorldTerrainAsync(), // 如果离线可以用EllipsoidTerrainProvider });

shouldAnimate: true必须设置,Cesium粒子系统是依赖时钟推进的,不开启动画的话system会卡在出生帧,雨就成了一张静态贴图。

还有一个细节:如果大屏是24小时连续运行,建议把viewer.clock.shouldAnimate保持为true,但把viewer.clock.multiplier设置为1,别加速时间。因为粒子速度向量是按真实世界时间计算的,时间加速会导致粒子一帧跳很远,雨丝直接“瞬移”,看起来像闪现。

2.3 相机初始视角定位

雷雨效果要让人一眼看出“正在下雨”,前提是相机要处于能看到空间感的视角,比如斜45度俯视一片园区建筑。纯粹的正俯视视角几乎看不到雨丝的方向,体验很糟。我设了个定位函数:

function flyToStormCenter(viewer) { viewer.camera.flyTo({ destination: Cesium.Cartesian3.fromDegrees(116.391, 39.907, 1200), orientation: { heading: Cesium.Math.toRadians(0), pitch: Cesium.Math.toRadians(-35), roll: 0 }, duration: 2 }); }

pitch设置在-35度左右,既能看到雨丝倾角,又能看到地面上建筑和道路的反射湿润感。如果想要更强烈的“倾盆大雨”感,pitch到-50度,雨丝的透视角度会更斜。

3. 雷雨天气系统的模块化设计

整个天气系统我没有写在单个组件里,而是按功能拆成独立模块,更好的扩展性:

src/ components/ StormViewer.vue // 场景容器组件 composables/ useCesiumViewer.js // viewer初始化 useRainSystem.js // 雨粒子系统 useLightningSystem.js // 闪电管理器 useCloudLayer.js // 云层叠加

这样拆分后,后续想加下雪、沙尘暴、雾霾,只需要新增一个useSnowSystem.js,不需要动原有代码。

3.1 模块间通信机制

几个系统模块都依赖同一个viewer实例,但又不应该互相直接访问。我用了一个简单的发布订阅对象作为事件总线,比如闪电模块触发lightning-flash事件时,云层模块接收到事件后把云层透明度再压低一点、把云层颜色往蓝紫方向偏一偏;雨粒子模块则可以接收rain-intensity-change事件动态调节发射率。

Vue3的reactive也可以做这件事,但事件总线的语义更清晰,而且不会因为响应式代理导致Cesium对象的意外触发。

// eventBus.js const listeners = new Map(); export const eventBus = { on(event, callback) { if (!listeners.has(event)) listeners.set(event, []); listeners.get(event).push(callback); }, emit(event, payload) { if (!listeners.has(event)) return; listeners.get(event).forEach(cb => cb(payload)); }, off(event, callback) { const cbs = listeners.get(event); if (!cbs) return; const index = cbs.indexOf(callback); if (index > -1) cbs.splice(index, 1); } };

实际开发中这个事件总线帮了大忙,比如后续甲方要“晴天切雷雨”的过渡效果,只需要发布一个weather-change事件,雨粒子模块从低发射率渐变到高发射率,云层从半透明变深色,闪电开始随机触发,整个切换动画是平滑的,而不是生硬地瞬间出现。

3.2 粒子系统的核心配置与参数解读

Cesium粒子系统核心是四个概念:发射器(emitter)、粒子纹理(image)、更新回调(updateCallback)、以及生命周期。雨粒子我这样配:

function createRainParticleSystem(viewer, options = {}) { const intensity = options.intensity || 300; // 每秒发射粒子数 const rainParticleSystem = viewer.scene.primitives.add( new Cesium.ParticleSystem({ image: createRainTexture(), // canvas生成的雨滴纹理,见下文 imageSize: new Cesium.Cartesian2(8, 16), startScale: 1.0, endScale: 0.6, particleLife: 8.0, speed: 8.0, mass: 1.0, emissionRate: intensity, emitter: new Cesium.CircleEmitter(1.0), startColor: new Cesium.Color(0.7, 0.8, 1.0, 0.1), endColor: new Cesium.Color(0.7, 0.8, 1.0, 0.6), updateCallback: (particle, dt) => { // 雨滴方向偏斜,模拟风 particle.velocity = new Cesium.Cartesian3(-2.0, -1.0, -8.0); } }) ); return { system: rainParticleSystem, setIntensity: (rate) => rainParticleSystem.emissionRate = rate }; }

几个参数是反复调过的经验值:

  • imageSize: 8x16,刚好是雨丝的长宽比。太宽了像雪花,太短了看不出来是下落,太长了又显得生硬。如果想要暴雨的密集感,尺寸再小一点,比如6x12,粒子多了以后整体视觉更密。
  • particleLife: 8秒,粒子从出生到消失的时间。这个时间要和emissionRate配合:粒子存在时间越长,同一时间画面里的粒子总数越多,雨就越密。但太长也会导致粒子穿越地面之后还在渲染,浪费性能。
  • speed: 8米/秒,这是粒子在Cesium世界中的运动速度。现实中雨滴终极速度大约9米/秒,这里取8配合风的偏斜已经足够。
  • startColor的alpha只有0.1,这很重要。粒子刚出生时几乎透明,下落过程中逐渐变明显,到生命周期快结束时alpha到0.6,这样雨丝会有“从天而降越来越清晰”的景深感。

3.3 用Canvas生成雨滴纹理

Cesium的粒子系统可以接收一个图片路径,也可以是canvas或者dataURL。为了完全离线,我用canvas动态画一个雨滴纹理:

function createRainTexture() { const canvas = document.createElement('canvas'); canvas.width = 32; canvas.height = 32; const ctx = canvas.getContext('2d'); const gradient = ctx.createLinearGradient(16, 0, 16, 32); gradient.addColorStop(0, 'rgba(200, 220, 255, 0)'); gradient.addColorStop(0.6, 'rgba(200, 220, 255, 0.8)'); gradient.addColorStop(1, 'rgba(220, 235, 255, 1)'); ctx.fillStyle = gradient; ctx.fillRect(14, 0, 4, 32); // 雨滴头部稍微圆润一点 ctx.beginPath(); ctx.arc(16, 28, 3, 0, Math.PI * 2); ctx.fillStyle = 'rgba(230, 240, 255, 0.9)'; ctx.fill(); return canvas; }

圆润的雨滴头部会让视觉上雨滴下落更有“冲击力”,如果只用直线,远看容易被误认成雪花或者下划线。

3.4 雨区范围与跟随相机

CircleEmitter(1.0)表示粒子在地面上以半径为1米的圆内出生,但这只在原点生效。为了能让雨下在相机周围而不是原点,需要每帧把粒子系统模型矩阵设置为跟随相机位置。

Cesium ParticleSystem本身没有直接提供“跟随相机”的能力,我是在render事件里更新modelMatrix:

viewer.scene.preUpdate.addEventListener((scene, time) => { const cameraPos = viewer.camera.positionWC; const height = Math.max(200, viewer.camera.positionCartographic.height); // 更新雨粒子系统位置到相机前方100米处 rainMatrix = Cesium.Transforms.eastNorthUpToFixedFrame(rainPosition); rainParticleSystem.modelMatrix = rainMatrix; // 相机高度越高,雨粒子发射率越低,远处看个氛围就行 const rate = clamp(300 - (height - 200) * 0.2, 80, 300); rainParticleSystem.emissionRate = rate; });

这样相机飞到高空俯瞰时,雨不会因为粒子区域离得太远而看不见,也不会在低空时粒子太稀。

4. 闪电效果实现:从静态折线到动态闪烁

闪电是雷雨效果里视觉冲击力最强的部分,也是我花时间最多的地方。来来回回试了好几版,最后沉淀出一套“多层叠加”的方案。

4.1 闪电形状生成算法

真实的闪电并不是一根直线,而是一条从云层往下、随机分叉的折线。生成算法很简单:先确定起始点(云层底部)和终点(地面),然后在这两个点之间做递归细分,每次在折线中点处加上一个垂直于原方向的随机偏移。

function generateLightningPath(start, end, offset = 300) { const points = [start, end]; // 递归细分3次 for (let i = 0; i < 3; i++) { const newPoints = []; for (let j = 0; j < points.length - 1; j++) { const p1 = points[j]; const p2 = points[j + 1]; const mid = Cesium.Cartesian3.midpoint(p1, p2, new Cesium.Cartesian3()); // 在垂直方向上加入随机偏移 const normal = Cesium.Cartesian3.normalize( Cesium.Cartesian3.cross(mid, Cesium.Cartesian3.UNIT_Z, new Cesium.Cartesian3()), new Cesium.Cartesian3() ); const randomOffset = (Math.random() - 0.5) * offset; const offsetVec = Cesium.Cartesian3.multiplyByScalar( normal, randomOffset, new Cesium.Cartesian3() ); const displacedMid = Cesium.Cartesian3.add(mid, offsetVec, new Cesium.Cartesian3()); newPoints.push(p1, displacedMid); } newPoints.push(points[points.length - 1]); points.length = 0; points.push(...newPoints); offset *= 0.6; // 每次细化偏移量缩小 } return points; }

每次细分偏移量乘以0.6,这样整体形状虽然随机,但不会太离谱。如果你希望闪电看起来更“炸裂”,可以到0.8;想要更纤细的“树枝状”,可以到0.4。

4.2 闪电的视觉分层

一根闪电在视觉上由三层组成:核心亮白线、外层泛光、以及地面被照亮的泛光圆。核心亮白线用polyline,宽2像素,颜色纯白;外层泛光用同样的polyline路径,但宽度放大到6像素,颜色用极淡的蓝色带透明渐变;地面泛光则是对应闪电终点位置放一个billboard,加载一个canvas画的径向渐变圆,放大到很大尺寸,透明度极低。

这个设计在静态截图时可能看不太出区别,但闪电闪烁的瞬间,三层叠加的效果非常真实——核心线极亮,外面包裹着一圈蓝白的光晕,然后地面从落点向外泛起一片惨白的光。

function createLightningEntity(viewer, path, groundPosition) { // 核心亮线 const coreLine = viewer.entities.add({ polyline: { positions: path, width: 2, material: new Cesium.ColorMaterialProperty(Cesium.Color.WHITE), classificationType: Cesium.ClassificationType.BOTH } }); // 外层泛光 const glowLine = viewer.entities.add({ polyline: { positions: path, width: 6, material: new Cesium.PolylineGlowMaterialProperty({ glowPower: 0.3, color: Cesium.Color.fromBytes(160, 200, 255, 150) }) } }); // 地面亮光 const groundGlow = viewer.entities.add({ position: groundPosition, billboard: { image: createGlowTexture(), width: 800, height: 800, color: Cesium.Color.fromBytes(200, 220, 255, 80), disableDepthTestDistance: Number.POSITIVE_INFINITY } }); return { coreLine, glowLine, groundGlow }; }

这里一个细节是disableDepthTestDistance: Number.POSITIVE_INFINITY。如果不设置这个,地面泛光可能会被地形挡掉,闪电劈在山谷里时地上那圈光就看不见了。但这个属性要慎用,给所有billboard都加上会导致其他广告牌透过山体显示,场景层次就乱了。我是在创建闪电临时billboard时显式加上的,生命周期结束后就移除。

4.3 闪电闪烁节奏控制

闪电不是持续发光的,它是一次放电过程中几次快速闪烁,然后消失。我用一个简单的状态机来控制:

  • idle:等待随机时间(2到8秒)
  • strike:闪电路径生成并显示,持续0.1秒
  • fade:透明度降低至0,持续0.2秒
  • repeat:重复strike-fade过程2到3次,形成“连闪”效果
  • destroy:移除所有闪电实体
function scheduleNextLightning(viewer, lightningEntities) { const delay = 2000 + Math.random() * 6000; setTimeout(() => { const path = generateLightningPath(start, end); const entities = createLightningEntity(viewer, path, end); // 连闪:控制透明度闪烁次 let flashCount = 2 + Math.floor(Math.random() * 2); let index = 0; const flashTimer = setInterval(() => { if (index >= flashCount) { clearInterval(flashTimer); destroyLightningEntities(entities); scheduleNextLightning(viewer, null); return; } entities.coreLine.polyline.material = new Cesium.ColorMaterialProperty( Cesium.Color.WHITE.withAlpha(1) ); setTimeout(() => { entities.coreLine.polyline.material = new Cesium.ColorMaterialProperty( Cesium.Color.WHITE.withAlpha(0.1) ); }, 100 + Math.random() * 100); index++; }, 150 + Math.random() * 200); }, delay); }

这里有个注意点:setTimeout在组件卸载之后可能还在执行。我在组件卸载函数里要放一个clearAllTimers的逻辑,或者用一个isDestroyed标志位,否则页面切走之后闪电还在暗戳戳地创建entity,最后内存泄漏,浏览器越来越卡。

5. 性能优化与常见问题排查

天气特效这个东西,最怕的就是“看着好看但掉帧”。我在真机调试时遇到过粒子数量拉满后FPS直接掉到20以下的情况,花了不少精力做了优化。

5.1 粒子系统性能优化策略

粒子系统最影响性能的是同屏粒子总数,它等于emissionRate乘以particleLife。比如emissionRate=300、particleLife=8秒,同一时刻画面里最多有2400个粒子在更新、碰撞、渲染。2400个粒子在Cesium的CPU端更新还算可以,但加上模型的遮挡计算、透明排序之后,GPU的压力也不小。

优化手段有三个:

  1. 根据设备像素比动态调整粒子参数。如果是retina屏,window.devicePixelRatio=2,但粒子数量不需要跟着翻倍,直接人为压低20%到30%的发射率。
  2. 设置粒子系统的modelMatrix不动,粒子数量恒定,但用startScaleendScale来模拟景深感——近处粒子大、远处粒子小,不需要真的为每个粒子单独计算距离。
  3. 如果大屏上还有别的实体在频繁绘制,优先用scene.debugShowFrustums(只在调试时打开)看是不是存在大量离屏粒子在浪费渲染。如果是,就把CircleEmitter的半径调小一点,让粒子限制在视野范围内出生。

5.2 闪电特效常见的三个坑

闪电实现过程中,我踩过几个很容易被忽略的坑,列一下:

第一个坑是PolylineGlowMaterialProperty的glowPower参数。这个参数越大,发光范围越大,但如果直接设置成1.0,闪电会糊成一片,反而看不出分支形状。我最终调到0.3,配合外层的泛光线(宽度6像素、低透明度),既保持清晰的核心线,又有光晕扩散感。

第二个坑是闪电位置和相机位置的关系。如果闪电离相机太远(比如几公里外),用户根本看不见;太近了又容易穿帮,正好劈在镜头前,大屏上反而是惊吓。我把闪电生成位置固定在相机前方一定半径的环形区域内,高度在云层底部以下,这样始终会出现在视野中,但不至于劈在镜头正脸上。

第三个坑是setTimeoutsetInterval的清理。这是老生常谈,但雷雨系统这种循环调度特别容易忘记。我封装了一个ticker管理对象,所有定时器都由它统一注册和销毁:

const timers = new Set(); function registerTimer(timerId) { timers.add(timerId); } function clearAllTimers() { timers.forEach(id => { clearTimeout(id); clearInterval(id); }); timers.clear(); }

组件卸载时调用clearAllTimers,页面切走再也不会出现“你看不见雨但是雨还在下”的问题。

5.3 天空与云层的颜色协调

闪电效果要让天空的颜色做配合。Cesium的viewer.scene.skyAtmosphere可以用代码调节:

viewer.scene.skyAtmosphere.hueShift = -0.1; // 色调偏移,偏向灰蓝 viewer.scene.skyAtmosphere.saturationShift = -0.3; // 降低饱和度 viewer.scene.skyAtmosphere.brightnessShift = -0.2; // 降低亮度 viewer.scene.globe.baseColor = Cesium.Color.fromBytes(42, 48, 58);

同时,全局光照的强度降低之后,闪电闪烁的反差才起得来。如果天空还是亮堂堂的晴天蓝色,闪电再亮也压不住,效果就没了。

5.4 大屏长时间运行的稳定性

大屏项目要求7x24小时不断运行,WebGL长时间运行最怕显存泄漏。我排查过一次内存持续上涨的问题,最后定位到是闪电闪烁时反复创建新的ColorMaterialProperty对象,导致GPU端材料对象没有及时释放。

解决方案是:闪电实体的材质不要频繁新建对象,而是直接修改已有材质的属性。Cesium里大多数material支持动态传参,比如color这个属性可以用new Cesium.CallbackProperty去实时计算:

const colorProperty = new Cesium.CallbackProperty(() => { const alpha = getCurrentFlashAlpha(); // 由状态机计算 return Cesium.Color.WHITE.withAlpha(alpha); }, false); entity.polyline.material = new Cesium.ColorMaterialProperty(colorProperty);

这样材质对象只创建一次,不用每次闪烁都重建。改完之后内存曲线平了非常多。

6. 完整代码结构与扩展方向

这一节把整体的代码文件和关键代码串起来,作为一个可以直接抄的模板。

6.1 组件层代码

StormViewer.vue是顶层组件,只负责初始化viewer并调用各个子系统的加载函数。整个过程中,Vue组件只感知到开启和关闭两个动作:

<template> <div ref="cesiumContainer" class="cesium-container"></div> <div class="weather-controls"> <button @click="toggleWeather">{{ isStorm ? '停止雷雨' : '开启雷雨' }}</button> </div> </template> <script setup> import { ref, onMounted, onBeforeUnmount } from 'vue'; import { useCesiumViewer } from '../composables/useCesiumViewer'; import { useRainSystem } from '../composables/useRainSystem'; import { useLightningSystem } from '../composables/useLightningSystem'; import { useCloudLayer } from '../composables/useCloudLayer'; const cesiumContainer = ref(null); const isStorm = ref(false); let viewer; let rainSystem; let lightningSystem; let cloudLayer; onMounted(async () => { viewer = await useCesiumViewer(cesiumContainer.value); // 先加载云层,再下雨,最后闪电,层次感更自然 cloudLayer = useCloudLayer(viewer); rainSystem = useRainSystem(viewer); lightningSystem = useLightningSystem(viewer); }); function toggleWeather() { if (!isStorm.value) { cloudLayer.enable(); rainSystem.start(); lightningSystem.start(); isStorm.value = true; } else { cloudLayer.disable(); rainSystem.stop(); lightningSystem.stop(); isStorm.value = false; } } onBeforeUnmount(() => { if (viewer) { viewer.destroy(); } }); </script>

启动顺序有讲究:云层先出现,雨下落,闪电再劈,逻辑上就像是“乌云聚集、开始下雨、云中放电”。反过来停的时候先停闪电,再停雨,最后云层散开,过渡也比较舒服。

6.2 composable的核心抽象

每个composable都接收viewer作为参数,返回一组控制方法。这样抽出来的好处是,每个模块可以单独被测试:比如只测雨系统,可以不启动闪电,直接在场景里看下雨效果。

雨系统的start和stop:

export function useRainSystem(viewer) { let system = null; let preUpdateListener = null; function start(options = {}) { if (system) return; system = createRainParticleSystem(viewer, options); preUpdateListener = (scene, time) => { // 更新雨粒子系统位置跟随相机 }; viewer.scene.preUpdate.addEventListener(preUpdateListener); } function stop() { if (!system) return; viewer.scene.primitives.remove(system); system = null; if (preUpdateListener) { viewer.scene.preUpdate.removeEventListener(preUpdateListener); preUpdateListener = null; } } return { start, stop }; }

闪电系统的start和stop同理,但需要额外启动一个循环调度器,内部不断生成闪电路径与闪烁动画。

云层系统更简单,本质上是一个entity的show字段切换:

export function useCloudLayer(viewer) { let cloudEntity = null; function enable() { if (!cloudEntity) { cloudEntity = createCloudEntity(viewer); } cloudEntity.show = true; } function disable() { if (cloudEntity) { cloudEntity.show = false; } } return { enable, disable }; }

6.3 进一步扩展:多天气系统共存

这套结构想扩展非常容易。比如要加雷暴结束后的“小雨转阴”,只需要把雨系统的emissionRate从300降到80,闪电调度器暂停,云层的透明度调高,不需要重新创建任何东西。

再比如要做“暴风雪”,本质上就是粒子纹理换成雪花形状,粒子速度降低,生命周期加长,加一个水平风场让粒子飘起来。云层颜色从深灰改成灰白,整个场景气质立刻就变了。

我做这个小项目最大的体会是:Cesium天气特效不一定要用高大上的shader、后处理或者WebGL自定义材质,组合运用好原生的粒子系统、entity、材质属性,完全能做出商业项目可用的视觉水平。关键是在设计的时候想清楚层次的叠加关系、闪烁的节奏、以及性能退路,而不是把几十个效果全部一股脑怼上去。

如果后续项目的渲染压力确实大,还有一个降级的planB:用CSS在canvas上层盖一层半透明的雨丝动画,闪电特效让Cesium里的粒子只负责地面泛光。这样WebGL不参与雨滴计算,GPU压力几乎为零,视觉效果也差不了太多。做可视化项目就是这样,方案不唯一,但心里要有几个备选路径,上线之前准备好降级策略,心里才踏实。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询