Cesium开发实战—动态虚线绘制技巧与性能优化
1. 从静态到动态为什么我们需要会“动”的虚线在Cesium里画一条虚线听起来好像挺简单的。我刚开始接触的时候也是这么想的不就是用个PolylineDashMaterialProperty嘛几行代码就搞定了。但后来项目需求来了客户说“这条导航路径的虚线能不能让它像流水一样动起来指示行进方向” 或者“这个区域的边界线能不能用闪烁的虚线来突出显示表示这是预警区域” 这时候我才发现原生的虚线材质是“静态”的它就在那儿不会动。这就是我们今天要聊的核心动态虚线。静态虚线只是定义了哪里是实线段哪里是空白。而动态虚线意味着虚线的图案比如短划线和间隔能够随着时间、或者沿着路径方向“流动”起来。这种效果在实时三维地理可视化中特别有用。想象一下在物流监控系统里一条动态流动的虚线清晰地指示出货车的预计行驶路线或者在应急指挥场景中闪烁的动态虚线快速勾勒出危险区域的边界比静态线条要醒目得多。实现这种效果本质上是在操控材质的纹理坐标。我们可以把虚线图案想象成一张很小的、由黑白条纹组成的图片纹理然后让这张图片沿着线条方向匀速移动。在Cesium的渲染体系里这通常需要我们深入到着色器Shader层面去修改代码。别怕虽然听起来有点“硬核”但我会带你一步步拆解你会发现它的原理其实很直观。我们先从最基础的两种画线方式说起这是所有Cesium开发者都会面临的第一个选择。2. Entity 还是 Primitive你的选择决定了天花板Cesium提供了两套主要的API来创建图形Entity API和Primitive API。选哪条路起步直接影响到你后面实现动态效果的复杂度和最终性能。我刚开始也在这上面踩过坑用Entity做复杂动画结果数据量一上去页面就开始卡顿。2.1 Entity API快速上手的“快捷方式”Entity系统是高级的、面向对象的数据模型。它用起来非常直观就像在组装乐高。对于绘制静态虚线它是最简单的方式。// 使用Entity API绘制一条静态黄色虚线 var dashedLineEntity viewer.entities.add({ name: 静态导航路径, polyline: { positions: Cesium.Cartesian3.fromDegreesArray([ 116.3, 39.9, 116.4, 39.95, 116.5, 39.85 ]), width: 4, material: new Cesium.PolylineDashMaterialProperty({ color: Cesium.Color.YELLOW, dashLength: 16.0, // 短划线长度像素 gapColor: Cesium.Color.TRANSPARENT // 间隔颜色通常设为透明 }) } });Entity的核心特点声明式你只需要关心“是什么”一条虚线而不是“怎么画”。自动管理Cesium帮你处理生命周期、视锥体裁剪、时间动态属性CallbackProperty等。易于绑定很容易与Cesium的DataSource、CZML等数据驱动框架集成。但是它的“高级”和“便捷”也带来了限制。PolylineDashMaterialProperty是封装好的你很难去修改它的着色器来实现动态流动效果。虽然你可以通过CallbackProperty动态改变dashLength或color来模拟一些变化但想要实现平滑、连续的图案流动几乎不可能。这就好比给你一辆自动挡的家用车开起来很舒服但你想改装发动机追求极致性能空间就很小了。2.2 Primitive API性能与灵活性的“手动挡”Primitive API是更接近WebGL底层的接口。它直接操作几何体和外观Appearance给你极大的控制权当然也需要你了解更多图形学知识。// 使用Primitive API绘制同样的静态虚线 var polylineGeometry new Cesium.PolylineGeometry({ positions: Cesium.Cartesian3.fromDegreesArray([116.3, 39.9, 116.4, 39.95, 116.5, 39.85]), width: 4.0, vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT }); var geometryInstance new Cesium.GeometryInstance({ geometry: polylineGeometry, id: dynamic-dash-line // 给实例一个ID便于后续拾取或更新 }); var dashedLinePrimitive new Cesium.Primitive({ geometryInstances: geometryInstance, appearance: new Cesium.PolylineMaterialAppearance({ material: Cesium.Material.fromType(Cesium.Material.PolylineDashType, { color: Cesium.Color.CYAN, gapColor: Cesium.Color.TRANSPARENT, dashLength: 16.0 }) }) }); viewer.scene.primitives.add(dashedLinePrimitive);用Primitive画静态虚线代码量看起来比Entity多但这只是开始。Primitive真正的威力在于它的Appearance和Material是可以完全自定义的。Cesium.Material.fromType方法虽然用了内置的PolylineDashType但我们可以创建自己的材质类型MaterialType并编写对应的GLSL着色器代码。这就是实现动态虚线的唯一途径。简单对比一下特性Entity APIPrimitive API易用性⭐⭐⭐⭐⭐ (极高)⭐⭐ (需要图形学基础)灵活性⭐⭐ (受限)⭐⭐⭐⭐⭐ (极高)性能一般 (封装层多)高 (接近底层)动态效果弱 (依赖属性回调)强 (可自定义着色器)适用场景快速原型、简单可视化、数据驱动高性能渲染、复杂自定义效果、动态材质所以如果你的需求只是展示一条固定的虚线Entity足矣。但如果你想让它“动”起来成为场景中的视觉焦点那么投入时间学习并使用Primitive API是必经之路。接下来我们就进入核心部分看看怎么让虚线“流动”起来。3. 实战手把手实现动态流动虚线理论说了不少现在我们来点实际的。我将用一个完整的例子展示如何用Primitive API配合自定义材质实现一条沿着路径方向匀速流动的虚线。这个效果常用于表示导航路径的进行方向或数据的流动趋势。3.1 核心原理用时间驱动纹理坐标动态虚线的本质是让虚线的纹理图案沿着线段的长度方向移动。在着色器中我们通过一个随时间变化的偏移量offset来修改纹理坐标v_textureCoordinates或自定义的st坐标。假设原始的虚线纹理坐标st.ss代表横向沿线条方向是从0到1均匀分布的。当我们每帧都给st.s加上一个time * speed的偏移量时纹理采样位置就移动了渲染出来的虚线图案也就跟着“流动”了。这个time通常来自Cesium的czm_frameNumber帧数或czm_currentTime当前时间以保证动画的连续性。3.2 第一步创建自定义动态虚线材质我们需要创建一个新的MaterialType。这里我直接给出经过实战检验的代码并加上详细注释。// 1. 定义动态虚线材质的着色器源码FragmentShader // 这是最核心的部分决定了像素最终的颜色 var DynamicDashMaterialSource // 统一变量由JavaScript传入 uniform vec4 color; // 虚线颜色 uniform vec4 gapColor; // 间隔颜色 uniform float dashLength; // 短划线长度像素 uniform float dashPattern; // 虚线模式二进制位如0xF0F0 uniform float offsetSpeed; // 流动速度 // 来自顶点着色器的变量 varying float v_polylineAngle; // 线条角度用于抗锯齿等这里我们主要用下面的 varying vec2 v_st; // 沿着线条方向的纹理坐标这是我们实现动态的关键 // 主函数 void fragmentMain(out vec4 fragColor) { // 计算动态偏移用时间乘以速度。czm_frameNumber是内置的帧计数器每帧加1。 float dynamicOffset czm_frameNumber * offsetSpeed * 0.01; // 乘0.01是为了调整速度尺度 // 将动态偏移应用到纹理坐标上 float st v_st.s dynamicOffset; // 将世界空间的dashLength转换为纹理空间的比例 // 这里假设线条在纹理空间的总长度是1.0我们需要根据dashLength和实际屏幕像素宽度来换算 // 这是一个简化计算更精确的计算需要考虑分辨率 float dashTextureLength dashLength * 0.001; // 经验系数需要根据实际效果调整 // 核心逻辑判断当前片段属于“划线”还是“间隔” // mod(st, dashTextureLength * 2.0) 将st映射到一个周期一个划线一个间隔内 // 如果结果小于dashTextureLength则在划线段否则在间隔段 if (mod(st, dashTextureLength * 2.0) dashTextureLength) { fragColor color; } else { fragColor gapColor; } // 可选添加抗锯齿让虚线边缘更平滑 // 计算距离周期边界的距离进行平滑过渡 float positionInCycle mod(st, dashTextureLength * 2.0); float distanceToEdge min(positionInCycle, dashTextureLength * 2.0 - positionInCycle); float smoothWidth 1.0; // 平滑过渡的像素宽度 float alpha clamp(distanceToEdge / smoothWidth, 0.0, 1.0); // 如果是间隔到划线的边缘进行混合 if (positionInCycle dashTextureLength) { fragColor.a * alpha; // 划线开始边缘渐入 } else { fragColor.a * (1.0 - alpha); // 划线结束边缘渐出间隔开始 } } ; // 2. 在Cesium中注册这个自定义材质类型 Cesium.Material._materialCache.addMaterial(DynamicDashType, { fabric: { type: DynamicDashType, uniforms: { color: new Cesium.Color(1.0, 0.0, 0.0, 1.0), // 红色默认值 gapColor: new Cesium.Color(0.0, 0.0, 0.0, 0.0), // 透明默认值 dashLength: 20.0, dashPattern: 0xF0F0, // 可以先保留未来扩展复杂模式 offsetSpeed: 1.0 // 流动速度默认1.0 }, source: DynamicDashMaterialSource }, translucent: function(material) { // 如果颜色或间隔颜色有透明度则材质是半透明的 return material.uniforms.color.alpha 1.0 || material.uniforms.gapColor.alpha 1.0; } });这段代码的关键是v_st这个变量。它需要在顶点着色器中计算并传递给片段着色器。Cesium的PolylineMaterialAppearance默认的顶点着色器可能没有直接提供v_st所以我们需要一个自定义的VertexShader。不过一个更简单的方法是复用Cesium内置的PolylineMaterialAppearance并确保我们的几何体包含了必要的属性。在实践中我们可以通过修改PolylineGeometry的创建方式或者使用PolylineColorAppearance并自己处理材质。为了简化上面的片段着色器假设v_st已经存在。3.3 第二步创建带有动态材质的Primitive注册好材质后我们就可以像使用内置材质一样使用它了。// 创建折线几何体 var positions Cesium.Cartesian3.fromDegreesArray([ 116.0, 40.0, 116.5, 39.8, 117.0, 40.2, 117.5, 40.0 ]); var polylineGeometry new Cesium.PolylineGeometry({ positions: positions, width: 5.0, vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT // 使用颜色外观的顶点格式 }); var geometryInstance new Cesium.GeometryInstance({ geometry: polylineGeometry, id: flowing-dash-line }); // 创建Primitive应用我们的动态材质 var flowingDashLine new Cesium.Primitive({ geometryInstances: geometryInstance, appearance: new Cesium.PolylineMaterialAppearance({ material: new Cesium.Material({ fabric: { type: DynamicDashType, uniforms: { color: Cesium.Color.fromCssColorString(#00FF00), // 绿色流动虚线 gapColor: new Cesium.Color(0.0, 0.0, 0.0, 0.0), // 完全透明间隔 dashLength: 25.0, // 划线较长 offsetSpeed: 2.0 // 流动速度较快 } } }), translucent: true // 因为gapColor是透明的所以需要开启半透明 }) }); viewer.scene.primitives.add(flowingDashLine);把这段代码放到你的Cesium应用中你应该就能看到一条绿色的虚线在沿着路径缓缓流动了。调整offsetSpeed可以改变流动快慢调整dashLength可以改变划线和间隔的比例。这里有个我踩过的坑dashLength的单位是像素但在着色器中与纹理坐标换算时需要乘以一个缩放系数如上面的0.001。这个系数不是固定的它和你的线条宽度、屏幕分辨率都有关系需要根据实际视觉效果进行微调直到流动的节奏看起来自然为止。4. 性能优化让你的动态虚线流畅如丝动态效果虽然酷炫但对性能是个考验。尤其是在大规模应用时比如同时显示上百条动态路径如果优化不到位很容易导致帧率下降。我经历过在复杂场景下动态虚线卡顿的问题通过下面这些方法才最终解决。4.1 合批渲染减少Draw Call的利器WebGL渲染中一个重要的性能指标是Draw Call绘制调用。每次切换不同的材质、几何体或状态都可能引起一次Draw Call。调用次数过多GPU再强也扛不住。Cesium的Primitive系统有一个强大的功能叫Geometry Instancing几何体实例化它允许你用一次Draw Call绘制多个相似的几何体。对于多条具有相同动态虚线材质、只是位置不同的路径一定要使用实例化。// 创建多个几何体实例共享同一个外观Appearance var geometryInstances []; var colors [Cesium.Color.RED, Cesium.Color.GREEN, Cesium.Color.BLUE]; for (var i 0; i 3; i) { // 为每条线生成不同的位置 var positions Cesium.Cartesian3.fromDegreesArray([ 115.0 i * 0.2, 39.5, 116.0 i * 0.2, 40.0, 116.5 i * 0.2, 39.0 ]); var geometry new Cesium.PolylineGeometry({ positions: positions, width: 3.0, vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT }); geometryInstances.push(new Cesium.GeometryInstance({ geometry: geometry, id: dynamic-line- i, attributes: { // 可以通过实例属性传递不同的颜色但需要修改着色器来接收 color: new Cesium.ColorGeometryInstanceAttribute(colors[i].red, colors[i].green, colors[i].blue, colors[i].alpha) } })); } // 用一个Primitive绘制所有实例 var batchPrimitive new Cesium.Primitive({ geometryInstances: geometryInstances, appearance: new Cesium.PolylineMaterialAppearance({ material: myDynamicDashMaterial, // 使用上面定义的动态材质 translucent: true }), // 关键开启异步生成提升性能 asynchronous: true }); viewer.scene.primitives.add(batchPrimitive);通过这种方式三条动态虚线会被合并到一次渲染中性能远高于创建三个独立的Primitive。记住一个原则尽可能将材质、外观相同的几何体合并绘制。4.2 着色器优化细节决定流畅度我们写的自定义着色器是运行在GPU上的每一帧对屏幕上的每个像素都要执行一次。因此着色器代码的效率至关重要。避免分支和复杂函数早期的GPU对if-else分支处理效率不高。虽然现代GPU有所改进但在可能的情况下尽量使用mix、step、smoothstep等GLSL内置函数来替代条件判断。在我们之前的片段着色器中可以用step函数来重写划线/间隔的判断逻辑有时性能更好。精简计算像sin、cos、pow这类数学函数开销较大。在我们的动态虚线中主要计算是取模mod和比较。mod函数开销相对较小可以接受。但要避免在着色器里做不必要的循环或递归。利用内置变量和函数Cesium提供了丰富的内置GLSL变量如czm_frameNumber、czm_currentTime和函数如czm_antialias。使用它们通常比自己实现的更高效而且能保证与Cesium其他部分的兼容性。精度选择在GLSL中可以使用lowp、mediump、highp来指定变量的精度。对于颜色、纹理坐标等使用lowp或mediump通常就足够了这可以减少GPU的计算量和内存带宽占用。在片段着色器开头可以写precision lowp float;。4.3 可视范围裁剪与细节层次LOD不是所有虚线都需要一直渲染也不是所有虚线在很远时都需要保持复杂的动态效果。DistanceDisplayCondition距离显示条件 这是Cesium实体和Primitive都支持的一个属性。你可以设置一个可视距离范围只有当相机与物体的距离在这个范围内时物体才被渲染。对于远距离的、不重要的动态虚线可以直接隐藏。flowingDashLine.distanceDisplayCondition new Cesium.DistanceDisplayCondition(0.0, 50000.0); // 只在0到50公里内显示Level of DetailLOD细节层次 当虚线距离相机很远时你可能不需要那么精细的动态效果。一个简单的优化思路是根据距离动态调整材质的offsetSpeed。距离很远时将速度设为零或者使用更简单的静态虚线材质来代替。这需要你在每一帧或相机移动时去计算距离并更新材质uniform变量。虽然增加了CPU逻辑但能有效减轻GPU负担。4.4 统一的时间管理如果你的场景中有很多独立的动态虚线物体每个都用自己的czm_frameNumber去计算偏移虽然结果一样但计算是分散的。一个更好的实践是在JavaScript端统一计算一个时间偏移量然后通过uniform传递给所有使用相同动态材质的Primitive。这样GPU只需接收一个统一的变量而不是每个实例都去访问czm_frameNumber尽管访问内置变量也很快但统一管理逻辑更清晰。// 在渲染循环或时钟回调中 function updateDynamicMaterials() { var currentTime Date.now() * 0.001; // 转换为秒 var globalOffset currentTime * globalSpeed; // 假设我们有一个存储了所有动态虚线Primitive的数组 for (var i 0; i dynamicLines.length; i) { var material dynamicLines[i].appearance.material; material.uniforms.globalTimeOffset globalOffset; // 在着色器中改用这个uniform } } // 在着色器中使用传入的globalTimeOffset // uniform float globalTimeOffset; // float dynamicOffset globalTimeOffset * offsetSpeed;5. 进阶技巧与实战场景应用掌握了基础的动态绘制和优化后我们可以玩点更花的让动态虚线在真实项目中大放异彩。5.1 实现路径导航与流向指示这是动态虚线最经典的应用。一条沿着规划路径流动的虚线能非常直观地指示方向或运动趋势。要实现更逼真的效果可以考虑以下几点速度与路径长度关联 不要让所有路径的流动速度都一样。可以让流动速度与路径的实际长度或预计通行时间成正比。长路径流动快一些短路径慢一些这样视觉上更符合直觉。头部高亮 在流动的“头部”增加一个亮点或改变颜色让流向更加明确。这需要在着色器中做更多判断根据片段在周期中的位置比如在划线段的前10%赋予一个更亮的颜色。与实体模型结合 让一个代表车辆或飞行器的模型沿着这条动态虚线路径运动。使用Cesium的SampledPositionProperty或CallbackProperty来驱动模型的位置使其与虚线的流动节奏同步创造出模型在沿着光路前进的沉浸感。5.2 创建边界闪烁与预警效果静态的边界线容易被忽略而动态闪烁的虚线能立刻抓住人的注意力。我们可以修改着色器实现不同的动态模式而不仅仅是单向流动。脉冲闪烁效果 让整条虚线的透明度或亮度随时间正弦波变化实现呼吸灯一样的闪烁效果。这比单纯的流动更能表示“异常”或“预警”。// 在片段着色器中添加脉冲效果 float pulse sin(czm_currentTime * 3.0) * 0.5 0.5; // 产生0-1之间的周期性值 fragColor.a * pulse; // 让透明度随脉冲变化 // 或者改变亮度 fragColor.rgb * (0.7 0.3 * pulse);分段激活效果 不是整体闪烁而是让虚线的不同段落依次亮起、熄灭像跑马灯一样。这需要更复杂的逻辑可能要根据片段的世界坐标来计算其所属的段再结合时间决定状态。5.3 应对复杂情况拐弯与贴地现实中的路径不是直线虚线在拐弯处可能会因为纹理拉伸而显得不均匀。此外如果路径是贴地的比如山路动态效果也需要适配。拐弯处的纹理拉伸 在急转弯处线条外侧的纹理会被拉长内侧会被压缩导致虚线间隔变形。一个缓解方法是在着色器中使用沿着线条方向的纹理坐标v_st而不是依赖于屏幕空间或世界空间的距离。Cesium的折线着色器通常已经处理了这一点保证了v_st是沿折线长度均匀分布的这有助于保持虚线图案的一致性。贴地线的处理 使用Cesium.PolylineGeometry时设置arcType: Cesium.ArcType.GEODESIC可以创建测地线大地线它会贴附在地球曲面上。动态虚线的着色器逻辑对于贴地线和非贴地线是一样的因为v_st坐标的计算是基于折线本身的几何长度与是否贴地无关。性能上贴地线由于顶点更多为了拟合曲线几何体更复杂需要更注意实例化合批和视锥体裁剪。最后分享一个我调试动态虚线时的常用技巧在着色器中输出调试颜色。当你对效果不满意又不知道哪里出错时可以临时修改片段着色器将中间变量如计算后的st、positionInCycle直接映射为输出颜色。比如用fragColor vec4(vec3(positionInCycle), 1.0);你就能直观地看到纹理坐标在折线上的分布情况这对于理解问题和验证算法非常有帮助。调试完毕后再换回正常的颜色计算逻辑。