ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Cesium特效实战:Primitive+Shader实现雷达扫描与飞线动画

Cesium特效实战:Primitive+Shader实现雷达扫描与飞线动画 做 Cesium 特效开发的人大概率都遇到过同样的困惑同样是一张飞线别人做的带流动光效、发射渐变、点击还有反馈自己用官方 Entity API 拉一条线就干巴巴的同样是雷达扫描别人的波纹能呼吸、有淡出、边缘还带羽化自己的就是一个半透明圆片叠在贴图上。问题不出在业务逻辑而出在是否真正理解 Cesium 底层那套 Primitive Appearance Shader 的渲染链路。这篇文章不聊大屏设计不聊 UI只做一件事把 Cesium 特效从“调 API”往下拆到 WebGL Shader 层看地图扫描、飞线动画这些常见特效到底是怎么画出来的。看完你会知道效果好坏的分水岭在哪里也能自己改出一套不依赖业务代码的特效封装。1. 核心能力速览能力项说明技术栈Cesium WebGL GLSL Shader核心内容自定义 Primitive / Appearance / MaterialShader 实现扫描、飞线流动运行环境支持 WebGL 的浏览器Chrome、Edge 均可无需后端服务显存需求由场景复杂度决定本地验证建议 GPU 开启硬件加速硬件要求普通办公电脑即可特效密集时建议独立显卡扩展能力可接入离线地形、本地瓦片、天地图等数据源接口能力通过 Material / Primitive 暴露 uniforms可动态控制特效参数批量任务支持多 Primitive 批量渲染可合并 Geometry 降低 DrawCall适合场景数字孪生、智慧城市、航天可视化、军事态势、大屏展示这里先给一个结论Cesium 的 Entity API 适合快速落地但要做到“丝滑特效”真正的控制点全在CustomShader、MaterialAppearance、Primitive这三层里。后面的章节会逐一拆开。2. 先理解 Cesium 特效的底层链路Primitive、Appearance 与 Shader很多人用 Cesium 很久但没分清 Entity、Primitive、Appearance、Material 四者的关系。在源码层面Cesium 的渲染管线是这样的Entity业务层 - 内部创建 Primitive几何体 渲染状态 - Appearance决定用什么 Shader 渲染 - Material填充 Shader 的 uniforms 与材质代码 - WebGL 顶点着色器 片元着色器Entity 只是方便业务调用的壳最终所有东西都要落到 Primitive 上。Primitive 负责几何数据组织、DrawCall 提交、渲染状态管理。Appearance 负责 Shader 的拼接Material 负责把用户自定义的材质代码和 uniforms 注入到 Shader 中。所以如果你想让飞线上有流动的光、扫描圆上有波纹扩散必须在这两层动手要么写自定义Material适合在材质层面做纹理、透明度、颜色变化要么写自定义Appearance适合在顶点和片元层面做几何变换更彻底一点直接写CustomShader从 Primitive 的顶点数据一路接管到输出颜色。从源码角度看Cesium 官方内置了PolylineColorAppearance、MaterialAppearance、EllipsoidSurfaceAppearance等几种 Appearance它们各自生成了不同的 GLSL 代码。比如MaterialAppearance的顶点着色器里就把position3DHigh、position3DLow做了高精度拆分再用czm_modelViewProjectionRelativeToEye做矩阵变换。这一点在和 Three.js 混写时尤其重要因为 Cesium 为了规避大地坐标系下的浮点精度问题引入了相对眼坐标和 RTCRelative To Center机制不同的渲染引擎对坐标处理方式不一样。3. WebGL Shader 底层原理顶点、片元与 uniform 传递Cesium 的 Shader 是 GLSL 写的运行在 GPU 上。理解三个基本概念就能读得懂 Cesium 的任何自定义材质。3.1 顶点着色器顶点着色器负责把模型坐标系下的顶点坐标变换到屏幕裁剪坐标。Cesium 内置了很多czm_开头的 uniform例如// Cesium 内置矩阵 czm_modelViewProjectionRelativeToEye czm_viewport czm_frameNumberczm_frameNumber是每帧递增的帧号是做动画的关键变量。你不需要自己维护一个计数变量直接在 Shader 里读它就可以驱动时间变化。3.2 片元着色器片元着色器决定每个像素最终的颜色和透明度。Cesium 的材质体系里所有自定义材质都要实现一个函数czm_material czm_getMaterial(czm_materialInput materialInput)czm_materialInput里面包含了纹理坐标、位置坐标、法线等数据。通过materialInput.st就能拿到 UV 坐标用它做条纹、渐变、扩散、环形都非常方便。czm_material是输出的材质结构主要字段如下struct czm_material { vec3 diffuse; // 漫反射颜色 float specular; // 高光强度 float shininess; // 高光范围 float alpha; // 透明度 };绝大多数自定义材质只用diffuse和alpha。3.3 uniform 与动画传递uniform 是 CPU 和 GPU 之间的数据通道每帧可以更新。Cesium 的 Material 通过uniforms对象传入而在 Shader 中直接使用同名变量即可。驱动动画有两种常见方式用czm_frameNumber做时间源不需要额外变量外部每帧更新uniforms.speed、uniforms.time适合做可控暂停和回放。在自定义材质里可以用取模运算做循环动画// 让数值在 0 到 1 之间循环 float t fract(czm_frameNumber * speed / 60.0);这段写法在许多飞线流动、扫描扩散的 Shader 中都能看到原理就是把帧号映射到 0-1 的区间再配合smoothstep做边缘羽化。4. 地图扫描雷达扫描特效源码拆解地图扫描是 Cesium 特效里最常见的一种常用于显示某个区域正在被雷达探测、事件告警或者态势感知。它的核心效果是一个圆形或扇形区域带有波纹扩散、颜色渐变、边缘发光。这个特效的底层就是一个圆形的Geometry加一个自定义Material。不需要图片贴图所有的渐变和波纹全部在 GLSL 片元着色器里计算。4.1 创建圆形 GeometryCesium 提供了CircleGeometry可以直接生成圆形几何体。把它放进Primitive中使用MaterialAppearanceimport * as Cesium from cesium; // 圆心和半径 const center Cesium.Cartesian3.fromDegrees(116.39, 39.9); const radius 5000.0; // 圆形几何体 const geometry new Cesium.CircleGeometry({ center: center, radius: radius, vertexFormat: Cesium.VertexFormat.POSITION_AND_ST, }); const instance new Cesium.GeometryInstance({ geometry: geometry, id: radarScan }); // 使用 MaterialAppearance材质类型在下一步注册 const primitive new Cesium.Primitive({ geometryInstances: instance, appearance: new Cesium.MaterialAppearance({ material: Cesium.Material.fromType(RadarScan, { color: new Cesium.Color(0.0, 1.0, 0.4, 1.0), speed: 3.0 }), translucency: true, closed: false }), asynchronous: false }); viewer.scene.primitives.add(primitive);4.2 注册扫描材质Cesium.Material.fromType第一次调用时如果类型没注册过会直接失败。所以要先往材质缓存里注册RadarScan类型Cesium.Material._materialCache.addMaterial(RadarScan, { fabric: { type: RadarScan, uniforms: { color: new Cesium.Color(0.0, 1.0, 0.4, 1.0), speed: 3.0, repeat: 300.0 }, source: czm_material czm_getMaterial(czm_materialInput materialInput) { czm_material material czm_getDefaultMaterial(materialInput); vec2 st materialInput.st; // 以圆心为原点的距离场 float dist length(st - vec2(0.5)); // 波纹通过距离取模生成循环扩散带 float ripple 1.0 - fract(dist * repeat / 2.0 - czm_frameNumber * speed / 60.0); ripple smoothstep(0.0, 0.8, ripple); // 整体透明度从内到外逐渐淡出 float alpha (1.0 - dist) * 0.8; material.diffuse color.rgb; material.alpha alpha * ripple; return material; } } });这里materialInput.st实际上是圆心为(0.5, 0.5)的纹理坐标。用length计算每个像素到圆心的距离再通过取模生成波纹czm_frameNumber驱动波纹向外扩散。这就是雷达扫描的核心数学逻辑。4.3 判断效果是否成功加载后应该能在地图上看到以圆心为中心波纹向外扩散波纹到边缘时透明度逐渐降低整个圆片是半透明的能看到底图。如果看不到任何东西优先排查三件事MaterialAppearance是否设置了translucency: true否则材质 alpha 不生效Cesium.Material.fromType之前是否执行了addMaterial注册Primitive 的asynchronous是否设置为false否则本地几何体可能无法及时加载。如果想做扇形扫描、范围环、点扩散等多个变体只需要改 Geometry 的形状以及 Shader 里的距离场公式。5. 飞线动画源码级拆解飞线动画在数字孪生和大屏项目中几乎是标配效果。它由两部分组成轨迹线一般用大圆航线Great Circle插值出无数个点形成弯曲的线流动光效在线的材质上做 UV 流动让光点从起点移动到终点。5.1 大圆航线轨迹生成大圆航线是地球两点之间的最短路径在二维地图上看起来是弯曲的但在三维地球上它其实是贴在地球表面的大圆弧。如果要做视觉上的“从地面到天空再到地面”的飞线效果就需要在插值点基础上叠加高度。import * as Cesium from cesium; function generateFlightPath(start, end, segments 100, heightScale 300000) { const startCarto Cesium.Cartographic.fromDegrees(start[0], start[1]); const endCarto Cesium.Cartographic.fromDegrees(end[0], end[1]); const geodesic new Cesium.EllipsoidGeodesic(startCarto, endCarto); const positions []; for (let i 0; i segments; i) { const fraction i / segments; const carto geodesic.interpolateUsingFraction(fraction); // 高度随进度做正弦变化形成弧形 const height Math.sin(Math.PI * fraction) * heightScale; positions.push( Cesium.Cartesian3.fromRadians(carto.longitude, carto.latitude, height) ); } return positions; }EllipsoidGeodesic是 Cesium 官方提供的测地线插值工具适合做跨越长距离的轨迹线。普通短距离飞线也可以直接用Cesium.Cartesian3.lerp插值但长距离会出现明显绕路问题。5.2 使用 PolylineGeometry 创建飞线 Primitive拿到轨迹点后通过PolylineGeometry创建线图元const positions generateFlightPath( [116.39, 39.9], [121.47, 31.23], 200, 500000 ); const geometry new Cesium.PolylineGeometry({ positions: positions, width: 6.0, vertexFormat: Cesium.VertexFormat.POSITION_AND_ST }); const instance new Cesium.GeometryInstance({ geometry: geometry, id: flightLine }); const primitive new Cesium.Primitive({ geometryInstances: instance, appearance: new Cesium.MaterialAppearance({ material: Cesium.Material.fromType(FlightLine, { color: new Cesium.Color(0.0, 0.8, 1.0, 1.0), speed: 2.0 }), translucency: true }), asynchronous: false }); viewer.scene.primitives.add(primitive);5.3 飞线流动材质 Shader飞线的核心材质逻辑让线的一端到另一端产生连续的光带移动。由于 PolylineGeometry 的纹理坐标是沿着线的方向从 0 到 1所以直接对st.s做取模移动即可Cesium.Material._materialCache.addMaterial(FlightLine, { fabric: { type: FlightLine, uniforms: { color: new Cesium.Color(0.0, 0.8, 1.0, 1.0), speed: 2.0 }, source: czm_material czm_getMaterial(czm_materialInput materialInput) { czm_material material czm_getDefaultMaterial(materialInput); vec2 st materialInput.st; // 沿线的进度循环变化 float progress fract(st.s * 3.0 - czm_frameNumber * speed / 60.0); // 只在光点附近显示亮色 float glow smoothstep(0.0, 0.2, progress) * smoothstep(0.35, 0.15, progress); // 线尾到线首整体淡入淡出 float grad smoothstep(0.0, 0.5, st.s) * smoothstep(1.0, 0.5, st.s); material.diffuse color.rgb * (0.5 glow * 2.5); material.alpha glow * grad * 0.9; return material; } } });这里的核心是fract(st.s * 3.0 - czm_frameNumber * speed / 60.0)。st.s是沿线的 UV乘以 3 表示线上一共有 3 个光点减去帧号让光点移动取模保证循环。5.4 飞线变体发射体、多色渐变、虚线流动在飞线基础上常见的变体包括起点大终点小的点状光可以通过progress不同位置的 alpha 权重实现双色渐变线可以用mix(colorA, colorB, st.s)实现多条飞线排队飞行可以给每条线传入不同的speed或phase。批量处理时建议把多条飞线的GeometryInstance合并到一个Primitive中减少 DrawCall。例如const instances flightPaths.map((item, index) { const positions generateFlightPath(item.start, item.end); return new Cesium.GeometryInstance({ geometry: new Cesium.PolylineGeometry({ positions: positions, width: item.width || 4.0, vertexFormat: Cesium.VertexFormat.POSITION_AND_ST }), id: line_${index} }); }); viewer.scene.primitives.add( new Cesium.Primitive({ geometryInstances: instances, appearance: new Cesium.MaterialAppearance({ material: Cesium.Material.fromType(FlightLine, { color: new Cesium.Color(0.2, 0.8, 1.0, 1.0), speed: 1.5 }), translucency: true }) }) );需要注意同一 Primitive 下的多个 GeometryInstance 共用同一个 Appearance 和 Material所以它们的 uniforms 也是共享的。如果每条飞线需要不同速度或颜色需要把材质参数收进顶点属性或者拆成多个 Primitive但不要拆太多否则 DrawCall 会飙升。6. 从源码到丝滑性能调优的通用思路很多 Cesium 特效卡顿根源不在 GPU而在 CPU 每帧重复创建对象、频繁更新 Entity、或 DrawCall 过多。这里给一套通用的调优思路适配扫描、飞线、水面、动态光照等所有特效。6.1 用 Primitive 而不是 Entity 做高频动画Entity内部每次属性变化都可能触发 geometry 重建或状态刷新。如果在动画循环里每秒更新几十个 Entity 的位置CPU 很容易成为瓶颈。改用手动PrimitiveGeometryInstance几何数据固定不变只需要每帧更新 uniforms压力小一个量级。6.2 用 uniform 控制动画不要每帧重建几何正确做法是动画状态尽可能放到 Shader 里。czm_frameNumber是 Cesium 自带的帧计数器直接用它做时间源完全不用每帧更新任何 CPU 数据。如果确实需要外部控制播放、暂停、重置可以在 Primitive 外面包一层对象只更新Appearance.material.uniforms.timeconst material primitive.appearance.material; material.uniforms.time performance.now() / 1000;这种更新不会触发几何体重建性能开销基本可以忽略。6.3 限制透明物体的排序开销透明物体的渲染顺序对最终效果影响极大。扫描圆、飞线、光晕这一类特效都依赖透明度使用过多时Cesium 的透明排序算法可能导致渲染闪烁或者重叠区域效果异常。至少做到不要让大量透明 Primitive 互相穿插重叠渲染顺序不稳定时手动调整primitive.show或深度偏移。6.4 避坑网络瓦片和特效的帧率矛盾在大多数项目中页面上同时有底图、标注、特效层。特效帧率突然下降很多时候不是 Shader 的问题而是底图请求过多、瓦片解码占用主线程。排查方法按 F12 打开浏览器开发者工具看 Network 面板的瓦片请求数量把特效层临时隐藏如果帧率回升说明卡顿来自数据加载使用本地瓦片或离线地形减少网络 IO。6.5 观察资源占用的方法直接在浏览器按 F12Performance 面板可以看到每帧的渲染耗时。重点看Scripting、Rendering、Painting三项。如果Scripting很高说明 CPU 侧对象更新过多如果Rendering很高说明 Shader 过度复杂或者透明排序压力大。GPU 显存占用可以在chrome://gpu页面查看但 Cesium 场景下绝大多数卡顿不是显存不够而是 GPU 每帧渲染指令过多即 draw call 过多。7. 常见问题与排查方法问题现象可能原因排查方式解决方案页面提示 WebGL 不支持或 GPU 不可用浏览器关闭硬件加速、显卡驱动问题打开chrome://gpu查看状态开启硬件加速更新显卡驱动或换一个 GPU 环境自定义材质不生效模型透明或纯黑addMaterial未注册、MaterialAppearance 未设置 translucency检查控制台是否有材质类型错误先注册材质再设置translucency: true飞线没有流动效果UV 方向或帧号计算有问题临时输出st.s到颜色通道打印st.s确认是否从 0 到 1 分布扫描波纹边缘锯齿严重Shader 中直接硬切 alpha把step改成smoothstep用smoothstep做羽化过渡特效闪烁重叠区域闪动透明排序不稳定观察是否多个透明 Primitive 重叠减少重叠层数增加深度偏移或调整渲染顺序地形或底图加载后特效被遮挡深度测试问题检查是否开启深度检测调整 Primitive 的depthTestAgainstTerrain或把特效放到独立图层多实例批量绘制无法统一控制每个实例需要不同参数但共用 uniforms拆成独立 Primitive 或用顶点属性传参按业务拆分 Primitive控制数量在几十个以内本地瓦片加载慢特效帧率不稳网络 IO 和瓦片解码阻塞主线程查看 Network 面板请求量使用本地瓦片服务或对瓦片做预切片8. 最佳实践与使用边界8.1 封装成独立特效模块不要把 Shader 代码直接写在业务页面里。建议每个特效抽成一个独立类例如RadarScanMaterial、FlightLineMaterial对外只暴露参数内部封装材质注册和 Primitive 创建。class RadarScanEffect { constructor(viewer, options) { this.viewer viewer; this.options Object.assign({ color: Cesium.Color.GREEN, speed: 3.0, radius: 5000 }, options); this._primitive null; this._init(); } _init() { // 注册材质并创建 Primitive } update(options) { // 更新 uniforms } destroy() { this.viewer.scene.primitives.remove(this._primitive); } }这样在项目里可以快速复用也能方便地做批量参数控制。8.2 数据合规与版权边界Cesium 特效开发中底图、地形、业务数据是另一个层面的合规问题。这里明确几条硬边界地理底图、地形数据要使用有授权的数据源自建离线瓦片时也要确认数据来源合法涉及实时态势、人员位置、车辆轨迹等业务数据时在大屏或公开展示前要脱敏和授权显示敏感区域、涉密坐标、未公开测绘数据的内容禁止在公开项目中使用飞线、扫描、态势推演等特效如果用于展示具体机构的实时位置必须确认信息发布边界。特效本身没有风险但数据接入和使用场景需要开发者自行把关。8.3 真机测试策略至少覆盖三种浏览器环境Chrome、Edge、Firefox。Chrome 对 WebGL 的支持最好但也要关注用户机器上是否关闭了硬件加速。如果目标环境是老旧电脑或集成显卡尽量降低 Shader 复杂度减少纹理采样次数、避免分支抖动、降低几何体顶点数、缩小纹理尺寸。飞线顶点数从 200 降到 50肉眼几乎无差别但 Geometry 创建和 GPU 处理效率会明显提升。8.4 不要期望所有特效都从零手写Cesium 社区里有很多成熟的开源特效库直接基于 Cesium 的 Primitive 体系封装。如果时间紧先站在开源方案上改参数和 Shader比自己从几何体开始写快得多。但核心原理没掌握时遇到问题会无从下手所以这篇文章把底层链路拆开目的就是让你在接任何库时都知道它动了哪一层。9. 总结与下一步Cesium 特效的本质是搞清楚 Geometry 怎么组织、Appearance 怎么选、Material 怎么写、uniform 怎么动。读懂了 Primitive 这条链路地图扫描、飞线动画、动态水面、局部雨、可视域分析、天际线分析这些特效其实就是不同几何体加不同 Shader 的组合游戏。从这篇文章开始建议按下面的顺序动手验证先跑通雷达扫描的 CircleGeometry 自定义 Material再跑通飞线轨迹的 EllipsoidGeodesic 插值 流动材质把两个特效封装成独立模块用参数控制启停逐步叠加到自己的大屏或数字孪生项目中。最容易踩的坑就是材质注册顺序、translucency没打开、透明排序混乱、以及用 Entity API 做高频动画导致卡顿。这四个点记在心里能省掉大半的查错时间。后续可以继续扩展的方向在自定义 Material 里叠加噪声贴图做更真实的动态水面结合 Three.js 共享 GL 上下文做 Cesium 无法直接实现的后处理特效用 CustomShader 对已有 3D Tiles 模型做局部高亮和描边把特效参数接到 WebSocket 实时驱动实现态势联动。建议先收藏这篇按章节实测一遍再回头改自己的业务代码会顺手很多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进