Cesium自定义材质实战:从Fabric语法到GLSL动态着色器全解析

发布时间:2026/10/10 6:40:39
Cesium自定义材质实战:从Fabric语法到GLSL动态着色器全解析
前一阵做三维态势项目客户要在卫星图上叠加一圈可调节的雷达扫描波纹Cesium内置材质里翻了一圈——纯色、条纹、棋盘格、发光箭头都试过要么太生硬要么根本模拟不了“波峰从中心一圈圈往外推”的动态效果。后来去翻了Cesium的材质源码才把自定义材质这条路径彻底走通。这篇就把Cesium自定义材质从Fabric语法到GLSL着色器、从静态贴图到动态光照的完整玩法梳理一遍适合已经会基本Cesium操作、想在地图可视化里做点不一样效果的同学参考。1. 为什么内置材质不够用先摸清Cesium材质体系的天花板很多刚接触Cesium的人以为材质Material就是给多边形上色内置的Color、Image随便加个纹理就够了。真实项目里一旦涉及动态效果比如水流方向、雷达波纹、气象云团流动、光照扫过地面内置材质立刻捉襟见肘。1.1 内置材质到底提供了什么Cesium内置材质主要分两类一类是面状材质用在Polygon、Rectangle、Ellipse这些几何体上包括Color、Image、Checkerboard、Dot、Grid、Stripe等另一类是线状材质用在Polyline上比如PolylineArrow、PolylineDash、PolylineGlow。这些材质做静态展示绰绰有余配合Entity的CallbackProperty也能做出一些简单的动画但本质上是“贴图”思路——Color就是填纯色Image就是贴一张图Stripe就是按UV坐标做条纹。它们的局限非常明显没有时间维度材质本身的GLSL代码是写死的无法感知Clock变化做不出“随时间扩散”的效果。没有业务语义想做“雷达扫描波纹”这种效果需要在着色器里根据像素位置计算距离、判断波峰波谷内置材质没有这类逻辑。没有光照交互diffuse、specular、emissive这些分量拆不开做不出“白天受光、晚上自发光”的态势效果。用内置材质硬凑动态效果的人大概率会去写CallbackProperty每帧改颜色结果就是整个多边形每帧重传Uniform性能差效果还生硬。1.2 自定义材质适合用在哪几类场景从我接触过的项目来看值得动手写自定义材质的场景大概就四类。第一类是动态扫描和扩散效果典型的就是雷达扫描圈、信号波纹、污染扩散范围这类效果的核心是在GLSL里用distance()和sin()/fract()根据时间变量计算波峰位置。第二类是业务语义可视化比如根据业务字段给地块染色、用颜色梯度表达等值线/等值面气象上的温度场、气压场就是典型热词里的“cesium气象”经常用到。第三类是模拟光照和昼夜变化比如太阳光照方向实时影响地面明暗夜间建筑自动亮灯这类效果需要用materialInput.normalEC和光源方向做点积。第四类是纹理融合和动态扭曲比如流动的河流、动态云层需要把多张纹理贴图混合再用时间驱动UV偏移。这几类共同的特点是效果背后有一套业务算法参数要能实时修改而且需要和场景时钟绑定。这些恰好是Fabric机制提供的核心能力——通过Uniform变量暴露参数通过GLSL着色器写算法逻辑。2. Fabric机制拆解材质工厂如何把一个JSON变成屏幕上的像素Cesium的自定义材质核心是Fabric一套JSON格式的材质描述规范。它定义了材质的类型名、Uniform参数、以及GLSL着色器代码Cesium内部会根据这套描述动态生成ShaderProgram。2.1 Fabric JSON的基本结构一个最简的Fabric长这样const fabric { type: RainbowGlow, uniforms: { u_color: new Cesium.Color(0.0, 1.0, 0.0, 1.0), u_time: 0.0 }, components: { diffuse: u_color.rgb, alpha: u_color.a } };type是材质类型名这段代码里用u_前缀命名uniform用来暴露参数。components是两种写法中比较简单的一种——直接指定输出给渲染管线的颜色分量。更灵活、更常用的写法是通过source字段直接写GLSL函数const fabric { type: RainbowGlow, uniforms: { u_color: new Cesium.Color(0.0, 1.0, 0.0, 1.0), u_time: 0.0 }, source: czm_material czm_getMaterial(czm_materialInput materialInput) { czm_material material czm_getDefaultMaterial(materialInput); material.diffuse u_color.rgb; material.alpha u_color.a; return material; } };2.2 czm_getMaterial每个材质都必须实现的入口函数czm_getMaterial是Cesium材质系统的核心约定每个Fabric里的GLSL都必须实现它。它接收一个czm_materialInput结构体返回一个czm_material结构体。czm_material长这样struct czm_material { vec3 diffuse; float specular; float shininess; vec3 normal; vec3 emissive; float alpha; };diffuse是漫反射颜色受光照影响specular和shininess控制高光normal是表面法线emissive是自发光颜色不受光照影响适合用来做发光源alpha是透明度。czm_getDefaultMaterial(materialInput)会返回一个所有分量都是默认值的材质通常做法是拿这个默认材质然后按需赋值。最容易犯的错是把diffuse和emissive搞混——在光照场景里diffuse会被光源颜色乘一遍如果地面光照很弱设置了diffuse但没设置emissive颜色会被压得很暗反过来做雷达扫描效果时用emissive可以让扫描圈在黑底上高亮显示不受环境光影响。2.3 materialInput里到底装了什么东西写GLSL的时候czm_materialInput提供了在着色器里计算所需的各种坐标和向量。下面这几个是我经常用到的materialInput.st纹理坐标范围0到1。对于Polygon几何体它通常覆盖整个外接矩形对于Rectangle也是类似。雷达扫描圈效果就靠这个来算像素位置。materialInput.positionEC眼睛坐标系下的位置坐标适合做透视效果比如让材质跟随距离衰减。materialInput.normalEC眼睛坐标系下的法线向量方向光照计算必须用它。materialInput.positionToEyeEC从当前像素指向眼睛的向量可以配合法线做边缘高亮。当材质作用在Primitive上时materialInput里还会有更多字段比如切空间矩阵。但做Entity的Polygon/Rectangle材质时st和normalEC基本够用。3. 手写第一个自定义材质动态雷达扫描圈完整实操雷达扫描是自定义材质最典型的练手案例从设计思路到代码落地完整走一遍能覆盖Fabric的大部分要点。3.1 设计思路如何在矩形UV里画出圆形波纹前提是把Polygon的材质UV空间当成一张画布。雷达扫描圈的效果是波纹从中心点一圈圈往外扩散波峰亮、波谷暗并且波纹只出现在圆形范围内矩形四角应该完全透明。算法拆成三步第一步算当前像素到中心点的距离dist第二步用dist减去时间变量得到一个随时间移动的相位再对这个相位取正弦得到波纹强度第三步用step(dist, 0.5)判断是否在圆内超出半径直接透明。中心点用vec2(0.5, 0.5)表示因为Polygon材质的UV范围是0到1中心点正好在UV空间正中央。如果想让扫描圈跟随某个经纬度而不是几何体中心需要把经纬度换算成相对于Polygon包围盒的UV比例这一步可以用CallbackProperty动态改uniform实现。3.2 完整Fabric定义与GLSL实现下面是可用的雷达扫描材质定义。这里用了三个uniformu_color控制颜色u_speed控制扩散速度u_time是外部传入的时间变量秒。u_frequency控制波纹密度即同一时刻可见的波峰数量。const radarFabric { type: RadarScan, uniforms: { u_color: new Cesium.Color(0.0, 1.0, 0.0, 0.9), u_speed: 0.6, u_frequency: 12.0, u_time: 0.0 }, source: czm_material czm_getMaterial(czm_materialInput materialInput) { czm_material material czm_getDefaultMaterial(materialInput); vec2 st materialInput.st; vec2 center vec2(0.5, 0.5); float dist distance(st, center); float radius 0.5; float phase dist * u_frequency - u_time * u_speed; float wave sin(phase); wave wave * 0.5 0.5; float inCircle 1.0 - smoothstep(radius - 0.02, radius 0.02, dist); wave wave * inCircle; material.diffuse u_color.rgb * wave; material.alpha u_color.a * wave; material.emissive u_color.rgb * wave * 1.5; return material; } };判断圆形边界时用了smoothstep(radius - 0.02, radius 0.02, dist)这样圆形边缘会出现一个柔和过渡不会出现明显的锯齿。如果不需要柔边直接用step(radius, dist)取反也能做但效果会生硬不少。为什么用sin(phase)取反再加?因为sin()的范围在-1到1区间直接乘颜色会出现负值导致黑色所以要把范围映射到0到1。这一步是雷达材质最容易被忽略的细节。3.3 注册材质并挂载到Entity上定义完Fabric之后要做的第一件事是注册材质类型然后才能在Entity里用字符串或者Material实例引用它Cesium.Material.register(RadarScan, radarFabric); const entity viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees(116.39, 39.9), polygon: { hierarchy: Cesium.Cartesian3.fromDegreesArray([ 116.3, 39.8, 116.5, 39.8, 116.5, 40.0, 116.3, 40.0 ]), material: new Cesium.Material({ fabric: { type: RadarScan } }) } });注意new Cesium.Material({ fabric: { type: RadarScan } })这里材质类型在注册之后可以直接通过type创建。如果直接传radarFabric对象也可以但注册后再引用会更清晰尤其当多个Entity共用一个材质类型时。3.4 让时间变量驱动动画材质本身不知道Cesium的Clock在走秒需要手动把时间传进uniform这是好多人第一次写自定义材质会卡住的地方。推荐在viewer.clock.onTick事件里更新const radarMaterial entity.polygon.material; viewer.clock.onTick.addEventListener(function(clock) { radarMaterial.uniforms.u_time clock.currentTime.secondsOfDay; });这里用secondsOfDay而不是绝对时间是为了让动画从每天0点从零开始避免数值过大导致浮点精度问题。GLSL里float精度有限如果u_time变成几十万秒的大数sin计算会开始抖动。还可以加一个交互式调节用HTML的input typerange控制速度每次修改后直接赋值radarMaterial.uniforms.u_speed valueScene会在下一帧自动重绘不需要额外调用接口。4. 进阶实操动态光照材质与多纹理混合雷达扫描圈这种纯emissive的材质做完之后接下来要解决的是更接近真实世界的渲染问题——光照。热词里频繁出现的“cesium 动态光照”在自定义材质里是可以直接落地的。4.1 emissive和diffuse为什么夜晚的建筑要发光先说结论diffuse会被场景中的光源调制emissive不会。意思是如果多边形法线背向光源diffuse亮度会大幅下降甚至到0而emissive始终维持原色。做一个昼夜可变的建筑群效果时逻辑很简单vec3 lightDir normalize(vec3(0.5, 0.3, 0.8)); float NdotL dot(normalize(materialInput.normalEC), lightDir); NdotL clamp(NdotL, 0.0, 1.0); // 白天受光面亮背光面暗 vec3 dayColor u_dayColor.rgb * NdotL; // 晚上整体切换为自发光 vec3 nightColor u_nightColor.rgb * (1.0 - NdotL); material.diffuse dayColor; material.emissive nightColor; material.alpha 1.0;这样白天靠太阳光做出明暗面夜间没有光照时diffuse几乎是黑的但emissive让建筑轮廓亮起来。如果还想模拟动态光照——比如手电筒扫过地面——就把lightDir也做成uniform每帧在JS里根据光源位置换算方向再赋给材质。4.2 子材质组合Fabric的materials字段怎么用Fabric还支持把已注册的材质作为子材质嵌套使用字段叫materials。比如把上一节的雷达扫描圈和一张楼宇纹理图叠加const fabric { type: RadarWithBaseImage, uniforms: { u_strength: 0.5 }, materials: { radar: { type: RadarScan }, base: { type: Image, uniforms: { image: buildings.png } } }, source: czm_material czm_getMaterial(czm_materialInput materialInput) { czm_material material czm_getDefaultMaterial(materialInput); material.diffuse materialInput.materials.base.diffuse; material.alpha materialInput.materials.base.alpha; material.emissive materialInput.materials.radar.emissive * u_strength; return material; } };子材质通过materialInput.materials.xxx访问字段名就是materials对象里的键。每个子材质本身也是一个完整的czm_material结构体可以取它的diffuse、alpha、emissive等分量做混合计算。这个小功能很实用。比如做态势标绘时底图铺影像纹理上面浮一层动态扫描波纹不用写两张纹理的UV混合逻辑直接组合两个材质就行。实际项目里我用得最多的组合是“BaseImage RadarScan”和“Grid Color”前者做航迹监控背景后者做网格叠加底图。4.3 外部纹理参与材质计算的技巧如果不想依赖Fabric的子材质也可以直接在uniforms里传纹理对象uniforms: { u_texture: new Cesium.Texture({ context: viewer.scene.context, source: image }) }然后在GLSL里vec4 texColor texture2D(u_texture, materialInput.st); material.diffuse texColor.rgb; material.alpha texColor.a;这里有个版本兼容细节Cesium底层仍是WebGL1的GLSL ES 1.00纹理采样必须用texture2D不能写WebGL2的texture()。如果你在材质效果里用了WebGL2独有的语法整段shader编译会直接失败报错信息又比较隐晦大概率只会看到多边形变成透明或者黑色。5. 材质不生效的排查路径从编译失败到颜色异常写自定义材质最折磨人的不是写代码而是代码看着没问题渲染结果要么空白、要么纯黑、要么整个多边形消失。5.1 常见的编译期坑GLSL版本和保留字我遇到的第一个坑是GLSL版本问题。Cesium的材质着色器运行在WebGL1环境只能用GLSL ES 1.00语法。texture2D、varying、gl_FragColor这一套是老语法texture()、in/out这些是WebGL2语法完全不能混用。第二个坑是uniform命名。Fabric里定义的uniform名字会直接拼进Cesium生成的shader源码所以不能用czm_开头的名字——那是Cesium保留给内置函数的命名空间冲突会导致编译报错。同时尽量避免用GLSL内置变量名比如gl_开头的都是保留字。5.2 材质变透明或纯黑的运行时问题如果材质能编译但显示透明大概率是alpha被算成了0。雷达扫描材质里我用wave * u_color.a作为alpha波谷位置的alpha是0这本身没问题但如果你想让整个材质常显却忘了设置alpha就会得到肉眼几乎不可见的透明多边形。如果是纯黑问题通常在diffuse受光照影响。室内或夜间场景里光源很弱甚至没有diffuse乘以光照后趋近0。解法是给emissive也赋值或者把颜色放到emissive里而不是diffuse。很多模板代码默认只写diffuse照抄出来的效果在夜间项目里就“翻车”。5.3 多个多边形同材质时消失的合批问题另一个隐性问题是渲染合批。当多个Entity引用同一个材质类型但每个Entity都new Cesium.Material()时Cesium内部会按材质类型对几何体做合批。如果材质uniform里有纹理合批会受纹理图集限制如果多边形数量巨大纹理数量可能超出WebGL纹理单元上限导致部分多边形材质丢失。比较稳健的做法是全场景共用一个材质实例不要每个Entity都new一次。修改uniform时只改这一个实例所有引用它的多边形同步更新性能消耗还低。5.4 匹配Primitive还是Entity材质和Appearance的分工自定义材质最常用的载体是Entity的polygon、rectangle、polyline。如果你走Primitive路线直接操作geometryInstances和appearance材质加载路径不太一样——Primitive用的是MaterialAppearance也需要传Material但顶点着色器、法线处理都更底层坑更多。给个实用建议需要快速态标绘、业务数据驱动选Entity需要大规模渲染、精细控制顶点才考虑Primitive。自定义材质在这两套体系里的定义和注册方式一模一样但接入方式完全不通别混着用。还有一个经常被混淆的概念Entity属性体系里有ColorMaterialProperty、ImageMaterialProperty这类“Property”结尾的对象它们和Fabric材质不是一个层面的东西。Property是Entity系统的数据描述Material是渲染层的具体实现。Cesium会做转换但如果你直接用new Cesium.Material()赋值给entity.polygon.material实际上就跳过了Property层直接走渲染层。两者各有用途理解这点能避免很多文档阅读上的困惑。6. 材质复用的工程化经验命名、性能与扩展一个项目里的材质数量通常不止一个如果二十种材质都裸写在代码里维护成本会很高。6.1 uniform命名规范与类型选择建议所有uniform都用u_前缀颜色统一用Cesium.Color实例数值统一用Number。在Fabric里uniforms的值类型决定GLSL里uniform的声明类型Cesium.Color会被展开成vec4普通数字展开成floatboolean展开成bool。传错类型比如把颜色写成普通数组着色器会拿到奇怪的数据很难排查。一个工程化的约定材质类型名用大驼峰文件名用小写加连字符。比如类型是RadarScan文件叫radar-scan.jsUniform变量全部u_开头临时变量在GLSL里用有意义的名称不要全部叫v、t这种单字母。6.2 性能优化Shader卡顿和Uniform更新频率自定义材质在场景中占比过大时首次渲染会明显卡顿。原因是每个不同材质的Fabric都会生成独立的ShaderProgram浏览器需要现场编译着色器。几十种材质同时出现编译耗时可能到秒级。优化思路有两个维度。第一个维度是控制材质种类数能用参数组合解决的不要另开新类型第二个维度是控制uniform更新频率只有动画运行时才更新u_time静止材质不用每帧赋值。实际项目里我还会把材质按模块封装每个材质文件导出Fabric对象和默认uniform值再写一个统一的材质管理器负责注册和获取材质实例。6.3 从材质到业务扩展气象、态势、模型加载的边界最后说一句边界问题。Cesium自定义材质主要管的是面状和线状几何体的外观它解决不了模型加载、海量数据格式转换的问题。热词里提到的“cesium加载obj模型”、“cesium加载mvt格式”、“cesium加载nc二进制数据”这些是数据接入层的事不是材质层的事。材质能做的是加载进来之后的显示效果——比如给气象等值面配色、给态势区域加动态光效、给模型表面做自定义着色。如果做3D Tiles模型的自定义效果Cesium还有CustomShader机制那是另一套接口和本文的Fabric材质不通用。确定需求落在哪个图层再选哪套方案能省下大量折腾时间。