上篇结尾我留了这么一句话:
“点聚合解决的是’点太多’,可当你手里是几万个不规则多边形、几万条折线时,引擎又该怎么一口气流掉它们、又不卡成幻灯片?海量不规则多边形与线,我们下一篇见。”
#57 把镜头交给时间轴,讲清"演"的代价;#58 回到数据侧,看清"点"怎么在屏幕空间里抱团。两次都在跟"海量"打交道,可手里的几何体都还很小——点是一个像素级的小方块,团是一片带数字的圆片,再密也还是"小东西"。
今天——轮到大块头了。
这回要往地球上塞的不是点,是面:几万、几十万个不规则多边形,每一片都有自己的形状、颜色、高度。当数量推到十万、百万,事情的性质就变了——不再是"怎么算得对",而是"怎么塞得下、画得完"。
大家好,我是 Cesium 酱(也可以叫我"本猿"),一名在 WebGIS 领域摸爬滚打多年的前端开发者。
写这篇前,我照例先用最熟的老办法——给每个多边形建一个Entity——把一万个面丢进去。浏览器风扇立马呼呼转起来,鼠标拖一下地球,画面卡得像在放 PPT。那一声风扇,就是这篇的开场白。
本文目标:把 Cesium 的Primitive批量实例拆开,看清它怎么把十万个面塌成一次绘制,又为什么在百万级开始付出真金白银的代价。💪
💡 本文涉及的全部代码均已开源,完整的仓库地址见文末,可自行取用、随意魔改。
一、痛点与场景:为什么"一堆面"比"一堆点"难缠
🎯 背景现状:面数据是 WebGIS 里最"重"的一类要素——它不像点只占一个像素,每一片都要参与三角化、要算光照、要往 GPU 里送顶点。当业务把一个城市的宗地、一省的行政区划、一片区域的统计网格一次性铺上地球,数量很容易冲到十万级。
典型场景里,它要同时扛住这些:
- 🏙️行政区划 / 宗地:几万个地块一次性上图,每一片形状都不一样,颜色还得按属性区分
- 🌾遥感解译 / 分类栅格弧段:把影像切成成千上万个不规则小多边形,逐片看类别
- 📊格网统计 / 选址分析:等值面、热力格网动辄十万片,密度决定它能不能看出"分布"
- 🗺️AOI / 兴趣面:用户圈选或后台下发的大批面,需要"拉到任意层级都还在、还看得清"
🤦 传统痛点:这件事不新,翻车的姿势倒很稳定。
| 方案 | 问题 |
|---|---|
每个面一个Entity(PolygonGraphics) | 一万个面就是一万个 Entity 加一万份图形描述,属性系统、动态更新、逐个绘制调用全得付;数量一上来,CPU 光提交指令就先跪 |
每个面一个Primitive | 绘制调用数量和面数 1:1,十万个面就是十万次 draw call,还没算几何体上传 |
逐面GroundPrimitive贴地分类 | 贴地分类要读深度缓冲、逐面一套参数,数量一大比不贴地还慢 |
| 后端预渲染成瓦片 | 要搭服务、要预处理;前端失去"任意时刻按新参数重算"的自由 |
💡 本文方案:用Primitive的批量实例,三步把海量面驯服:
1️⃣造实例:每个多边形包装成一个GeometryInstance,颜色塞进ColorGeometryInstanceAttribute(纯顶点属性,不依赖贴图)
2️⃣一次提交:所有实例塞进同一个Primitive的geometryInstances数组,共享一份PerInstanceColorAppearance
3️⃣分帧 + 可取消:生成按批让出主线程,generationToken让新请求能立刻打断旧任务
✨ 一句话总结:把 N 个小面当成"一批货"打包,让
combineInstances在提交前把它们拼成一整块顶点缓冲,N 次绘制塌成 1 次。
二、核心技术原理:Primitive 批量实例到底做了什么
2.1 面从哪来、长什么样
demo 里的面很"老实"——纯随机撒在地球上,只是形状不规则,刚好方便我们观察批量渲染本身:
// 源码级剧透:MassPolygonsDemo.vue 的造面逻辑functionrandomPositions():number[]{constvertexCount=vertexMode.value==='triangle'?3:4constlon=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}每个面用PolygonGeometry描述,顶点格式直接抄PerInstanceColorAppearance.VERTEX_FORMAT:
newGeometryInstance({geometry:newPolygonGeometry({polygonHierarchy:newPolygonHierarchy(Cartesian3.fromDegreesArray(randomPositions())),height:height.value,// 可选离地高度vertexFormat:PerInstanceColorAppearance.VERTEX_FORMAT}),attributes:{color:ColorGeometryInstanceAttribute.fromColor(resolveColor(j))// 逐面颜色},id:`mass-polygon-${j}`// 便于后续拾取/定位})2.2 核心流程
我们设计的完整技术路线:
核心亮点:把"逐个提交"换成"先攒后并"是本文的杀手锏 🔪
秘密藏在 Cesium 源码里。当你把一批GeometryInstance交给Primitive,它在内部调用了:
// 源代码级剧透:Build/CesiumUnminified/Cesium.js L55648letgeometries=GeometryPipeline_default.combineInstances(instances);combineInstances把这一批实例的顶点、索引拼成一整块缓冲;紧接着把 double 坐标编码成 float 高低位:
// 同文件 L55687-55692GeometryPipeline_default.encodeAttribute(geometry,name,`${name}3DHigh`,`${name}3DLow`);最后生成绘制命令时,命令数只跟"合并后的顶点数组个数"有关,而不是面数:
// 同文件 L57599colorCommands.length=primitive._va.length*multiplier;也就是说,十万个共享同一Appearance的实例,合并后通常只剩一块顶点缓冲,对应一次绘制(外加一次拾取通道)。
2.3 为什么用 Primitive 批量,而不是 Entity
| 方案 | 原理 | 绘制调用 | 复杂度 |
|---|---|---|---|
逐面Entity | 每个面独立图形 + 独立几何 | 😵 与面数 1:1 | 低,但数量一上就崩 |
逐面Primitive | 每个面一个小 Primitive | 😵 与面数 1:1 | 中,同样崩 |
批量Primitive | 一批实例共享Appearance,提交前合并 | ⚡ 通常 1 次 | 中,需要自己管生成与取消 |
简单说:GPU 天生就擅长"一大堆顶点、一次画完",而 CPU 最不擅长的就是"画十万次"。批量实例要做的,就是把后者变前者。
三、代码实战:关键实现拆解
⚠️ 温馨提示:本文实现为了看清"批量"这一件事,颜色只做了纯色/交替/随机三种,面也固定为三角或四边形两种规则形状。真实业务里的复杂属性和任意多边形,思路完全一样,只是造的实例更"花"一点。
3.1 给每个面算一个颜色
颜色不能挂在材质上(那样每个面都得一套材质),得塞进顶点属性:
// MassPolygonsDemo.vuefunctionresolveColor(index:number):Color{if(colorMode.value==='single'){returnColor.fromCssColorString(color1.value).withAlpha(opacity.value)}if(colorMode.value==='random'){// 🌰 随机色:固定饱和度 0.75、亮度 0.6,避免出现脏色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:
// MassPolygonsDemo.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:newPolygonGeometry({polygonHierarchy:newPolygonHierarchy(Cartesian3.fromDegreesArray(randomPositions())),height:height.value,vertexFormat:PerInstanceColorAppearance.VERTEX_FORMAT}),attributes:{color:ColorGeometryInstanceAttribute.fromColor(resolveColor(j))},id:`mass-polygon-${j}`}))}progress.value=end/totalawaitnextFrame()// 🌰 让出主线程,进度条才能刷新、按钮才能点}if(token!==generationToken)returnpolygonPrimitive=newPrimitive({asynchronous:false,// ⚠️ 同步!所有几何在这一行里一次性建完geometryInstances:instances,// 🔑 全部实例塞进一个数组appearance:newPerInstanceColorAppearance({closed:true})})viewer.scene.primitives.add(polygonPrimitive)- 🔑
geometryInstances接收数组:这是"批量"的入口;数组越长,越多面塌成同一块缓冲 - ⚠️
asynchronous: false是双刃剑:好处是"加进去就能立刻用",代价是几何创建全在这一行同步跑完,面数一大就冻住主线程(坑 1 详述) - 🔑
closed: true:让面按闭合体渲染,光照法线才正确
3.3 分帧生成与任务取消
两个细节决定了它"卡不卡、乱不乱":
functionnextFrame():Promise<void>{returnnewPromise((resolve)=>setTimeout(resolve,0))// 让出一个宏任务,浏览器得以重绘}// 每次重新生成前自增 token,旧循环在下一次检查时自然退出consttoken=++generationToken- 🔑
chunkSize = 10000+await nextFrame():把 10 万次的生成摊到 10 帧里,用户看到进度条在走,界面不假死 - 🔑
generationToken:用户连点两次"重新生成"时,第一个循环会在下一轮检查发现token !== generationToken而主动返回,不会和第二个任务撞车
⚠️ 这里一定要提醒:很多人会忘了generationToken这道闸,结果连点几下,几个生成任务同时在跑,内存瞬间起飞——相信我,你会踩的,别问我怎么知道的 o(╥﹏╥)o
四、功能亮点与性能优势
4.1 六个可调旋钮
面板把"造面"这件事暴露成了六个参数,方便你亲自感受数量与代价的关系:
- 数量:1 万 / 5 万 / 10 万 / 50 万 / 100 万,五档一键切换
- 形状:三角形(3 顶点)或四边形(4 顶点)——直接决定顶点预算
- 随机范围:0.2°~5°,控制每个面"抖动"多大,也就是面看起来有多不规则
- 高度:0~1000 m,把面抬离地表,从平面铺贴变成空间漂浮
- 透明度:0.1~1,叠加区的层次感全靠它
- 颜色模式:双色交替 / 单色 / 随机,验证"颜色不进材质也能逐面不同"
4.2 性能实测
本猿自己的电脑实测(Node 探针复刻createGeometry的逐面开销 + 线性外推,未开 GPU 渲染):
| 多边形数量 | 顶点总数 | 同步生成耗时 | 顶点缓冲 |
|---|---|---|---|
| 1 万 | 3 万 | ≈ 0.12 s | ≈ 1.2 MB |
| 5 万 | 15 万 | ≈ 0.6 s | ≈ 6 MB |
| 10 万 | 30 万 | ≈ 1.2 s | ≈ 11 MB |
| 50 万 | 150 万 | ≈ 6.2 s | ≈ 54 MB |
| 100 万 | 300 万 | ≈ 12.4 s | ≈ 108 MB |
- 单个三角面实测:3 个顶点、3 个索引、108 字节(
position为 3×float64 = 72 字节,normal为 3×float32 = 36 字节) - 逐面
createGeometry约12 µs,几乎与面数成正比 - 顶点缓冲按编码后每顶点约 36 字节估(double 坐标被拆成
3DHigh/3DLow两个 float),不含索引与 BatchTable
4.3 核心优势
- 🚀十万级真流畅:10 万个面塌成 1 次绘制,缩放旋转不卡
- 🎨逐面颜色零成本:颜色是顶点属性不是材质,10 万种颜色也不需要 10 万套材质
- 📦一套参数走天下:
Appearance只有一份,改一次影响全批 - 🔧可取消、有进度:分帧生成 + token 取消,交互始终跟手
五、小贴士:避坑指南
🎯关于asynchronous: false的同步冻结
asynchronous: false让Primitive在构造时就把所有几何一次性建完。生成阶段你可以用chunkSize分帧让出主线程,但最后那一句new Primitive(...)依然是同步的——所有面的createGeometry都在这一行里跑完。
- ⚠️ 重要提示:分帧只能救"造实例"阶段,救不了"提交 Primitive"这一锤子
- 建议:面数上到百万级时,优先考虑异步
Primitive(去掉asynchronous: false),或把生成与提交整段挪进 Web Worker(几何数据可结构化克隆传回)
📦关于内存账单
每一个面都实打实地占顶点预算。实测单个三角面 108 字节,100 万个面就是约 108 MB 的顶点缓冲,还没算索引、BatchTable 与拾取通道。
- ⚠️ 重要提示:每次调参(数量、形状、颜色……)demo 都是
removePrimitive()后重建整个 Primitive,不是增量更新 - 建议:能少一个顶点就少一个——四边形比三角形多 1 个顶点、多 3 个索引;能用三角形表达的形状,就别硬凑四边形
⚡关于"它能用来做什么"
批量实例擅长"静态、海量、同款外观"的面。如果你的场景是"每个面都要单独换材质/贴图",或者"每秒都在增删改面",那它不是最佳选择。
- ⚠️ 重要提示:
PerInstanceColorAppearance只能表达逐面纯色,做不了逐面贴图或逐面自定义着色器 - 需要频繁更新的少量面,老老实实用
Entity反而更省心;海量 + 静态,才是批量实例的主场
坦诚局限:这个 demo 只做了"三角/四边形"两种规则形状,真实业务里的任意凹多边形、带洞多边形,得让PolygonHierarchy带上内环;颜色也只到纯色。这些都不改变"批量 + 合并"的主线,只是实例造得更复杂而已。
六、写在最后
Cesium 是头优雅的大象。
我们日常用Entity、Primitive,是在抚摸它的鼻子;
而今天,我们潜入了它的胃——看着它把十万片各自为政的土地,嚼碎了、压成一整块,再一口吞下。
从 #57 把相机交给时间,到 #58 把点交给屏幕,再到今天把面交给批量——我们一直在学同一件事:别跟引擎的强项较劲,顺着它的性子来。
10 万个面只画一次,是引擎替我们打的包;100 万个面先冻 12 秒,是我们必须自己扛的账。 🎉
这个案例还不完美:生成与提交仍是主线程的活,调个参数就得整批重建。但它把"海量面为什么能画得动、又为什么画不动"这件事讲清楚了,这就够了。
如果你也用 Cesium 铺过几十万个面,欢迎留言说说你是怎么扛住内存与冻结的——是乖乖走 Worker,还是干脆交给瓦片?
批量实例解决了"面太多",可当你手里是几十万条折线时,线的顶点排布、接缝、批量方式,又是另一套讲究。海量不规则线,我们下一篇见。
🌟 项目已开源,欢迎 Star!(你的 Star ⭐ 就是对我最大的支持!)
仓库地址:https://github.com/jianlei-wang/CESIUM-TREASURE-BOX
代码在src/cases/mass-polygons/目录下,核心就一个MassPolygonsDemo.vue+ 注册用的index.ts,复制进你的项目、把随机造面换成真实坐标就能跑。
Cesium酱の百宝箱 · 第 59 篇
十万次绘制叠成一次,是引擎替我们打的包
2026 年的秋夜,与你共探"一块缓冲如何吞下十万片土地"