
做Cesium进阶级开发迟早要跟着色器Shader打交道。大多数人在Cesium里建个实体、换个颜色、贴个图觉得好像已经够用了一旦遇到水面波动、扫描线、雷达渐变、动态高亮、热力图或者想把ShaderToy上那些炫酷的效果搬到三维地球上就会发现内置API完全不够用。这篇内容就是我自己在Cesium里折腾ShaderToy风格效果时积累的一套方法包括原理拆解、选型思路、能直接跑的案例代码还有踩坑记录适合已经会基本Cesium操作、想往可视化底层再走一步的开发者。先声明一点Cesium里的着色器不是只有一种玩法至少能分出四条路线每条路线的适用场景、性能特征和坑都完全不同。如果一开始就走错路线后面会非常别扭。我会把每条路线讲清楚再给三个能直接抄的案例最后分享排查问题的经验。1. 整体设计与思路拆解Cesium里为什么要碰着色器1.1 内置材质与真实需求之间的差距Cesium内置了一些材质像Color、Stripe、Grid、PolkaDot这些用来画个纯色墙、条纹面确实没问题。但真实项目里需求往往更刁钻比如动态水面要随视角和时间变化雷达扫描要有波纹扩散和衰减边界倾斜摄影模型要高亮选中楼栋、其余部分压暗热力图需要按数据密度做颜色渐变。这些用内置材质加贴图很难做到强行写Canvas纹理又卡效率正确的做法是用GLSL在GPU上算每一帧动态生成结果。我记得去年做数字孪生项目甲方要求水面必须看起来像真的能反射楼宇灯光内置材质顶多能调个透明度加个棋盘格完全压不住场子。最后就是写了自定义的Fabric材质把法线扰动、菲涅尔反射、颜色混合都放到shader里效果立刻就不一样了。这不是花活而是当你的可视化场景复杂度上来以后shader几乎就是唯一选择。当然用shader不只是为了炫技。在Cesium里用gl_FragCoord做后处理或者在3D Tiles上逐片元改色可以绕过CPU端的频繁数据更新把大量颜色计算放进GPU管线对性能提升也很明显。尤其是需要做单体化、动态光照、热力融合这些场景时片元着色器的并行计算优势是CPU端Canvas完全不能比的。1.2 ShaderToy风格与Cesium渲染体系的本质差异ShaderToy上大量效果都是全屏片元着色器Fragment Shader输入一般是时间、鼠标、分辨率输出是一帧图像。它没有顶点逻辑整个世界都在gl_FragCoord里算。Cesium是完整的3D引擎有顶点着色器、片元着色器、材质系统、光照模型、渲染顺序、帧缓冲等多层结构。所以直接从ShaderToy复制一份代码到Cesium里几乎不可能直接跑出来。具体差异有三点入口函数不同ShaderToy用mainImage(vec4 fragColor, vec2 fragCoord)作为入口Cesium的材质shader用czm_getMaterial()这类接口而后处理shader直接写void main()。内置变量命名不同ShaderToy提供iTime、iResolution、iMouseCesium里对应的是czm_frameNumber、czm_viewportOrtho2D或者自定义uniform这些命名不统一不熟悉的话很容易迷路。语义不同ShaderToy对纹理采样用iChannel0Cesium后处理用sampler2D colorTexture而且需要自己在main里调用texture2D取出场景渲染结果再叠加效果。理解了这几点移植ShaderToy效果就不是靠运气乱试而是有明确套路了。后面我会专门讲移植五步法。1.3 四条技术路线的选型对比Cesium里用shader至少能走四条路线很多新手上来就瞎猜遇到问题也不知道是不是路线选错了。我把它们整理成一张表每条路线的入口API、适用对象、能改shader到多深、以及我推荐的优先级。技术路线核心API适用对象可控程度推荐场景Fabric材质Cesium.MaterialPrimitive、Entity的材质参数中等只能改片元着色器相关逻辑水面、扫描、动态纹理CustomShaderCesium.CustomShader3D Tiles瓦片模型较高可改顶点和片元倾斜摄影高亮、单体化PostProcessStageCesium.PostProcessStage全屏后处理效果高整帧任意处理滤镜、描边、像素化自定义Primitive/AppearanceCesium.Appearance完全自定义几何体最高完全接管渲染状态复杂特效、性能极致优化我的经验是最常用的其实是前三条。自定义Primitive功能最强大但工作量也成倍增加如果不是做底层引擎的一般人用不太到。把前三条吃透足以覆盖绝大多数业务需求。选型还有一个原则能放在顶点着色器里的逻辑不要放到片元着色器能后处理一帧解决的事不要拆成几十次draw call。这些看着是小事真正上线以后帧率差距巨大。2. 核心原理拆解Cesium着色器体系与ShaderToy移植心法2.1 Fabric材质被低估的自定义能力Fabric是Cesium官方提供的一套材质声明语法你不需要自己去注册整个Appearance只需要在Material里写一段GLSL片元着色器代码声明一些uniform参数Cesium会自动帮你接入渲染管线。这个机制经常被教程一句话带过但实际用处非常大。一个典型的Fabric材质结构是type、uniforms、source三部分。source里面写czm_getMaterial(czm_materialInput materialInput)函数返回一个czm_material结构体。czm_material里有diffuse、specular、emission、alpha这些字段设置它们就等于设置了材质光照属性Cesium会根据场景光照自动计算结果。这里最重要的是czm_materialInput它包含当前片元的st坐标、纹理坐标、法线、位置等数据。写动态水面就用st做范围用时间uniform做波动相位最后把算出来的法线写入material.normal立刻就有立体水波效果。Fabric材质还有一个隐藏优势它天然兼容Cesium的光照系统不需要自己计算太阳光、环境光的方向省掉很多世界坐标转换的体力活。需要注意uniform的类型映射。Fabric的uniforms里Cesium.Color、Number、Cartesian2这些会被自动转成GLSL里的vec4、float、vec2。如果传错类型比如想要float却给了字符串编译期不会报错渲染时会出现莫名结果这个调试起来非常难受。所以我建议uniform数量一多就把命名规范固定下来比如时间类后缀_time颜色类后缀_color用起来清爽很多。2.2 CustomShader3D Tiles的官方入口Cesium从某个版本开始提供了Cesium.CustomShader可以直接作用在3D Tiles上不需要你去拆bin、改模型这算是对普通开发者最友好的一条官方通道。和Fabric不同CustomShader区分顶点着色器和片元着色器分别提供vertexMain和fragmentMain两个函数而且函数签名固定接收一个VertexInput或FragmentInput输出一个czm_modelVertexOutput或czm_modelMaterial。在片元着色器里FragmentInput包含attributes、material这些字段你可以直接读模型的属性值。举例来说要对一栋楼做动态高亮可以读取属性里的唯一ID判断是否等于目标ID是就把material.diffuse设为亮色否则压暗。这就是单体化最常见的一种实现思路不需要后端配合纯前端着色器解决。CustomShader的性能特点值得单独说。它作用于整个瓦片集的所有片元因此如果shader里用了条件分支而分支判断对每个片元结果不同GPU的并行效率会明显下降。尽量把分支改成数学运算比如用step函数做门限用mix做颜色插值。这个习惯对后处理同样适用。2.3 PostProcessStage把ShaderToy直接搬到地球上PostProcessStage是Cesium里最接近ShaderToy的入口。它的本质是在场景渲染完成后把当前的colorTexture传给你的shader由你重新算出每个像素的颜色再输出到屏幕。这就和ShaderToy在fragCoord上画图是同一套思路所以它是移植ShaderToy效果的首选路线。用PostProcessStage做效果时shader的输入除了colorTexture以外还可以拿到depthTexture能用来做扫描线遮挡、视差效果、描边这些深度相关的处理。你还可以通过uniform传时间、鼠标位置、分辨率等参数动态控制效果强度。需要注意Cesium里PostProcessStage的uniform支持常量和函数两种传法函数能实时更新值非常适合时间动画。我曾把ShaderToy上一个雷达波纹效果移植到PostProcessStage整体思路就是把mainImage里的iTime换成czm_frameNumber / 60.0把vec2 uv fragCoord / iResolution.xy换成vec2 uv gl_FragCoord.xy / resolution.xy把最终输出从fragColor ...改成gl_FragColor ...。这其实就是ShaderToy移植到Cesium的通用框架。2.4 从ShaderToy到Cesium的移植五步法这一步值得单独讲因为很多人就是卡在不知道怎么把那些炫酷的ShaderToy效果翻译到Cesium里。我总结了一套固定的五步法照着走基本不会跑偏去掉mainImage包装把代码里的void mainImage(out vec4 fragColor, in vec2 fragCoord)改成void main()fragCoord换成gl_FragCoord。替换时间变量ShaderToy用iTimeCesium里可以自己定义uniformu_time然后在外部用scene.clock.currentTime或帧号更新。最简单的是uniforms: { u_time: () performance.now() / 1000.0 }。替换分辨率变量ShaderToy用iResolutionCesium里设resolution: () new Cesium.Cartesian2(drawingBufferWidth, drawingBufferHeight)shader里再乘上1.0转成浮点。处理输入纹理如果效果还用到了外部纹理ShaderToy是iChannel0Cesium里用sampler2D colorTexture需要时用texture2D采样。调整输出颜色范围Cesium后处理的颜色范围是线性空间还是sRGB空间可能与ShaderToy差异较大如果发现颜色发灰可以在shader尾部做一次伽马校正gl_FragColor pow(color, vec4(1.0 / 2.2))。这个套路我用了很多次95%的ShaderToy效果都能在一个小时内移植过来。剩下的5%大多是因为涉及多纹理通道或者依赖WebGL2独占特性需要额外改纹理格式或FFT计算。3. 实操过程三个能直接落地的着色器案例3.1 案例一动态水面材质Fabric实现先说需求要在数字孪生场景里做一条河水面要有波动感、能反射楼宇灯光、颜色要自然的蓝绿色不能像贴图一样死板。位移动态水面其实只要一个片元着色器就能搞定。核心思路是把片元坐标st乘上一个频率系数加上时间产生波的传播相位用sin和cos组合出多层波浪再把这些波浪合成到法线上让光照看起来像真的有起伏面。最后把diffuse设成水的颜色加上specular高光alpha略微透明。下面是我的简化代码基于Fabric的czm_getMaterial接口const waterMaterial new Cesium.Material({ fabric: { type: CustomWater, uniforms: { speed: 0.8, frequency: 8.0, amplitude: 0.08, baseColor: Cesium.Color.fromCssColorString(#2a6f8f) }, source: czm_material czm_getMaterial(czm_materialInput materialInput) { czm_material material czm_getDefaultMaterial(materialInput); vec2 st materialInput.st * frequency; float time czm_frameNumber * 0.016 * speed; float wave1 sin(st.x time) * cos(st.y time * 0.8); float wave2 sin(st.x * 1.7 st.y * 0.6 time * 1.3) * 0.5; float wave3 sin(st.x * 0.3 st.y * 2.3 - time * 0.4) * 0.3; float height wave1 wave2 wave3; vec3 normal normalize(vec3( -height * amplitude, 1.0, -height * amplitude * 0.7 )); material.diffuse baseColor.rgb; material.specular 0.5; material.normal normal; material.alpha 0.88; material.emission baseColor.rgb * 0.1; return material; } } });我实测过这个效果在光照打开的场景里水波会随视角和时间变化看起来有真实的隆起和阴影。如果想做湖面反射可以把emission调低、specular调高再把baseColor换成深色就能呈现出镜面质感。这类材质可以赋给一个Cesium.RectangleGeometry或者椭圆几何体放在场景里直接用。注意Fabric里不能随意使用sin以外的数学函数吗可以GLSL支持的数学函数基本都可用但要注意Cesium自带的一些内置uniform比如czm_frameNumber是整数想换算成秒要乘帧间隔不然动画速度会跟帧率挂钩。我在代码里用czm_frameNumber * 0.016粗略代表每秒约60帧如果场景实际帧率是30动画就会偏快更严谨的做法是定义外部uniform传performance.now()。3.2 案例二像素化后处理滤镜PostProcessStage实现像素化Pixelate是非常典型的ShaderToy风格效果很适合演示Cesium后处理的移植套路。它把屏幕分成一个个小格子每个格子内部都采样格子中心颜色画面看起来就像游戏里的马赛克。我直接用PostProcessStage实现shader长这样uniform sampler2D colorTexture; uniform vec2 resolution; void main() { vec2 uv gl_FragCoord.xy / resolution.xy; float pixelSize 6.0; vec2 gridUv floor(uv * resolution / pixelSize) * pixelSize / resolution; vec4 pixelColor texture2D(colorTexture, gridUv); gl_FragColor pixelColor; }然后在JS里创建stage并加入场景const pixelStage new Cesium.PostProcessStage({ fragmentShader: uniform sampler2D colorTexture; uniform vec2 resolution; void main() { vec2 uv gl_FragCoord.xy / resolution.xy; float pixelSize 6.0; vec2 gridUv floor(uv * resolution / pixelSize) * pixelSize / resolution; vec4 pixelColor texture2D(colorTexture, gridUv); gl_FragColor pixelColor; } , uniforms: { resolution: () { const width viewer.scene.drawingBufferWidth; const height viewer.scene.drawingBufferHeight; return new Cesium.Cartesian2(width, height); } } }); viewer.scene.postProcessStages.add(pixelStage);这段代码里最关键的其实就是gl_FragCoord.xy / resolution.xy这一步它把屏幕物理像素坐标换算成0到1的UV坐标是所有ShaderToy移植的基础。像素大小调成2到4效果比较细腻调成8以上就变成很夸张的像素风怎么用取决于项目气质。一个容易踩的坑是resolution如果用常量传一个固定的Cesium.Cartesian2窗口尺寸变了就不会更新画面会变形。所以要用函数动态返回特别在窗口resize之后drawingBufferWidth会变化函数版本能自动适配。我还试过在像素化基础上叠加扫描线只要在main里把gl_FragColor再乘一个基于uv.y的sin值就行。这些后处理效果加多了以后性能影响会累积所以实际项目里要控制stage数量或者做成可选开关。3.3 案例三倾斜摄影模型的动态高亮与单体化效果CustomShader实现单体化是Cesium项目里绕不开的话题尤其是倾斜摄影模型。常规做法是后端分析碰撞拾取然后前端把楼栋ID关联到瓦片属性上。这里用CustomShader在片元着色器里做高亮会非常直观。假设模型里每栋楼有自己的唯一ID存放在属性里比如buildingId。当鼠标拾取时把这个ID传给shader的uniformu_highlightId在fragmentMain里判断当前片元属于哪栋楼是就把material.diffuse设为亮色否则压暗成灰色。关键代码如下const highlightShader new Cesium.CustomShader({ uniforms: { u_highlightId: { type: Cesium.UniformType.FLOAT, value: -1.0 } }, fragmentShaderText: void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) { float id fsInput.attributes.buildingId; if (id 0.5 id u_highlightId) { material.diffuse vec3(1.0, 0.85, 0.3); material.emission vec3(0.6, 0.4, 0.05); } else { material.diffuse vec3(0.35, 0.35, 0.38); } } }); tileset.customShader highlightShader;如果属性名不是buildingId需要先用attribute声明Cesium的CustomShader支持通过attributes声明读取自定义属性也可以直接用内置的position、normal这些。具体读取方式可以看官方文档但思路是一致的。实际项目里我还把高亮和闪烁结合过u_highlightId为目标楼层shader里再加一个基于czm_frameNumber的脉冲因子让emission在小范围内周期性变化实现呼吸灯效果。这种动态高亮做出来以后甲方的观感提升非常明显而且整个逻辑都跑在GPU上CPU几乎没压力。要注意的是CustomShader在Cesium版本之间接口微调过几次老版本要用vertexShaderText、fragmentShaderText新版本仍然兼容。如果遇到报错先检查Cesium版本号不要照搬旧工程代码。3.4 额外指南动态光照与热力图融合的延伸思路上面三个案例覆盖了Cesium最主流的着色器入口但实际项目里ShaderToy效果的需求远不止这些。常见延伸还有动态光照和热力图。动态光照的核心思路是用shader把太阳方向、模型法线、时间都纳入计算模拟白天黑夜的光照变化。Cesium里法线信息在Fabric材质里天然可用在CustomShader里也能拿到所以只要把光照颜色与时间关联起来就能做出太阳移动、室内灯光渐亮的场景。热力图则更适合用后处理实现在CPU端把数据点渲染成圆形渐变再用PostProcessStage做颜色映射和平滑效果比传统的Canvas叠加流畅很多。这些延伸都建立在理解Cesium渲染管线和shader入口的基础上。真正掌握了Fabric、CustomShader、PostProcessStage三条路线你会发现可视化场景不再受限于内置组件很多看起来很难做的需求其实只是shader里几行数学运算的事。4. 常见问题与排查技巧实录4.1 官方文档没说透的坑第一个坑是WebGL1和WebGL2的差异。Cesium在有WebGL2的浏览器里默认使用WebGL2但在低端设备或某些浏览器里会回退到WebGL1。WebGL2下很多GLSL语法有扩展比如texture2D依然兼容但如果你在shader里写了texture函数在WebGL1下会报错或得不到结果。兼容写法是统一用texture2D或写个宏来判断版本。第二个坑是Fabric出材质属性名的冲突。Cesium自带的czm_getDefaultMaterial会返回一个默认材质如果你在自定义材质里只设置了一部分字段比如只改了diffuse忘了alpha大概率会出现不透明白块。我吃过这个亏解决方法是每次都在自定义shader里显式调一遍czm_getDefaultMaterial把所有字段初始化一遍再改。第三个坑是PostProcessStage的执行顺序。如果加多个后处理stage它们的执行顺序取决于加载顺序和order参数而不是添加顺序。做滤镜叠加时如果顺序不对效果会和预期完全相反。排查方法是在uniform里加一个u_mixRatio手动控制叠加比例先让一个效果为0另一个为1确认各自没问题再叠加。第四个坑是Cesium版本差异。比如CustomShader在1.85之前可能不能用PostProcessStage的老版本uniform不支持函数传值。我建议写博客或项目时锁死Cesium版本号升级框架前先用Demo验证一遍再上线否则shader可能突然崩掉。4.2 性能瓶颈与优化思路着色器并不是写得越长越好片元着色器是每个屏幕像素都要跑一遍的。分辨率越高像素越多GPU压力越大。如果后处理shader里有大循环或高密度纹理采样帧率会肉眼可见地往下掉。优化思路主要有三点。一是降低像素采样量比如像素化效果可以先降低后处理缓冲的分辨率再拉回屏幕大小。二是把重计算往顶点着色器挪很多与片元坐标无关的常量可以预先算好。三是尽量少用分支改成数学运算用mix和step替代if-else这对GPU的并行度特别友好。另外一个性能杀手是每帧创建新对象。比如在更新uniform时用new Cesium.Cartesian2一帧里可能创建了几百个临时对象JavaScript内存反复GC会导致帧率抖动。正确做法是创建一个全局临时变量每次更新值即可。这个细节很多人会忽略但优化后GC明显变少画面也稳定很多。4.3 调试着色器的三板斧最有效的方法是开Cesium的Layer和scene调试面板。Cesium内部有一个debugShowGlsl相关的调试入口打印场景里所有正在编译的shader源码。看到实际被拼出来的GLSL比对着官方文档瞎猜要快得多。其次是chrome的WebGL调试工具。在Chrome里打开WebGL的trace可以看到每一个draw call用的Program对象、uniform值和纹理绑定。Cesium后处理shader用的Program也是常规WebGLProgram同样能被捕获。用这些工具能精确看到uniform到底是0还是预期值很快就能定位问题。第三板斧是颜色debug。在shader里临时让某一块输出纯红色比如加一句if (uv.x 0.5) gl_FragColor vec4(1.0, 0.0, 0.0, 1.0);看红色区域是否出现在预期位置。如果有颜色则说明代码执行到了没有则说明前面有编译错误或uniform传值失败。这个方法虽然土但真的很管用尤其适合定位被优化的编译器分支。最后再说一个日常习惯在代码里写shader字符串时一定要用模板字符串或者把换行符转义清楚。Cesium会把shader拼进自己的源码里如果你漏了一个分号或者少了一个大括号报错信息往往不是指向你的地方而是指向Cesium内部源码行号很容易懵。我的做法是先把shader单独写到一个文件里在编辑器里格式化无误后再复制进JS可以省去大量找错时间。结尾一点个人总结玩Cesium着色器这几个月最大的体会是千万别把ShaderToy代码当成黑盒复制粘贴。它背后的数学原理、坐标映射和渲染层级才是真正值钱的部分。每次移植一个效果我不光看它好看还会拆开看它用了哪些函数、哪些坐标变换、哪些时间变量然后再思考Cesium的坐标系和渲染管线里哪里能对接。很多效果看似天马行空剥开以后无非就是sin、cos、mix、texture2D这些基础操作的组合。最后分享一个小技巧当你想验证一个shader效果时先用PostProcessStage做全屏验证再决定要不要移植到Fabric或CustomShader。全屏验证的成本最低出问题也最容易排查确认效果了再往具体材质上搬能省掉不少反复编译的等待时间。Cesium的着色器体系其实没有想象中那么神秘掌握了Fabric、CustomShader、PostProcessStage这三条路线再加上一遍又一遍地实践你会发现那些看起来很难搞的高级效果最终都会变成你工具箱里的常规操作。