ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Cesium特效优化:地图扫描与飞线动画的Shader实现与原理

Cesium特效优化:地图扫描与飞线动画的Shader实现与原理 做 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 或世界坐标传给片元着色器uniformCPU 每帧或按需上传全局共享时间、颜色、中心点、透明度varying是很多人理解不到位的地方。顶点着色器给每个顶点计算了一个值但片元着色器要处理的是三角形内部的无数个像素。GPU 会把顶点上的 varying 值做插值让三角形内部的颜色自然过渡。这就是为什么飞线的 UV 坐标可以平滑地从起点过渡到终点中间不会出现断层。5.3 为什么 gl_FragCoord、texture2D、czm_frameNumber 高频出现这三个东西在特效 Shader 里几乎绕不开gl_FragCoord代表当前片元在屏幕上的坐标。适合做全屏后处理、扫描波纹、边缘检测因为你能知道每个像素在屏幕的哪个位置。texture2D纹理采样函数传入采样器和 UV 坐标返回纹理颜色。适合把复杂的渐变图、噪声图提前烘焙成图片减少 Shader 里的实时计算。czm_frameNumberCesium 内置的帧号 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 端的渲染逻辑分清楚让每一帧都在最合适的层级里运行。
返回列表