前一阵做三维态势项目,客户要在卫星图上叠加一圈可调节的雷达扫描波纹,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实现
下面是可用的雷达扫描材质定义。这里用了三个uniform:u_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 type="range">控制速度,每次修改后直接赋值radarMaterial.uniforms.u_speed = value,Scene会在下一帧自动重绘,不需要额外调用接口。
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,普通数字展开成float,boolean展开成bool。传错类型,比如把颜色写成普通数组,着色器会拿到奇怪的数据,很难排查。
一个工程化的约定:材质类型名用大驼峰,文件名用小写加连字符。比如类型是RadarScan,文件叫radar-scan.js;Uniform变量全部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材质不通用。确定需求落在哪个图层,再选哪套方案,能省下大量折腾时间。