上篇结尾我留了这么一句话“体之后还有一个更麻烦的东西当你要在地球上贴几十万个文字标签时每一个字都是一张贴图、一次 billboard。那又是完全不同的一本账。海量文字我们下一篇见。”#59 的面、#60 的线、#61 的体走的是同一条路Primitive批量实例提交前用combineInstances把 N 个图元合并成一大块顶点缓冲然后一次画完。那条路的账单我们都算过了——顶点数从 3 涨到 8 再涨到 24100 万个方块要 2400 万顶点、将近 1 GB。文字走的是另一条路。它压根没有几何——一个字不是三角形是一张图片。而 Cesium 为这条路准备了三件完全不同的武器。今天——轮到文字了。大家好我是 Cesium 酱也可以叫我本猿一名在 WebGIS 领域摸爬滚打多年的前端开发者。写这篇前我又干了一件傻事按 #61 的思路给十万个文字各造一个BoxGeometry再往上贴图。结果画面还没出来内存先报警了。那一声报警就是这篇的开场白。本文目标看清BillboardCollection是怎么用「文字池去重 纹理图集 GPU 实例化」三招把每个字都是一张贴图这件听起来最贵的事压到比立方体还便宜的。 本文涉及的全部代码均已开源完整的仓库地址见文末可自行取用、随意魔改。一、痛点与场景为什么文字是最不像几何的图元 背景现状前面三种图元不管面线体本质都是一堆三角形。文字不是。文字是排版结果——它要先被字体渲染成像素才能上球。这个先变成像素的步骤决定了它后面所有的脾气。典型场景里它要对付的是这些️地名注记 / POI一座城市几十万个兴趣点每个点上方挂一个名字AIS 船舶 / 航班标签移动目标实时标注数量大且随时刷新设备 / 杆塔 / 井盖编号管网与电力巡检标签跟着资产走️军标与态势标号番号、代号、时间密集且要求不遮挡统计值标注每个行政区挂一个数字本质是海量短文本 传统痛点翻车姿势和前几篇都不一样因为这次多了字体渲染这一环。方案问题每个标签一个Entitylabel十万份LabelGraphics 属性系统每个都要独立排版CPU 先跪用RectangleImageMaterialProperty贴文字一个标签一个矩形图元回到图元数与绘制数 1:1的老路DOM 覆盖层div 绝对定位十万级 DOM 节点浏览器直接躺平且无法被深度遮挡每个字现画一张 canvas不做缓存十万次toDataURL()光 PNG 编码就要几秒 本文方案BillboardCollection 三件武器1️⃣文字池去重先造一个只有几百种文字的小池子十万个标签从池子里随机取——贴图种类只跟池子大小有关跟标签数量无关2️⃣纹理图集BillboardCollection内部把所有贴图塞进同一张纹理图集一次绑定3️⃣GPU 实例化每个 billboard 在顶点缓冲里只占1 个顶点靠instanceCount展开✨ 一句话总结前三种图元是把 N 个图元合并成一大块顶点文字是把 N 个标签压缩成 N 个顶点 几百张贴图——两条完全不同的路终点却都是一次绘制。二、核心技术原理三件武器与一张图集2.1 文字不是几何是一张贴图demo 里的第一件事是把一个字符串画成一张图片// 源码级剧透MassTextDemo.vue 的文字贴图constcanvasdocument.createElement(canvas)canvas.width160*devicePixelRatio// devicePixelRatio min(dpr, 2)上限 2canvas.height48*devicePixelRatioconstctxcanvas.getContext(2d)// ...ctx.font${fs}px Microsoft YaHei, PingFang SC, sans-serifctx.fillText(text,canvas.width/2,canvas.height/2)returncanvas.toDataURL()// 一张 PNG 的 base64注意ctx.fillStyle传进来的是#ffffff——画出来的永远是白字。颜色不进贴图这点后面会讲。这里有个容易被忽略的细节canvas 固定160 × 48CSS 像素跟文字长短无关。所以城市1和城市100用的是同一张 160×48 的画布后者可能被裁掉——画布宽度不会自适应文字。2.2 武器一文字池把贴图种类压下来如果十万个标签各不相同就要十万张贴图那谁也救不了。demo 的对策是一个文字池// MassTextDemo.vue :: regenerate()constpoolpoolSize.value// 10 / 50 / 100 / 500 / 1000consttexts:string[][]for(leti0;ipool;i1){texts.push(i%40?城市${i1}:randomHanzi(2))// 1/4 是城市N3/4 是两个随机汉字}// ...consttexttexts[Math.floor(Math.random()*pool)]// 十万个标签都从池子里取constimagegetTextImage(text,#ffffff)// 命中缓存就不重画const{longitude,latitude}randomPosition(j,total)billboards.add({position:Cartesian3.fromDegrees(longitude,latitude),image,...})十万个标签贴图种类只有池子那么大。配合textImageCache按文本|字号|白边做键同样的字只画一次 canvas、只编一次 PNG。我实测了每个池子档位真正不同的文字有多少种——汉字表是 34 个省级简称两个字的组合有 34² 1156 种再叠加 1/4 的城市N撞车是必然的池子设定其中城市N去重后实际不同文字10310 种501350 种1002597 种500125446 种1000250798 种也就是说池子调到 1000实际只有 798 张不同的贴图——池子大小是上限不是实际值。2.3 武器二纹理图集所有贴图塞进一张BillboardCollection内部会建一个TextureAtlas把所有 billboard 的图片打包进同一张纹理。我翻了源码Renderer/TextureAtlas.js初始尺寸只有16 × 16按需动态增长增长时要重新打包并整张重建贴图的guid会变相邻图片之间默认留1 像素边框borderWidthInPixels 1防止采样时互相渗色于是图集要多大只取决于池子里有多少张图、每张多大。按 dpr 2 算单张是 (3202) × (962) 322 × 98 31556 像素池子图集总面积RGBA 显存等效正方形边长100.32 Mpx1.2 MB562 px501.58 Mpx6.0 MB1256 px1003.16 Mpx12.0 MB1776 px50015.78 Mpx60.2 MB3972 px100031.56 Mpx120.4 MB5617 px⚠️ 最后一行要瞪一眼5617 px 已经超过了 4096——很多设备的MAX_TEXTURE_SIZE就是 4096。池子拉满时图集可能撞上 GPU 的单张纹理上限。2.4 武器三GPU 实例化一个标签只占一个顶点这是本篇最反直觉的一处也是文字比方块便宜的根因。我原以为一个 billboard 是一个 quad、4 个顶点——和 #61 的立方体一样按顶点数线性涨。翻源码才发现不是。createVAF()的最后一行是// One vertex is needed for each (instanced) billboard.returnnewVertexArrayFacade(context,attributes,numberOfBillboards,true);注释写得清清楚楚每个实例化的billboard 只需要 1 个顶点末尾那个true就是 instancing 开关。quad 的四个角由共享的实例化缓冲提供direction属性挂的是getVertexBufferInstanced(context)。而真正发出绘制命令的地方BillboardCollection.jsL2088-2089更是直白command.count6;// 每个实例画 2 个三角形6 个索引command.instanceCountbillboardsLength;// 实例数 billboard 总数一次绘制命令count固定 6instanceCount等于标签总数。十万个标签不是十万个 quad而是一个 quad 画十万遍。不过那一个顶点可不轻——它背着 10 个 vec4 加 1 个 float位置高低位、像素偏移、眼偏移、按距离缩放、距离显示条件、disableDepthTestDistance……合起来约164 字节。摊到每个 billboard 上billboard1 顶点 × 164 B 164 B/个立方体#6124 顶点 × 36 B 864 B/个顶点缓冲这一侧文字只有方块的 19.0%。2.5 颜色不进贴图一张白字万种颜色getTextImage(text, #ffffff)永远画白字颜色由billboards.add({ color })单独给billboards.add({position:Cartesian3.fromDegrees(longitude,latitude),image,// 永远是白色字形color:resolveColor(j),// 逐标签的颜色与贴图相乘scale:scale.value,disableDepthTestDistance:Number.POSITIVE_INFINITY})形状与颜色解耦贴图只负责长什么样颜色是一个属性。所以十万种随机色不需要十万张贴图——这正是文字池 随机色能同时成立的原因⚠️ 这也意味着换颜色是免费的改属性即可换字号/换白边则要让缓存失效缓存键里含这两项2.6 为什么这次不是 Primitive面 / 线 / 体#59-#61文字#62载体PrimitiveGeometryInstanceBillboardCollectionBillboard合并方式combineInstances拼成一整块顶点缓冲贴图进纹理图集顶点走实例化顶点数N × 每图元顶点数面 3 / 线 8 / 体 24N1/个绘制合并后通常 1 次1 次count6, instanceCountN每图元顶点缓冲108 / 640 / 864 B164 B顺带一提#61 里那个每个实例一个modelMatrix就要逐顶点烘焙的麻烦在 billboard 这条路上根本不存在——billboard 的位置就是一个position属性没有自带坐标系这回事。三、代码实战关键实现拆解⚠️ 温馨提示本文 demo 为了看清文字池 图集 实例化这一件事文字是随机汉字、随机撒在中国或全球。真实业务里的地名、编号、统计值来自数据字段但造池子 → 画贴图 → 批量 add的主线一字不差。3.1 造文字贴图并缓存// MassTextDemo.vueconstHANZI京沪津渝冀晋蒙辽吉黑苏浙皖闽赣鲁豫鄂湘粤桂琼川贵云藏陕甘青宁新港澳台// 34 个字functiongetTextImage(text:string,color:string):string{constkey${text}|${fontSize.value}|${color}|${border.value}constcachedtextImageCache.get(key)if(cached)returncached// 命中就不重画 canvas、不重编 PNGconstimagecreateCanvasText()({text,fontSize:fontSize.value,color,border:border.value})textImageCache.set(key,image)returnimage}缓存键要带全参数字号、白边都影响像素结果漏一个就会复用错图⚠️createCanvasText()每次调用都新建一个 canvas这里只在缓存未命中时调用所以还好别把它挪进循环里⚠️画布固定 160 × 48长文本会被裁切不是自适应宽度3.2 一个 BillboardCollection 装下全部// MassTextDemo.vue :: regenerate()billboardsnewBillboardCollection()viewer.scene.primitives.add(billboards)constchunkSize20000// 每 2 万个让出一帧比前三篇的 10000 大一倍for(leti0;itotal;ichunkSize){if(token!generationToken)returnconstendMath.min(ichunkSize,total)for(letji;jend;j){consttexttexts[Math.floor(Math.random()*pool)]billboards.add({position:Cartesian3.fromDegrees(longitude,latitude),image:getTextImage(text,#ffffff),color:resolveColor(j),scale:scale.value,disableDepthTestDistance:Number.POSITIVE_INFINITY})}progress.valueend/totalawaitnextFrame()}BillboardCollection自己是批量容器add()只是往集合里塞真正的上传与绘制由它在update()里统一做⚠️chunkSize 20000billboard 的add()比造几何便宜得多实测约 2 µs/个所以一批可以更大没有asynchronous: false这条路径本来就没有同步建几何那一锤子代价摊在add()与首次update()上3.3 分帧生成与任务取消和前三篇完全同款functionnextFrame():Promisevoid{returnnewPromise((resolve)setTimeout(resolve,0))}consttokengenerationToken⚠️ 老规矩别省掉generationToken。连点几下重新生成几个循环会同时往集合里塞——相信我你会踩的 o(╥﹏╥)o3.4 关于disableDepthTestDistance: Infinity一顶永不摘下的安全帽这行是本篇最显眼的一个设置也是最容易被误用的一行。我顺着源码追了一遍// BillboardCollection.js L1255-1264简化letdisableDepthTestDistancebillboard.disableDepthTestDistance;disableDepthTestDistance*disableDepthTestDistance;// 先平方if(clampToGround||disableDepthTestDistance0.0){if(disableDepthTestDistanceNumber.POSITIVE_INFINITY){disableDepthTestDistance-1.0;// Infinity 变成 -1.0 哨兵}// ...}Infinity平方还是Infinity于是命中那个-1.0哨兵。再看顶点着色器L135257 一带if (!clipped (disableDepthTestDistanceSq 0.0 || (lengthSq 0.0 lengthSq disableDepthTestDistanceSq))) { // Position z on the near plane. gl_Position.z -gl_Position.w; // ... }哨兵为负 → 把 z 压到近平面 → 这个 billboard 永远画在最前面。✅好处标签不会被地形、建筑、甚至地球本身挡住密集场景里可读性最好⚠️代价地球背面的文字会直接穿透显示出来。转到地球另一侧你看到的是正反两层标签叠在一起 想要正常遮挡就别用Infinity给一个有限的距离例如1e5米或者直接不设四、功能亮点与性能优势4.1 八个可调旋钮数量1 万 / 5 万 / 10 万 / 50 万 / 100 万文字池10 / 50 / 100 / 500 / 1000决定贴图种类也就决定图集大小字号12~48缩放0.2~2billboard 的scale默认 0.6白边给字形描一圈白边提升复杂底图上的可读性分布中国73135°E、1854°N随机/ 全球黄金角螺旋确定性颜色模式随机色 / 单色单色取色器4.2 性能实测本猿自己的电脑实测Node 探针贴图尺寸与图集面积按源码参数算BillboardCollection.add()为真实计时未开 GPU 渲染标签数量顶点缓冲164 B/个add()耗时(估)1 万1.6 MB≈ 20 ms5 万7.8 MB≈ 102 ms10 万15.6 MB≈ 216 ms50 万78.2 MB≈ 1.0 s100 万156.4 MB≈ 2.1 s逐 billboardadd()实测2.025 ~ 2.161 µs/个三个档位中位数稳定在这一区间顶点缓冲按源码10 个 vec4 1 个 float ≈ 164 B/个推算非 GPU 实测值显存的另一半在纹理图集且只跟池子有关池 100 约 12 MB池 1000 约120 MB横向对比四篇这一步终于便宜了下来同样100 万图元顶点缓冲面约 108 MB、线约 640 MB、方块约 961 MB、文字约 156 MB文字还要额外付图集的钱池 1000 时 120 MB但即便如此也只有方块的零头4.3 核心优势百万级真流畅一次绘制命令 instanceCount展开缩放旋转不卡贴图种类与数量解耦标签涨到 100 万贴图还是池子那几百张换颜色零成本颜色是属性不是贴图随机色随便给字体自由canvas 能画什么标签就是什么中英混排、emoji、描边都行五、小贴士避坑指南️关于永远浮在最前面disableDepthTestDistance: Number.POSITIVE_INFINITY会被平方后变成-1.0哨兵着色器把 z 压到近平面。地球背面的标签会穿透显示。⚠️ 重要提示这是 demo 的默认设置不是推荐设置想要正常的前后遮挡改成有限值例如1e5或不设该项只想解决被地形挡住disableDepthTestDistance给一个几百米的小值就够了不必Infinity关于图集上限图集面积 池子大小 × 单张面积与标签数量无关。但它是一张纹理边长受MAX_TEXTURE_SIZE限制。⚠️ 重要提示池 500 时等效边长已到3972 px池 1000 是5617 px——后者超过 4096建议池子按需取不要盲目拉满dpr 高的设备视网膜屏单张贴图会翻倍更吃紧另图集动态增长时会整张重建并改变guid运行时频繁加新文字会触发反复重建关于全球分布是个椭圆盘world分布用的是黄金角螺旋实测常量2.399963229728653 rad 137.5078°constgoldenAngleindex*2.399963229728653constradiusMath.sqrt(index/total)longitude180*radius*Math.cos(goldenAngle)latitude90*radius*Math.sin(goldenAngle)radius sqrt(index / total)保证在圆盘上面积均匀看着很漂亮——但它在经纬平面上均匀不是球面上均匀它只覆盖一个椭圆盘(经度/180)² (纬度/90)² ≤ 1。实测覆盖球面89.04%454.145 / 510.066 百万 km²在地图上占 π/4 78.54%⚠️地图四角是空的实测|经度| 150 且 |纬度| 60的点数为0最大 |经度| 到 179.99° 时纬度只有 0.35°最大 |纬度| 到 89.99° 时经度只有 0.17°⚠️球面密度仍是 1/cos 律极带8090°密度是赤道带010°的9.549 倍理论 1/cos 85° ≈ 11.474 想要球面均匀纬度要按asin反变换采样光靠黄金角螺旋救不了关于文字池的碰撞池子大小是上限不是实际值。两个汉字的组合只有 34² 1156 种池子开到 1000 时实测只有798 种不同文字。想让贴图种类真的等于设定值得先保证文字本身不撞车。⚡关于它能用来做什么BillboardCollection擅长海量、静态、同款样式的标签。⚠️ 重要提示billboard 是面向相机的——它永远正对着你不会随地球躺下需要贴地、随地形起伏的文字这条路不合适需要频繁增删的少量标签Entity的label反而更省心还自带 SDF 缩放不糊需要极大量且要能拾取记得打开 pick 支持并留意batchTable的开销坦诚局限这个 demo 只做了随机短文字 纯色真实业务里的长地名换行、多级避让、随缩放分级显示LOD一个都没做。这些都不改变池子 图集 实例化的主线但每一个都是另一个量级的工程。六、写在最后Cesium 是头优雅的大象。我们曾经以为每个字都是一张贴图是这条路上最贵的一段今天才发现它用池子去重、用图集收纳、用实例化摊平——反倒比 24 个顶点的方块还轻。从 #59 的面到 #62 的文字四篇走下来我们看的其实是同一个问题的四种解法引擎总要先想清楚这批东西的共同点是什么才能把它们压成一次绘制。面的共同点是同一份外观线的共同点是同一条带子方块的共同点是各自那张坐标系文字的共同点则是——它们长得都一样只是颜色不同。而这次省下来的代价换成了别处的一顶帽子Infinity让所有标签永远浮在最前面。省得越狠越要记得自己放弃了什么。这个案例还不完美图集会随池子膨胀并撞上纹理上限全球分布只覆盖了 89% 的球面还把四角留白了Infinity让背面的字穿透地球。但它把海量文字为什么能画得动、又便宜在哪儿这件事讲清楚了这就够了。下一篇我们换条线——不再画自己的数据而是把别人做好的一整座塔搬进地球。3DTiles 的大雁塔模型我们下一篇见。如果你也用 Cesium 贴过几十万个标签欢迎留言说说你是怎么解决遮挡与可读性的——是老老实实给有限距离还是干脆做了套避让算法 项目已开源欢迎 Star你的 Star ⭐ 就是对我最大的支持仓库地址https://github.com/jianlei-wang/CESIUM-TREASURE-BOX代码在src/cases/mass-text/目录下核心就一个MassTextDemo.vue 注册用的index.ts复制进你的项目、把随机汉字换成真实地名就能跑。Cesium酱の百宝箱 · 第 62 篇一个字没有几何只有一张被反复贴用的白纸2026 年的秋夜与你共探最不像图元的图元如何被压成一次绘制