做 Cesium 三维地球开发的人,大概率遇到过一种落差:别人做出来的雷达扫描像霓虹灯一样顺滑,飞线动画像有电流在管子里跑,而自己照着官方示例改出来的效果,要么不动,要么一卡一顿,最后只能安慰自己是显卡不行。这个差距通常不在 Cesium API 用得熟不熟,而在底层 WebGL Shader。Cesium 本身封装得很深,但地图扫描、飞线动画这类特效,真正决定速度、渐变、层次、循环节奏的都是着色器逻辑。这篇文章我会按“为什么、怎么准备、怎么实现、底层原理、怎么排查”的顺序,把地图扫描、飞线动画和 Shader 的关键点拆一遍。适合已经在用 Cesium 做项目、想做出更细腻特效的开发者,也适合第一次接触 GLSL、在自定义材质里看不太懂的初学者。
1. 为什么别人的 Cesium 特效更丝滑?先看 Shader 层
很多人做 Cesium 特效的第一反应,是用 JavaScript 定期修改实体的样式、颜色、透明度或者坐标。这种方式写起来很直白,但对性能非常不友好,尤其当下一次特效涉及几百个实体、上千个顶点时,主线程会在每一帧都忙着提交数据,动画自然就变得卡顿。
真正丝滑的地图扫描、飞线动画,绝大多数走的是另一条路:让 GPU 用 Shader 去计算像素颜色。也就是每个像素根据“它在哪里、当前是第几帧、几个控制参数”,自己算出颜色和透明度。GPU 是并行处理器,一次可以处理成千上万个像素,而 CPU 只需要每帧传一个很小的 uniform,比如时间和中心点,开销小非常多。
1.1 丝滑的真相不在 Cesium API,而在渲染管线
Cesium 的 entity 和 primitive 虽然提供了很多高层 API,但底层仍然是 WebGL 渲染管线。你设置材质的颜色、透明度、纹理,最终都会变成一组 GLSL 代码运行在 GPU 上。
那为什么同样的 API,别人做出来的效果更细腻?关键区别在于:别人是在扩展材质管线,直接写 Shader,而不是每帧更新 Cesium 属性。
举个例子,一个雷达扫描圈,本质上是圆环半径随时间增大、透明度随半径衰减。如果你在 JavaScript 端每帧修改圆的半径和透明度:
// 示意:不适合直接用在生产环境 entity.ellipse.material = new Cesium.ColorMaterialProperty(color); entity.ellipse.semiMajorAxis = radius;Cesium 收到属性变化后,要重新走一遍图元更新流程,重新准备几何数据、重新上传 GPU,代价非常高。而 Shader 方式不需要改这些属性,它只是在片元着色器里通过一个时间 uniform 计算当前应该显示哪一圈,半径、透明度、边缘柔化都是根据时间推导出来的。GPU 不需要把中间结果传回 CPU,所以动画连续且稳定。
1.2 哪些特效适合 Shader,哪些适合属性驱动
没有哪种方式是万能的,合理的分工可以这么理解:
| 特效类型 | 推荐方式 | 原因 |
|---|---|---|
| 雷达扫描、扩散波纹、飞线流动、墙体流动、动态水面、闪烁箭头 | Shader 材质 | 视觉变化可以用位置、时间和参数直接表达,GPU 并行计算更高效 |
| 物体位置移动、相机视角切换、业务数据更新 | CPU 属性驱动 | 依赖外部输入和异步逻辑,不是纯粹的视觉效果 |
| 模型节点姿态、顶点形变 | 自定义顶点 Shader | 需要改变几何形状,属性驱动很难做到平滑 |
| 点击选中、信息弹窗 | CPU 事件处理 | 与渲染无关,适合放在业务层 |
判断标准很简单:如果视觉变化能用“当前片元的位置 + 当前帧号 + 几个参数”表达,那就尝试 Shader;如果视觉变化依赖后端数据、用户操作、异步事件,就留在 CPU 端。
1.3 Shader 之于 Cesium 的核心逻辑
Cesium 自定义材质最常见的入口是czm_getMaterial函数。这个函数接收一个materialInput,返回一个czm_material结构体,里面包含diffuse、specular、emission、alpha等字段。你只要改这些字段,Cesium 就会把结果合并到渲染管线里。
一个最简单的自定义材质骨架大概长这样:
czm_material czm_getMaterial(czm_materialInput materialInput) { czm_material material = czm_getDefaultMaterial(materialInput); material.diffuse = vec3(1.0, 0.2, 0.0); material.alpha = 0.5; return material; }你可以把czm_getMaterial理解成“材质插件点”。Cesium 在绘制每个片元时会调用这个函数,而你返回的颜色最终会决定屏幕上的像素。正因为是逐像素计算的,渐变、扫描、流动这类效果写起来反而比 CPU 控制更自然。
2. 把 WebGL 和 Cesium 的基础环境检查清楚
很多特效不显示,第一个原因不是 Shader 写错,而是 WebGL 本身没有正常工作。做特效之前,先把基础环境确认好,能省下大量排查时间。
2.1 先确认浏览器 WebGL 状态
Chrome 浏览器可以在地址栏输入chrome://gpu,查看 WebGL 是否启用、硬件加速是否打开。也可以直接在控制台写一段检测脚本:
const canvas = document.createElement('canvas'); const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl'); console.log(gl ? 'WebGL OK' : 'WebGL not supported');如果返回null,先从浏览器设置里打开“使用硬件加速”,然后重启浏览器。部分虚拟机或远程桌面环境会禁用显卡加速,这时候即使能跑,FPS 也会很低。不要把这类环境问题误判成代码问题。
Cesium 在初始化时会自己创建 WebGL 上下文。如果你手动给同一个 canvas 再创建一次 context,可能会报错或导致上下文冲突,所以调试时不要过度操作 canvas,尤其是不要在不同库之间频繁抢 context。
2.2 初始化 Cesium 的常用参数
一个接近最小可运行的 Cesium 页面,通常只需要创建Viewer,并关掉不用的控件:
const viewer = new Cesium.Viewer('cesiumContainer', { animation: false, timeline: false, baseLayerPicker: false, geocoder: false });如果打开后地球是白屏,或者控制台提示需要有效的Cesium ion access token,说明底图资源没有加载成功。Cesium 默认使用 ion 的在线底图,需要 token 或改成本地/离线数据源。这里要强调:先让基础地球能正常显示,再去做地图扫描和飞线。基础地球都出不来,特效写在上面是没有意义的。
2.3 一个最小验证流程:先画出一个图元
我第一次调自定义材质时,习惯先创建一个最简单的图元,比如一个圆形或矩形,给它设置纯色材质。确认能显示后,再替换成自定义材质,最后加入 shader 逻辑。这样可以把问题范围缩小:
- 如果纯色图元显示正常,说明 Cesium 初始化、图元创建、基础渲染都没有问题。
- 如果替换成自定义材质后变成紫色或消失,多半是 Shader 编译失败,Cesium 可能回退到默认材质。
- 如果图元根本没出现,优先看 JavaScript 报错和资源加载情况,而不是跑去改 Shader。
这一步非常值得做。很多人在飞线不显示时,一上来就调整贝塞尔曲线参数,结果最后发现是材质变量名拼写错误,浪费时间。
3. 地图扫描效果的实现思路与 Shader 核心
地图扫描、雷达扫描、动态扩散圈,本质上是同一类效果:一个圆上某一点的颜色,取决于它到圆心的距离、它所在的角度,以及当前时间。理解这一点后,实现思路就会非常清晰。
3.1 地图扫描效果本质上是距离场
如果你要在一个多边形或圆面上做扫描效果,第一步是把平面坐标换算成“到圆心的距离”。Cesium 的材质输入里通常会带st或uv坐标,你可以把它当作平面坐标使用。
一段简化示意:
vec2 uv = materialInput.st - vec2(0.5); float d = length(uv);d是当前片元到中心的距离。距离越大,颜色越淡,这样就得到了一个从中心向外的圆形渐变。如果你想要一圈一圈的波纹,可以继续用距离做周期:
float ring = smoothstep(0.45, 0.5, abs(d - 0.3));abs(d - 0.3)会让波纹出现在距离中心 0.3 左右的地方,smoothstep控制环的宽度和边缘柔化程度。
3.2 用 mod 函数做周期循环
静止的扫描圈没有意义,它需要动起来。怎么动?最常见的方式是用帧号取模。Cesium 提供了一个内置变量czm_frameNumber,它就是当前帧号。把它取模之后,可以映射到一个循环进度。
例如:
float time = mod(czm_frameNumber, 120.0) / 120.0;这样time会每 120 帧从 0 循环到 1。如果 60 帧一秒,这个动画周期就是 2 秒。
mod是取余操作,本质上就是“周期重复”。搜索材料里提到shader mod函数,在雷达扫描、波纹扩散、流动效果里确实非常高频。你也可以用fract(czm_frameNumber / 120.0)达到类似效果。fract是取 x 的小数部分,它会让结果在 0 到 1 之间往复,非常适合做进度条和循环动画。
关键点在于:不要每帧从 JavaScript 端传一个new Date().getTime()给 Shader。虽然能用,但时间戳跨度大、数值精度高,传给 uniform 后会出现跳动,而且每次都要更新 uniform,效率也低。直接用czm_frameNumber,既稳定又省事。
3.3 透明度渐变、边缘锐化和扇区裁剪
雷达扫描通常不是一个完整的圆,它有一个扇形扫描范围,或者一圈亮边。这里的细节决定效果的上限:
- 透明度渐变:用
smoothstep(edge0, edge1, x),避免硬边界带来的锯齿。 - 边缘锐化:如果要一条很细的扫描线,就缩小
smoothstep的过渡区间,比如smoothstep(0.49, 0.5, d)。 - 扇区裁剪:用
atan(uv.y, uv.x)计算当前片元所在角度,再和当前旋转角度比较。只有落在指定角度范围内的片元才输出颜色。
一个简化示意:
float angle = atan(uv.y, uv.x); float sweep = mod(czm_frameNumber / 60.0, 6.28318); float inSector = smoothstep(0.0, 0.05, sweep - angle);atan的结果单位是弧度,周期是2 * PI,所以对czm_frameNumber / 60.0取模,相当于每 60 帧转一圈。smoothstep(0.0, 0.05, sweep - angle)会在扫描边沿产生一点点过渡,让扇形边界不那么生硬。
3.4 通过材质挂到 Cesium 图元上
在 Cesium 中,你可以创建一个自定义材质,然后把 GLSL 字符串塞进去。常见写法:
const scanMaterial = new Cesium.Material({ fabric: { type: 'RadarScan', uniforms: { color: Cesium.Color.fromCssColorString('#ff4444'), speed: 1.0 }, source: `...GLSL...` } });这里有几个容易踩的坑:
uniforms里的键名,必须和 GLSL 里的变量名保持一致,大小写都要一致。source字符串里要包含czm_getMaterial函数,否则 Cesium 不知道去哪拿材质颜色。- 调试时先把 source 简化成“只输出纯色”,确认材质编译通过后再加入距离、角度、时间计算。
如果你看到的效果是纯色但不动,说明 Shader 编译成功,问题出在时间变量或动画逻辑;如果效果是紫色或消失,说明 Shader 本身没编过去,优先找 GLSL 编译错误。
4. 飞线动画:路径、插值、动态纹理
飞线动画在地图大屏里非常常见,视觉上是若干条弧线从起点飞向终点,线上有一个亮点或光带在移动。很多人实现飞线时总是卡顿,核心问题通常不在 Shader,而在于把几何路径和渲染动画混在了一起。
4.1 飞线的基础:起点终点与贝塞尔插值
飞线的几何不是一个简单的直线段,它需要贴合地球弧面并抬升高度。常见做法:
- 拿到起点和终点的经纬度。
- 把经纬度转成 Cesium 世界坐标。
- 在两点之间做插值,并在高度方向加一个抛物线隆起。
- 把插值得到的顶点序列写入线几何体。
这一步在 CPU 端只需要做一次,不需要每帧更新。如果业务要求动态改变起终点,也应该是重新生成几何,而不是用CallbackProperty每帧去改坐标。
这里要特别提醒:贝塞尔曲线不是越多控制点越好。飞线通常只需要一条抛物线,用二次贝塞尔就够。控制点太多,顶点数量增加,反而影响绘制效率。
4.2 流动效果由 UV 和时间驱动
飞线的“流动”效果,最稳妥的做法是在片元着色器里根据 UV 坐标计算。
给线几何体设置 UV 坐标时,让uv.x代表“沿线方向的进度”,uv.y代表“横向位置”。然后在片元着色器里:
float flow = fract(uv.x - time); float brightness = smoothstep(0.6, 1.0, flow);fract(uv.x - time)的意思是:随着 time 增大,亮带从 uv.x 小的地方向大的地方移动。smoothstep(0.6, 1.0, flow)会让亮带只出现在进度接近 1 的区域,从而形成“一个点在往前飞”的视觉。
你也可以把一张渐变纹理贴在线上,让uv.x去采样纹理。但建议刚开始先直接在 Shader 里生成颜色,少引入纹理坐标采样、纹理重复模式这些变量,排查起来更容易。
4.3 为什么别人做得丝滑,你做得卡
飞线数量一多,帧率掉到二三十,是常见问题。常见原因如下:
- 每帧重新计算所有顶点位置。这是最伤性能的,等于把几何计算从 GPU 搬回 CPU。
- 每根线单独创建一个 Primitive。几十根线就有几十个 draw call,这种开销在移动端尤其明显。
- Shader 里写了大量 if 分支。GPU 是并行执行的,分支会让部分线程空转,效率大打折扣。
- 没有做透明度排序。多条半透明线交叉时,会出现闪烁或遮挡错误。
改进思路:
- 把多条飞线的顶点合并到一个 Geometry 里,用不同顶点段区分不同线路。
- 用一个 uniform 时间统一驱动所有飞线,不要每条线单独传时间。
- 如果线路数量很大,考虑用实例化绘制。
- 先让单条飞线跑通,再扩展到批量。批量跑通后,再检查 draw call 和帧率。
5. WebGL Shader 底层原理:能看懂,才能改得不报错
要改别人写的 Shader,或者自己开发新特效,不能只会复制粘贴。需要把 WebGL Shader 的最基本结构理清楚。
5.1 顶点着色器与片元着色器各管什么
一个 WebGL 程序至少有两个着色器:
| 着色器 | 执行频率 | 核心职责 |
|---|---|---|
| 顶点着色器 | 每个顶点执行一次 | 计算顶点位置,输出gl_Position,并设置要传给片元的数据 |
| 片元着色器 | 每个像素执行一次 | 根据插值结果和 uniform 计算颜色,输出最终像素 |
在 Cesium 里,自定义材质主要改的是片元阶段。因为地图扫描、飞线流动、墙体流动这些效果关注的是“每个像素显示什么颜色”,不涉及几何形状变化。如果要做模型节点旋转、顶点波浪形变,那才需要动顶点着色器。
5.2 uniform、varying、attribute 的分工
GLSL 里三个变量类型经常把人绕晕。可以这样记:
| 类型 | 数据来源 | 更新频率 | 典型用途 |
|---|---|---|---|
| attribute | 顶点缓冲 | 创建几何时写入 | 顶点坐标、法线、UV 坐标 |
| varying | 顶点着色器输出,GPU 自动插值 | 每个片元自动生成 | 把 UV 或世界坐标传给片元着色器 |
| uniform | CPU 每帧或按需上传 | 全局共享 | 时间、颜色、中心点、透明度 |
varying是很多人理解不到位的地方。顶点着色器给每个顶点计算了一个值,但片元着色器要处理的是三角形内部的无数个像素。GPU 会把顶点上的 varying 值做插值,让三角形内部的颜色自然过渡。这就是为什么飞线的 UV 坐标可以平滑地从起点过渡到终点,中间不会出现断层。
5.3 为什么 gl_FragCoord、texture2D、czm_frameNumber 高频出现
这三个东西在特效 Shader 里几乎绕不开:
gl_FragCoord:代表当前片元在屏幕上的坐标。适合做全屏后处理、扫描波纹、边缘检测,因为你能知道每个像素在屏幕的哪个位置。texture2D:纹理采样函数,传入采样器和 UV 坐标,返回纹理颜色。适合把复杂的渐变图、噪声图提前烘焙成图片,减少 Shader 里的实时计算。czm_frameNumber:Cesium 内置的帧号 uniform,让 Shader 能在不依赖 JavaScript 时间的情况下驱动动画。
另外还要知道 Cesium 提供了很多czm_开头的内置变量和函数,比如czm_viewport、czm_getDefaultMaterial。它们和czm_frameNumber一样,是 Cesium 在编译 Shader 时自动注入的。你在自定义材质里直接调用就行,不需要手动声明。
5.4 常见 Shader 报错和排查顺序
Shader 报错很容易让人摸不着头脑,因为错误信息经常指向编译后的代码,行号和源码不完全对应。我的排查顺序一般是这样:
- 看浏览器控制台里的 GLSL 错误文本,重点关注引用的变量名和函数名。
- 检查 uniform 名称是否和 JS 端 uniforms 对象完全一致。
- 检查数据类型。GLSL 对类型严格,
vec2不能直接赋给float,必须先取.x或.y。 - 把复杂 Shader 逐步简化,先输出固定颜色,确认编译通过。
- 确认 Shader 里没有未声明变量或拼写错误,比如把
materialInput写成materialInput的变体。 - 确认有没有使用当前 GLSL 版本不支持的特性。WebGL 1 和 WebGL 2 的着色器语法有差异,Cesium 内部会做很多处理,但自定义材质里仍然要小心。
需要注意,gl_FragColor是 WebGL 1 的固定输出变量,WebGL 2 中更常见的是out vec4 fragColor。Cesium 的自定义材质一般不需要你控制最终输出,它已经把片元输出流程封装好了,你只要返回czm_material。所以遇到gl_FragColor相关报错时,先检查是不是代码里重复定义了输出变量。
6. 从“能跑”到“稳”:效果验证、性能边界和优化
能把效果跑出来只是第一步,真正进入项目还要面对稳定性、性能、多效果叠加等问题。
6.1 先给“成功”定一个可检查的标准
很多人看到效果出来了就认为完成,但“能显示”和“稳定运行”是两码事。
我一般用下面几个标准判断效果是否合格:
- 动画连续,循环进度平滑,没有跳变或闪断。
- 扫描边缘清晰,锐化和渐变过渡符合设计预期。
- 多个扫描圈、多条飞线同时运行时,不互相遮挡、不闪烁。
- 相同配置下,长时间运行 10 分钟以上,内存和帧率没有持续恶化。
- 窗口缩放、视角切换后,特效仍然保持正确位置和形态。
如果只是本地学习,跑通就算成功;但如果要放到大屏项目里,必须做长期运行测试。
6.2 性能边界:Shader 不是零成本
Shader 效率高,但并不是零成本。地图扫描如果覆盖整个屏幕,片元着色器里的三角函数、距离计算、分支语句会被放大到几百万个像素上执行,复杂度上升一点,帧率就可能明显下降。
常见的性能优化方向:
- 减少纹理采样次数,能用数学函数生成的渐变就不要贴图。
- 避免在片元着色器里做复杂循环。
- 用
smoothstep、step替代 if 分支,让 GPU 分支预测更友好。 - 如果扫描范围很大,考虑用较低分辨率的离屏纹理,再上屏放大,牺牲一点清晰度换性能。
- 多条飞线尽量合并为一个 Primitive,降低 draw call 数量。
6.3 Cesium 与其他渲染器共享 GL 上下文时的注意点
搜索材料里提到了 Cesium 和 three.js 共享 GL 上下文。这确实是可行的方向,但细节很多,而且高度依赖两个库的版本。
一个核心原则:两个库共享同一个 canvas 和同一个 WebGL context,而不是各创建各的。否则上下文会被覆盖,后初始化的库可能抢走绘制状态,导致其中一个库白屏。
具体实践时要注意:
- 明确渲染顺序,先渲染 Cesium 还是先渲染 three.js,需要固定下来。
- 每次切换渲染器之前,保存或重置 WebGL 状态,比如深度测试、混合模式、视口。
- 不要在 Cesium 的渲染过程中同步执行 three.js 的渲染,容易造成状态污染。
- 做 resize 时也要同时通知两个库,避免画布大小不一致。
如果只是给 Cesium 加一个后处理效果或局部特效,优先考虑在 Cesium 内部用Scene.postProcessStages或CustomShader完成,尽量不引入第三个渲染器。只有跨界需求非常强烈时,才考虑共享上下文方案。
6.4 一个可以反复使用的排查链路
把常见的 Cesium 特效问题汇总成一条排查链路,能省掉大量试错时间:
- 浏览器控制台有没有 JavaScript 报错或 GLSL 编译报错。
- WebGL 是否可用,硬件加速是否开启。
- 图元是否显示,先用纯色材质验证。
- 自定义材质是否编译成功,把 Shader 简化为纯色输出。
- uniform 变量名和类型是否一致,给 uniform 一个固定值测试。
- 动画是否被执行,把时间周期调慢,观察变化过程。
- 帧率是否稳定,打开性能监控看 draw call 和 GPU 占用。
这套顺序覆盖了 90% 的“特效不显示”“特效不动”“特效卡顿”问题。
最后补充一个长期维护建议:项目里如果用了多个自定义材质,不要把 GLSL 字符串全部写在组件里,最好单独提取成.glsl或.js文件统一管理。后期排查时能直接搜到变量定义,不用在每个页面里翻字符串。地图扫描、飞线动画这类效果,真正做到丝滑,靠的不是某个高深算法,而是把 CPU 端的管理逻辑和 GPU 端的渲染逻辑分清楚,让每一帧都在最合适的层级里运行。