ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Cesium自定义材质实战:从Fabric到雷达扫描与动态光照

Cesium自定义材质实战:从Fabric到雷达扫描与动态光照 做了几年的三维GIS项目我最深刻的感受是决定一个Cesium项目高级感的往往不是数据精度而是材质效果。贴一张卫星图、放几个点那只是把地球当成PPT真正让我自己满意的效果是雷达扫描一圈、卫星波束从轨道斜插而下、海面在真实日照下泛着波光——这些效果几乎全部依赖Cesium自定义材质。Cesium内置材质库确实够用但内置材质的表现很有限一旦要做动态光照、雷达探测、卫星视锥、热力扩散这类定制化效果就必须自己上手写材质。这篇文章我会把Cesium的材质体系、Fabric的两种写法、雷达扫描与动态光照的完整可复现代码以及我踩过的渲染问题和性能调优经验一并梳理出来适合正在做Cesium可视化、或者刚接手三维地球项目的同学。1. 先把Cesium的材质体系摸清楚1.1 材质到底跑在渲染管线的哪一层很多人第一次接触Cesium的材质会懵因为Cesium的渲染链路是几何体Geometry、实例GeometryInstance、图元Primitive、外观Appearance、材质Material一层层套起来的。我用一个毛坯房装修的类比来解释Geometry就是房子的结构是承重墙和户型Primitive是整栋楼负责把多个房间组织起来统一管理Appearance是装修风格决定你贴墙纸还是刷漆而Material就是那一桶墙漆本身墙漆里掺了什么颜料、有没有荧光、会不会随时间变色完全由Material控制。关键点在于Cesium里的Material并不是一张静态图片它本质上是一段会被编译进GPU着色器的GLSL代码每次渲染时GPU都要执行这段代码来决定当前片元的颜色和透明度。所以自定义材质的核心不是找一张好看的贴图而是写出一段能产生期望效果的着色器代码。理解了这一点你就能明白为什么Cesium自带的ColorMaterial、ImageMaterial只是最基础的材质而像水波纹、流光、雷达扫描这些动态效果全部来源于代码层面的实时计算。1.2 自定义材质的三条入口别只在Entity上打转Cesium的自定义材质主要有三条入口很多人只记得Entity上那个material参数这样思路就窄了。第一条是Entity层面的material属性rectangle、polygon、polyline、ellipse、corridor这些图形都有material字段。它的优势是上手最快几行代码就能给一块地或者一条线换皮肤适合做沙盘演示、平面标注、区域高亮。缺点是Entity本身封装了太多逻辑遇到批量创建、精细控制顶点格式的场景会有点力不从心。第二条是Primitive层面的Appearance典型代表是MaterialAppearance、EllipsoidSurfaceAppearance、PolylineMaterialAppearance。Primitive更接近Cesium渲染底层你可以自己指定Geometry、自己控制顶点格式材质照样用Fabric定义。大批量绘制、需要自己管理几何结构的时候这条路更可控。第三条是针对glTF模型的CustomShader。从Cesium 1.88之后开始支持现在已经稳定它允许你对模型内部的顶点和片元做自定义处理实现护盾、扫描线、流光这类模型级特效。CustomShader的语法和Fabric完全不同使用的是vertexMain、fragmentMain这种入口函数但思路是一致的通过GPU代码控制最终像素。如果你刚开始做自定义材质我建议从Entity加Fabric入手先把材质效果调出来再考虑是否下沉到Primitive或者CustomShader。直接上手底层会同时面对几何体、顶点格式、着色器继承等多重问题排查起来很麻烦。2. 用Fabric JSON定义第一个自定义材质2.1 Fabric是什么四个核心字段必须吃透Fabric是Cesium描述材质的一种JSON方言你不需要学一门新语言只需要按它的约定组织JSON结构Cesium会把JSON中的GLSL代码片段编译成最终可执行的着色器。我常用的Fabric字段有四个它们的配合关系并不难理解type材质唯一名称相当于给这套皮肤起个名字。这个名称不能跟Cesium内置材质重名比如Color、Image、Water这些都要避开否则会覆盖默认实现。uniforms声明你在GLSL代码中用到的uniform变量以及初始值。你可以把颜色、速度、透明度、半径这些参数都塞进来后续在外部还能动态改值。components用短小的GLSL表达式拼接材质Cesium会帮你生成完整的czm_getMaterial函数适合做简单渐变、颜色混合这类效果。source完整的czm_getMaterial函数整个函数体都由你自己写自由度最高动态扫描、波纹、波束这类复杂效果基本都走这条路。components和source是两种互斥的写法选一种就用到底。我的习惯是简单效果用components涉及到动画、空间坐标计算、多变量混用的效果老老实实用source因为source里能完整控制整个函数逻辑调试时思路更清晰。2.2 用components拼一个竖向渐变面先来一个最简单的例子感受Fabric是怎么工作的。我要在一块矩形区域上做一个从上到下的红蓝渐变用components只需要两行表达式const viewer new Cesium.Viewer(cesiumContainer, { infoBox: false }); const fabric { type: VerticalGradient, uniforms: { topColor: new Cesium.Color(1.0, 0.0, 0.0, 1.0), bottomColor: new Cesium.Color(0.0, 0.6, 1.0, 1.0) }, components: { diffuse: mix(topColor.rgb, bottomColor.rgb, materialInput.st.y), alpha: 1.0 } }; viewer.entities.add({ rectangle: { coordinates: Cesium.Rectangle.fromDegrees(110, 30, 120, 40), material: new Cesium.Material({ fabric: fabric, translucent: true }) } });这里最关键的是materialInput.st它是当前片元在几何体上的纹理坐标范围在0到1之间。st.y等于0代表底部等于1代表顶部用GLSL内置的mix函数在topColor和bottomColor之间做线性插值就得到了竖向渐变。如果你把st.y换成st.x渐变方向就变成水平方向试试看就能理解纹理坐标的威力。2.3 用source写完整GLSL函数雷达扫描圈的雏形渐变太简单真正体现自定义材质能力的是动态效果。我用一个雷达扫描圈来演示source写法这段代码可以直接跑起来const radarFabric { type: RadarScan, uniforms: { lightColor: new Cesium.Color(0.0, 1.0, 0.0, 1.0), scanSpeed: 3.0 }, source: czm_material czm_getMaterial(czm_materialInput materialInput) { czm_material material czm_getDefaultMaterial(materialInput); vec2 st materialInput.st; vec2 delta st - vec2(0.5, 0.5); float dist length(delta) * 2.0; float angle atan(delta.y, delta.x); float scanAngle fract(czm_frameNumber * scanSpeed / 60.0) * czm_twoPi; float angleMask smoothstep(0.05, 0.0, abs(angle - scanAngle)); float ringPhase fract(dist * 5.0 - czm_frameNumber * scanSpeed / 60.0); float ringMask 1.0 - smoothstep(0.08, 0.0, abs(ringPhase - 0.5)); float mask max(angleMask, ringMask); material.diffuse lightColor.rgb * mask; material.alpha mask * (1.0 - dist * 0.6); return material; } , translucent: true }; viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees(116.39, 39.9), ellipse: { semiMajorAxis: 20000, semiMinorAxis: 20000, material: new Cesium.Material({ fabric: radarFabric, translucent: true }) } });这段代码的逻辑分四步把纹理坐标平移中心点vec2(0.5, 0.5)是面片的中心length(delta)得到当前点到中心的距离后续用来做圆环。用atan求当前点的极坐标角度czm_frameNumber * scanSpeed / 60.0算出当前帧对应的扫描线角度两者做差用smoothstep做一条细线。用fract(dist * 5.0 - czm_frameNumber * scanSpeed / 60.0)制造往外扩散的波纹fract让数值循环回0到1波纹就会一圈圈往外推。把扫描线和波纹用max叠加乘上颜色再按距离做径向透明度衰减。czm_frameNumber是Cesium内置的帧号每渲染一帧自动加一。用它来做动画材质本身不需要外部更新任何参数就能自己动起来。这是写动态材质时最高效的方案比在tick事件里手动改uniform值省事得多。3. 实战拆解雷达、动态光照、卫星波束3.1 雷达扫描圈从一条扫线升级到多层波环上面的代码是雷达扫描的基础版实际项目中雷达探测图往往要同时显示多个波环、扫描扇区、目标高亮。我一般会在source里把波纹扩散和角度扫描拆成两个函数最后再合成这样可维护性更好。一个实用的增强是把单个圆环改成多圈只需要把ringPhase里的固定频率改成参数化float ringPattern(float dist, float phase) { float p fract(dist * 8.0 - czm_frameNumber * scanSpeed / 40.0); return 1.0 - smoothstep(0.1, 0.0, abs(p - phase)); }然后分别用0.2、0.5、0.8三个phase值叠加出三圈脉冲波。这样雷达就像真的有探测能力一样能看到能量一圈圈打出去。注意smoothstep的三个参数第一个是低阈值第二个是高阈值第三个是输入值。差值的绝对值越接近0输出越接近1这就是扫描线和波环能保持细线状的原因。实际操作中还有一个容易忽略的点ellipse的semiMajorAxis、semiMinorAxis单位是米如果数值写得太大或太小材质里的st并不会跟着拉伸它始终是0到1的归一化坐标。如果你想控制扫描圈的实际覆盖范围应该调整椭圆的长短半轴而不是在材质里做缩放。3.2 动态光照让建筑、水体看起来在呼吸动态光照是我在项目里用得最多的自定义材质之一常见场景包括夜晚城市楼宇的呼吸灯、水面模拟真实日照、被选中目标的高亮脉冲。原理都差不多用一个周期性函数驱动颜色和透明度。下面是我经常用的脉冲光照材质模板const pulseFabric { type: PulseLight, uniforms: { baseColor: new Cesium.Color(1.0, 0.9, 0.4, 1.0) }, source: czm_material czm_getMaterial(czm_materialInput materialInput) { czm_material material czm_getDefaultMaterial(materialInput); float pulse 0.5 0.5 * sin(czm_frameNumber * 0.1 materialInput.st.x * 8.0); material.diffuse baseColor.rgb * (0.6 0.4 * pulse); material.alpha 0.8 0.2 * pulse; return material; } , translucent: true };英文里sin(czm_frameNumber * 0.1 st.x * 8.0)这一项前半部分决定时间流速后半部分让同一帧内不同位置的亮度不一样形成流动感。如果把st.x换成positionMC.x还能做出从模型一端扫到另一端的流光扫过效果。这个材质挂在polygon上就是呼吸灯挂在polyline上就是动态脉冲线换汤不换药。如果你想让它和场景的真实太阳方向联动可以用Cesium内置的czm_sunDirectionWC它是世界坐标下的太阳方向向量和法线点积就能得到漫反射强度。这种做法的效果比单纯的周期函数更自然适合做日照分析、光影模拟。3.3 卫星波束视锥从锥体到渐变材质卫星波束和视锥效果通常是一个从卫星位置指向地面的半透明圆锥或圆柱需要在底端亮、顶端逐渐消失或者反过来。用Cesium的CylinderGraphics就能搭出这个几何形状再配上自定义渐变材质就很出效果。const beamFabric { type: SatelliteBeam, uniforms: { beamColor: new Cesium.Color(0.0, 0.7, 1.0, 1.0) }, source: czm_material czm_getMaterial(czm_materialInput materialInput) { czm_material material czm_getDefaultMaterial(materialInput); float gradient 1.0 - materialInput.st.y; material.diffuse beamColor.rgb * gradient; material.alpha 0.3 0.5 * gradient; return material; } , translucent: true }; const beam viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees(116.39, 39.9, 0), cylinder: { length: 500000, topRadius: 100000, bottomRadius: 20000, topCap: false, bottomCap: false, material: new Cesium.Material({ fabric: beamFabric, translucent: true }) } });这个锥体默认以实体位置为基准朝当地东、北、上方向延伸。如果你要让锥体真正指向某个卫星坐标就得给entity设置orientation用四元数把本地坐标系的Z轴转成卫星方向这一步涉及卫星星下点、轨道倾角等计算超出材质范畴但几何体的正确是材质效果呈现的前提。我当时调这个效果时踩过一个坑锥体默认从位置点向天顶方向延伸而卫星波束是从天上往下打所以往往需要把length、topRadius、bottomRadius的值反过来看再多加一个方向旋转才符合视觉直觉。4. 高级扩展Primitive材质挂载与模型CustomShader4.1 在Primitive上挂载自定义材质获得更强的控制力Entity用起来方便但当你需要绘制的是大量矩形、需要批量更新颜色、需要和业务数据紧密结合时Entity的开销和封装反而成了累赘。这时候可以下沉到Primitive自己创建Geometry再用MaterialAppearance把Fabric材质挂上去。下面这段代码展示了如何用RectangleGeometry配MaterialAppearance画一个带自定义材质的面const rectangleGeometry new Cesium.RectangleGeometry({ rectangle: Cesium.Rectangle.fromDegrees(110, 30, 120, 40), vertexFormat: Cesium.MaterialAppearance.VERTEX_FORMAT }); const primitive new Cesium.Primitive({ geometryInstances: new Cesium.GeometryInstance({ geometry: rectangleGeometry, id: custom-rect }), appearance: new Cesium.MaterialAppearance({ material: new Cesium.Material({ fabric: radarFabric, translucent: true }) }) }); viewer.scene.primitives.add(primitive);注意vertexFormat一定要用Cesium.MaterialAppearance.VERTEX_FORMAT这里面包含位置、法线、纹理坐标缺了纹理坐标MaterialAppearance的材质根本拿不到materialInput.st。我见过不少人的Primitive材质全部发黑十有八九就是Geometry的vertexFormat没配对材质代码里用的属性在顶点里根本不存在。Primitive相比Entity的另一个优势是同一个Fabric可以被多个Primitive共享改一个材质全部立即生效。如果你要给城市几万栋楼做统一的白天夜晚渐变用Entity会卡到没法操作用Primitive加一个共享材质则非常顺畅。4.2 模型级特效对glTF模型节点使用CustomShaderFabric只能管平面、线条、椭球这些Cesium原生几何体管不了glTF模型。想在模型上做节点高亮、扫描线、护盾就要用Cesium的CustomShader API。这是另一个层面的自定义着色器语法是用vertexMain和fragmentMain两个入口函数const customShader new Cesium.CustomShader({ uniforms: { u_scanColor: { type: Cesium.UniformType.VEC4, value: new Cesium.Color(0.0, 1.0, 0.0, 1.0) } }, fragmentShaderText: void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) { material.diffuse mix(material.diffuse, u_scanColor.rgb, 0.3); } }); model.customShader customShader;实际用法就是把customShader挂到model上。它和Fabric最大的区别是它直接作用在模型的原始材质结果上能读取到模型自身的PBR材质属性比如material.diffuse、material.normal等然后在这个基础上做叠加。我通常用它做目标高亮和预警效果让选中模型的边缘发出动态光晕同时保留原模型的纹理细节。如果是单纯换色直接改diffuse就行如果要流光扫过就在fragment里用世界坐标或模型坐标配合时间变量计算扫描带。CustomShader的具体函数签名在1.88到当前版本之间有过迭代如果你发现代码报错第一反应是去查对应版本的官方API文档。5. 性能调优与常见问题排查实录5.1 帧率、RenderError、报错窗口一个都不能回避动态材质写一堆性能跟不上等于白写。Cesium调试性能最直接的入口是帧率显示viewer.scene.debugShowFramesPerSecond true;打开后左上角会显示实时帧率。如果你发现设置了requestRenderMode渲染模式改为按需渲染动态材质会表现为“完全不转”因为Cesium默认认为场景没有变化就不触发重绘。解决方式是在tick事件里手动调用viewer.scene.requestRender()或者干脆别用requestRenderMode动态材质场景老老实实连续渲染。关于Cesium自带的报错弹窗新手经常被它吓得手足无措。最稳妥的做法是在创建Viewer时就关掉默认错误弹窗然后自己监听renderError做日志处理const viewer new Cesium.Viewer(cesiumContainer, { showRenderLoopErrors: false }); viewer.scene.renderError.addEventListener(function(scene, error) { console.error(渲染错误, error); });关掉弹窗不等于无视错误你自己必须要能接住这些错误信息。很多材质问题恰恰通过renderError里的GLSL编译信息能直接定位比如uniform没声明、某个变量拼写错误、类型不匹配都会在这里报出来。5.2 材质不生效、全黑、透明不见了这些问题怎么破我把高频问题整理成了一张速查表按我的排查优先级排列问题现象可能原因我的排查方法材质完全黑色Geometry顶点格式缺少ST或NORMAL把vertexFormat显式设为MaterialAppearance.VERTEX_FORMAT材质透明但看不到translucent没设置或alpha恒为0检查Material构造参数里是否传了translucent: true动态效果静止不动requestRenderMode按需渲染或uniform没更新改为连续渲染或使用czm_frameNumber驱动动画扫描线明显锯齿smoothstep阈值太窄适当放宽阈值比如从0.05调到0.1自定义type不生效与内置材质type重名换一个不冲突的type名称Entity材质没变化给material直接赋了字面量对象而不是Material实例用new Cesium.Material({fabric: ...})包装自定义Shader报错版本不匹配或变量名拼写错误打开renderError监听看编译日志材质全黑这个问题我至少碰到过十几次每次都是因为Geometry的vertexFormat设置不对。czm_getMaterial拿到materialInput.normalEC和materialInput.st如果你建的几何体压根没给法线或者纹理坐标这些值就是无意义数据计算结果自然一团黑。透明度看不到是另一个诡异的问题。Fabric里定义了alpha不等于1但物体还是完全不透明或者完全透明大概率是translucent属性没置true。Cesium为了优化渲染批次把透明和不透明几何体分开处理你如果不告诉它这个材质是透明的它就不会走透明管线。所以创建Material时务必带上translucent: true。5.3 高清显示、海底地形、MVT加载和Qt内嵌这些集成问题怎么处理先聊画面模糊。很多人觉得Cesium默认画质糊其实除了显卡原因外主要是分辨率缩放和抗锯齿设置。把resolutionScale提到2再打开MSAA或FXAA观感立刻不一样viewer.resolutionScale 2; viewer.scene.msaaSamples 4; viewer.scene.postProcessStages.fxaa.enabled true;需要注意MSAA在WebGL2环境下才有效如果场景里某些自定义材质用了旧式着色器可能会强制回退到WebGL1MSAA就不会起作用。这个问题排查起来比较隐蔽我一般会先看浏览器WebGL版本再决定抗锯齿方案。图标不清晰则多半是Label或者Billboard的尺寸设置问题优先检查resources的高清倍率和canvas的devicePixelRatio。海底地形这个需求核心不在材质而在Cesium的深度测试开关。默认情况下Cesium会把海平面以下的地形盖住要看到海底地形需要开启viewer.scene.globe.depthTestAgainstTerrain true; viewer.scene.globe.undergroundColor new Cesium.Color(0.0, 0.0, 0.0, 0.0);开启后海底地形会按真实地形起伏显示然后你再用自定义材质做水下渐变或热力可视化效果会真实很多。不过注意depthTestAgainstTerrain开启后地下埋设的管线和隧道会被地形裁掉如果项目同时做地下管线可视化这就要权衡了。加载MVT格式的问题本质上不是材质问题而是Cesium原生并不直接支持MVT。需要先把MVT瓦片解析成GeoJSON再转成Entity或Primitive渲染。解析后的要素就可以用Fabric材质按属性着色比如按道路等级不同给不同颜色和透明度这正好发挥自定义材质的优势。Qt5.12内嵌Cesium则是另一个坑Qt WebEngine的Chromium内核偏老WebGL2特性不全CustomShader很容易报错建议要么升级Qt版本要么用本机Chromium调试把Cesium页面单独跑在浏览器里再通过IPC和Qt通信。结尾说点大实话踩过几次坑之后我养成了一个习惯所有自定义材质我都会保留一份fabric的JSON版本号同一个type名称的材质只在一处定义绝不散落在各处复制粘贴。Cesium材质系统的可维护性其实很脆弱因为GLSL代码是字符串没有编译期检查一旦某天你改了一个uniform名字忘了改另一处引用项目可能直接黑屏。所以我强烈建议把常用材质统一封装成一个模块比如materials.ts所有自定义材质都用函数生成颜色、速度、透明度都做成配置项既能复用也方便排查。最后一个实用技巧写自定义材质时可以先用一个小矩形面片测试把radius、speed这些uniform设置成极端值观察输出是否异常确认效果后再放到正式的ellipse、polygon或模型上。这个习惯能帮你省下大量调试时间也是我始终愿意用自定义材质而不是去网上抄素材的原因。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进