Cesium三维行政区划立体贴图:顶侧边UV与材质拆分实践
第一次在 Cesium 里给行政区划做立体效果我天真地以为给 polygon 挂一个ImageMaterialProperty就完事了。代码跑起来从正上方俯视顶面贴着图看着挺像那么回事结果把相机压到低角度一拉整个人都不好了——柱子侧面那条纹被压成了一条竖线边界上的贴图全部糊成一团相邻区块之间还对不上缝。后来陆续又碰到几个类似需求才慢慢把这套东西摸清楚三维行政区划贴图效果的本质不是给多边形加张图而是给顶面、侧面、轮廓线三套完全不同的几何分别设计纹理坐标和采样规则。这件事在 Cesium 里之所以容易翻车是因为它把贴图这件事包装得太简单了。material字段一填看起来顶面和侧面都吃到了同一张纹理但这两部分用的是两套完全不同的 UV 生成规则顶面按包围盒映射侧面按周长和高度归一化硬套同一张图必然有一边是错的。如果你正在做区域态势、园区展示、数据可视化里的区域分层这类效果或者只是想让某个行政区划从地面长起来并且表面有内容下面这套流程应该能直接拿去用。我按数据处理、纹理生成、UV 重写、几何拆分、材质编写、描边收尾、问题排查这个顺序讲中间会穿插几个我踩过的坑。1. 立体贴图的三个面顶、侧、边共用一套材质是万恶之源1.1 一张贴图套在立体多边形上会发生什么先做个思想实验。你有一个不规则的多边形边界拉伸成一根柱子。这根柱子有三个可视表面顶面、侧面、以及顶面那一圈轮廓线。Cesium 在建几何的时候顶面和侧面其实是同一个PolygonGeometry里的两组三角形它们共享同一个材质实例。问题就出在这里。顶面的纹理坐标Cesium 默认是按多边形的外围矩形做线性映射经度方向映射到 s0 到 1纬度方向映射到 t0 到 1。这意味着如果你的多边形是个瘦长条或者形状很不规则纹理就会被硬生生压扁或者拉开。侧面的纹理坐标是另一套s 沿着多边形周长累积归一化到 0 到 1t 沿着拉伸高度归一化到 0 到 1。两套规则共用一个材质结果就是顶面勉强能看侧面把整张图纵向压缩成一条颜色糊成一片。我第一次遇到这个问题的时候还以为是自己贴图分辨率不够硬生生把纹理从 1024 提到 4096文件大了十六倍侧面该糊还是糊。1.2 Cesium 内置材质能力的两条硬边界Cesium 自带的ImageMaterialProperty提供的能力其实不少image、repeat、color、transparent、stRotation、alpha都能调。但它有两条明确的边界绕不过去。第一条它没法区分顶面和侧面。stRotation是整张纹理矩阵一起转repeat也是全局生效。你没办法说顶面按 A 规则采样、侧面按 B 规则采样。想做到这一点要么自己重写纹理坐标让两套规则能在一个 shader 里区分要么干脆把顶面和侧面拆成两个独立的几何对象各配各的材质。第二条它管不了纹理和周边环境的对齐。有多个相邻行政区划的时候每个多边形各贴各的图接缝处必然对不齐。想让整片区域看起来像一张完整的图被切成了若干块就必须让所有多边形共享同一套纹理坐标基准也就是全域统一的一张图。这两条边界决定了后面的技术路线。如果你的需求只是一个孤立的区块贴一张正方形图案用户基本只从俯视角度看那么ImageMaterialProperty完全够用别过度设计。但只要涉及相邻区块拼接、侧面可见、或者低角度浏览就得往下走。方案适用场景顶面贴图侧面贴图实现成本Entity ImageMaterialProperty单体区块、俯视为主、无拼接需求可用但会变形基本不可用极低Entity 自定义 Material需要侧面控制但几何不想拆需重写 st需判断法线中等Primitive 顶侧几何分离多区块拼接、低角度浏览、正式项目完全可控完全可控偏高3D Tiles 预烘焙数据量大、样式固定可控可控需要离线管线2. 数据与纹理的前置准备让 UV 有统一的坐标系可依2.1 边界数据的清洗与坐标精度取舍拿到手的行政区划边界通常是 GeoJSON 格式的MultiPolygon坐标是[lon, lat]数组。这里第一个要做的事情是降采样。原始数据为了精确一个区县可能有几万个点直接拿到 Cesium 里生成几何光顶点数组就够喝一壶的。我的经验是省级或者区域级的展示用 0.01 度左右的容差做道格拉斯-普克抽稀就够了地市级用 0.002 度真正到街区级别才需要 0.0005 以下。抽稀的工具可以用 turf 的simplify也可以自己写一个。抽稀的时候要特别注意保持拓扑否则相邻两个区块抽稀之后可能会出现缝隙或者重叠视觉上就是一条条白线或者色块交叠。另一个容易忽略的点是环的方向。GeoJSON 规范里外环是逆时针、内环洞是顺时针但实际数据经常不遵守。Cesium 在生成多边形几何时会根据绕向判断内外绕向反了会导致洞变成实体、实体被挖空。稳妥的做法是在入库前统一做一次绕向纠正用鞋带公式算有向面积面积为负就翻转。还有一个坑跨 180 度经线。某些区域的边界数据会出现经度从 179 跳到 -179 的情况如果直接按线性映射算纹理坐标中间会横穿整个地球贴图完全错乱。处理方式是在数据预处理阶段就检测经度跳变必要时把坐标整体平移 360 度保证连续。// 鞋带公式判断绕向返回 true 表示逆时针 function isCounterClockwise(ring) { let area 0; for (let i 0, len ring.length; i len; i) { const [x1, y1] ring[i]; const [x2, y2] ring[(i 1) % len]; area x1 * y2 - x2 * y1; } return area 0; } // 统一绕向外环逆时针内环顺时针 function normalizeRing(ring, isHole) { const ccw isCounterClockwise(ring); return (ccw ! isHole) ? ring : ring.slice().reverse(); }2.2 用 canvas 把行政区划画成一张全域纹理让所有区块共享同一张纹理这个思路是整个方案的地基。具体做法是算出所有多边形的合并包围盒然后用 canvas 按这个包围盒的经纬度范围画一张大图每个区块填不同的颜色或者图案最后把这张图作为一张纹理喂给所有几何对象。function buildAdminTexture(polygons, bbox, size 4096) { const canvas document.createElement(canvas); canvas.width size; canvas.height size; const ctx canvas.getContext(2d); ctx.clearRect(0, 0, size, size); const lonSpan bbox.maxLon - bbox.minLon; const latSpan bbox.maxLat - bbox.minLat; // 经纬度 - 画布像素 const toPixel (lon, lat) [ (lon - bbox.minLon) / lonSpan * size, (bbox.maxLat - lat) / latSpan * size ]; const palette [#2f6f9f, #3b8a6b, #a86b3c, #7a5aa8, #b04a5a]; polygons.forEach((poly, idx) { ctx.beginPath(); poly.rings.forEach((ring) { ring.forEach(([lon, lat], i) { const [x, y] toPixel(lon, lat); if (i 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.closePath(); }); ctx.fillStyle palette[idx % palette.length]; // 用 evenodd 规则才能正确挖出内环的洞 ctx.fill(evenodd); ctx.strokeStyle rgba(255,255,255,0.65); ctx.lineWidth 2; ctx.stroke(); }); return canvas; }这段代码里的fill(evenodd)是个关键细节。默认的nonzero规则在内环和外环绕向一致时会填满整个区域evenodd则不管绕向只要交叉次数是偶数就认为是洞。行政区划数据里经常有飞地和洞用evenodd最省心。纹理尺寸怎么定我的经验是按区域大小来算如果覆盖范围经度跨度在 60 度左右4096 的纹理大约是每度 68 个像素肉眼看已经足够跨度到 100 度以上就得考虑 8192但 8192 在移动端很可能超过纹理尺寸上限得做降级。可以先在启动时查一下gl.getParameter(gl.MAX_TEXTURE_SIZE)拿不到理想值就往下退一档。2.3 投影选错贴图就和底图对不上这里有个非常容易翻车的点canvas 用什么投影纹理坐标就得用什么投影。上面那段代码用的是等距圆柱也就是经纬度直接线性映射。这在自绘纹理时没问题但如果你想让贴图内容和底图瓦片对齐比如纹理是从某张影像裁出来的就必须用 Web 墨卡托因为影像瓦片是墨卡托的。在墨卡托下y 方向的映射不是线性的const R 6378137; function lonLatToMercator(lon, lat) { const x lon * Math.PI / 180 * R; // 纬度限制在 ±85.05112878 度超出部分墨卡托无法表示 const clampedLat Math.max(Math.min(lat, 85.05112878), -85.05112878); const y Math.log(Math.tan((90 clampedLat) * Math.PI / 360)) * R; return [x, y]; }画纹理和算 UV 的时候两边都用这个函数贴图才能和底图严格吻合。我见过一个案例纹理是墨卡托画的UV 却按等距圆柱算的结果在高纬度区域偏移了十几公里区块内的色块跑到了区外。还有个细节纹理采样默认是线性插值边界处如果纹理边缘是透明的采到边界外会变暗。解决办法是在 canvas 绘制时给最外圈留 1 像素的 padding或者把纹理的 wrap 模式设成CLAMP_TO_EDGE并保证边缘像素已经是实色。3. Entity 路线能走多远ImageMaterialProperty 的实测边界3.1 最小可运行示例先把最简单的方案跑通这样你心里有个基准线后面往复杂方案走的时候知道自己在优化什么。const viewer new Cesium.Viewer(cesiumContainer, { animation: false, timeline: false, baseLayerPicker: false, infoBox: false }); const positions Cesium.Cartesian3.fromDegreesArray(flatCoords); const entity viewer.entities.add({ polygon: { hierarchy: positions, height: 8000, extrudedHeight: 0, material: new Cesium.ImageMaterialProperty({ image: textureUrl, transparent: true, color: Cesium.Color.WHITE, repeat: new Cesium.Cartesian2(1, 1) }), outline: true, outlineColor: Cesium.Color.WHITE.withAlpha(0.7) } });几个参数需要说清楚因为这几个地方特别容易搞反height是顶面的高度extrudedHeight是底面的高度。很多人第一次写的时候以为extrudedHeight是拉伸量于是写了个extrudedHeight: 8000配上不写height结果柱子从 0 拉伸到 8000方向反了或者干脆看不见。记住一句话height是头extrudedHeight是脚头必须比脚高。repeat在这里实际效果很有限因为它是对整张纹理矩阵生效的顶面和侧面一起变。设成 2 的时候顶面会变成 2×2 平铺侧面也会跟着变但侧面本来就被压扁了平铺之后更糊。还有一个隐藏问题outline: true在部分显卡驱动或者浏览器上不生效官方文档里也有提及。如果发现轮廓线没出来别怀疑代码直接换成 polyline 方案后面第 6 章会讲具体写法。3.2 侧面被压缩的真实原因我把 Cesium 的PolygonGeometry生成逻辑翻了一遍确认了纹理坐标的生成规则。对于拉伸多边形它会把顶点分成两组顶盖顶点和侧壁顶点。顶盖顶点的 st 是这样算的取多边形的外接矩形经度线性映射到 s∈[0,1]纬度线性映射到 t∈[0,1]。所以如果一个多边形又长又窄比如东西 20 公里、南北 2 公里那纹理在南北方向就会被压缩十倍看起来像被捏扁了。侧壁顶点的 st 是另一套s 沿着多边形边界从起点开始累积弧长除以总周长归一化到 [0,1]t 是从底到顶归一化到 [0,1]。所以侧壁上整张纹理被拉伸到一圈周长 × 整个高度的范围内。如果你的纹理是一张区域图那侧面上看到的就是这张图被极限压扁又拉长之后的结果基本等于噪点。理解了这一点解决方案就呼之欲出了要么让顶面和侧面用不同的纹理坐标要么让它们用不同的纹理。前者需要重写 st后者需要拆几何。3.3 什么时候可以止步于此说句实在话如果你的项目满足以下几个条件上面这段 Entity 代码就够了不需要往下折腾用户基本只从俯视或者 45 度角看区块数量在几十个以内侧面的视觉效果不重要甚至可以不显示侧面贴图是一张抽象图案而不是真实地理内容。我做过一个园区展示的需求用户只要看到地块从地面凸起来表面有点颜色区分侧面根本没人看。这种情况下用ImageMaterialProperty配一个透明度渐变的纯色纹理五分钟就能交付。不要因为技术上能做到更复杂就假设需求一定需要更复杂。一旦出现侧面要能看到竖向纹理相邻区块花纹要接上低角度浏览时不能穿帮这三个信号中的任何一个就得切到 Primitive 路线了。4. 重写纹理坐标把顶面的 st 从包围盒映射换成全局映射4.1 在 createGeometry 之后动手脚Cesium 提供了PolygonGeometry.createGeometry这个静态方法能直接把参数转成Geometry对象里面的attributes就是裸的Float32Array。拿到这个对象之后我们可以直接改attributes.st.values里的数值再交给Primitive使用。这条路子的好处是不用碰 shader 就能改变顶面的采样方式。核心思路是把每个顶面顶点的经纬度算出来然后用全局包围盒而不是这个多边形自己的包围盒来做归一化。这样所有多边形都会在同一张全局纹理上采样相邻区块自然就对上了。function rewriteTopST(geometry, globalBbox, instanceModelMatrix) { const posArr geometry.attributes.position.values; const norArr geometry.attributes.normal.values; const stArr geometry.attributes.st.values; const { minLon, minLat, maxLon, maxLat } globalBbox; const lonSpan maxLon - minLon; const latSpan maxLat - minLat; const cartesian new Cesium.Cartesian3(); const carto new Cesium.Cartographic(); for (let i 0, v 0; i posArr.length; i 3, v 2) { // 法线朝上 顶盖顶点侧壁法线基本水平 if (norArr[i 2] 0.5) continue; cartesian.x posArr[i]; cartesian.y posArr[i 1]; cartesian.z posArr[i 2]; // 如果实例挂了 modelMatrix需要先变换到世界坐标 if (instanceModelMatrix) { Cesium.Matrix4.multiplyByPoint(instanceModelMatrix, cartesian, cartesian); } Cesium.Cartographic.fromCartesian(cartesian, Cesium.Ellipsoid.WGS84, carto); const lon Cesium.Math.toDegrees(carto.longitude); const lat Cesium.Math.toDegrees(carto.latitude); stArr[v] (lon - minLon) / lonSpan; stArr[v 1] (lat - minLat) / latSpan; } }这里有几个地方值得展开。normal.z 0.5这个判定能成立是因为PolygonGeometry生成的顶点坐标本质上是地心地固坐标ECEF下的而顶盖的三角形法线方向就指向上方。判定顶盖和侧壁就靠这个。反过来如果要改侧壁的 st就判断normal.z 0.5。createGeometry出来的position已经是 ECEF 坐标了不需要再做矩阵变换除非你在GeometryInstance上挂了自定义的modelMatrix。这一点容易搞混因为我见过有人习惯性地给GeometryInstance挂一个Transforms.eastNorthUpToFixedFrame的矩阵然后忘了坐标已经被变换过一次结果算出来的经纬度全是错的。还有一点直接修改Float32Array里的值是可以的因为此时Geometry还没被上传到 GPUPrimitive构造的时候会读取当前的值。但如果你用的是asynchronous: true的 Primitive 并且几何是异步生成的那修改时机就不好把握了。稳妥做法是先同步createGeometry改完 st再构造 Primitive。4.2 顶点到经纬度的反算与精度问题上面这段代码每个顶点要调一次Cartographic.fromCartesian这是相对重的运算。几千个多边形、每个几百个顶点的话总调用次数可能到百万级在页面初始化时会卡顿几百毫秒甚至更久。优化思路是不要逐个顶点做精确反算用局部近似。因为一个行政区域的经纬度跨度通常不大可以取多边形的中心点作为参考然后用 ENU 局部坐标系做线性近似