前不久,一个做智慧园区项目的朋友打来电话,问我在Cesium里怎么让湖面水波看起来有流动感。我反问他:Cesium自带了Water材质,你没试过?他说试了,那水面看起来像一层蓝绿色的果冻,客户看了直摇头。我听完笑了笑,跟他讲:你要是想靠调参数调出真实的水,那大概率要失望了。真正要迈过去的坎,是着色器。
这篇是Cesium进阶学习系列的第四篇,主题是ShaderToy风格的着色器在Cesium里的落地。我会从Cesium的材质系统讲起,带你看懂Fabric语法,再讲怎么把ShaderToy上的特效移植到Cesium中,最后给三个可以直接放进项目的实战案例,以及我调试着色器时积累的经验。适合那些已经会用Entity、Primitive往地球上放东西,但总觉得效果不够炸裂,想进一步深入渲染底层的开发者。
1. 为什么Cesium进阶路上,着色器是一道绕不过去的坎
1.1 从一次“做水面效果”的需求说起
数字孪生和智慧城市类的项目,这两年我接触得很多。客户的需求翻来覆去就是那几类:园区楼栋要高亮、河面水面要有流动感、重点区域要做雷达扫描、地下管线的颜色要跟着数据动态变化。这些需求有个共同特点:Cesium自带的基础材质做不出细节,或者说,做得出来但特别“塑料感”。
我那位朋友就是把Cesium的Water材质参数来回调了一下午,Frequency、AnimationSpeed、NormalMap换了个遍,最后水还是像一层油。为什么?因为Water材质本质上是一套固定的着色器程序,它只暴露了有限的几个uniform参数给你。你想让水波有多个尺度叠加的层次感,想在水面上加上视觉误差的法线扰动,想在岸边做菲涅尔渐变,这些逻辑都会涉及重写着色器源码,而不是简单调参能解决的。
所以,做Cesium进阶,第一关就是破除对“材质参数”的依赖,学会直接写GLSL。水、光、高亮、扫描、热力,这些特效的答案,最后都落在着色器里。
1.2 Cesium自带的“材质”不是什么玄学
很多人用Entity设material的时候,都把它当黑盒。其实Cesium的材质体系非常透明:你在new Cesium.Material({...})里传入的fabric配置,最终都会被编译成一个片元着色器,交给GPU执行。
Cesium之所以叫“智能地球底座”,渲染这块做了大量封装,但封装不等于黑盒。任何一个Entity上的PolygonGeometry,最终都会走Primitive渲染管线。Primitive由Geometry和Appearance组成,Appearance内部持有Material,Material内部持有ShaderSource。这个链条捋清楚之后,你就知道:改材质,就是在改片元着色器的一句话。
更直接一点,在浏览器里打开Cesium源码目录Source/Shaders/Materials/,你能看到每个内置材质的GLSL源码,比如Water.glsl、BumpMap.glsl、PolylineGlow.glsl。这些文件有挂满贴图的复杂版本,也有几行就搞定一个基础效果的简单版本。看一遍源码,比你上十节网课都管用。
1.3 ShaderToy与Cesium的同频学习法
ShaderToy是shader圈子里的“GitHub”,上面大量实时渲染的着色器效果,从水面、火焰、云层到各种抽象视觉,全是片元着色器写的。你不需要建模、不需要光照设置,打开网页就能跑起来。
对Cesium开发者来说,ShaderToy最大的价值是算法验证。你可以在ShaderToy上快速验证一个噪声函数、一套水波算法、一个UV动画的效果,确认视觉OK了,再把它移植到Cesium里。这个工作流能帮你省掉大量反复编译调试的时间。
有人问,Cesium的着色器和ShaderToy是完全一样的语言吗?答案是:都是GLSL,但环境不同。ShaderToy的入口是mainImage,Cesium的材质入口是czm_getMaterial;ShaderToy拿到的是像素坐标,Cesium拿到的是纹理坐标和法线等几何信息。所以两者的结合点在于“算法”,而不是“代码”。下一章我就把Cesium这套材质系统的运行机制拆开讲清楚。
2. Fabric材质语法:Cesium把GLSL包起来的那层“糖衣”
2.1 一个最小自定义材质长什么样
要理解Cesium材质系统,直接看代码最快。下面这段代码创建了一个最基础的自定义材质,效果是让一个矩形表面呈现出左右两种不同颜色,透明度90%。
const material = new Cesium.Material({ fabric: { type: 'MyTwoColor', uniforms: { leftColor: Cesium.Color.fromCssColorString('#ff6600'), rightColor: Cesium.Color.fromCssColorString('#0066ff') }, source: ` czm_material czm_getMaterial(czm_materialInput materialInput) { czm_material material = czm_getDefaultMaterial(materialInput); if (materialInput.st.x < 0.5) { material.diffuse = leftColor.rgb; material.alpha = leftColor.a; } else { material.diffuse = rightColor.rgb; material.alpha = rightColor.a; } return material; } ` } });这段代码里有三个核心要素。type是材质名,相当于你在Cesium内部注册了一个新材质,可以在别处通过Cesium.Material.fromType('MyTwoColor')直接复用;uniforms是外部传入的变量,相当于给着色器传参;source是真正的片元着色器源码,里面必须实现czm_getMaterial函数。
理解了这个结构,再看Cesium内置材质就一目了然了。比如自带的Color材质,本质上就是返回一个固定diffuse的着色器;Stripe材质就是根据纹理坐标做条纹渐变。所有材质都是这套模板的变体。
2.2 czm_materialInput:片元着色器能拿到哪些“料”
在czm_getMaterial里,你一定会用到materialInput这个结构体。它就像GPU塞给你的一个信封,里面装满了当前片元在场景中的各种信息。我常用的几个字段是:
st/uv:纹理坐标。st范围是0到1,uv是以0.5为中心归一化到-0.5到0.5的版本。做大多数UV动画、条纹、SDF计算,都从这两个出发。positionToEyeEC:当前片元指向相机位置的向量。做菲涅尔、边缘光、水面反射的时候必须用到它。normalEC:当前片元的法线,在眼睛坐标系下。注意,这里的法线是几何法线,如果你想用法线贴图,需要用纹理采样出的法线结合tangentEC和binormalEC做切线空间变换。longitudeLatitude:当前片元的经纬度,适合做区域渐变、行政边界相关特效。position3D:世界坐标。像雷达扫描按距离衰减这类效果,用世界坐标比用纹理坐标方便得多。
理解了这些字段,再看ShaderToy就有一个很自然的坐标映射思路:ShaderToy里的fragCoord是屏幕像素坐标,Cesium里的materialInput.st是对象的局部纹理坐标。要把ShaderToy的算法搬过来,第一步就是搞清楚它依赖的是哪个坐标。
2.3 czm_material的五个输出字段怎么理解
czm_getMaterial的返回值是一个czm_material结构体,有很多人刚接触时不太理解为什么要返回这么多字段。其实这五个字段对应的是光照模型里的几个关键概念:
diffuse:物体本身的固有色,即“它是什么颜色”。光线打到上面后,这个颜色会被反射出来。specular:镜面反射强度,控制高光的明显程度。数值越大,物体表面越像塑料或镜子。shininess:高光锐度,控制高光斑的大小。数值越小,高光越柔和,越大越尖锐。emission:自发光颜色。这个颜色不依赖光源,直接叠加到最终颜色上。做扫描特效、发光体基本都用它。alpha:透明度。1.0为完全不透明,0.0为完全透明。
实际写材质时,你要做的核心工作就是:根据materialInput输入的各种坐标和向量,用算法算出这五个字段的值。比如水面材质,就是根据波浪高度和法线扰动,动态改变diffuse和specular;雷达扫描特效,就是让emission随距离和时间变化。
2.4 材质与自定义Primitive的分工线
Cesium里实现自定义渲染效果,有两条路。一条是上面说的Material路线,它被设计用来给现成的几何体“换肤”,比如给PolygonGeometry、RectangleGeometry、PolylineGeometry附上动态材质。另一条是完全自定义Primitive,你可以自己定义顶点数据、顶点着色器、片元着色器,甚至自己管理绘制状态。
什么时候走哪条路,我的经验是:如果效果是作用在某个已有的几何表面,比如给楼层立面换动态贴图、给地面加扫描圈,走Material就够了,开发量小,代码也好维护。如果你要往地球上画一个全新的几何体,比如上万粒子的流动线条、体积云、自己算顶点位置的变形体,那就必须自定义Primitive。自定义Primitive的核心是重写Primitive.update,创建DrawCommand,挂上自己的ShaderProgram。这条路普通人不需要上来就学,但理解它的存在,你就能在工作中判断“该不该用shader”以及“该用哪个级别的shader”。
3. 把ShaderToy作品搬进Cesium:从像素坐标到材质输入
3.1 入口函数换成czm_getMaterial
ShaderToy里的每一个效果都有同一个入口:
void mainImage(out vec4 fragColor, in vec2 fragCoord)fragCoord是当前片元的像素坐标,你需要自己除iResolution把它归一化到0到1之间。Cesium材质的世界里,入口变成了:
czm_material czm_getMaterial(czm_materialInput materialInput)返回值不是一个vec4颜色,而是一个czm_material结构体。移植时最直观的做法,就是把ShaderToy里算出的颜色写到material.diffuse和material.alpha,其他字段用czm_getDefaultMaterial填充。
举个例子,ShaderToy里一个最简单的渐变:
void mainImage(out vec4 fragColor, in vec2 fragCoord) { vec2 uv = fragCoord / iResolution.xy; fragColor = vec4(uv, 0.0, 1.0); }移植到Cesium就变成:
czm_material czm_getMaterial(czm_materialInput materialInput) { czm_material material = czm_getDefaultMaterial(materialInput); vec2 uv = materialInput.st; material.diffuse = vec3(uv, 0.0); material.alpha = 1.0; return material; }逻辑完全一一对应,只是换了壳。这样写你就能在Cesium里看到红绿渐变的矩形。
3.2 iResolution、iTime、iMouse怎么落进Cesium
这是移植过程中最容易卡住的地方。你翻开的ShaderToy代码里,十有八九会有这么三个uniform:
uniform vec3 iResolution; uniform float iTime; uniform vec4 iMouse;iResolution在Cesium里没有直接对应物。如果你的算法需要像素分辨率来做缩放,可以在uniforms里声明一个vec2 uResolution,然后在JS侧传入当前的canvas画布尺寸。不过大多数效果用materialInput.st就够,不需要知道具体像素。iTime在Cesium里有两种接法。一种是用内置的czm_frameNumber / 60.0当作秒数,但精度和可控制性一般,而且全局帧号是共用的,不同材质的动画时间会同步。更推荐在uniforms里声明一个float uTime,然后在渲染循环里更新它:
material.uniforms.uTime = performance.now() / 1000.0;这样不同材质可以有不同的时间轴,暂停、加速都很容易控制。
iMouse一般用于交互特效。Cesium材质里没有全局鼠标状态的统一传参,需要自己做。监听screenSpaceEventHandler的MOUSE_MOVE事件,把鼠标在地图上的经纬度或画布像素坐标写进uniform。
Cesium默认不开启持续渲染,如果你不操作鼠标,画面是静止的。要让材质动起来,需要把viewer.scene.requestRenderMode设为true的对应逻辑处理好,或直接调用scene.requestRender()。
3.3 坐标系与精度差异:最容易翻车的地方
ShaderToy里很多效果直接用像素坐标当世界坐标,比如画一个以屏幕中心为圆心的圆环。但Cesium的材质作用在三维几何体上,纹理坐标st是物体局部坐标,而且它的范围取决于你的几何体展开方式。
两个最容易翻车的地方,我单独拿出来说。
一个是UV方向。Cesium中部分几何体的st原点在左下角,部分在左上角,不同几何体不一样。你从ShaderToy搬运一个上下渐变的效果,如果方向反了,第一时间不要怀疑算法,先试试把st.y改成1.0 - st.y。
另一个是精度问题。ShaderToy是全屏Quad,一个像素对应很小的物理尺寸,坐标数值范围不大。但Cesium的材质可能贴在跨度几十公里的地面上,直接拿经纬度或世界坐标算,很容易因为浮点数精度不足出现“顶点抖动”“画面闪烁”。这种情况要从需求出发,看是否需要把坐标中心移到片元附近,或者用地图形学里常见的“相对坐标”技巧。
3.4 一份可复用的移植步骤
我每次从ShaderToy往Cesium搬代码,都会按固定流程走,这里整理出来供参考:
- 先看ShaderToy的效果是什么:纯颜色变化、UV动画、依赖时间、依赖鼠标,还是依赖某些纹理输入。
- 把
mainImage里的核心算法拆出来,先不管fragColor,只看它用了哪些变量。 - 把
fragCoord / iResolution.xy替换成materialInput.st;把iTime替换成自定义的uTime。 - 把最后写进
fragColor的颜色,按需求写进material.diffuse、material.emission、material.alpha。 - 在Cesium里挂到一个简单的多边形上看效果,先做静态验证,再加时间动画。
- 效果确认后再考虑性能优化和参数抽取。
这套流程走几十遍之后,你再看ShaderToy的代码,脑子里会自动浮现“它在Cesium里会是什么样的”,这种翻译能力就练出来了。
4. 三个能直接用进项目的着色器案例
4.1 案例一:可调参数的水面材质
用一个简化版的水面效果来说明完整流程。这个材质基于多层正弦波叠加,虽然不如复杂的水面渲染算法精致,但比Cesium自带Water材质的“果冻感”好得多,关键是你能完全控制它。
const waterMaterial = new Cesium.Material({ fabric: { type: 'RiverWater', uniforms: { uTime: 0.0, uBaseColor: Cesium.Color.fromCssColorString('#1a6f8a'), uDeepColor: Cesium.Color.fromCssColorString('#0d3b4a'), uWaveStrength: 0.08 }, source: ` czm_material czm_getMaterial(czm_materialInput materialInput) { czm_material material = czm_getDefaultMaterial(materialInput); vec2 st = materialInput.st; float t = uTime; // 多层正弦波叠加,产生不规则的涟漪 float wave1 = sin(st.x * 10.0 + t * 1.2) * cos(st.y * 8.0 + t * 0.8); float wave2 = sin((st.x + st.y) * 16.0 + t * 2.0) * 0.6; float wave = wave1 * 0.7 + wave2 * 0.3; // 用波浪值扰动纹理坐标,模拟水面的扭曲感 vec2 distortedSt = st + wave * uWaveStrength; // 根据波浪值在浅色和深色之间过渡 float mixFactor = 0.5 + 0.5 * sin(distortedSt.x * 20.0 + distortedSt.y * 15.0 + t); vec3 waterColor = mix(uBaseColor.rgb, uDeepColor.rgb, mixFactor); material.diffuse = waterColor; material.specular = 0.6; material.shininess = 12.0; // 菲涅尔近似:视线越倾斜,反射越强,这里简化处理 vec3 toEye = normalize(-materialInput.positionToEyeEC); float fresnel = pow(1.0 - abs(toEye.z), 2.0); material.emission = vec3(0.05, 0.1, 0.12) * fresnel; material.alpha = 0.95; return material; } ` } });挂载的时候,如果你要给一个河面多边形用:
const riverEntity = viewer.entities.add({ polygon: { hierarchy: Cesium.Cartesian3.fromDegreesArray([...]), material: waterMaterial } });然后在viewer.scene.preUpdate事件里更新时间:
viewer.scene.preUpdate.addEventListener(() => { waterMaterial.uniforms.uTime = performance.now() / 1000.0; });这个案例想说明的重点是:materialInput.positionToEyeEC可以用来做菲涅尔,st可以经过算法扰动之后再用于采样和计算。掌握了这两个思路,你可以把ShaderToy里大量水面算法都搬过来。
4.2 案例二:3DTiles动态光扫效果
3DTiles模型的高亮、扫光、单体化,是Cesium进阶绕不开的高频需求。Cesium新版提供了CustomShader,专门用来给3D Tiles模型自定义顶点和片元着色,非常强大。
const customShader = new Cesium.CustomShader({ uniforms: { uTime: { type: Cesium.UniformType.FLOAT, value: 0.0 }, uScanColor: { type: Cesium.UniformType.VEC3, value: new Cesium.Cartesian3(0.0, 0.8, 1.0) } }, fragmentShaderText: ` void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) { // 通过模型坐标的Y方向做从上到下的扫光 vec3 modelPos = fsInput.attributes.positionMC.xyz; float scan = sin(modelPos.y * 2.0 + uTime * 2.5) * 0.5 + 0.5; material.diffuse = mix(material.diffuse, uScanColor, scan * 0.4); material.emission = uScanColor * scan * 0.6; } ` }); tileset.customShader = customShader;然后在渲染循环里更新:
viewer.scene.preUpdate.addEventListener(() => { customShader.setUniformValue('uTime', performance.now() / 1000.0); });这个效果非常适合用在重点建筑、区域标注、单栋楼宇高亮等场景。需要注意两点:第一,CustomShader是Cesium 1.89之后的正式API,更早的版本里是ModelExperimental模式,接口略有差异;第二,如果模型自身带有PBR材质,你的material.diffuse修改的是基础色,想保留纹理细节就多写几行拿到material.baseColor再叠加,不要整个覆盖。
4.3 案例三:多边形雷达扫描特效
雷达扫描也是数字孪生项目里点的比较多的效果。我们先在Cesium里画一个圆形多边形,再给它附上一个带时间动画的径向渐变材质,就能模拟出扫描圈。
const scanMaterial = new Cesium.Material({ fabric: { type: 'RadarScan', uniforms: { uTime: 0.0, uColor: Cesium.Color.fromCssColorString('#00eaff'), uRadius: 500.0 }, source: ` czm_material czm_getMaterial(czm_materialInput materialInput) { czm_material material = czm_getDefaultMaterial(materialInput); vec2 st = materialInput.st - vec2(0.5); // 计算片元到圆心的距离,并映射为0-1 float dist = length(st) * 2.0; float angle = atan(st.y, st.x); // 旋转的扫描臂:角度随时间变化 float sweepAngle = mod(uTime * 0.8, 6.2831853); float angleDiff = abs(mod(angle - sweepAngle + 3.14159265, 6.2831853) - 3.14159265); // 扫描臂是一条高亮窄带,后方是渐隐尾巴 float beam = exp(-angleDiff * 8.0); float rangeFade = 1.0 - dist; vec3 color = uColor.rgb * beam * rangeFade; material.emission = color * 2.0; material.diffuse = vec3(0.0); material.alpha = beam * rangeFade * 0.8; return material; } ` } }); viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees(120.12, 30.28), ellipse: { semiMajorAxis: 500.0, semiMinorAxis: 500.0, material: scanMaterial } });里面用了atan算角度、用st - vec2(0.5)把圆心移到多边形中心,这些思路在ShaderToy的极坐标类效果里非常常见。做这类效果时,如果发现扫描圈和几何体对不齐,先检查st的中心化是否正确,再看纹理坐标的V方向是否反了。
5. 调试与性能:着色器开发真正见功夫的地方
5.1 着色器调试三板斧:颜色编码、增量简化、单测
写着色器最痛苦的就是看不见中间量。GLSL没有断点,没有console.log,出了错只能黑屏或者糊成一团。我调试Cesium着色器,主要靠三板斧。
第一板斧是颜色编码。想确认某个值到底算出来是什么范围,直接把它映射到颜色上输出。比如material.diffuse = vec3(materialInput.st, 0.0),就能看到纹理坐标的分布是否和预期一致。fragCoord在不同区域显示不同的RGB,一看便知。这个方法尤其适合排查UV方向、坐标中心化这类问题。
第二板斧是增量简化。ShaderToy上很多效果代码动辄上百行,不要直接全量搬进来。把特效拆成几个阶段,比如先画静态渐变,再叠加噪声,再加时间,最后做光照。每加一个阶段就验证一次。绝大多数着色器问题,都是因为一次引入太多变量导致的。
第三板斧是单测。Cesium材质本质是函数,你可以把czm_getMaterial里那段核心算法单独抽出来,放到ShaderToy里跑一遍,用同样的输入验证输出,把环境差异和算法错误彻底分开。很多看起来很诡异的Bug,其实是Cesium的坐标系和ShaderToy的屏幕坐标打架,跟算法本身没关系。
5.2 性能优化:片元着色器里哪些操作会悄悄吃掉帧率
移动端和低端机的表现就不说了,就算在桌面端,一个全屏特效如果写得不好,也能把帧率从60拉到10。我见过的最典型性能杀手有这么几类。
第一类是过度采样。很多人直接搬ShaderToy的SDF场景,里面动不动就是几十次的for循环采样,每次采样还有复杂的pow和sqrt。片元着色器是逐像素执行的,对于覆盖半个屏幕的多边形来说,这就是几百万次计算。务必先用低分辨率、低循环次数跑,确认效果之后再慢慢提升质量。
第二类是动态分支。在片元着色器里写if (dist < 0.5)这种量级的分支,GPU的并行度会下降,因为同一批次执行的线程都得把所有分支走一遍。能用mix、smoothstep、step这类函数替代的分支,尽量替代,这也是习惯问题。
第三类是又要实时计算,又要在CPU侧做逻辑。有些效果完全可以在CPU上预计算成纹理,比如热力图的权重场,你把数据算成一张低分辨率纹理传给shader,比在shader里逐像素计算要高效得多。这就是为什么很多成熟的Cesium热力图方案会选择“先画到离屏纹理,再贴到地球上”的原因。
Cesium里还有个细节:默认模式下场景不会每帧都重绘,但如果你启用了这种持续渲染模式,一个shader效果一直跑,GPU始终满载。做项目交付时,务必评估这个效果是不是需要一直动,如果只需要在特定事件期间动,事件结束就停掉时间更新,让场景回到静默模式。
5.3 常见坑位与排查思路
整理几个我踩过多次的坑,给后来人当路标。
第一个坑是uniform更新时机问题。很多人直接写material.uniforms.uTime = performance.now() / 1000.0,但忘了Cesium的preUpdate事件触发顺序。如果你在scene.postUpdate里更新uniform,某些渲染批次可能已经提交,画面会滞后一帧。建议统一在preUpdate里更新动画相关uniform。
第二个坑是材质复用冲突。同一个Material实例如果被多个Entity共用,你更新uniform就会影响所有Entity。如果只想让某一个Entity出现扫描动画,要给这个Entity单独创建一个Material实例。
第三个坑是几何体大小对效果的影响。同一个水面材质,贴在一个100米宽的池塘上,和贴在一个10公里宽的湖泊上,波浪密度会完全不同。纹理坐标是0到1的,几何体越大,单位纹理覆盖的实际距离就越大。实际项目里一定不要用固定频率,要按经纬度跨度或者世界坐标来动态换算频率参数。
第四个坑是不同Cesium版本的API差异。旧版的ModelExperimental和新版的CustomShader接口不完全相同,网上很多教程跑的代码在新版本里已经废弃。遇到编译报错不要慌,先确认你的Cesium版本,再看文档里对应的API变更记录。我见过不少朋友把老代码直接抄进新版本,报错后以为是自己写错了,折腾半天。
还有一个容易被忽略的:Cesium的着色器在WebGL2模式下和WebGL1模式下,一些GLSL写法会有差异。如果你的项目启用了WebGL2,注意检查texture2D这类旧写法是否需要替换成texture。这类问题在浏览器控制台会有明确的报错提示,按照提示逐行改就行。
最后再分享一点个人体会
从我接触Cesium到现在,最大的感受是:不要把着色器当成一个“高级功能”去回避。它其实就是一段用GLSL写的小程序,而Cesium已经给了你非常友好的接入方式。你不需要成为图形学专家,只需要理解纹理坐标、时间、颜色这几个核心概念,就能做出比自带材质好得多的效果。
平时浏览ShaderToy的时候,可以多留意那些短小精悍的效果,十个以内的代码就能驱动出让人惊艳的画面。这些就是天然的练习素材,每翻译一个,你对坐标、时间、光照的直觉就会更准一点。下次面试官再问“Cesium怎么给模型加一个动态高亮效果”,你就能从材质讲到CustomShader,再讲到性能优化,一套组合拳打下来,对方就知道你是真的做过。
如果你已经在项目里实现了一些有趣的Cesium着色器效果,欢迎拿出来交流。持续积累,把翻译ShaderToy变成肌肉记忆,你的Cesium进阶之路会顺畅很多。