首页 / 资讯中心 / 文章详情

Cesium动态雾效实战:深度后处理与Vue3封装指南

Cesium动态雾效实战:深度后处理与Vue3封装指南 ★ FEATURED ARTICLE
做三维可视化项目最容易被忽略的一个效果就是雾。去年我接了一个城市级应急演练的大屏需求甲方要求在清晨和傍晚分别模拟两场不同的雾清晨是灰蓝色的薄雾能见度一点点下降然后在中午完全散掉傍晚则变成偏暖的雾配合落日的金光整个城市的楼宇轮廓若隐若现。当时我用了很蠢的办法——直接在Canvas外层套一个半透明遮罩结果近处的模型也变得模糊远处的楼却清晰得可怕。后来在Vue3Cesium的项目里重新做这一块才把动态雾浓度和颜色这件事彻底想明白。这篇文章就聊聊我最终采用的方案基于Cesium PostProcessStage的深度雾后处理配合Vue3工程化封装实现浓度连续变化、颜色连续渐变的动态雾效。写这篇之前我也翻了社区里不少帖子大多只讲了scene.fog的静态配置对颜色从哪来、浓度怎么动态调基本没讲透这期就当作一个完整的落地记录。1. 为什么要在Cesium里做动态雾从给canvas加遮罩的翻车说起1.1 雾不只是天气更是场景的信息过滤器很多刚接触三维可视化的朋友一听到雾效果就以为是模拟天气。实际上在数字孪生、智慧城市、应急演练这类项目里雾扮演的角色更像是一个信息过滤器。一个城市级场景加载完之后远处可能有几千栋楼、几十公里地形网格这些细节对业务来说毫无意义但又不能真的把它们删掉——用户一旦拉近视角它们还得在。雾的妙处在于它让远处的模型和地形逐渐隐入背景视觉噪音被压制用户的注意力自然落在近处的关键业务要素上比如选中高亮的楼宇、飞行轨迹、风险点图标。换句话说雾是廉价但有效的场景层次控制手段比辛辛苦苦做LOD、模型简化省事得多。1.2 静态雾与动态雾的本质区别静态雾设一个固定浓度和颜色跑起来就是一层不变的白灰色罩子。截图汇报可以用但在大屏上连续播放几分钟人眼很快就会疲劳。动态雾则完全不同浓度缓慢变化画面就活了观众能感知到时间流逝或场景演进。浓度和颜色同时动态变化还能承担叙事功能——大屏讲解时雾慢慢变浓配合语音讲到能见度持续下降沉浸感极强。动态浓度和动态颜色这两个需求在气象可视化里尤其常见雾的浓度通常对应能见度数据颜色则受时间、光照影响。如果只是做一层固定的灰雾数据与画面就对不上甲方一眼就能看出来这是贴上去的特效。1.3 为什么不能直接用CSS遮罩我最初翻车的原因很简单给整个canvas容器加一个filter: blur或者半透明遮罩div确实能看起来雾蒙蒙但它的问题是致命的——没有深度概念。遮罩对近处和远处一视同仁结果近处本该清晰的核心模型也变得模糊远处的高楼却在模糊层后面若隐若现看起来非常假。更麻烦的是给canvas加滤镜会直接影响浏览器合成性能画面一卡一卡的鼠标拾取时还可能因为transform/filter链路的偏差导致点选偏移。真正的雾必须发生在渲染管线内部每个像素离相机越远雾的混合权重越大。要做到这一点就得拿到每个像素的深度信息在后处理阶段做混合也就是Cesium里的PostProcessStage方案。2. Cesium里能用的三种雾方案选型对比与原理2.1 scene.fog内置距离雾能做什么不能做什么Cesium提供了一个开箱即用的scene.fog它属于大气散射体系逻辑很简单基于片元深度和视线方向让远处的景物向着大气颜色逐渐淡化。打开它只需要几行代码viewer.scene.fog.enabled true; viewer.scene.fog.density 0.0005; viewer.scene.fog.minimumBrightness 0.6;density控制雾的浓度minimumBrightness控制雾化后最低亮度防止画面彻底变黑。这个方案零成本做大雾天气导致能见度降低的演示完全够用。但它的短板也很明显雾的颜色不是自由指定的。scene.fog最终混合的颜色来自Cesium大气的计算值你没法直接写一个scene.fog.color new Cesium.Color(...)。它受太阳方向、大气散射参数影响想做出清晨灰蓝、傍晚暖金这种指定颜色变化几乎做不到。另外scene.fog也没有只对某些对象生效的能力更没法做颜色随距离渐变的Ramp效果。如果你的需求只是远处模模糊糊用它没错一旦涉及颜色和精细控制就得换方案。2.2 PostProcessStage全屏后处理为何是动态雾的最佳画布Cesium的PostProcessStage是渲染管线里的全屏后处理Pass。它会把当前帧的渲染结果作为colorTexture输入同时给你一份depthTexture然后执行你自定义的GLSL片元着色器把输出写回屏幕。这意味着你可以完全掌控雾的算法从深度纹理还原出片元到相机的距离按距离计算混合权重再把权重和雾颜色、原图颜色做mix。雾的颜色可以是一个动态变化的uniform vec4浓度也可以是一个动态变化的uniform float。每帧在JS侧改一下uniform画面就动态了。更重要的是PostProcessStage天然支持selected属性可以指定只对某几个Entity/Primitive生效这在后面章节会说到。它还能叠加多层前后关系由添加顺序决定可玩性非常强。2.3 方案对比与最终选择三种方案放在一起对比会更清楚维度scene.fogCSS遮罩PostProcessStage自定义Shader是否有深度感知是否是颜色自由指定否可以但不真实完全自由支持动态浓度动态变化勉强改density改透明度完全自由可结合数据选择性雾化否否是selected性能开销极低低但不稳定中高全屏逐像素上手难度极低极低中高我最后选了PostProcessStage自定义Shader核心原因就一个颜色和浓度都要动态而且还要能融合业务数据。后面章节的完整代码就是为这个方案写的。3. Vue3 Vite 工程下的Cesium集成与环境准备3.1 依赖安装与Vite配置我的项目是Vue3 Vite Cesium的标配安装阶段有两个选择。第一个选择是用vite-plugin-cesium它能自动处理Cesium的静态资源、CESIUM_BASE_URL等麻烦事npm install cesium npm install vite-plugin-cesium -D然后在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()] })第二个选择是手动配置适用于不想引入额外插件的项目把node_modules/cesium/Build/Cesium拷贝到public/cesium然后在组件里提前设置window.CESIUM_BASE_URL /cesium再import * as Cesium from cesium。两种方式都实测过我更推荐第一种少踩很多静态资源404的坑。3.2 Viewer初始化必要的关闭项与默认参数雾效演示不需要Cesium那一堆默认UI初始化时把组件都关掉画面更干净性能也更好。下面是一个比较清爽的初始化方式script setup import * as Cesium from cesium import { onMounted, ref, onUnmounted } from vue const mapContainer ref(null) let viewer null onMounted(() { viewer new Cesium.Viewer(mapContainer.value, { baseLayer: false, // 不要默认的Ion影像后面细说 baseLayerPicker: false, sceneMode: Cesium.SceneMode.SCENE3D, infoBox: false, selectionIndicator: false, animation: false, timeline: false, fullscreenButton: false, homeButton: false, geocoder: false, navigationHelpButton: false, shouldAnimate: true }) // 调整相机到适合观察雾效的位置 viewer.camera.setView({ destination: Cesium.Cartesian3.fromDegrees(116.39, 39.9, 6000), orientation: { heading: 0, pitch: Cesium.Math.toRadians(-45), roll: 0 } }) }) /script这里baseLayer: false是关键它的意思是先不加载任何影像图层只留下一个蓝色椭球体。没有影像干扰时雾的颜色、浓度变化会看得特别清楚调试效率高很多。3.3 不依赖Ion Token的演示环境很多同学卡在没有Ion TokenCesium不显示影像。实际上你可以完全不处理Ion资源直接给Cesium塞一个离线影像源比如用TileMapServiceImageryProvider挂本地瓦片或者干脆就用baseLayer: false配合一个简单的GeoJSON建筑块来演示雾效。雾效的核心逻辑和影像无关后处理是作用在最终渲染结果上的。所以本地有地形、模型、Entity都行什么都没有也有一片蓝蓝的椭球面。我实际调试时起手就是纯色背景一个随相机飘的BoxGeometry先确认远近雾浓度对不对再补业务模型。这个最小可验证环境的习惯帮我避开了很多干扰项。4. 核心实现用后处理Shader写一个距离雾并驱动浓度与颜色4.1 从全屏变灰到按距离变灰拿到PostProcessStage后最容易犯的错是直接写一个gl_FragColor mix(color, fogColor, 0.5)整个画面均匀变灰。这种效果和CSS遮罩没什么区别近处也糊了。正确思路是按距离变灰从depthTexture中读出当前片元深度把深度换算成相机到片元的真实距离距离越远雾的混合权重越大。核心公式是fogFactor f(distance) finalColor mix(originalColor, fogColor, fogFactor)其中fogFactor在近处趋近0远处趋近1。这个公式定下来整个雾效的骨架就立住了。4.2 核心Shader代码拆解下面是我在项目里稳定使用的片元着色器。先看完整代码再逐段解释uniform sampler2D colorTexture; uniform sampler2D depthTexture; uniform float fogDensity; uniform vec4 fogColor; uniform float fogStartDistance; uniform float fogEndDistance; varying vec2 v_textureCoordinates; void main() { // 1. 读取当前片元深度非线性范围0~1 float depth czm_readDepth(depthTexture, v_textureCoordinates); // 2. 把窗口坐标深度转成眼空间坐标得到真实距离 vec4 windowCoord vec4(gl_FragCoord.xy, depth, 1.0); vec4 eyeCoord czm_windowToEyeCoordinates(windowCoord); float dist -eyeCoord.z; // 3. 线性雾因子距离在fogStart和fogEnd之间平滑变化 float fogFactor clamp((dist - fogStartDistance) / (fogEndDistance - fogStartDistance), 0.0, 1.0); // 4. 可选用指数雾替代线性雾视觉更自然 // float fogFactor 1.0 - exp(-fogDensity * fogDensity * dist * dist); // fogFactor clamp(fogFactor, 0.0, 1.0); // 5. 雾色alpha强制为1避免半透明叠加 vec4 finalFogColor vec4(fogColor.rgb, 1.0); // 6. 混合原图颜色与雾色 vec4 color texture2D(colorTexture, v_textureCoordinates); gl_FragColor mix(color, finalFogColor, fogFactor); }这里有几个关键函数需要理解czm_readDepth(depthTexture, uv)是Cesium内置函数负责从深度纹理中读取片元深度值得到的结果是0~1之间但非线性的NDC深度。直接把这么深的值拿去做雾你会发现近处变化剧烈、远处几乎不变非常不自然所以必须先做第二步。czm_windowToEyeCoordinates把窗口坐标恢复成眼空间坐标vec4的第三个分量传入深度第四分量传1。返回值的z就是深度距离因为Cesium相机的眼空间坐标里视线方向是负Z所以取-eyeCoord.z就是到相机的真实距离单位是米。这个距离才是雾混合权重应该用的物理量。我保留了两种雾算法线性雾和指数雾。线性雾用fogStartDistance和fogEndDistance两个参数控制直观、容易调参指数雾用fogDensity控制衰减快慢更接近真实大气效果但参数量级很小一不留神就调过头。生产项目里两种我都用过建议先用线性雾把场景层次调好再决定要不要换成指数雾。4.3 JS侧动态控制正弦波、时间戳与HSL插值Shader写好后剩下的工作就是往PostProcessStage里塞uniform然后让数值随时间动起来。先创建后处理阶段function createFogStage(viewer) { return viewer.scene.postProcessStages.add( new Cesium.PostProcessStage({ name: dynamicFog, fragmentShader: FOG_FRAGMENT_SHADER, uniforms: { fogDensity: 0.0005, fogColor: new Cesium.Color(0.82, 0.85, 0.95, 1.0), fogStartDistance: 800, fogEndDistance: 4000 } }) ) }创建之后uniform对象可以直接改属性。下面是我用的动态驱动逻辑用requestAnimationFrame跑一个循环浓度用正弦波做来回变化颜色用HSL做缓慢漂移。function startFogAnimation(fogStage) { const startTime performance.now() function tick(now) { const elapsed (now - startTime) / 1000 // 浓度动态在2000~5000之间来回切换等效起止距离 const cycle (Math.sin(elapsed * 0.4) 1) / 2 fogStage.uniforms.fogStartDistance 400 cycle * 600 fogStage.uniforms.fogEndDistance 2500 cycle * 3500 // 颜色动态色相在0.58~0.46之间漂移偏冷到偏暖 const hue 0.58 - 0.12 * cycle fogStage.uniforms.fogColor Cesium.Color.fromHsl(hue, 0.3, 0.82, 1.0) requestAnimationFrame(tick) } requestAnimationFrame(tick) }这里有个很值得说的细节Cesium.Color.fromHsl的色相参数范围是0到1对应0到360度不是数值直接挂度数的。我第一次用的时候填了个220进去颜色在几种赤橙黄绿之间疯狂跳动排查了半天才发现是色相单位错了。这个坑后面还会在注意事项里提到。如果你对时间的精确同步有更高要求也可以改用viewer.clock.onTick事件viewer.clock.onTick.addEventListener(() { const seconds Cesium.JulianDate.toDate(viewer.clock.currentTime).getTime() / 1000 // 用seconds做同样的计算 })requestAnimationFrame适合纯前端演示胜在简单viewer.clock适合和场景里的其他动画同步比如太阳位置、云层流动。两者我都用过按业务需要选即可。4.4 封装成可复用的组合式函数实际项目里雾效不可能只写在一个演示页面里。我在Vue3工程中把它封装成了一个组合式函数暴露几个常用方法多个页面直接复用// useFogController.js import * as Cesium from cesium export function useFogController(viewer) { let fogStage null let animationId null const FOG_FRAGMENT_SHADER uniform sampler2D colorTexture; uniform sampler2D depthTexture; uniform float fogStartDistance; uniform float fogEndDistance; uniform vec4 fogColor; varying vec2 v_textureCoordinates; void main() { float depth czm_readDepth(depthTexture, v_textureCoordinates); vec4 windowCoord vec4(gl_FragCoord.xy, depth, 1.0); vec4 eyeCoord czm_windowToEyeCoordinates(windowCoord); float dist -eyeCoord.z; float fogFactor clamp((dist - fogStartDistance) / (fogEndDistance - fogStartDistance), 0.0, 1.0); vec4 color texture2D(colorTexture, v_textureCoordinates); gl_FragColor mix(color, vec4(fogColor.rgb, 1.0), fogFactor); } function enable(options {}) { disable() fogStage viewer.scene.postProcessStages.add( new Cesium.PostProcessStage({ name: dynamicFog, fragmentShader: FOG_FRAGMENT_SHADER, uniforms: { fogStartDistance: options.startDistance ?? 800, fogEndDistance: options.endDistance ?? 4000, fogColor: new Cesium.Color(0.82, 0.85, 0.95, 1.0) } }) ) } function setDensity(startDistance, endDistance) { if (!fogStage) return fogStage.uniforms.fogStartDistance startDistance fogStage.uniforms.fogEndDistance endDistance } function setColor(color) { if (!fogStage) return fogStage.uniforms.fogColor color } function startAnimation() { stopAnimation() const startTime performance.now() const tick (now) { const elapsed (now - startTime) / 1000 const cycle (Math.sin(elapsed * 0.4) 1) / 2 setDensity(400 cycle * 600, 2500 cycle * 3500) const hue 0.58 - 0.12 * cycle setColor(Cesium.Color.fromHsl(hue, 0.3, 0.82, 1.0)) animationId requestAnimationFrame(tick) } animationId requestAnimationFrame(tick) } function stopAnimation() { if (animationId ! null) { cancelAnimationFrame(animationId) animationId null } } function disable() { stopAnimation() if (fogStage viewer !viewer.isDestroyed()) { viewer.scene.postProcessStages.remove(fogStage) fogStage null } } return { enable, disable, setDensity, setColor, startAnimation, stopAnimation } }这个包装思路核心是把创建后处理和驱动动画解耦业务侧可以只调用enable开雾、setDensity设置当前能见度完全不需要知道Shader细节。页面销毁时记得在onUnmounted里调disable()否则组件卸载后后处理还挂在viewer上会报错或造成内存泄漏。5. 动态雾落地时的踩坑清单深度、MSAA、后处理顺序5.1 深度读取的玄学与MSAAczm_readDepth是Cesium官方推荐从深度纹理取值的函数大多数时候都能正常工作。但如果你把viewer.scene.msaaSamples调高了或者同时开启了FXAA某些边缘像素上深度值可能会异常表现为模型轮廓附近出现细碎的描边闪烁。我排查这类问题的顺序很固定先把FXAA后处理关掉再把MSAA调回1看问题是否消失如果消失就是抗锯齿和深度采样的兼容性问题。实际项目里我的做法是保留FXAA但把雾效后处理排在它之前执行让雾效先基于原始深度算好再做FXAA平滑视觉上问题明显减轻。另外如果你在Shader里直接用texture2D(depthTexture, v_textureCoordinates).x代替czm_readDepth结果可能完全不同——深度纹理在Cesium里可能是编码过的直接采样得到的数值不能当作线性深度用。所以不要为了省事绕开czm_readDepth。5.2 参数量级density不是随便给的指数雾那个fogDensity数值量级极其敏感。在Cesium默认相机的城市尺度下0.0001可能几乎没效果0.001就已经全屏发白。我最初的调试方式是打开Cesium源码里的默认值找参考但真正有效的是在页面上挂一个临时滑块实时调参。我推荐一个更稳妥的做法优先用线性雾的fogStartDistance和fogEndDistance调层次。这两个参数的物理含义是多少米开始起雾、多少米完全遮蔽和业务上说的能见度直接对应甲方说能见度2公里那你把fogEndDistance设成2000就对了。相比之下指数雾的density不好解释给业务听。5.3 后处理叠加顺序与画面发白问题Cesium的后处理流传片段的顺序决定了效果的叠加方式。雾效和光晕、辉光、景深等后处理一起用时顺序会影响最终画面。举个例子如果先加Bloom再加雾雾会把Bloom的高亮边缘全部刷白画面像蒙了一层磨砂玻璃亮部细节全没了。正确的做法通常是把雾效加在前面让雾色先混入原图后续的Bloom再基于雾化后的画面做提亮。调试顺序问题有个实用技巧临时把其他后处理全部enabledfalse单独开雾效确认雾本身效果OK后再一个一个开启其他后处理排查是谁把画面搞白的。我遇到过好几次以为是雾的问题最后发现是雾和某个旧版本后处理插件的冲突。5.4 相机高度变了雾效会怎么办还有一个容易忽略的问题相机拉高到几万米或者压低到街道近景时同样的fogEndDistance会带来完全不同的视觉感受。高空视角下4公里的雾程可能覆盖不到主要画面雾等于没开近地视角下4公里又可能让整屏都白掉。我的经验是把雾效参数和相机高度绑定做一层简单的映射。比如在viewer.camera.changed事件里根据viewer.camera.positionCartographic.height动态调整fogStartDistance和fogEndDistanceviewer.camera.changed.addEventListener(() { if (!fogStage) return const height viewer.camera.positionCartographic.height const start height * 0.4 const end height * 1.5 fogStage.uniforms.fogStartDistance start fogStage.uniforms.fogEndDistance end })这个映射系数不是定死的你可以根据项目尺度调。但方向是对的雾应该跟随相机高度自适应而不是写死一组参数走天下。6. 进阶玩法天气联动、渐变纹理与选择性雾化6.1 天气系统联动接口数据驱动雾效动态雾浓度最大的价值是接业务数据。气象类项目的接口通常会返回能见度数值单位是米比如当前能见度1800米。这个数值可以直接映射到fogEndDistanceasync function updateFogByWeather() { const res await fetch(/api/weather/current) const visibility res.data.visibility // 单位米 fogStage.uniforms.fogEndDistance visibility fogStage.uniforms.fogStartDistance visibility * 0.35 }为了平滑过渡接口返回的目标值不要直接赋给uniform而是做一个线性插值let targetEnd 4000 function lerp(a, b, t) { return a (b - a) * t } viewer.clock.onTick.addEventListener(() { const current fogStage.uniforms.fogEndDistance const next lerp(current, targetEnd, 0.02) fogStage.uniforms.fogEndDistance next })这样能见度快速变化时画面不会出现啪一下跳变的生硬感而是慢慢变浓或者慢慢散开更像真实天气演变。6.2 颜色渐变Ramp让雾色随空间变化单一雾色在某些场景下还是不够。比如我想让地平线附近的雾色偏暖头顶上方偏冷或者让近处雾色淡白、远处雾色灰蓝这时候可以在Shader里引入一张渐变纹理。先用Canvas生成一张水平的渐变图function createRampCanvas() { const canvas document.createElement(canvas) canvas.width 256 canvas.height 1 const ctx canvas.getContext(2d) const gradient ctx.createLinearGradient(0, 0, 256, 0) gradient.addColorStop(0, #7d8ba6) gradient.addColorStop(0.5, #aebdd1) gradient.addColorStop(1, #e8d9c2) ctx.fillStyle gradient ctx.fillRect(0, 0, 256, 1) return canvas }然后把Canvas做成Cesium纹理传给Shader的uniformconst rampTexture new Cesium.Texture({ context: viewer.scene.context, source: createRampCanvas(), pixelFormat: Cesium.PixelFormat.RGBA, flipY: false }) fogStage.uniforms.fogColorRamp rampTextureShader里按雾因子采样uniform sampler2D fogColorRamp; // 在main函数中 vec4 rampColor texture2D(fogColorRamp, vec2(fogFactor, 0.5)); gl_FragColor mix(color, vec4(rampColor.rgb, 1.0), fogFactor);这样同一个雾效随着距离从近到远颜色会沿着渐变图从淡白过渡到灰蓝再过渡到暖黄。这种细节在Cesium大屏项目里非常提升质感计算成本几乎可以忽略。6.3 选择性雾化只对指定对象生效如果场景里有几个高亮要素需要永远清晰比如应急指挥中心重点关注的几个避难场所雾可以把它们排除在外。PostProcessStage的selected属性就是干这个的const keyBuildings getEntitiesByName(viewer.entities, [shelterA, shelterB]) fogStage.selected keyBuildings设置之后只有selected列表里的对象会经过雾化后处理其他对象保持原样。我第一次看到这个功能时觉得非常神奇后来想想原理也合理——后处理Pass可以单独对某些对象执行等于给雾加了个白名单。不过要注意一点使用selected后TestEnvironment里需要确保对象确实能被后处理捕获。我遇到的情况是某些通过Primitive低层API创建的几何体可能不会自动出现在后处理渲染目标里这时需要看具体Primitive实现或者改用Entity API。在业务项目里我尽量统一用Entity省得折腾。6.4 结合时间轴模拟一天里的雾最完整的一套玩法是把雾效和场景时间轴绑定。Cesium的viewer.clock可以设置起始/停止时间再用clock.shouldAnimate控制流动。比如做一个24小时循环6:00日出雾从灰蓝逐渐变暖浓度中等9:00白天雾最薄能见度最高18:00日落雾重新聚集颜色偏暖23:00夜间雾色冷调浓度较高把每个时间点的雾参数做成一个数组在clock.onTick里根据当前时刻做插值const fogSchedule [ { time: 06:00, startDist: 500, endDist: 3000, hue: 0.58, sat: 0.3, light: 0.8 }, { time: 09:00, startDist: 1500, endDist: 7000, hue: 0.5, sat: 0.15, light: 0.9 }, // ...更多 ] viewer.clock.onTick.addEventListener(() { const time Cesium.JulianDate.toDate(viewer.clock.currentTime) const scheduleItem findScheduleItem(fogSchedule, time) applyFogSchedule(fogStage, scheduleItem) })这样雾效就成了场景叙事的一部分大屏播放时观众能看到雾随着时间演示而变化比硬切场景高级很多也更容易让业务方理解这个系统能表达时间尺度上的信息。最后再分享一个我自己调雾时的体会。动态雾浓度和颜色这个东西最忌炫技。刚开始做的时候我恨不得把浓度拉满、颜色调得五颜六色结果场景一出来远处的业务数据全部被盖住大屏被吐槽什么都看不清。后来我发现高级的雾效是克制的近景核心要素保持95%以上的清晰度中景隐约可见轮廓远景才彻底隐入雾色。颜色变化也要慢最好让人在无意识中感受到诶好像变暖了一点而不是一眼看出彩虹漂移。这个度掌握好了动态雾才是真正在帮场景加分而不是添乱。
阅读完成 · 觉得有帮助?
咨询建站