☰
第59篇:数据可视化-海量不规则多边形——十万个面一次画完,一百万个面先冻你十二秒
2026/10/16 8:00:56 网站建设 项目流程

上篇结尾我留了这么一句话:

“点聚合解决的是’点太多’,可当你手里是几万个不规则多边形、几万条折线时,引擎又该怎么一口气流掉它们、又不卡成幻灯片?海量不规则多边形与线,我们下一篇见。”

#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 年的秋夜,与你共探"一块缓冲如何吞下十万片土地"

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询