
很多人第一次看到别人做的 Cesium 大屏时都会有同一个疑问地图上那个雷达扫描圈是不是一个 GIF 贴图飞线是不是 Cesium 官方 API 里现成的方法等自己动手去查文档却发现 Cesium 自带的材质只有颜色、条纹、网格这些基础类型根本找不到“扫描”和“飞线”的入口。这里可以给出一个明确判断你看到的高质量地图特效绝大多数不是前端动画拼出来的而是在 WebGL 的片元着色器里逐像素“算”出来的。换句话说别人做得丝滑不是因为他调用了什么隐藏 API而是他掌握了一个底层能力——自定义 Shader。这篇文章会从 WebGL 渲染管线讲起然后拆解两个最经典的特效地图扫描雷达扇形扫描 波纹扩散和飞线动画。全程会给完整可运行的 Cesium 自定义材质代码并解释每个 Shader 函数在干什么。读完你会理解一个核心事实Cesium 特效的本质是控制 GPU 在每个像素上做什么。1. 为什么官方 API 做不出你想要的特效先看 Cesium 官方内置了哪些材质。在 Entity 的 polygon、polyline 上Cesium 默认提供了 Color、Image、Stripe、Grid、Checkerboard 等基础材质。这些材质能解决“贴图”问题但都缺少同一个能力随时间变化的动态计算。有人会想那我用 JavaScript 定时器不断修改 Entity 的 position 或 style是不是也能做出扫描效果可以但这是典型的错误方向。原因有三点第一Cesium 场景中的 Entity 数量一旦增多CPU 提交绘制命令的开销会快速上升。如果你为了让扫描圈动起来每帧都去修改 geometry 的坐标或材质参数主线程会被拖垮。第二前端定时器的精度只能到毫秒级而且大量 setInterval 会让帧率不稳定。扫描特效本身需要的是“逐帧输入时间”而不是“定时触发一段逻辑”。第三如果你用透明度渐变、CSS 旋转、Canvas 贴图这些方式去模拟效果是平面化的和三维地形、倾斜摄影、模型之间没有任何遮挡关系一眼就能看出是假的。真正的高质量方案是把“扫描”这个过程的计算交给 GPU。在 Cesium 中你可以通过自定义 Material 把一段 GLSL 代码注入到渲染管线中。每一帧GPU 会为几何体覆盖的每一个像素执行这段代码同时 Cesium 会把当前帧号注入给 Shader。这样动画不再依赖 JavaScript 定时器而是完全由显卡的渲染节奏驱动。所以你要掌握的第一个关键点是Cesium 特效 几何体轮廓 自定义 Shader 材质。几何体负责“画在哪里”Shader 负责“每一帧画成什么样”。2. WebGL 渲染管线与 Shader 核心原理在进入代码之前有必要把 Shader 的基础讲清楚。很多人看到 Shader 代码就害怕其实它没有想象中复杂。2.1 顶点着色器和片元着色器WebGL 的渲染管线里有两个必须写的着色器。顶点着色器Vertex Shader的作用是决定几何体的顶点在屏幕上哪个位置。它执行的次数等于顶点数。对一个多边形来说顶点可能只有几十个到几百个。片元着色器Fragment Shader的作用是决定每个像素最终显示成什么颜色。它执行的次数等于几何体覆盖的像素数。一块覆盖几百个像素、面积很大的多边形片元着色器会被执行几十万甚至上百万次。Cesium 的自定义 Material 本质上就是让你写一个“片元着色器的片段”而不是完整的着色器。你不需要关心顶点怎么变换也不需要自己去拿 gl_FragColor。Cesium 定义了自己的材质接口你写一个 czm_getMaterial 函数返回一个 czm_material 结构体Cesium 会把它继续交给后续的光照阶段。czm_material czm_getMaterial(czm_materialInput materialInput) { czm_material material czm_getDefaultMaterial(materialInput); // 在这里修改 material.diffuse 和 material.alpha return material; }这段结构是所有 Cesium 自定义材质的地基。materialInput 里带着这个像素的纹理坐标、位置、法线等信息czm_getDefaultMaterial 会返回一个默认材质对象你只需要修改它的颜色和透明度字段。2.2 三个高频使用的 GLSL 函数在 Shader 里做扫描和飞线特效离不开 mod、fract、smoothstep 这三个函数。mod 是取模函数。它可以让一个递增的数值在某个区间内循环。Shader 动画的本质就是把时间变成周期性信号。比如 mod(x, 2.0) 的结果永远在 0 到 2 之间循环这就构成了“波”。fract 是取小数部分的函数。fract(x) 返回 x 去掉整数部分后的值结果永远在 0 到 1 之间。它比 mod 更适合做“从头到尾再回到头”的循环效果。smoothstep 是平滑过渡函数。smoothstep(edge0, edge1, x) 会在 edge0 到 edge1 之间做一次平滑插值返回 0 到 1 之间的值。用它来实现边缘虚化、扫描线渐变比单纯用 if 判断要自然得多。2.3 时间从哪来Shader 是每一帧被执行的它怎么知道当前时间Cesium 在编译材质时会注入一组内置 uniform 变量其中就包括 czm_frameNumber表示当前帧号。你可以在材质代码中直接使用它不需要声明。float time czm_frameNumber * 0.01;帧号是一个持续递增的整数乘以一个很小的系数就变成了一个稳定的时间流。有了这个时间流你就可以用它驱动角度旋转、波纹扩散、飞线流动。2.4 一个关键认知关于 Cesium 的自定义材质最容易误导新手的一点是你在 Material 里面写的代码不是“一个完整的 Shader”而是“材质计算函数”。所以你不能直接在代码里写 gl_FragColor也不能直接用 attribute、varying 这些顶点着色器里的东西。你需要理解 materialInput 这个结构体里有哪些字段以及 czm_material 需要你填哪些字段。在实际开发里你只需要关注两个字段diffuse 控制颜色alpha 控制透明度。其他的如 specular、emission 等在特效材质里通常不做特殊处理。3. 环境准备与基础配置本文的示例基于 Cesium 1.100 以上的版本演示但核心的 Material 机制在早期版本中就已经存在基本原理一致。如果你用的是 Vue Vite 工程可以按下面步骤搭建一个最小环境。3.1 初始化项目npm create vitelatest cesium-shader-demo -- --template vanilla cd cesium-shader-demo npm install cesium安装完毕后需要在入口文件中引入 Cesium 的样式和核心库。以原生 Vite 项目为例// main.js import * as Cesium from cesium; import cesium/Build/Cesium/Widgets/widgets.css; // 如果使用官方在线资源需要配置 token Cesium.Ion.defaultAccessToken 你的 token;如果你在内网环境或者不想接入 Cesium Ion可以自己准备离线影像和地形数据。但需要说明的是地图瓦片和地形切片属于数据工程问题和本文要讲的特效 Shader 没有直接关系。特效运行在白膜、影像、地形、模型之上都可以。3.2 创建基础场景const viewer new Cesium.Viewer(cesiumContainer, { animation: false, timeline: false, baseLayerPicker: false, infoBox: false, selectionIndicator: false }); viewer.scene.globe.enableLighting false;一个干净的 viewer 就创建好了。接下来的所有特效都是在 viewer 场景中叠加几何体。3.3 检查浏览器是否支持 WebGL如果运行后页面黑屏优先检查 WebGL 是否可用。在 Chrome 地址栏输入 chrome://gpu 查看 WebGL 状态或直接在控制台执行下面代码const canvas document.createElement(canvas); const gl canvas.getContext(webgl); console.log(gl ? WebGL OK : WebGL 不可用);如果控制台输出 WebGL 不可用通常是浏览器硬件加速被关闭或者显卡驱动太旧。在设置中开启硬件加速并重启浏览器即可。4. 地图扫描特效雷达扇形扫描与波纹扩散现在开始写本文第一个完整特效。雷达扫描通常由两部分组成一个扇形扫过的区域以及从中心向外扩散的波纹。两部分都是通过对像素坐标的计算实现的。4.1 特效思路拆解先明确一个基础事实所有基于平面坐标的特效都是在纹理坐标 st 上做数学运算。st 坐标的范围是 0 到 1。对于多边形来说(0, 0) 是左下角(1, 1) 是右上角。我们希望扫描效果从多边形中心向外扩散所以要把 st 坐标从“左下角原点”变换到“中心原点”。vec2 uv st - vec2(0.5);变换后uv 的范围大约在 -0.5 到 0.5 之间中心点的坐标就是 (0, 0)。接下来用 atan 计算每个像素相对于中心点的角度用 length 计算半径。有了角度和半径就可以组合出各种扫描效果。4.2 雷达扫描材质代码创建一个 radarScanMaterial.js 文件// radarScanMaterial.js import * as Cesium from cesium; export function createRadarScanMaterial() { // 在 Cesium 的材质缓存中注册自定义材质 Cesium.Material._materialCache.addMaterial(RadarScan, { fabric: { type: RadarScan, uniforms: { color: Cesium.Color.fromCssColorString(#00ffaa), scanSpeed: 2.0, sectorAngle: 0.6 }, source: czm_material czm_getMaterial(czm_materialInput materialInput) { czm_material material czm_getDefaultMaterial(materialInput); vec2 st materialInput.st; vec2 uv st - vec2(0.5); // 1. 计算当前像素相对中心的角度和半径 float angle atan(uv.y, uv.x); float radius length(uv) * 2.0; // 2. 扫描角度随时间递增周期为 2 * PI float time czm_frameNumber * 0.01 * scanSpeed; float scanAngle mod(time, czm_twoPi); // 3. 计算当前像素角度与扫描角度的夹角 float angleDiff abs(scanAngle - angle); // 处理角度环绕角度差超过 PI 时取补角 angleDiff min(angleDiff, czm_twoPi - angleDiff); // 4. 扇形扫描夹角小于 sectorAngle 时高亮 float sector 1.0 - smoothstep(0.0, sectorAngle, angleDiff); // 5. 半径方向做波纹扩散用 fract 产生循环波 float ringCount 4.0; float wave 1.0 - abs(fract(radius * ringCount - time * 2.0) - 0.5) * 2.0; float ring (1.0 - wave) * 0.6; // 6. 合成颜色 vec3 scanColor color.rgb; float alpha sector * 0.7 ring * sector; material.diffuse scanColor; material.alpha alpha; return material; } }, // 多边形需要关闭纹理坐标的重复采样 translucent: true }); }这段代码有几个关键点需要理解。atan(uv.y, uv.x) 会返回该像素相对于中心点的角度范围是 -PI 到 PI。扫描角 scanAngle 由帧号驱动范围是 0 到 2PI。直接比较两者会碰到角度环绕问题比如扫描角是 350 度时目标像素是 10 度角度差计算出来是 340 度但实际差异只有 20 度。所以代码里用 min(angleDiff, czm_twoPi - angleDiff) 把角度差折算到 0 到 PI 范围内。波纹扩散的核心是这段float ringCount 4.0; float wave 1.0 - abs(fract(radius * ringCount - time * 2.0) - 0.5) * 2.0;fract 的参数是一个随半径增加而增加、随时间变化而变化的数值。fract 的结果在 0 到 1 之间循环这就在半径方向上形成了多个“环”。1.0 - abs(x - 0.5) * 2.0 会把这个循环信号变成三角波。三角波的值在环的边界处是 0在环的中心是 1。然后我们用 1.0 - wave 反转让环变成暗底亮线。4.3 创建扫描实体材质注册之后在场景中添加一个多边形即可看到效果// createRadarEntity.js import * as Cesium from cesium; import { createRadarScanMaterial } from ./radarScanMaterial.js; export function addRadarScan(viewer, center, radius) { // 确保材质已注册 createRadarScanMaterial(); // 以中心点为中心创建一个正六十四边形区域作为扫描底座 const positions []; const count 64; for (let i 0; i count; i) { const angle (i / count) * Cesium.Math.TWO_PI; const x center.longitude radius * Math.cos(angle) / 100000; const y center.latitude radius * Math.sin(angle) / 100000; positions.push(Cesium.Cartesian3.fromDegrees(x, y)); } return viewer.entities.add({ polygon: { hierarchy: new Cesium.PolygonHierarchy(positions), material: new Cesium.Material({ fabric: { type: RadarScan } }), classificationType: Cesium.ClassificationType.BOTH } }); }注意这里用了一个近似换算把半径除以 100000 当作经纬度增量。这种写法只适合在近距离、低纬度区域演示实际项目中建议把半径换算成经纬度差或者改用 Cesium 的 Cartesian3 坐标计算。运行后你会看到一个以中心点为圆心的扇形扫描区域同时半径方向上会有波纹从中心向外扩散。因为整个流程都在 GPU 的片元着色器中完成持续运行时不会占用 JavaScript 主线程。4.4 波纹扩散效果增强思路如果觉得波纹不够明显可以增加 ringCount 的数值或者提高 time 前的系数。如果觉得扫描扇形边缘太锐利可以提高 smoothstep 的第二个参数值让过渡更柔和。一个可以继续深入的方向是把扫描效果从“贴在地面”改成“垂直扫描面”用于山体或建筑的可视域分析。思路本质相同差别在于把 st 坐标换成垂直平面上的坐标。Cesium 的可视域分析、天际线分析底层用的也是类似的渲染技巧。5. 飞线动画实战PolylineTrail 材质飞线是另一个高频特效。它看起来像是带有渐变尾迹的光线从 A 点飞到 B 点常用于人口迁徙、物流流向、航线分布等可视化场景。5.1 飞线的核心原理飞行线的几何体是折线。折线本身是静态的要让“飞行”的感觉出现需要在 Shader 里沿着折线方向移动一个高亮区域。折线材质有一个特殊的优势materialInput.st 中st.s 通常代表沿折线的进度st.t 代表垂直于折线的方向。这意味着我们只需要根据 st.s 的值来控制亮度就能得到沿折线移动的效果。飞线尾迹的核心算法是float progress fract(st.s - time);st.s 是 0 到 1 的进度值减去随时间递增的时间值再用 fract 取小数部分就得到了一个从 0 到 1 周期性循环的进度。然后计算当前片元在这个循环中的位置尾迹处亮度高前端之外透明度为 0。5.2 飞线材质代码创建 trailLineMaterial.js 文件// trailLineMaterial.js import * as Cesium from cesium; export function createTrailLineMaterial() { Cesium.Material._materialCache.addMaterial(PolylineTrail, { fabric: { type: PolylineTrail, uniforms: { color: Cesium.Color.fromCssColorString(#00e5ff), trailLength: 0.3, speed: 1.0 }, source: czm_material czm_getMaterial(czm_materialInput materialInput) { czm_material material czm_getDefaultMaterial(materialInput); // 获取沿线进度 float progress materialInput.st.s; // 用时间驱动进度fract 保证循环 float time czm_frameNumber * 0.01 * speed; float tail fract(progress - time); // 头部亮尾部逐渐变暗 float gradient smoothstep(0.0, trailLength, tail); // 超出尾迹范围的部分透明 float alpha smoothstep(trailLength, 0.0, tail); // 让飞线在垂直方向上有一个高光核心 float core 1.0 - abs(materialInput.st.t - 0.5) * 2.0; core pow(core, 2.0); vec3 lineColor color.rgb; material.diffuse lineColor; material.alpha alpha * gradient * core; return material; } }, translucent: true }); }这里有两个容易踩坑的地方。第一st.s 的方向不一定总是从起点到终点。在某些几何体上纹理坐标可能被翻转。如果你发现飞线是从终点往起点飞的有两种处理方式一种是把 st.s 改成 1.0 - st.s另一种是反转线的坐标顺序。第二尾迹的长度由 trailLength 控制取值在 0 到 1 之间。值越小尾迹越短飞线看起来就越接近“点状流星”值越大尾迹越长越像“彗星”。5.3 创建飞线实体// createTrailEntity.js import * as Cesium from cesium; import { createTrailLineMaterial } from ./trailLineMaterial.js; export function addTrailLine(viewer, start, end) { createTrailLineMaterial(); const startPoint Cesium.Cartesian3.fromDegrees(start[0], start[1], start[2] || 0); const endPoint Cesium.Cartesian3.fromDegrees(end[0], end[1], end[2] || 0); // 计算中间点比两端更高形成弧形 const midPoint Cesium.Cartesian3.midpoint(startPoint, endPoint, new Cesium.Cartesian3()); const height Cesium.Cartesian3.distance(startPoint, endPoint) * 0.2; const midCartographic Cesium.Cartographic.fromCartesian(midPoint); midCartographic.height height; return viewer.entities.add({ polyline: { positions: [startPoint, Cesium.Cartesian3.fromRadians(midCartographic.longitude, midCartographic.latitude, midCartographic.height), endPoint], width: 4, material: new Cesium.Material({ fabric: { type: PolylineTrail } }) } }); }调用方式addTrailLine(viewer, [116.39, 39.9, 0], [121.47, 31.23, 0]);把起点设在北京终点设在上海可以看到一条带有蓝色尾迹的光线从北京飞向上海到达后重新开始循环。这里的弧线生成方式比较简单取起点和终点的中点抬高一定距离后作为曲线的控制点。实际项目中如果要贴合地球曲率或避开山脉可以把中点替换为更复杂的贝塞尔曲线采样点。飞线性能优化的重点是减少折线顶点数顶点越少CPU 提交压力越小。如果你需要一条光滑的曲线优先考虑用少量控制点生成 Catmull-Rom 样条而不是直接传入上千个点。5.4 多飞线场景的组织方式当飞线数量达到几十条甚至上百条时不要给每条飞线单独创建一个 Entity。更推荐的做法是使用 Primitive API把所有飞线合并成同一个 Geometry再共用同一个材质。// 使用 GroundPolylineGeometry 或 PolylineGeometry 聚合多条线 // 然后通过 GeometryInstance 添加到 PolylineColorAppearance 或自定义材质中如果使用 Entity 方式每条飞线都是一个独立的绘制命令几百条飞线会让 CPU 开销明显上升。合并 Primitive 后原本几百次的 draw call 会收敛到一次帧率能得到显著提升。6. 运行结果与效果验证在本地启动项目npm run dev浏览器打开页面后应该可以看到两个效果第一个是雷达扫描效果。多边形区域内有一个扇形亮区顺时针旋转同时圆环波纹从中心向外扩散。扇形边缘是柔和的渐变波纹是细线状。第二个是飞线效果。一条发光的弧线从起点飞向终点头部最亮尾部逐渐变暗到达终点后自动从头开始。判断这个特效确实是 GPU 在渲染而不是动画纹理可以做一个简单验证打开浏览器的 Performance 面板观察运行特效时的 FPS。如果 FPS 稳定在 60 附近即使页面中有比较复杂的场景也说明 Shader 方案性能是达标的。然后你在控制台执行大量 JavaScript 计算模拟主线程卡顿此时 Shader 动画依然会保持流畅。这是因为特效的动画节奏由显卡的渲染帧驱动不受 JavaScript 主线程阻塞影响。如果效果没有出现按以下顺序排查第一步检查控制台是否报错。Cesium 的材质编译错误会直接输出到控制台包含 GLSL 代码的编译日志。第二步检查材质是否已经注册。如果实体已经创建但 polygon 显示为默认的白色或黑色说明材质 type 没有找到检查 addMaterial 的调用时机。第三步检查 polygon 是否被其他实体挡住。在 Cesium 中如果多边形贴地而地形高度高于多边形会被地形遮挡。把 polygon 的 height 设置为一个较小的值或者关闭 terrain 测试。7. 常见问题与排查思路问题现象可能原因排查方式解决方案页面黑屏控制台提示 WebGL 上下文创建失败浏览器硬件加速被关闭或显卡驱动过旧打开浏览器设置检查硬件加速控制台执行 canvas.getContext(webgl) 验证开启硬件加速并重启浏览器更新显卡驱动自定义材质不生效多边形显示为默认颜色材质未注册或 type 名称拼写不一致检查 addMaterial 是否在创建实体前执行对比材质 type 字符串在创建实体前调用 createRadarScanMaterial 或 createTrailLineMaterial雷达扫描效果不旋转czm_frameNumber 没有被正确使用或扫描系数过小调大 scanSpeed 观察效果在 Shader 中尝试用固定值测试确认 Shader 中可以直接读取 czm_frameNumberCesium 会自动注入飞线方向反了st.s 的方向与线坐标顺序不一致在 Shader 中输出 st.s 的灰度图观察方向将 st.s 改为 1.0 - st.s或反转线的顶点顺序波纹扩散效果不明显ringCount 太小或 wave 值计算错误调大 ringCount观察是否出现更多环增大 ringCount并检查 fract 参数中 radius 的缩放系数多边形边缘出现锯齿多边形顶点数不够导致边缘不圆滑增加 count 参数到 128 或更高提高多边形细分数量多条飞线时帧率下降Entity 数量过多draw call 过多打开 Performance 面板观察 draw call 数量改用 Primitive 聚合批量提交几何体8. 最佳实践与工程建议8.1 材质封装与统一管理不要在每个页面里直接写 Cesium.Material._materialCache.addMaterial。更合理的做法是把所有自定义材质抽成独立模块封装成统一的注册函数。项目启动时调用一次 setupMaterials()把所有材质注册好页面任何位置都可以直接引用。材质名称建议带上项目前缀比如 AppRadarScan、AppTrailLine避免与其他第三方库的材质重名。Cesium 的材质注册表是全局共享的一个不经意的重名可能导致已有材质被覆盖。8.2 动画控制与资源回收Cesium 的 Material 动画是由 czm_frameNumber 驱动的默认情况下它会一直运行。如果你需要暂停动画可以临时把材质的 uniforms.speed 设置为 0或者直接隐藏对应的 Entity。页面销毁时需要调用 viewer.entities.remove(entity) 或 viewer.entities.removeAll() 来释放绘制资源。如果使用了 Primitive记得调用 primitive.destroy()。8.3 性能优化Shader 特效本身是 GPU 计算性能瓶颈通常不在片元着色器而在 CPU 端的几何体提交。雷达扫描这种大面积覆盖屏幕的 Polygon如果像素过多片元着色器的执行次数会增加。在满足视觉效果的前提下尽量缩小多边形范围不要让无关区域参与 Shader 计算。飞线设计时要控制折线顶点数。一条飞线两三个控制点就足够了把弧线的平滑度交给 Shader 或者样条插值去处理而不是用几百个点拼一条曲线。对于大屏项目如果扫描特效和飞线特效同时显示需要注意几何体绘制顺序。Cesium 默认按照创建顺序绘制如果飞线被地面多边形遮挡可以调整 depthTestAgainstTerrain 或实体的 zIndex 相关配置。8.4 数据安全与生产环境注意事项在生产环境中飞线轨迹、雷达中心点这些数据通常来自后端服务。建议在写入 Cesium 实体之前对坐标值做范围校验避免异常数据导致几何体绘制异常。如果项目部署在内网需要注意 Cesium 的离线资源问题。影像、地形切片、3D Tiles 都需要做本地化处理。瓦片和地形文件的格式比如 1.terrain 格式与特效渲染是两个独立环节建议先搭建好基础数据环境再叠加特效层。Cesium 版本升级时要重点回归测试自定义材质。Cesium 的材质系统相对稳定但底层的 GLSL 编译环境可能随版本变化。升级后尽早检查 Shader 是否正常编译而不是等到上线前再发现。9. 总结与后续学习方向这篇文章的核心结论可以概括成一句话Cesium 地图特效的本质不是找现成 API而是通过自定义 Material 在片元着色器里编写 GLSL 代码用 GPU 的逐像素计算去替代前端的 JavaScript 动画。我们完整拆解了两个经典案例。雷达扫描效果的实现路径是将 st 坐标平移到中心点通过 atan 计算角度、length 计算半径再结合 czm_frameNumber 驱动扫描角旋转和波纹扩散。飞线动画的实现路径是利用折线材质的 st.s 表示沿线进度用 fract 函数构造循环尾迹用 smoothstep 控制头部亮度和尾部渐变。如果你要继续深入建议按三个方向学习。第一个方向是读 Cesium 源码。重点看 Scene 目录下材质相关的实现理解 fabric 机制是如何把 GLSL 注入到渲染管线的。第二个方向是系统学 GLSL。Cesium 的自定义材质只是 GLSL 的一个应用场景学透之后你可以把同样能力用到地图可视域分析、动态水面、局部雨雪、天际线、模型外发光等更复杂的特效上。第三个方向是研究 CustomShader 和 PostProcessStage。前者可以让你对 3D Tiles 模型做逐像素定制后者可以做全屏后期特效。这些都是在 Cesium 中迈向更高阶特效的必经之路。动手做一个小实验把本文的雷达扫描和飞线放到同一个场景里再叠加一份地形数据理解 Shader 特效和地形的遮挡关系。做到这一步你对 Cesium 特效的理解就已经超过绝大多数只会调 API 的开发者了。