Cesium特效优化:地图扫描与飞线动画的Shader实现与原理
2026/9/1 11:47:14 网站建设 项目流程

做 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结构体,里面包含diffusespecularemissionalpha等字段。你只要改这些字段,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 逻辑。这样可以把问题范围缩小:

  1. 如果纯色图元显示正常,说明 Cesium 初始化、图元创建、基础渲染都没有问题。
  2. 如果替换成自定义材质后变成紫色或消失,多半是 Shader 编译失败,Cesium 可能回退到默认材质。
  3. 如果图元根本没出现,优先看 JavaScript 报错和资源加载情况,而不是跑去改 Shader。

这一步非常值得做。很多人在飞线不显示时,一上来就调整贝塞尔曲线参数,结果最后发现是材质变量名拼写错误,浪费时间。

3. 地图扫描效果的实现思路与 Shader 核心

地图扫描、雷达扫描、动态扩散圈,本质上是同一类效果:一个圆上某一点的颜色,取决于它到圆心的距离、它所在的角度,以及当前时间。理解这一点后,实现思路就会非常清晰。

3.1 地图扫描效果本质上是距离场

如果你要在一个多边形或圆面上做扫描效果,第一步是把平面坐标换算成“到圆心的距离”。Cesium 的材质输入里通常会带stuv坐标,你可以把它当作平面坐标使用。

一段简化示意:

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...` } });

这里有几个容易踩的坑:

  1. uniforms里的键名,必须和 GLSL 里的变量名保持一致,大小写都要一致。
  2. source字符串里要包含czm_getMaterial函数,否则 Cesium 不知道去哪拿材质颜色。
  3. 调试时先把 source 简化成“只输出纯色”,确认材质编译通过后再加入距离、角度、时间计算。

如果你看到的效果是纯色但不动,说明 Shader 编译成功,问题出在时间变量或动画逻辑;如果效果是紫色或消失,说明 Shader 本身没编过去,优先找 GLSL 编译错误。

4. 飞线动画:路径、插值、动态纹理

飞线动画在地图大屏里非常常见,视觉上是若干条弧线从起点飞向终点,线上有一个亮点或光带在移动。很多人实现飞线时总是卡顿,核心问题通常不在 Shader,而在于把几何路径和渲染动画混在了一起。

4.1 飞线的基础:起点终点与贝塞尔插值

飞线的几何不是一个简单的直线段,它需要贴合地球弧面并抬升高度。常见做法:

  1. 拿到起点和终点的经纬度。
  2. 把经纬度转成 Cesium 世界坐标。
  3. 在两点之间做插值,并在高度方向加一个抛物线隆起。
  4. 把插值得到的顶点序列写入线几何体。

这一步在 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 或世界坐标传给片元着色器
uniformCPU 每帧或按需上传全局共享时间、颜色、中心点、透明度

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_viewportczm_getDefaultMaterial。它们和czm_frameNumber一样,是 Cesium 在编译 Shader 时自动注入的。你在自定义材质里直接调用就行,不需要手动声明。

5.4 常见 Shader 报错和排查顺序

Shader 报错很容易让人摸不着头脑,因为错误信息经常指向编译后的代码,行号和源码不完全对应。我的排查顺序一般是这样:

  1. 看浏览器控制台里的 GLSL 错误文本,重点关注引用的变量名和函数名。
  2. 检查 uniform 名称是否和 JS 端 uniforms 对象完全一致。
  3. 检查数据类型。GLSL 对类型严格,vec2不能直接赋给float,必须先取.x.y
  4. 把复杂 Shader 逐步简化,先输出固定颜色,确认编译通过。
  5. 确认 Shader 里没有未声明变量或拼写错误,比如把materialInput写成materialInput的变体。
  6. 确认有没有使用当前 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 效率高,但并不是零成本。地图扫描如果覆盖整个屏幕,片元着色器里的三角函数、距离计算、分支语句会被放大到几百万个像素上执行,复杂度上升一点,帧率就可能明显下降。

常见的性能优化方向:

  • 减少纹理采样次数,能用数学函数生成的渐变就不要贴图。
  • 避免在片元着色器里做复杂循环。
  • smoothstepstep替代 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.postProcessStagesCustomShader完成,尽量不引入第三个渲染器。只有跨界需求非常强烈时,才考虑共享上下文方案。

6.4 一个可以反复使用的排查链路

把常见的 Cesium 特效问题汇总成一条排查链路,能省掉大量试错时间:

  1. 浏览器控制台有没有 JavaScript 报错或 GLSL 编译报错。
  2. WebGL 是否可用,硬件加速是否开启。
  3. 图元是否显示,先用纯色材质验证。
  4. 自定义材质是否编译成功,把 Shader 简化为纯色输出。
  5. uniform 变量名和类型是否一致,给 uniform 一个固定值测试。
  6. 动画是否被执行,把时间周期调慢,观察变化过程。
  7. 帧率是否稳定,打开性能监控看 draw call 和 GPU 占用。

这套顺序覆盖了 90% 的“特效不显示”“特效不动”“特效卡顿”问题。

最后补充一个长期维护建议:项目里如果用了多个自定义材质,不要把 GLSL 字符串全部写在组件里,最好单独提取成.glsl.js文件统一管理。后期排查时能直接搜到变量定义,不用在每个页面里翻字符串。地图扫描、飞线动画这类效果,真正做到丝滑,靠的不是某个高深算法,而是把 CPU 端的管理逻辑和 GPU 端的渲染逻辑分清楚,让每一帧都在最合适的层级里运行。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询