资讯动态

Cesium后处理实战:管线原理、内置效果与自定义着色器

发布时间:2026/10/13 5:09:13 来源:尧图企业网站定制
做三维GIS项目的人应该都有这种感觉模型、地形、矢量图层叠了几十层业务功能也都通了但画面总差一口气——标签平铺直叙飞行漫游没有代入感夜间场景的灯光怎么看都不像真的。这个缺口通常不是建模问题也不是数据问题而是画面缺了一道后处理。Cesium 后处理效果就是在这个阶段入场的。它做的事情其实很直白在真实感渲染完成之后把整帧画面当作一张输入纹理再做一轮甚至多轮像素级加工最后才输出到屏幕上。泛光、景深、边缘描边、调色、模糊都属于这个范畴。你不需要改动业务数据不需要重建模型渲染管线加几个阶段就能让整个场景的“质感”上一个台阶。这篇文章我打算把我的实操经验完整梳理一遍。不写那种贴官方文档式的空话重点讲三件事Cesium 后处理的管线逻辑是什么内置效果怎么快速落地以及如何自己写一个可运行的自定义后处理效果。最后附上一份我在多个项目里踩过的坑列表给正在调效果的同行一点参考。1. 后处理到底是什么先把这层窗户纸捅破很多刚接触三维开发的同学会把后处理和“特效”混为一谈觉得它像 CSS 的 filter 一样往对象上叠加样式而已。这个理解只对了一半。后处理不是作用在某个模型上的而是作用在“已经渲染好的整张画面”上的。1.1 一张画面纹理引发的“再加工”三维引擎每帧的渲染流程可以简化成三步场景里的所有几何体经过顶点变换、光栅化、片元着色得到一张包含颜色信息的画面这张画面对应一张纹理也就是 color texture同时硬件还会生成一张深度纹理 depth texture记录每个像素离相机的远近之后画面可能还要经过若干次全屏片元着色器的处理再最终呈现到显示器上。Cesium 后处理效果控制的就是第三步。你可以把viewer.scene.postProcessStages理解成一条流水线流水线上的每个工位都是一个PostProcessStage。每个工位拿到上一站传来的纹理用自己那段 GLSL 片段着色器逐像素处理再输出给下一站。最后一个工位的输出才是显示器上最终看到的画面。这个机制和我们平时给图片加滤镜非常像。你在手机相册里对一张照片做美白、加锐化、调整色温每一步都是拿上一步的结果继续加工。后处理在三维场景里干的也是这件事只不过每一步都在运行时实时执行而且输入输出都是完整的三维画面。1.2 为什么偏要用后处理解决视觉问题实际项目里我见过不少同类场景夜视效果、黑白监控画面、目标高亮描边、漫游时的景深模糊。这些如果不用后处理实现成本会非常难看。拿目标高亮描边来说不用后处理的方案是先遍历所有图元、读它的深度和法线再通过几何计算往外扩一圈边线。遇到千万级三角面的倾斜摄影数据这个方案会卡到怀疑人生。而后处理方案完全不关心场景有多复杂它只关心“当前像素是什么、旁边是什么”一个边缘检测算子跑完所有物体的轮廓都出来了。更重要的是后处理天然能“一帧通杀”。无论画面里是地形、模型、标签还是动态粒子只要最终颜色到了这一帧后处理就能统一影响。这对多源数据汇聚的可视化项目特别友好。不用分别去改每个图层的材质十几种数据源最后都会被同一个后处理阶段接管。提示后处理不是模型特效的替代品。如果你想做单个实体专属的流光、发热、呼吸效果优先考虑自定义 Material 或者 Primitive 着色器后处理更适合做全局性、帧级、氛围级的视觉加工。两者结合使用才是完整方案。2. PostProcessStage 的底子集合、纹理与执行周期真正上手之前我建议先把 Cesium 后处理涉及的对象关系摸清楚。否则你会遇到一个特别典型的困惑明明用官方示例创建了一个后处理效果但加了之后没反应或者加了一堆阶段不知道谁先谁后。2.1 场景里的后处理集合和单个阶段Cesium 中每个Viewer的scene下都挂了一个postProcessStages对象类型是PostProcessStageCollection。它管理了场景内所有后处理阶段并提供add、remove、getStageByName这些常规操作。代码一般长这样const stage new Cesium.PostProcessStage({ name: my-stage, fragmentShader: myShader }); viewer.scene.postProcessStages.add(stage); // 随时可以移除 viewer.scene.postProcessStages.remove(stage);集合里还内置了几个常用效果postProcessStages.fxaa、postProcessStages.bloom、postProcessStages.ambientOcclusion。这几个不需要你手动创建开个开关就能生效算是最省事的一档。单个PostProcessStage才是真正干活的单元。它内部包含一段可执行的完整片段着色器以及一组可暴露给着色器的 uniform 参数。通过修改stage.enabled或者stage.uniforms.xxx可以实时控制效果和参数。多个单阶段又可以组成PostProcessStageComposite。这个复合体存在的意义是为了处理“一个完整效果需要串行多个 pass”的情况。比如一个高质量的景深效果往往要先降采样模糊、再计算前景背景深度差、最后合成输出。把几个独立阶段拼成一个复合体对调用方来说反而变成了一整个效果单元容易管理。2.2 一帧画面在管线里是怎么走的我拿一个三阶段管线举例帮助理解执行顺序第一帧渲染开始时Cesium 先正常绘制所有场景对象颜色结果写入颜色纹理。第一个后处理阶段采样颜色纹理经过自己的处理输出一张新纹理。第二个阶段采样的不再是原始场景纹理而是第一个阶段的输出。后续阶段同理。所有阶段跑完后最终纹理被绘制到画布上。这里有个非常关键的认知每个后处理阶段默认拿到的colorTexture是“当前管线传入的纹理”不一定是原始场景渲染结果。如果你写了多个后处理阶段最后一个阶段里采样到的颜色已经混合了前面阶段的加工结果。所以多阶段效果叠加时一定要想清楚顺序。另外后处理阶段并不一定非要全屏串行执行。Cesium 在多阶段时可以并行处理互不依赖的阶段减少不必要的等待。但这属于引擎内部的优化调度我们写业务代码时不需要额外干预只要别人为制造伪依赖就行。2.3 内置效果工厂的取舍Cesium 提供了Cesium.PostProcessStageLibrary里面有很多直接返回PostProcessStage或复合体的工厂函数。模糊、黑白、边缘检测、夜晚模式、景深、轮廓描边几乎不用自己写 GLSL 就能落地。我的习惯是凡是库里有的效果一律先用它兜底只有库里没有或效果细节要求特别偏门时才自己写着色器。这样做的原因不只是省代码。内置效果是官方维护的随版本升级会不断修正兼容性问题。自己写的着色器如果踩到某个旧显卡的精度坑排查成本很高。能用现成的就别重复造轮子这是三维可视化开发里一个很朴实的节约手段。3. 内置效果实战泛光、模糊、景深与描边这节直接放代码。我给的是在真实项目里能用通的写法涉及的效果尽量选高频的。3.1 打开一个柜台上就有的泛光效果Cesium 自带的泛光效果挂在scene.postProcessStages.bloom上不需要创建打开开关就行const bloom viewer.scene.postProcessStages.bloom; bloom.enabled true;默认参数往往刺激亮部会很容易过曝。我通常会把对比度调低一点也可以用glowOnly直接输出“只显示泛光部分”的画面方便调试阈值bloom.uniforms.contrast 60; bloom.uniforms.glowOnly true;glowOnly在调参时非常有用。你先打开纯泛光输出看哪些区域被选入泛光范围再把亮度阈值调到只覆盖自己想突出亮区的位置。调完记得把它关掉恢复成合成画面。泛光适合的场景是夜景、城市灯光、告警闪烁。白天场景不加也行加了反而容易把天空云层搞成一片白。3.2 用库函数创建模糊和黑白效果模糊效果适合做“进入某一个区域后周边视野模糊化”的沉浸式效果。官方库里的createBlurStage可以直接用const blurStage Cesium.PostProcessStageLibrary.createBlurStage(); blurStage.enabled false; viewer.scene.postProcessStages.add(blurStage); // 某个时机触发 blurStage.enabled true; blurStage.uniforms.delta 1.2;delta控制模糊偏移量。数值往大了调画面会糊得更厉害调太大容易采样到纹理边缘出现奇怪的贴图重复感。我习惯把 delta 控制在 1.0~2.5 之间超过 3.0 基本要引入额外的边缘处理了。黑白效果就更简单了const bw Cesium.PostProcessStageLibrary.createBlackAndWhiteStage(); bw.enabled true; viewer.scene.postProcessStages.add(bw);黑白效果很适合“监控状态切换”“历史模式”“低功耗模式”的交互场景。不过贴一个纯黑白会丢失很多画面层次我的建议是后面串一个轻微的颜色映射把对比度再压低一点观感更高级。3.3 景深与轮廓描边景深效果模拟相机聚焦特定距离、背景模糊的视觉效果。官方有createDepthOfFieldStage会给一个可用的景深效果。它依赖深度纹理来判断聚焦范围。旋转视角、移动相机时效果会有明显差异建议在相机路径上多做几轮预览。轮廓描边是另一类高频需求比如把某个重点建筑、某条规划路线突出出来。最简单的方式是先做边缘检测再做反色合成const edge Cesium.PostProcessStageLibrary.createEdgeDetectionStage(); const silhouette Cesium.PostProcessStageLibrary.createSilhouetteStage(); viewer.scene.postProcessStages.add(edge); viewer.scene.postProcessStages.add(silhouette);这两个效果一起用场景里所有几何体的边缘会泛出一层描边。如果你只想给特定对象描边库函数不够精细需要自定义。自定义方案我在下一节用完整例子演示。3.4 效果的启停与临时调试调试后处理时最常见的痛点是效果太多分不清哪个在起作用。我推荐一个土办法在界面上放一排复选框把每个 stage 的enabled暴露出去逐个开关看效果。调试完成后这排开关还能顺手变成演示模式的快捷入口一举两得。function toggleEffect(stage, checked) { stage.enabled checked; }这比反复改代码注释、刷新页面高效得多。尤其在后处理参数联动时实时开关能帮你快速确认“上一个阶段对后续阶段的污染”到底来自哪里。注意stage.uniforms的字段名必须与着色器里声明的 uniform 变量完全一致。拼写差一个字母控制台不报错参数就是死活不生效。我在刚上手的时候被这个坑折腾过一整天。4. 自定义后处理效果一个区域高亮描边的完整实现只靠内置效果永远解决不了“现场感”问题。项目做深了你一定会走到自定义着色器这一步。所以我用一个“区域高亮描边”的例子把自定义后处理从零到一拆完整。4.1 自定义着色器里到底有哪些输入创建一个自定义PostProcessStage时fragmentShader 里可以直接引用的内置变量主要是这几个v_textureCoordinates当前片元在屏幕纹理上的 UV 坐标。colorTexture传入的颜色纹理采样器。depthTexture传入的深度纹理采样器。out_FragColor当前片元的最终输出颜色。一个最朴素的“原样输出”着色器长这样uniform sampler2D colorTexture; in vec2 v_textureCoordinates; void main() { out_FragColor texture(colorTexture, v_textureCoordinates); }这段代码虽然什么都没做但是验证管线是否走通的第一块“试金石”。新建一个空 stage把这段着色器挂上去画面如果没有任何变化说明管线正常如果发黑、发白、花屏问题多半出在着色器输出或纹理采样上往下排查才靠谱。4.2 高亮描边的思路拆解“区域高亮描边”想达到什么效果呢简单说场景里大多数物体保持原样但某些区域内的物体边缘出现明显的亮色描边。这个需求如果用常规思路——去改每个图元的材质或几何边线——在大场景里性能完全扛不住。后处理的思路相对巧妙先用深度或颜色特征判断“当前像素是不是边缘”再判断“当前像素的深度/位置是不是处在目标区域范围内”两个条件都满足就把颜色替换成高亮色。下面是个简化但能跑通的示例用深度纹理做区域判断uniform sampler2D colorTexture; uniform sampler2D depthTexture; uniform sampler2D previousDepthTexture; in vec2 v_textureCoordinates; float getDepth(sampler2D tex, vec2 uv) { return texture(tex, uv).r; } void main() { vec4 color texture(colorTexture, v_textureCoordinates); float depth getDepth(depthTexture, v_textureCoordinates); // 假设目标区域深度值小于 0.92照顾相对靠前的物体 float inRegion step(depth, 0.92); // 一个简单的中心差分对比左右上下四个邻域的深度差差大说明是边缘 vec2 texel vec2(1.0) / vec2(textureSize(colorTexture, 0)); float dL getDepth(depthTexture, v_textureCoordinates - vec2(texel.x, 0.0)); float dR getDepth(depthTexture, v_textureCoordinates vec2(texel.x, 0.0)); float dD getDepth(depthTexture, v_textureCoordinates - vec2(0.0, texel.y)); float dU getDepth(depthTexture, v_textureCoordinates vec2(0.0, texel.y)); float edge step(0.05, abs(dL - dR) abs(dD - dU)); // 边缘且处于目标区域则叠加亮色 vec3 targetColor vec3(1.0, 0.3, 0.2); color.rgb mix(color.rgb, targetColor, edge * inRegion); out_FragColor color; }这段代码的核心是把深度纹理当成一张“深度图”做边缘检测。同一平面上的相邻像素深度接近深度差很小边界处前后物体深度突变邻域差立刻放大。再叠加区域条件就只高亮目标区域内的轮廓。你可能会问为什么不直接用颜色纹理做边缘检测因为颜色纹理受光照、阴影、纹理图案的干扰太大。地面上一条白色车道线可能被识别成边缘但深度纹理不会。深度只反映远近关系边缘检测更稳定。注意这里用的深度值并非严格线性的世界坐标距离它和相机近远裁剪面关系很大。不同相机状态下“深度小于 0.92”的物理意义会变化。正式项目里建议先用一个纯深度可视化着色器把场景深度输出成灰度图肉眼确认阈值范围再套进上面的代码。4.3 暴露一个能实时控制的参数上一步的着色器写死了0.92这个区域阈值很不好调。更合理的做法是把它暴露成 stage 的 uniformconst stage new Cesium.PostProcessStage({ name: region-outline, fragmentShader: shaderText, uniforms: { regionDepth: 0.92, highlightColor: new Cesium.Color(1.0, 0.3, 0.2, 1.0) } }); // 业务代码里动态更新 stage.uniforms.regionDepth 0.80; stage.uniforms.highlightColor Cesium.Color.YELLOW;对应的着色器里这样声明uniform sampler2D colorTexture; uniform sampler2D depthTexture; uniform float regionDepth; uniform vec4 highlightColor; in vec2 v_textureCoordinates;这里有一个非常实用的经验Cesium 的uniforms对象本身是公开的修改它不需要重建 stage也不需要重启渲染。你可以把参数绑到 UI 的 slider 上拖动时直接看画面变化。这比刷新页面、重新编译着色器快太多了。4.4 多阶段叠加时的纹理关系如果你在自定义阶段后面又加了一个后处理后面的阶段采样到的colorTexture已经包含了自定义阶段的输出。这不是问题但很容易引发理解偏差。举个例子你先加了一个黑白效果再加了一个“区域高亮描边”。如果描边效果在黑白之后执行它采样的颜色已经是灰的描边叠加亮色时会显得突兀。反过来如果描边在黑白之前执行黑色转换会把描边颜色一起压成灰色等于白加了。解决办法是规划好阶段顺序。Cesium 的postProcessStages.add按照添加顺序执行调试时先明确“黑白是最后一道全局化效果描边必须在它之前”。我给自己的团队定的规矩是调色、风格化效果统一放最前面或最后面几何特征提取类效果描边、高亮放中间避免互相干扰。5. 性能优化与发布前的坑后处理效果好做性能难控。我见过不少项目功能开发得很顺利后处理一挂上帧率直接腰斩。这里有几个方向和坑值得在动工前就规划好。5.1 全屏像素运算的成本意识每个后处理阶段本质上是屏幕每个像素执行一次片段着色器。假设最终画面分辨率是 1920×1080那一次全屏 pass 至少要跑 200 万像素。着色器里如果再有几次循环采样计算量直接翻几倍。一个模糊着色器如果写 7 个采样点像素运算大约是单 pass 的 7 倍。3 个模糊 stage 叠加屏幕像素运算量非常可观。所以我的性能原则很明确能用单个着色器完成的处理不要拆成三个 stage。模糊、景深这类效果尽量先用小分辨率再放大叠加视觉效果差异往往不大。后处理只在需要的时候开比如进入漫游模式再启用景深平时保持关闭。移动端优先减少阶段数量少用重度循环不要在片段着色器里写深度遍历。5.2 深度纹理的一些细节自定义效果里如果采样了 depthTexture要留意部分设备、部分浏览器对深度纹理读取的支持差异。同一段深度采样代码桌面端完全正常移动端可能读出来是空白或异常值。我的备选方案是在代码里做降级用PostProcessStageLibrary.createDepthOfFieldStage这类内置效果时如果发现目标设备深度相关的效果表现不对先确认驱动和浏览器是否支持 WebGL 的深度纹理扩展再考虑用颜色纹理近似替代深度判断。后处理本来就是锦上添花不能让它变成项目上线的主风险。5.3 常见问题速查表现象常见原因处理思路后处理开启后整屏黑屏或白屏着色器里没有正确给out_FragColor赋值或采样纹理坐标越界先换成最朴素的“原样输出”着色器验证管线uniform 参数不生效uniform 变量名拼写不一致或类型不对逐字比对着色器声明和 JS 侧uniforms对象画面没有变化控制台也不报错效果被加到了集合末尾但被后续阶段覆盖检查阶段添加顺序把调试目标挪到最后一个位置边缘检测效果在原地面纹理处大量误报颜色边缘检测受纹理干扰改用深度纹理做边缘检测或者增加阈值限制泛光开启后整体过曝泛光阈值太低把非高亮区域也圈了进来用glowOnly调试泛光选区再调阈值和亮度多个区域高亮后效果不一致深度阈值和相机距离不匹配用深度可视化着色器先确认阈值对应的物理距离后处理开启后帧率直线下滑阶段数量多或着色器循环采样太重减少 pass、降低模糊采样数、仅在需要时开启5.4 版本升级带来的兼容风险Cesium 是个迭代很频繁的开源库后处理相关的 API 也在持续调整。早期版本里后处理集合的某些字段写法到新版本里可能换了名字或者内置效果的行为发生了变化。这点在二次开发里特别容易踩雷。我的处理方式是把 Cesium 版本锁定并在项目里做一次“后处理相关 API 的可选集封装”。所有对后处理阶段的创建、启停、参数修改都走自己封装的模块不直接在业务代码里散落viewer.scene.postProcessStages.add。升级库版本时只改封装层不用满项目翻修改。export function addPostProcess(name, shader, uniforms) { const stage new Cesium.PostProcessStage({ name, fragmentShader: shader, uniforms }); viewer.scene.postProcessStages.add(stage); return stage; }这样的封装看着多了一层但长期维护回报很高。尤其是项目中后期后处理效果越来越多没有一个统一出入口排查问题的成本会指数上升。6. 经验清单和最后一个调试技巧最后再分享几条我个人沉淀下来的经验不光是技术还包括工作习惯。第一个经验后处理效果必须放到真实业务场景里调试。在空场景里调出来的泛光和边缘效果一加载倾斜摄影、模型、粒子系统之后几乎必然翻车。因为后处理是全局性的画面内容一变它的反馈就完全不一样。我的流程是先在小场景里验证着色逻辑正确再放到最复杂的业务场景里调参。第二个经验调后处理时把 UI 面板和代码分离。我的做法是后处理阶段全部加一个 name然后在前端界面上生成一个简单的控制面板用 stage.name 去定位效果开关和参数全部实时绑定。这样不但自己调试方便交付给演示人员之后他们也能自己微调参数不必每次找开发。第三个经验给每个后处理效果写一行注释记录“这个效果用了哪张纹理、依赖哪个阶段、适用场景限制”。看起来是件小事但后处理效果一多没有注释根本记不住哪个阶段在干什么。尤其过了两三个月再回来维护注释能救命。还有一个最实用的小技巧自定义后处理着色器里临时把out_FragColor赋值为纹理的某一通道可以用来可视化某个中间量。比如out_FragColor vec4(vec3(depth), 1.0);就能直接看到深度纹理的分布。我在调试深度阈值时几乎每次都这么干比自己脑补参数省太多时间。Cesium 后处理效果的成长期其实很短。管线逻辑搞明白、内置效果跑顺之后真正拉开差距的是对像素级加工的理解什么效果用颜色纹理就能做什么效果必须借助深度纹理什么阶段该先执行什么参数该暴露给外部。这些判断力没有捷径一场一场调出来的项目里会慢慢形成肌肉记忆。希望这篇文章的实操内容能让你少走几段弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑