作为长期跟三维可视化项目打交道的人我对这类场景实在太熟悉了同一套 Cesium 场景有人做出来就是“工程文档”有人做出来就是“电影质感”。其实差别往往不在模型精度也不在数据量而是渲染完那一帧之后你有没有做后处理。今天这篇就把我实际项目里用 Cesium 后处理效果的心得整理一遍包含怎么挂载自定义 shader、怎么调参数、哪些效果性价比高、哪些坑最好别踩。1. 为什么三维地球画面总显得“一眼假”后处理到底在解决什么1.1 工程级渲染和电影级渲染差在哪先说一个最直观的对比。默认的 Cesium 场景只要你模型精度够、光照方向对画面其实是“干净”的但这种干净往往等于“平”。高光区域没有扩散、暗部没有氛围、远处和近处没有层次感整体看起来就像一张高分辨率但毫无后期处理的照片。而电影工业里那些看着高级的画面其实颜色都是经过二次加工的亮的地方要有光晕、暗的角落要有渐变、轮廓边缘要有区分度。这堆东西如果全压在建模阶段做那项目成本和模型量都扛不住所以必须在渲染管线的最后阶段统一处理。后处理其实就是“事后处理”在 GPU 已经画完一帧场景之后把这张完整画面当成一张普通贴图再交给一系列全屏着色器去加工。你可以把它理解成给照片加滤镜、加光效、加暗角的动作只不过这套动作不是放在 Photoshop 里手动做而是写在 GLSL 里实时算。Cesium 的 PostProcessStage 就是干这个用的它允许你往渲染流水线里注入自定义的全屏处理过程拿到前一阶段输出的纹理改完再输出给下一阶段或直接显示到屏幕上。1.2 Cesium 渲染流水线里后处理的位置Cesium 的默认渲染流程大概是实体、模型、地形、动态标绘等内容先绘制到一个离屏缓冲区形成完整场景纹理接着交给后处理链PostProcessStageCollection逐个 stage 处理最后才送到屏幕。这个链条上每个 stage 的输入是一个纹理输出也是一个纹理后一个 stage 拿到的是前一个 stage 的结果。也就是说后处理的效果是叠加的你加一个灰度特效再加一个暗角特效最终画面就是“灰度且带暗角”而不是只有一个生效。这条链上除了用户自定义的 stageCesium 还自带几个比如scene.postProcessStages.fxaa做抗锯齿、scene.postProcessStages.bloom做泛光、scene.postProcessStages.ambientOcclusion做环境光遮蔽。可惜自带版参数固定实际项目里我基本只保留 fxaa其他都关掉自己重新写自定义 stage原因后面细说。提示如果你是第一次接触后处理建议先别急着写复杂 shader先把“挂载一个什么都不做的 stage”跑通再逐步加代码。后处理链路一旦断掉画面会直接黑屏调试成本比想象中大。2. 动手写第一个泛光效果从 API 到 shader 的完整链路2.1 PostProcessStage 的最简用法自定义后处理 stage 的入口非常简单核心就两步构造Cesium.PostProcessStage然后add到scene.postProcessStages。下面这段代码是我项目里最常用的挂载方式var bloomStage new Cesium.PostProcessStage({ name: myBloom, fragmentShader: bloomShaderText, uniforms: { u_strength: 1.8, u_threshold: 0.85, u_texelSize: new Cesium.Cartesian2(1.0 / width, 1.0 / height) } }); viewer.scene.postProcessStages.add(bloomStage);需要注意几个点。第一name不是随便填的它相当于这个 stage 的索引后面要用viewer.scene.postProcessStages.getStageByName(myBloom)来找它建议起名别太随意。第二fragmentShader是一个字符串Cesium 会在编译时自动注入一些内置变量比如texture上一阶段输出的纹理和v_textureCoordinates当前像素的 UV 坐标。你在 shader 里直接texture2D(texture, v_textureCoordinates)就能拿到画面颜色不需要自己声明 sampler。第三uniforms对象里可以直接传自定义变量类型支持 float、vec2、vec3 等后续要动态调参直接改这个对象的属性就行。我第一次用的时候把bloomStage.uniforms.u_strength 1.8写成了bloomStage.uniforms {...}结果整个对象被替换绑定全部失效调了半天没反应。2.2 一个全屏泛光 shader 拆开来看泛光Bloom的基本原理不复杂把画面里亮度超过阈值的区域提取出来做模糊处理再叠加回原图上让高亮边缘有一种“溢光”的柔和感。这个效果对三维地球特别有用因为太阳直射下的地标建筑、道路、高亮图层很容易显得生硬加一点泛光就柔和多了。我项目里用的一个简化版本 shader 长这样uniform float u_strength; uniform float u_threshold; uniform vec2 u_texelSize; vec4 fetchColor(vec2 offset) { return texture2D(texture, v_textureCoordinates offset); } void main() { vec4 color texture2D(texture, v_textureCoordinates); float brightness max(color.r, max(color.g, color.b)); float amount clamp(brightness - u_threshold, 0.0, 1.0); vec3 glow vec3(0.0); // 5x5 菱形采样保证中心点周边的像素参与模糊 for (int x -2; x 2; x) { for (int y -2; y 2; y) { if (abs(float(x)) abs(float(y)) 2.0) { glow fetchColor(vec2(float(x) * u_texelSize.x, float(y) * u_texelSize.y)).rgb; } } } glow / 13.0; float lum dot(glow, vec3(0.2126, 0.7152, 0.0722)); vec3 result color.rgb glow * u_strength * amount * lum; gl_FragColor vec4(result, 1.0); }这段代码的思路是先算当前像素的亮度亮度超过阈值的部分当作“发光强度”然后在周围 13 个像素点采样取平均得到一个模糊的亮部颜色最后把模糊亮部按强度叠加回原图。u_texelSize是每个纹素在 UV 坐标里占多大比如纹理宽度 1920那u_texelSize.x就是1.0 / 1920这样才能保证采样步长是“一个像素”而不是整块偏移。这个版本是单 pass 里完成亮度提取和模糊的近似方法效果够用但和大范围光晕比还是有差距。真要做大范围泛光就需要先降采样、再多次高斯模糊、最后合成这一套在 Cesium 默认后处理链上绕起来比较费劲我一般用前面说的“菱形采样”先顶着采样半径适当调大视觉上区别没那么明显。2.3 调节 uniforms 时的两个反复踩的坑第一个坑是改了 uniforms 但画面没变化。这种情况九成是 uniform 名字拼错了。GLSL 里的uniform float u_strength和 JS 里uniforms: { u_strength: 1.8 }必须完全一致大小写、下划线都不能差。还有一个容易忽略的点Cesium 在编译 shader 时会做优化如果 shader 里没有实际使用某个 uniform它会自动把它剔除掉你在 JS 端改了也白改。排查办法很简单先故意在gl_FragColor里用一下这个变量看画面有没有反应再逐步减少代码找原因。第二个坑是u_texelSize这种随分辨率变化的 uniform在窗口缩放之后没更新。你初始化时传了1.0 / width但用户把浏览器拉宽了分辨率变了这个值还是旧的泛光采样就会错位整个光晕会呈现在奇怪的位置。解决办法是监听场景分辨率变化重新设置这个 uniformviewer.scene.postRender.addEventListener(function () { var size viewer.scene.drawingBufferWidth; bloomStage.uniforms.u_texelSize new Cesium.Cartesian2(1.0 / size, 1.0 / size); });当然不用每次都注册监听一般项目里窗口尺寸变化事件里更新一次就够了只是很多人会漏掉这个细节。3. 暗角、灰度、边缘描边三个看着简单但很提气的小特效3.1 暗角是制造视觉聚焦最便宜的手段暗角Vignette可能是所有后处理里性价比最高的一个。它的实现逻辑就是按照像素离画面中心的距离给边缘压暗让视线不自觉地聚到画面中间。我在做某个三维地标展示项目时默认场景里标绘点分布在四周数据密集但观感散乱加了一层暗角之后画面重心立刻稳定了。shader 只有几行uniform float u_intensity; void main() { vec4 color texture2D(texture, v_textureCoordinates); vec2 uv v_textureCoordinates - vec2(0.5); float dist length(uv); float vignette 1.0 - dist * dist * u_intensity; gl_FragColor vec4(color.rgb * clamp(vignette, 0.0, 1.0), 1.0); }u_intensity控制在 0.3 到 1.0 之间比较合适太大会出现四个角全黑反而显得脏。这个效果看着简单但实际项目中“注意不要做过头”比“怎么做出来”更重要暗角太重会削弱卫星影像的信息量所以我会把它放在所有后处理的最后一步让它只影响输出效果不影响后续 stage 对画面的判断。3.2 灰度不是简单去色而是要保留亮度层次灰度后处理适合做“历史模式”“无趣模式”这种风格化场景。一个容易犯的错误是直接改成vec3(luminance)结果画面灰成一片层次全丢。我用的方案是保留一部分原始色彩信息让灰度不是均匀的uniform float u_mix; void main() { vec4 color texture2D(texture, v_textureCoordinates); float luma dot(color.rgb, vec3(0.2126, 0.7152, 0.0722)); vec3 gray vec3(luma); vec3 mixed mix(color.rgb, gray, u_mix); gl_FragColor vec4(mixed, 1.0); }u_mix设为 1.0 是纯灰度0.6 左右是“半褪色”效果后者在展示老城区规划、历史建筑保护这类主题时很受客户欢迎。注意权重系数不是随便写的0.2126、0.7152、0.0722 是标准 Rec.709 亮度公式比简单的(rgb)/3更符合人眼感知红色和蓝色在视觉亮度上的不同只取平均会怪怪的。3.3 边缘描边在 GIS 场景的正确用法边缘检测后处理最常见的用途是“线稿化”或者“卡通描边”放在三维 GIS 里还能用来突出建筑轮廓、道路边界让画面更有“图纸感”。Sobel 算子实现很简单对当前像素周围的 3x3 区域用横竖两组卷积核分别算出梯度再合成边缘强度。uniform vec2 u_texelSize; float luma(vec4 c) { return dot(c.rgb, vec3(0.2126, 0.7152, 0.0722)); } void main() { vec4 color texture2D(texture, v_textureCoordinates); float tl luma(texture2D(texture, v_textureCoordinates vec2(-u_texelSize.x, -u_texelSize.y))); float tc luma(texture2D(texture, v_textureCoordinates vec2(0.0, -u_texelSize.y))); float tr luma(texture2D(texture, v_textureCoordinates vec2(u_texelSize.x, -u_texelSize.y))); float ml luma(texture2D(texture, v_textureCoordinates vec2(-u_texelSize.x, 0.0))); float mr luma(texture2D(texture, v_textureCoordinates vec2(u_texelSize.x, 0.0))); float bl luma(texture2D(texture, v_textureCoordinates vec2(-u_texelSize.x, u_texelSize.y))); float bc luma(texture2D(texture, v_textureCoordinates vec2(0.0, u_texelSize.y))); float br luma(texture2D(texture, v_textureCoordinates vec2(u_texelSize.x, u_texelSize.y))); float gx tr 2.0 * mr br - tl - 2.0 * ml - bl; float gy bl 2.0 * bc br - tl - 2.0 * tc - tr; float edge sqrt(gx * gx gy * gy); vec3 withEdge mix(color.rgb, vec3(0.0), smoothstep(0.2, 0.8, edge) * 0.6); gl_FragColor vec4(withEdge, 1.0); }这段代码每一帧要做 9 次全屏纹理采样性能开销不低。我实际用下来1080P 桌面端没问题移动端会比较吃力。如果只是想要边缘变暗而不是完全描边可以把混合系数从 0.6 降到 0.3观感会更自然也看不出来是后处理做的。4. 多 stage 串联让上一个特效的输出成为下一个特效的输入4.1 链式输出逻辑与一个典型例子PostProcessStageCollection 支持挂多个 stage执行顺序就是add的先后顺序。每个 stage 拿到的是上一个 stage 的输出纹理也就是说如果你先加一个暗角再加一个色彩调整最终的画面是“先暗角再调色”而不是两个效果独立应用。这个顺序在实际项目里非常重要。举个例子我想做一个“低饱和夜景增强”效果先降饱和再加对比度。如果顺序反了先调对比度再降饱和画面会显得灰中带脏。正确的链式代码如下var saturationStage new Cesium.PostProcessStage({ name: desaturate, fragmentShader: desaturateShader, uniforms: { u_mix: 0.5 } }); var contrastStage new Cesium.PostProcessStage({ name: contrast, fragmentShader: contrastShader, uniforms: { u_contrast: 1.2 } }); viewer.scene.postProcessStages.add(saturationStage); viewer.scene.postProcessStages.add(contrastStage);这样contrastStage的输入纹理是saturationStage的输出纹理整条链路是连续的。如果中途想关掉某个 stage不一定要移除它直接把enabled设为 false 即可后面的 stage 会自动接上上一个“仍然启用”的 stage 的输出。4.2 链式结构做颗粒质感与动态场景的思路多 stage 链式结构除了做颜色叠加还能用来做“时间驱动的动态效果”。比如我想做一个屏幕噪点/胶片颗粒效果可以加一个 stage里面用czm_viewport拿到当前像素坐标再用一个简单伪随机函数生成噪声乘到画面上uniform float u_noiseAmount; float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); } void main() { vec4 color texture2D(texture, v_textureCoordinates); vec2 pixelCoord v_textureCoordinates * czm_viewport.zw; float noise hash(pixelCoord fract(czm_frameNumber)); color.rgb (noise - 0.5) * u_noiseAmount; gl_FragColor color; }这里的czm_frameNumber是 Cesium 内置的帧计数每帧都会变相当于给噪声加了时间种子。如果你希望噪声只出现在暗部区域可以乘上一个(1.0 - luma)系数效果更真实。这个特效看起来是“加东西”本质上没有破坏场景细节适合加在链式末端。4.3 一个常见误区为什么不能无限堆 stage 做大范围泛光很多人学后处理时看到“多 stage 是链式的”就想当然地以为可以用三个 stage 分别做“提取亮度—模糊—合成”达到电影级泛光。我之前也这么试过实际跑起来才发现一个致命问题第二个 stage 拿到的输入是第一个 stage 的输出也就是说当你在“模糊”那一步的时候你已经拿不到原始场景了后面“合成”的时候根本没有原始图层可叠加。这也是 Cesium 默认后处理链和通用渲染框架不一样的地方普通 OpenGL 后处理可以自己管理多个 render targetCesium 的 PostProcessStage 不开放这种底层控制。所以我在项目里的应对方案有两种。第一种是单 stage 近似法就是前面第 2 节写的菱形采样适合光晕半径在 2-3 个像素内的轻量需求。第二种是做大范围光晕时绕开后处理链直接对 Entity 或 Primitive 做简单的自发光材质让高亮区本身是“亮的再往上提一档”配合少量泛光观感也不差。如果你确实需要完整的 multi-pass bloom那就得自己接管渲染循环使用自定义的 framebuffer 和 shader这已经偏离 Cesium 的常规玩法不建议普通项目搞。5. 后处理性能账单哪些效果值得留、哪些该砍5.1 一个简单的开销量化方法后处理最花钱的地方是“全屏纹理采样”。每个 stage 每帧都要对整张画面做一遍全屏处理每加一个效果GPU 就多跑一遍全屏着色器。我用一个很朴素的方法估算开销看 shader 里每个像素采样了多少次纹理。普通调色/暗角每像素 1 次采样开销极低菱形泛光 13 次采样开销中等Sobel 边缘检测 9 次采样开销中等多 stage 串联每个 stage 各算一遍开销线性增加高斯模糊 4 pass横竖分离每 pass 约 5-9 次采样总采样次数高以 1080P 为例最终渲染像素约 200 万个一个 13 次采样的 stage 每帧就是 2600 万次纹理采样在普通集成显卡上已经能感到压力。移动端或弱机型上强烈建议后处理总采样次数控制在 20 次以内也就是最多挂两个中等效果的 stage或者三个低开销 stage。5.2 不同应用场景的后处理配方我按项目类型总结了三套实测下来比较稳的配置供参考。场景类型推荐配置理由数字孪生大屏泛光 暗角关闭自带 AO大屏幕远距离观看泛光提亮关键区域暗角聚焦中心智慧城市巡检离线渲染Sobel 边缘增强 色彩微调图纸感强细节清晰适合视频汇报移动端轻量展示只保留 fxaa 抗锯齿最多加暗角移动端 GPU 有限后处理复杂容易掉帧发热这里特别想说一下自带的环境光遮蔽ambientOcclusion。这个效果在办公户型、室内场景中很出彩但在三维地球大场景里经常把影像细节搞得斑斑驳驳而且性能消耗不小我默认都是关掉的。scene.postProcessStages.ambientOcclusion.enabled false这一行能劝退不少小白改错代码的念头。5.3 推荐一套默认参数如果你不想一上来就逐个调可以直接按下面这组起点来所有效果都很温和不会翻车泛光u_strength 1.5u_threshold 0.9暗角u_intensity 0.4灰度u_mix 0.65边缘增强混合系数 0.3这套参数在大多数 Cesium 场景白天卫星影像、城市模型、夜间灯光数据里都能做到“看着有点质感但不至于油腻”。然后根据客户反馈再微调每次只调一个参数避免两个变量同时改导致不知道是哪个生效。6. 我在项目里踩过的坑和几个顺手的小技巧6.1 enabled 和 active 的差异PostProcessStage有两个相似的开关属性enabled和active很多人第一次看文档会混淆。enabled是总开关设为 false 时这个 stage 完全不执行active更像“当前帧是否执行”设为 false 时该 stage 的输出会保持上一次的结果不变而不是清掉。这个差异可以做比较有意思的事情。比如你要做“渐变模糊效果”先将 stage 的 active 设为 false让画面维持模糊状态然后把场景里的图层切换掉再把 active 设回 true视觉上像是一个“解冻”的过程。我在做某个模拟项目时用这个方式实现了“初始化界面模糊—数据加载完—清晰显示”的过渡代码量很小效果却很自然。6.2 fxaa 和后处理链的顺序问题scene.postProcessStages.fxaa是内置的抗锯齿 stage默认位于后处理链的某个固定位置。如果自定义后处理里有模糊类的操作建议让 fxaa 在模糊之后执行否则先抗锯齿再模糊模糊会把边缘的锯齿重新“糊”回来抗锯齿等于白做。Cesium 提供一个排序接口postProcessStages内部可以通过scene.postProcessStages.fxaa的enabled和active配合调节我项目里的做法是把自动 fxaa 关掉改用viewer.scene.postProcessStages.add自定义顺序避免默认排序带来的不可控问题。6.3 高 DPI 屏上后处理纹理模糊问题最后说一个特别隐蔽的坑在 MacBook 这类高 DPI 屏幕上CSS 像素和设备像素不一样如果你在 uniforms 里传canvas.clientWidth而不是drawingBufferWidth后处理的u_texelSize会算错导致泛光采样半径偏大画面看起来像蒙了一层雾。正确做法是始终用viewer.scene.drawingBufferWidth和viewer.scene.drawingBufferHeight来算纹素大小而不是用container.clientWidth。这个细节我前前后后调了两天一度以为是 shader 写错了最后发现是分辨率来源用错了地方。另外屏幕缩放时纹理尺寸变化需要重新设置u_texelSize这一点我前面已经提过值得再重复一遍因为它是后处理消失/错位的头号原因。写在实操之外的一些建议后处理这个东西最忌“一步到位全菜单堆上去”。我自己的习惯是先把所有 stage 都单独做成可配置项用界面调试确认效果后再固化成代码。调参的时候一定要开着帧率监视面板看 GPU 压力很多视觉上的“高级感”只要花 0.5ms 就能换到但有些效果要花 5ms后者在项目里往往留不住。就像我项目里经常跟同事说的泛光和暗角是入场券边缘检测才是加分题但这张券得控制在 2ms 以内加分题做不到就用近似方案替代别让后处理变成项目优化的新瓶颈。