恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
第60篇:数据可视化-海量不规则线——十万条线一次画完,每条却背着三份坐标
首页
资讯中心
/
第60篇:数据可视化-海量不规则线——十万条线一次画完,每条却背着三份坐标
第60篇:数据可视化-海量不规则线——十万条线一次画完,每条却背着三份坐标
发布时间:2026/10/17 6:44:36
上篇结尾我留了这么一句话“批量实例解决了面太多”可当你手里是几十万条折线时线的顶点排布、接缝、批量方式又是另一套讲究。海量不规则线我们下一篇见。#59 把面塞进了同一个 Primitive看清了它怎么把十万个面塌成一次绘制。可面好歹是个有面积的 patch——三个顶点、一份法线齐活。线呢线没有面积。你想要一条看得见、还带粗细的线引擎得先替你把这条线扯成一条有宽度的带子而这条带子远比你想的要重。今天——轮到线了。大家好我是 Cesium 酱也可以叫我本猿一名在 WebGIS 领域摸爬滚打多年的前端开发者。写这篇前我照例先干了件傻事把一万条折线丢进一万份Entity的polyline里。地球还没转一圈浏览器风扇先转起来了拖一下画面像在看幻灯片。那一声风扇就是这篇的开场白。本文目标把 Cesium 的Primitive批量折线拆开看清它怎么把一条线在提交前偷偷展开成一条带子又为什么同样用批量实例线的内存账单会比面胖出好几倍。 本文涉及的全部代码均已开源完整的仓库地址见文末可自行取用、随意魔改。一、痛点与场景为什么海量线比海量面更拧巴 背景现状线数据是 WebGIS 里又轻又重的矛盾体——单条线可能只是两个点的连线轻得像根头发可一旦几十万条铺开它要同时扛住几何展开、宽度、接缝、批量提交哪一头没理顺画面就先跪。典型场景里它要同时对付这些️轨迹 / GPS 轨迹一辆车一天几万个位点几千辆车就是几百万段折线还要按时间着色️路网 / 管线 / 电缆城市路网几十万段管线要分图层、分状态颜色✈️航线 / 物流路径跨洲航线是贴在球面上的大圆弧数量一大就是满屏的弧️等高线 / 等值线地形分析产出的等值线一条就几百个节点几千条就是百万级顶点️气象风场线 / 流线风场把看不见的风画成一条条流线密度决定你能不能看出涡 传统痛点这件事不新翻车的姿势也稳定。方案问题每条线一个Entitypolyline图形一万条线就是一万个 Entity 加一万份图形描述属性系统、动态更新、逐个绘制调用全得付数量一上来 CPU 先跪每条线一个Primitive绘制调用数量和线数 1:1十万条线就是十万次 draw call还没算几何上传逐线GroundPolyline贴地线贴地分类要读深度缓冲、逐线一套参数数量一大比不贴地还慢后端预渲染成瓦片要搭服务、要预处理前端失去任意时刻按新参数重算的自由 本文方案用Primitive的批量折线实例把扯带子和批量提交两件事分开对付1️⃣造实例每条折线包装成一个GeometryInstance颜色塞进ColorGeometryInstanceAttribute和面一样是逐实例属性2️⃣一次提交所有实例塞进同一个Primitive的geometryInstances数组共享一份PolylineColorAppearance3️⃣分帧 可取消生成按批让出主线程generationToken让新请求能立刻打断旧任务✨ 一句话总结把 N 条线当成一批货打包让combineInstances在提交前把它们拼成一整块缓冲、再把每条线在几何阶段展开成三角带——N 次绘制塌成 1 次可每条线自己先胖了一圈。二、核心技术原理一条线是怎么被扯成带子的2.1 线从哪来、长什么样demo 里的线也很老实——纯随机撒在地球上只是节点数不规则刚好方便我们观察批量渲染本身// 源码级剧透MassLinesDemo.vue 的造线逻辑functionrandomPositions():number[]{constvertexCount2Math.floor(Math.random()*3)// 2~4 个节点故意不规则constlonMath.random()*360-180// 中心经度全球均匀constlatMath.random()*180-90// 中心纬度全球均匀constpositions:number[][]for(leti0;ivertexCount;i){// 在中心附近 ±span/2 度内抖动每条线的形状都不一样 - 不规则折线positions.push(lon(Math.random()-0.5)*span.value,lat(Math.random()-0.5)*span.value)}returnpositions}注意它用的是Cartesian3.fromDegreesArray只有经纬度没有高程所以这些线是贴着椭球面铺的——想让线离地漂浮得换成fromDegreesArrayHeights。这是线和面在 demo 上的一个细微差别面有height参数线默认就趴在地球上。2.2 折线是怎么被扯成带子的本篇的杀手锏这里藏着线和面最大的不同。一个三角形3 个顶点往 GPU 一送光栅化就填出面积可一条线在 OpenGL 里默认是GL_LINES——绝大多数 GPU 把线宽死死锁在 1 像素你写width: 10它也当没看见。Cesium 要是真用GL_LINES你永远画不出一条粗线。所以 Cesium 干了一件聪明事它在提交前把每条折线展开成一条三角带ribbon——把一像素的线变成有左右两条边的四边形条。展开之后宽度想多粗都行因为那已经是一块正经三角形了。核心亮点把画一条 1px 的线换成先扯成带子再画带子是本篇的命门 PolylineGeometry.createGeometry干的事我直接用 Node 探针跑出来看了。一条 K 个节点的折线展开后顶点数 4 × (K − 1)每段展开成 4 个顶点因为拐角处要做斜接 join比两端各 2 个多索引数 6 × (K − 1)每段 2 个三角形我实测了 K2 / 3 / 4顶点分别是 4 / 8 / 12索引分别是 6 / 12 / 18严丝合缝。也就是说一条 3 节点的折线不再是 3 个顶点而是8 个顶点。更关键的是这 8 个顶点里每个都背着四样东西探针读出来的真实属性名属性类型它干嘛用positionDOUBLE × 3这个顶点自己的世界坐标prevPositionDOUBLE × 3它在折线上前一个节点的坐标nextPositionDOUBLE × 3它在折线上后一个节点的坐标expandAndWidthFLOAT × 2它在带子里是左缘还是右缘±1以及这条线的像素宽度为什么非得背着前后邻居因为把一条线加粗这件事必须在顶点着色器里按屏幕方向算——你要知道当前顶点朝向前后分别往哪拐才能把左右两缘正确地推开width/2像素。所以prevPosition/nextPosition不是冗余是线有宽度这个事实硬塞进来的代价。源码里未压缩产物 L37440 附近的transformPoint(modelMatrix, attributes.prevPosition / nextPosition)以及 L38980 一带逐顶点读prevPosition/nextPosition并顺手处理 180° 经线 wrap就是这个逻辑落地的地方。2.3 为什么用 PolylineColorAppearance而不是 PerInstanceColorAppearance面用PerInstanceColorAppearance线用PolylineColorAppearance名字只差一个词坑却藏在顶点格式里。我同样用探针把两个外观的VERTEX_FORMAT打出来看了PerInstanceColorAppearance.VERTEX_FORMATposition: true, normal: true外加 normal不含 color——颜色是逐实例属性走 BatchTable不进顶点缓冲PolylineColorAppearance.VERTEX_FORMATposition: true只有 position连 normal 都没有——颜色同样是逐实例的ColorGeometryInstanceAttribute由外观的片元着色器读取这下线比面重的真因彻底清楚了。先算每顶点的字节账double 坐标在combineInstances里会被encodeAttribute拆成3DHigh/3DLow两个 float32字节数不变多边形三角形每顶点position24 normal12 36 字节3 个顶点 108 字节/面和 #59 实测一致折线每顶点position24 prevPosition24 nextPosition24 expandAndWidth8 80 字节而且还没算st和颜色会额外占的坑两个乘数叠在一起① 折线顶点数比同节点数的面多带状展开 8 vs 3② 折线每顶点比面重一倍多80 vs 36。一条 3 节点的折线几何体量约 640 字节一个 3 节点的三角形面才 108 字节——线足足重了约 6 倍。这账比线看起来更细要沉重得多。2.4 一个容易被忽略的细节为什么有的线会多长出来两个顶点你可能已经注意到上面那句顶点数 4 × (K − 1)。我用探针跑了两万条 3 节点折线测出来的平均顶点数是8.07而不是干干净净的 8.00——多出来的那 0.07藏着一个地球仪上的老麻烦180° 经线 wrap。折线的两个端点是随机撒在全球的总有极少数线前一个点在西半球、后一个点跑到了东半球中间的经度差超过了 180°。对屏幕上的一条线来说这是抄近道——它应该往另一侧绕过去而不是横穿整张地图。Cesium 在展开成带子之前会先做一次wrapLongitude把这种跨 180° 的线段在经线处切开于是这条线凭空多出 2 个顶点。源码里未压缩产物 L38980 一带逐顶点读prevPosition/nextPosition判断position.y与邻居y是否一正一负也就是恰好在经线两侧一旦命中就重新 pack 前后坐标——这就是多出来的顶点的来处。 绝大多数线不受影响平均只多了 0.07 个顶点但如果你造的是跨洲的长航线、或是按整圈生成的等值线wrap 会实打实增加顶点 所以线上顶点数不是个纯算术题几何展开 经线切开两件事一起决定最终缓冲大小⚠️ 如果你自己按下标去算缓冲预算记得给 wrap 留一点余量别把数字卡得死死的2.5 为什么用批量 Primitive而不是 Entity方案原理绘制调用复杂度逐线Entity每条线独立图形 独立几何 与线数 1:1低但数量一上就崩逐线Primitive每条线一个小 Primitive 与线数 1:1中同样崩批量Primitive一批实例共享Appearance提交前合并 展开⚡ 通常 1 次中要自己管生成与取消重要批量折线和批量面走的是同一套Primitive提交管线。combineInstances#59 引用的 L55648把一批折线实例拼成整块缓冲createCommands里colorCommands.length primitive._va.length * multiplierL57599让绘制命令数只跟合并后的顶点数组个数有关。所以十万条线塌成一次绘制的论断和十万面塌成一次绘制一模一样——面能享的福线照样享。三、代码实战关键实现拆解⚠️ 温馨提示本文 demo 为了看清批量 展开这一件事线都是随机短折线段2~4 节点颜色只做了纯色 / 交替 / 随机三种。真实业务里的轨迹、路网、等值线是几十到几千节点的长线但造实例 → 批量提交 → 展开成带子的主线一字不差只是每条线的节点更多。3.1 给每条线算一个颜色和面一模一样颜色不挂材质挂逐实例属性// MassLinesDemo.vuefunctionresolveColor(index:number):Color{if(colorMode.valuesingle){returnColor.fromCssColorString(color1.value).withAlpha(opacity.value)}if(colorMode.valuerandom){returnColor.fromHsl(Math.random(),0.75,0.6,opacity.value)// 固定饱和度/亮度避免脏色}// 双色交替偶数号用色一、奇数号用色二constbaseindex%20?color1.value:color2.valuereturnColor.fromCssColorString(base).withAlpha(opacity.value)}ColorGeometryInstanceAttribute.fromColor把颜色编码成逐实例属性不是材质这正是一个Appearance服务所有线的前提透明度直接乘进颜色withAlpha(opacity)之后整批线共享同一个半透明设置3.2 一个 Primitive 装下全部全篇的心脏——先攒满实例数组再一次性交给 Primitive// MassLinesDemo.vue :: regenerate()consttotalcount.valueconstchunkSize10000// 每 1 万个让出一帧constinstances:GeometryInstance[][]for(leti0;itotal;ichunkSize){if(token!generationToken)return// 发现已被新任务取代立刻退出constendMath.min(ichunkSize,total)for(letji;jend;j){instances.push(newGeometryInstance({geometry:newPolylineGeometry({positions:Cartesian3.fromDegreesArray(randomPositions()),width:width.value*3,// ⚠️ 几何 width 再乘 3仅为让线在地球尺度下更显眼vertexFormat:PolylineColorAppearance.VERTEX_FORMAT}),attributes:{color:ColorGeometryInstanceAttribute.fromColor(resolveColor(j))},id:mass-line-${j}}))}progress.valueend/totalawaitnextFrame()// 让出主线程进度条才能刷新、按钮才能点}if(token!generationToken)returnlinePrimitivenewPrimitive({asynchronous:false,// ⚠️ 同步所有几何在这一行里一次性建完含展开成带子geometryInstances:instances,// 全部实例塞进一个数组appearance:newPolylineColorAppearance()})viewer.scene.primitives.add(linePrimitive)geometryInstances接收数组这是批量的入口数组越长越多线塌成同一块缓冲⚠️width: width.value * 3width本身的单位是像素demo 把滑杆值再乘 3纯粹是为了在地球尺度下让线看得见——Cesium 的折线宽度从来不是米也不是地图单位是屏幕像素⚠️asynchronous: false是双刃剑好处是加进去就能立刻用代价是几何创建含带状展开、合并、编码全在这一行同步跑完PolylineColorAppearance()不带参数它没有closed选项线没有闭合体的概念也比PerInstanceColorAppearance少一个法线维度3.3 分帧生成与任务取消两个细节决定了它卡不卡、乱不乱和 #59 的面是完全同款functionnextFrame():Promisevoid{returnnewPromise((resolve)setTimeout(resolve,0))// 让出一个宏任务浏览器得以重绘}consttokengenerationToken// 每次重新生成前自增 token旧循环在下一次检查时自然退出chunkSize 10000await nextFrame()把 10 万次的造实例摊到 10 帧里用户看到进度条在走界面不假死generationToken用户连点两次重新生成时第一个循环会在下一轮检查发现token ! generationToken而主动返回不会和第二个任务撞车⚠️ 这里一定要提醒很多人会忘了generationToken这道闸结果连点几下几个生成任务同时在跑内存瞬间起飞——相信我你会踩的别问我怎么知道的 o(╥﹏╥)o3.4 关于vertexFormat为什么这块必须传你可能留意到造折线时我们显式写了vertexFormat: PolylineColorAppearance.VERTEX_FORMAT。这不是可选项是你得先告诉引擎这批线将来要配哪种外观的约定geometry:newPolylineGeometry({positions:Cartesian3.fromDegreesArray(randomPositions()),width:width.value*3,vertexFormat:PolylineColorAppearance.VERTEX_FORMAT// 与外观严格对应})它是几何和外观之间的一纸契约几何按这个格式造顶点外观按这个格式去读。两边不一致轻则读错颜色重则顶点格式对不上、画面直接废掉换外观就得换格式想从纯色换到PolylineMaterialAppearance虚线 / 流动线 / 贴图这里的vertexFormat也要跟着换成PolylineMaterialAppearance.VERTEX_FORMAT——它需要的属性比纯色版更多⚠️ 反面教材造几何时随便传一个VertexFormat、提交时又换了另一种Appearance引擎不会替你纠错只会让着色器去读一段它并不认识的顶点数据四、功能亮点与性能优势4.1 六个可调旋钮面板把造线这件事暴露成了六个参数方便你亲自感受数量与代价的关系数量1 万 / 5 万 / 10 万 / 50 万 / 100 万五档一键切换随机范围0.2°~5°控制每条线抖动多大也就是线看起来有多不规则线宽0.5~5几何里再乘 3直接决定带子有多宽——注意它是像素透明度0.1~1叠加区的层次感全靠它颜色模式双色交替 / 单色 / 随机验证颜色不进材质也能逐线不同两个取色器交替模式下分别取颜色一、颜色二4.2 性能实测本猿自己的电脑实测Node 探针复刻PolylineGeometry.createGeometry的逐线开销 线性外推未开 GPU 渲染顶点总数 / 顶点缓冲按实测每顶点 80 字节的几何核心估st 与颜色另算故为下界折线数量顶点总数逐线几何构建(估)顶点缓冲(估, ≥80B/顶点)1 万8 万≈ 46 ms≈ 6.4 MB5 万40 万≈ 230 ms≈ 32 MB10 万80 万≈ 460 ms≈ 64 MB50 万400 万≈ 2.3 s≈ 320 MB100 万800 万≈ 4.6 s≈ 640 MB单条 3 节点折线实测8 个顶点、12 个索引、约 640 字节position 24 prev 24 next 24 expandAndWidth 8×8 顶点逐线createGeometry约4.6 µsNode 探针采样 2 万条外推注意这只是造实例阶段不含合并与提交顶点缓冲按实测每顶点 80 字节估double 坐标被拆成3DHigh/3DLow两个 float32字节数不变还没算st与逐实例/逐顶点颜色真实峰值更高对比 #59同样 10 万条图元面约 11 MB、线约 64 MB同样 100 万条面约 108 MB、线约 640 MB——线比面在内存上重约 6 倍根子就在带状展开 三连坐标4.3 核心优势十万级真流畅10 万条线塌成 1 次绘制缩放旋转不卡逐线颜色零成本颜色和面一样是逐实例属性10 万种颜色也不需要 10 万套材质宽度随心因为是三角带而非GL_LINES线宽想多粗多粗不受 GPU 1px 死锁可取消、有进度分帧生成 token 取消交互始终跟手五、小贴士避坑指南关于asynchronous: false的同步冻结和面一模一样asynchronous: false让Primitive在构造时就把所有几何含带状展开、合并、编码一次性建完。生成阶段你可以用chunkSize分帧让出主线程但最后那一句new Primitive(...)依然是同步的——所有线的createGeometrycombineInstances 编码都在这一行里跑完。⚠️ 重要提示分帧只能救造实例阶段救不了提交 Primitive这一锤子而且线比面顶点更多这一锤子大概率比同数面更狠本篇未真机实测百万线的精确冻结秒数浏览器耗时取决于机器与显卡按几何规模推算会明显超过 #59 那 12.4 秒百万面建议百万级务必走异步Primitive或 Worker建议面数/线数上到百万级时优先考虑异步Primitive去掉asynchronous: false或把生成 提交整段挪进 Web Worker关于内存账单每一条线都实打实地背着三份坐标position / prevPosition / nextPosition。实测每顶点 80 字节100 万条线就是约640 MB的顶点缓冲——这还没算st与颜色真实峰值更高已经逼近甚至超过 WebGL 常见的单缓冲上限。⚠️ 重要提示每次调参数量、线宽、颜色……demo 都是removePrimitive()后重建整个 Primitive不是增量更新建议能少一个节点就少一个——折线顶点数 4×(K−1)节点数直接决定膨胀倍数能用短线段表达的别硬塞几千节点的长线到同一批关于线宽是像素、不是米PolylineGeometry的width是屏幕像素在顶点着色器里按屏幕方向把左右两缘各推width/2。所以放大缩小线在屏幕上的粗细不变不像真实世界里的马路会随缩放变细变粗demo 里width * 3只是为了让默认 1.5px 在地球尺度下别细成头发丝不是 Cesium 的硬性要求想要离地的线把fromDegreesArray换成fromDegreesArrayHeights加高程即可关于拐角处的尖刺折线在拐角用斜接miter展开成带子这正是每段 4 个顶点的来历。当两段夹角极锐时miter 会把外角拉出一根很长的尖刺。真实业务里如果是平滑轨迹问题不大如果是大量直角/锐角相接的网格线密集处可能炸毛。⚡关于它能用来做什么批量折线擅长静态、海量、同款外观的线。如果你的场景是每条线都要单独换材质/贴图/虚线那它就不是最佳选择。⚠️ 重要提示PolylineColorAppearance只能表达逐线纯色做不了虚线、渐变、沿线贴图想要虚线 / 流动线 / 贴纹理要换PolylineMaterialAppearance 材质那又是另一条线的故事了需要频繁更新的少量线老老实实用Entity反而更省心海量 静态才是批量实例的主场坦诚局限这个 demo 只做了2~4 节点的随机短折线真实业务里的长轨迹、带洞轮廓、贴地管线节点数能差出两个数量级颜色也只到纯色。这些都不改变批量 展开 合并的主线只是每条线造得更重而已。六、写在最后Cesium 是头优雅的大象。我们曾经以为它吞下面已经够神奇今天才发现为了给一条线加上宽度它得先替每条线记住前后邻居再把线扯成带子才肯下咽。从 #58 把点交给屏幕到 #59 把面交给批量再到今天把线交给展开——我们一直在学同一件事别跟引擎的强项较劲顺着它的性子来。10 万条线只画一次是引擎替我们打的包每条线背着三份坐标是我们必须自己扛的账。这个案例还不完美生成与提交仍是主线程的活调个参数就得整批重建百万线还会逼近显存天花板。但它把海量线为什么能画得动、又为什么比面更吃内存这件事讲清楚了这就够了。如果你也用 Cesium 铺过几十万条轨迹或路网欢迎留言说说你是怎么压住内存与冻结的——是乖乖走 Worker还是干脆交给瓦片批量实例解决了面和线可当你手里是几十万个立方体楼宇白模、体素、集装箱堆场时顶点、法线、面的排布又是一套新的账。海量立方体我们下一篇见。 项目已开源欢迎 Star你的 Star ⭐ 就是对我最大的支持仓库地址https://github.com/jianlei-wang/CESIUM-TREASURE-BOX代码在src/cases/mass-lines/目录下核心就一个MassLinesDemo.vue 注册用的index.ts复制进你的项目、把随机造线换成真实轨迹坐标就能跑。Cesium酱の百宝箱 · 第 60 篇一条线要背着前后邻居才敢在屏幕上显出粗细2026 年的秋夜与你共探无面积的线如何被扯成有宽度的带