上篇结尾我留了这么一句话:
“批量实例解决了"面太多”,可当你手里是几十万条折线时,线的顶点排布、接缝、批量方式,又是另一套讲究。海量不规则线,我们下一篇见。"
#59 把"面"塞进了同一个 Primitive,看清了它怎么把十万个面塌成一次绘制。可面好歹是个有面积的 patch——三个顶点、一份法线,齐活。线呢?线没有面积。你想要一条"看得见、还带粗细"的线,引擎得先替你把这条线"扯"成一条有宽度的带子,而这条带子,远比你想的要重。
今天——轮到线了。
大家好,我是 Cesium 酱(也可以叫我"本猿"),一名在 WebGIS 领域摸爬滚打多年的前端开发者。
写这篇前,我照例先干了件傻事:把一万条折线丢进一万份Entity的polyline里。地球还没转一圈,浏览器风扇先转起来了,拖一下画面像在看幻灯片。那一声风扇,就是这篇的开场白。
本文目标:把 Cesium 的Primitive批量折线拆开,看清它怎么把"一条线"在提交前偷偷展开成"一条带子",又为什么同样用批量实例,线的内存账单会比面胖出好几倍。💪
💡 本文涉及的全部代码均已开源,完整的仓库地址见文末,可自行取用、随意魔改。
一、痛点与场景:为什么"海量线"比"海量面"更拧巴
🎯 背景现状:线数据是 WebGIS 里"又轻又重"的矛盾体——单条线可能只是两个点的连线,轻得像根头发;可一旦几十万条铺开,它要同时扛住几何展开、宽度、接缝、批量提交,哪一头没理顺,画面就先跪。
典型场景里,它要同时对付这些:
- 🛰️轨迹 / GPS 轨迹:一辆车一天几万个位点,几千辆车就是几百万段折线,还要按时间着色
- 🛣️路网 / 管线 / 电缆:城市路网几十万段,管线要分图层、分状态颜色
- ✈️航线 / 物流路径:跨洲航线是贴在球面上的大圆弧,数量一大就是满屏的弧
- 🏔️等高线 / 等值线:地形分析产出的等值线,一条就几百个节点,几千条就是百万级顶点
- 🌬️气象风场线 / 流线:风场把看不见的风画成一条条流线,密度决定你能不能看出"涡"
🤦 传统痛点:这件事不新,翻车的姿势也稳定。
| 方案 | 问题 |
|---|---|
每条线一个Entity(polyline图形) | 一万条线就是一万个 Entity 加一万份图形描述,属性系统、动态更新、逐个绘制调用全得付;数量一上来 CPU 先跪 |
每条线一个Primitive | 绘制调用数量和线数 1:1,十万条线就是十万次 draw call,还没算几何上传 |
逐线GroundPolyline(贴地线) | 贴地分类要读深度缓冲、逐线一套参数,数量一大比不贴地还慢 |
| 后端预渲染成瓦片 | 要搭服务、要预处理;前端失去"任意时刻按新参数重算"的自由 |
💡 本文方案:用Primitive的批量折线实例,把"扯带子"和"批量提交"两件事分开对付:
1️⃣造实例:每条折线包装成一个GeometryInstance,颜色塞进ColorGeometryInstanceAttribute(和面一样,是逐实例属性)
2️⃣一次提交:所有实例塞进同一个Primitive的geometryInstances数组,共享一份PolylineColorAppearance
3️⃣分帧 + 可取消:生成按批让出主线程,generationToken让新请求能立刻打断旧任务
✨ 一句话总结:把 N 条线当成"一批货"打包,让
combineInstances在提交前把它们拼成一整块缓冲、再把每条线在几何阶段展开成三角带——N 次绘制塌成 1 次,可每条线自己先"胖"了一圈。
二、核心技术原理:一条线是怎么被"扯成带子"的
2.1 线从哪来、长什么样
demo 里的线也很"老实"——纯随机撒在地球上,只是节点数不规则,刚好方便我们观察批量渲染本身:
// 源码级剧透:MassLinesDemo.vue 的造线逻辑functionrandomPositions():number[]{constvertexCount=2+Math.floor(Math.random()*3)// 2~4 个节点,故意不规则constlon=Math.random()*360-180// 中心经度全球均匀constlat=Math.random()*180-90// 中心纬度全球均匀constpositions:number[]=[]for(leti=0;i<vertexCount;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 个三角形)
我实测了 K=2 / 3 / 4:顶点分别是 4 / 8 / 12,索引分别是 6 / 12 / 18,严丝合缝。也就是说,一条 3 节点的折线,不再是 3 个顶点,而是8 个顶点。
更关键的是,这 8 个顶点里,每个都背着四样东西(探针读出来的真实属性名):
| 属性 | 类型 | 它干嘛用 |
|---|---|---|
position | DOUBLE × 3 | 这个顶点自己的世界坐标 |
prevPosition | DOUBLE × 3 | 它在折线上"前一个节点"的坐标 |
nextPosition | DOUBLE × 3 | 它在折线上"后一个节点"的坐标 |
expandAndWidth | FLOAT × 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_FORMAT=position: true, normal: true(外加 normal,不含 color)——颜色是逐实例属性,走 BatchTable,不进顶点缓冲PolylineColorAppearance.VERTEX_FORMAT=position: 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 * multiplier(L57599)让绘制命令数只跟"合并后的顶点数组个数"有关。所以"十万条线塌成一次绘制"的论断,和"十万面塌成一次绘制"一模一样——面能享的福,线照样享。
三、代码实战:关键实现拆解
⚠️ 温馨提示:本文 demo 为了看清"批量 + 展开"这一件事,线都是随机短折线段(2~4 节点),颜色只做了纯色 / 交替 / 随机三种。真实业务里的轨迹、路网、等值线是几十到几千节点的长线,但"造实例 → 批量提交 → 展开成带子"的主线一字不差,只是每条线的节点更多。
3.1 给每条线算一个颜色
和面一模一样,颜色不挂材质,挂逐实例属性:
// MassLinesDemo.vuefunctionresolveColor(index:number):Color{if(colorMode.value==='single'){returnColor.fromCssColorString(color1.value).withAlpha(opacity.value)}if(colorMode.value==='random'){returnColor.fromHsl(Math.random(),0.75,0.6,opacity.value)// 固定饱和度/亮度,避免脏色}// 双色交替:偶数号用色一、奇数号用色二constbase=index%2===0?color1.value:color2.valuereturnColor.fromCssColorString(base).withAlpha(opacity.value)}- 🔑
ColorGeometryInstanceAttribute.fromColor:把颜色编码成逐实例属性,不是材质;这正是"一个Appearance服务所有线"的前提 - 🔑透明度直接乘进颜色:
withAlpha(opacity)之后,整批线共享同一个半透明设置
3.2 一个 Primitive 装下全部
全篇的心脏——先攒满实例数组,再一次性交给 Primitive:
// MassLinesDemo.vue :: regenerate()consttotal=count.valueconstchunkSize=10000// 每 1 万个让出一帧constinstances:GeometryInstance[]=[]for(leti=0;i<total;i+=chunkSize){if(token!==generationToken)return// 发现已被新任务取代,立刻退出constend=Math.min(i+chunkSize,total)for(letj=i;j<end;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.value=end/totalawaitnextFrame()// 让出主线程,进度条才能刷新、按钮才能点}if(token!==generationToken)returnlinePrimitive=newPrimitive({asynchronous:false,// ⚠️ 同步!所有几何在这一行里一次性建完(含展开成带子)geometryInstances:instances,// 🔑 全部实例塞进一个数组appearance:newPolylineColorAppearance()})viewer.scene.primitives.add(linePrimitive)- 🔑
geometryInstances接收数组:这是"批量"的入口;数组越长,越多线塌成同一块缓冲 - ⚠️
width: width.value * 3:width本身的单位是像素,demo 把滑杆值再乘 3,纯粹是为了在地球尺度下让线"看得见"——Cesium 的折线宽度从来不是米,也不是地图单位,是屏幕像素 - ⚠️
asynchronous: false是双刃剑:好处是"加进去就能立刻用",代价是几何创建(含带状展开、合并、编码)全在这一行同步跑完 - 🔑
PolylineColorAppearance()不带参数:它没有closed选项(线没有"闭合体"的概念),也比PerInstanceColorAppearance少一个法线维度
3.3 分帧生成与任务取消
两个细节决定了它"卡不卡、乱不乱",和 #59 的面是完全同款:
functionnextFrame():Promise<void>{returnnewPromise((resolve)=>setTimeout(resolve,0))// 让出一个宏任务,浏览器得以重绘}consttoken=++generationToken// 每次重新生成前自增 token,旧循环在下一次检查时自然退出- 🔑
chunkSize = 10000+await nextFrame():把 10 万次的"造实例"摊到 10 帧里,用户看到进度条在走,界面不假死 - 🔑
generationToken:用户连点两次"重新生成"时,第一个循环会在下一轮检查发现token !== generationToken而主动返回,不会和第二个任务撞车
⚠️ 这里一定要提醒:很多人会忘了generationToken这道闸,结果连点几下,几个生成任务同时在跑,内存瞬间起飞——相信我,你会踩的,别问我怎么知道的 o(╥﹏╥)o
3.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 MB |
| 5 万 | 40 万 | ≈ 230 ms | ≈ 32 MB |
| 10 万 | 80 万 | ≈ 460 ms | ≈ 64 MB |
| 50 万 | 400 万 | ≈ 2.3 s | ≈ 320 MB |
| 100 万 | 800 万 | ≈ 4.6 s | ≈ 640 MB |
- 单条 3 节点折线实测:8 个顶点、12 个索引、约 640 字节(position 24 + prev 24 + next 24 + expandAndWidth 8,×8 顶点)
- 逐线
createGeometry约4.6 µs(Node 探针采样 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(...)依然是同步的——所有线的createGeometry+combineInstances+ 编码都在这一行里跑完。
- ⚠️ 重要提示:分帧只能救"造实例"阶段,救不了"提交 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 年的秋夜,与你共探"无面积的线如何被扯成有宽度的带"