做三维可视化项目的时候总会碰到一类需求往 ArcGIS JSAPI 场景里放一堆 box 立方体每个立方体六个面还要按方位或业务属性显示不同颜色。比如我做楼层设备体块时朝南的立面要标红、朝北的标蓝、顶面标黄甲方要一眼看出设备朝向和管线走向。默认情况下MeshSymbol3D和FillSymbol3DLayer是“一个 Graphic 一种颜色”直接用Mesh.createBox()生成的正方体只能整体上色用 Polygon 拉伸又只能得到一个白模想逐面控制颜色就得另想办法。于是我转向最底层的方式——自己构造 Mesh 的顶点、索引和 UV把每个 box 拆成六个独立几何体再给每个面附上独立颜色。这篇文章把我从踩坑到落地的完整过程写下来内容包括顶点和索引的组织方式、UV 的语义、批量生成多个 box 时的位置处理以及当 box 数量涨到几百上千时怎么优化。适合已经会用 SceneView 和 GraphicsLayer、但对自定义三维几何体还比较陌生的同学照着代码可以一步步跑出结果。1. 为什么内置 Box 满足不了“每面不同颜色”从需求到 Mesh 结构1.1 我在做的项目里到底遇到了什么需求最直白的版本是场景中有很多栋建筑轮廓每栋楼按楼层切分成若干体块体块表面要根据属性上色。其中一部分体块要求“同一个长方体六个面各不相同”比如东面代表电力系统、西面代表给排水、顶面代表消防通道。这在 3D 建模软件里很容易但到了 ArcGIS JSAPI 里就被符号系统卡住了。最初我的做法是给每个面单独画一个 Polygon 再拉伸等于把同一个 box 做成六个单独要素。问题很快出现因为每个 Polygon 都是地面上的独立几何拉伸出来的侧面会和相邻面产生缝隙、重叠而且处理每个面的外法线方向很费劲。后来换成了Mesh.createBox()几何上确实是一个完整立方体可它只能整体给一个颜色压根没法把材质拆开。折腾一圈最后老老实实回到手工构建Mesh这条路上。1.2 Mesh 几何体的最小认知顶点、索引、UVArcGIS JSAPI 里的esri/geometry/Mesh其实和 WebGL 里的几何体结构同构核心就三样东西顶点vertexAttributes.position一个扁平数组每三个数字组成一个三维坐标[x, y, z]。索引faces一个数组每三个数字组成一个三角形数字是顶点在 position 数组中的下标。UVvertexAttributes.uv一个扁平数组每两个数字组成一个纹理坐标范围通常在 0 到 1 之间。顶点就是毛线稿上的点位索引告诉渲染器哪些点连成三角形UV 则是贴纸应该贴在哪个区域。这三个概念和强烈推荐搞清楚的“顶点缓冲、索引缓冲、纹理坐标”就是同义词。这里顺带说一句本文说的“索引”指的是 3D 几何体里的三角形索引和数据库索引完全是两码事别混了。如果你不传法线normal渲染器通常会基于三角形绕序自动生成默认法线光照效果够用。但如果某个面在转视角时出现异常黑脸就要考虑手动把法线也写进去。1.3 一个 Box 为什么必须拆成六个 Mesh原因很简单ArcGIS JSAPI 的FillSymbol3DLayer的material.color是几何体级别的一个 Graphic 只能有一种主色。想让 6 个面有 6 种颜色就必须让这 6 个面成为 6 个独立几何体各自带一个 Graphic。从几何角度看一个长方体由 6 个四边形组成每个四边形在渲染时拆成两个三角形所以整个 box 由 12 个三角形构成。如果只建一个Mesh它内部当然可以包含 12 个三角形但颜色仍然只能是一个。所以“拆”这件事不只是为了程序结构清晰而是直接由 JSAPI 的符号机制决定的。拆的时候还要注意每个面必须拥有自己独立的 4 个顶点不能和相邻面共用顶点。因为两个相邻面的法线方向不同、UV 也不同共用顶点会导致这些属性互相污染。这也是下面要重点讲的 24 个顶点的由来。2. 手工造一个 Box24 个顶点、12 个三角形和每个面的 UV2.1 为什么需要 24 个顶点而不是 8 个一个立方体在数学上只有 8 个角点但渲染时如果只用 8 个顶点每个角点只能存一份法线和一份 UV。问题就来了同一个角点同时属于三个面三个面的法线分别是 X、Y、Z 方向一个顶点不可能同时代表三个法线。UV 也一样同一个角点在“前面”的贴图位置和在“顶面”的贴图位置肯定不同。所以建模软件里一律把一个盒子展开为“6 个四边形、每个四边形 4 个顶点”总共 24 个顶点、36 个索引值。这就是标题里“顶点、索引、UV 自定义”的真正含义不是画几何体而是像拼接模型一样把每个面独立定义出来。2.2 面的定义顺序与绕序我建议的坐标系是X 朝东、Y 朝北、Z 朝上。每个面用 4 个相对中心点的偏移量定义单位是半个边长。假设我们要做的是以原点为中心、边长为 1 的 box那么六个面可以这样定义面四个角偏移x, y, zUV参考颜色前 Z(-0.5,-0.5,0.5) (0.5,-0.5,0.5) (0.5,0.5,0.5) (-0.5,0.5,0.5)(0,0)(1,0)(1,1)(0,1)红后 -Z(0.5,-0.5,-0.5) (-0.5,-0.5,-0.5) (-0.5,0.5,-0.5) (0.5,0.5,-0.5)(0,0)(1,0)(1,1)(0,1)青右 X(0.5,-0.5,0.5) (0.5,-0.5,-0.5) (0.5,0.5,-0.5) (0.5,0.5,0.5)(0,0)(1,0)(1,1)(0,1)蓝左 -X(-0.5,-0.5,-0.5) (-0.5,-0.5,0.5) (-0.5,0.5,0.5) (-0.5,0.5,-0.5)(0,0)(1,0)(1,1)(0,1)紫顶 Y(-0.5,0.5,0.5) (0.5,0.5,0.5) (0.5,0.5,-0.5) (-0.5,0.5,-0.5)(0,0)(1,0)(1,1)(0,1)黄底 -Y(-0.5,-0.5,-0.5) (0.5,-0.5,-0.5) (0.5,-0.5,0.5) (-0.5,-0.5,0.5)(0,0)(1,0)(1,1)(0,1)绿这里每个面都是沿“从面外看向面内”的方向按逆时针排列的。如果某个三角形绕序反了法线就会指向内部视觉上就是那个面发黑。后面我会专门讲怎么排查这种问题。2.3 从坐标到 Mesh 对象的完整代码先定义面表const FACE_DEFS [ { name: front, color: #e64c4c, offsets: [ [-0.5, -0.5, 0.5], [ 0.5, -0.5, 0.5], [ 0.5, 0.5, 0.5], [-0.5, 0.5, 0.5] ], uv: [[0,0],[1,0],[1,1],[0,1]] }, { name: back, color: #4bc0c0, offsets: [ [ 0.5, -0.5, -0.5], [-0.5, -0.5, -0.5], [-0.5, 0.5, -0.5], [ 0.5, 0.5, -0.5] ], uv: [[0,0],[1,0],[1,1],[0,1]] }, { name: right, color: #4898e0, offsets: [ [ 0.5, -0.5, 0.5], [ 0.5, -0.5, -0.5], [ 0.5, 0.5, -0.5], [ 0.5, 0.5, 0.5] ], uv: [[0,0],[1,0],[1,1],[0,1]] }, { name: left, color: #9848e0, offsets: [ [-0.5, -0.5, -0.5], [-0.5, -0.5, 0.5], [-0.5, 0.5, 0.5], [-0.5, 0.5, -0.5] ], uv: [[0,0],[1,0],[1,1],[0,1]] }, { name: top, color: #f2c856, offsets: [ [-0.5, 0.5, 0.5], [ 0.5, 0.5, 0.5], [ 0.5, 0.5, -0.5], [-0.5, 0.5, -0.5] ], uv: [[0,0],[1,0],[1,1],[0,1]] }, { name: bottom, color: #66cc66, offsets: [ [-0.5, -0.5, -0.5], [ 0.5, -0.5, -0.5], [ 0.5, -0.5, 0.5], [-0.5, -0.5, 0.5] ], uv: [[0,0],[1,0],[1,1],[0,1]] } ];然后写一个生成单个面 Mesh 的方法function makeFaceMesh(faceDef, center, size, spatialReference) { const [cx, cy, cz] center; const positions []; const uvs []; faceDef.offsets.forEach((off) { positions.push( cx off[0] * size, cy off[1] * size, cz off[2] * size ); }); faceDef.uv.forEach((uv) { uvs.push(uv[0], uv[1]); }); return new Mesh({ spatialReference, vertexAttributes: { position: positions, uv: uvs // normal 可以省略JSAPI 会根据三角形绕序生成默认法线 }, faces: [0, 1, 2, 0, 2, 3] }); }positions每 3 个数字是一个顶点uvs每 2 个数字是一个纹理坐标faces每 3 个数字是一个三角形。这个结构就是 ArcGIS JSAPI 的Mesh所期望的。顶点数是 4索引是两组三角形正好一个四边形。3. 每面独立配色把每个面变成独立 Graphic3.1 方案对比单 Mesh 顶点色、拆分 Mesh、外部渲染我查资料的时候看到有人问能不能一个 Mesh 里把 6 个面的顶点全放进去然后给每个顶点写color属性这样不就一个 Graphic 满足六个颜色了吗理论上顶点颜色确实存在于Mesh.vertexAttributes.color中但 ArcGIS JSAPI 内置的FillSymbol3DLayer渲染管线不会把这个顶点颜色直接作为最终颜色输出。我自己试过结果是整块几何体仍然显示符号里设置的纯色。所以这条路在“只用内置符号”的前提下走不通。三个方案的对比方案能否满足需求实现成本备注单 Mesh 顶点色不行低顶点色不会被内置符号渲染拆成 6 个 Mesh每个 Mesh 一个 Graphic能低推荐本文采用ExternalRenderers自定义 WebGL 渲染能高适合终极性能优化后文提结论很明确直接用拆分法一个 box 对应 6 个 Graphic。后面我会讲怎么优化数量。3.2 buildBoxFaces 与渲染示例把生成单个面的逻辑封装成生成一整套 box 面的函数function buildBoxFaces(center, size, spatialReference, rotation 0) { return FACE_DEFS.map((faceDef) { const mesh makeFaceMesh(faceDef, center, size, spatialReference); return new Graphic({ geometry: mesh, attributes: { face: faceDef.name }, symbol: { type: mesh-3d, symbolLayers: [ { type: fill, material: { color: faceDef.color } } ] } }); }); }这里最重要的一点symbol是普通对象JSAPI 会自动将其转成MeshSymbol3D和FillSymbol3DLayer。geometry是我们手工构建的Mesh。一个 box 就变成了 6 个 Graphic。放到场景里的完整代码const view new SceneView({ container: viewDiv, map: map, spatialReference: { wkid: 3857 }, zoom: 17, center: [120.15, 30.28] }); const layer new GraphicsLayer(); map.add(layer); const sr new SpatialReference({ wkid: 3857 }); // 这里的 [0, 0, 0] 在 3857 坐标系下是经纬度 0,0 的位置 const faceGraphics buildBoxFaces([0, 0, 0], 200, sr); layer.addMany(faceGraphics);运行后把相机飞到[0, 0]附近就能看到一个六个面颜色完全不同的立方体。注意center的 z 值是高程单位是米我这里先设 0表示贴在地表。3.3 如何验证六个面的朝向和颜色刚写完代码最容易出现的问题某个面看不见、某个面在特定角度是黑色或者颜色和方位对不上。我的验证办法是先把六个面的颜色调成极度饱和的红、青、蓝、紫、黄、绿然后手动旋转场景逐个方向确认。如果发现某个面发黑优先怀疑三角形绕序不对。这时候把该面的faces从[0, 1, 2, 0, 2, 3]改成[0, 2, 1, 0, 3, 2]就是调整两个三角形的顶点顺序反向之后法线就会翻转。这个排查在写复杂几何体时会频繁遇到建议建一个专门的测试页面只放一个 box其它要素全隐藏确认没问题再往下做。4. 批量放置多个 box坐标投影与矩阵变换4.1 中心点坐标与空间参考的关系单个 box 跑通之后马上要面对“多个 box”。批量放置最大的坑是坐标系SceneView 默认用 Web Mercator3857几何体坐标是投影后的平面坐标而不是经纬度。你如果直接把经纬度[120.15, 30.28]塞进center然后给 Mesh 设置 3857相当于把一个 120 多度的数字当成投影坐标box 会飞到大洋深处或者干脆因为数值异常而消失。新手最容易忽略的就是这里。我的原则是所有几何体统一使用 3857 坐标系经纬度在手算前先转成投影坐标这样底图、高程、几何体都在一个坐标系下工作。4.2 一个简易经纬度转 Web Mercator 辅助函数网上有很多 Web Mercator 投影公式对放置 box 来说不需要非常精确球体近似就够了function lonLatToWebMercator(lon, lat, z 0) { const x (lon * 20037508.34) / 180; let y Math.log(Math.tan((90 lat) * Math.PI / 360)) / (Math.PI / 180); y (y * 20037508.34) / 180; return [x, y, z]; }这样得到的坐标单位是米box 的size也用米两者可以直接叠加。例如一个直径 40 米的体块中心在杭州那么生成代码就是const data [ { lon: 120.15, lat: 30.28, z: 50, size: 40 }, { lon: 120.16, lat: 30.29, z: 80, size: 60 }, { lon: 120.17, lat: 30.30, z: 20, size: 30 } ]; const allGraphics []; data.forEach((item) { const center lonLatToWebMercator(item.lon, item.lat, item.z); const graphics buildBoxFaces(center, item.size, sr); allGraphics.push(...graphics); }); layer.addMany(allGraphics);这就能在真实地理位置上生成多个 box 了。注意z表示底部高程还是中心高程需要自己在业务里定清楚我的代码里是中心点高程。4.3 旋转和缩放的处理思路如果某些 box 不是正南正北朝向比如管廊节点需要偏转 30 度可以在生成顶点前对每个面的局部坐标做旋转变换。最简单的是只绕 Z 轴旋转function rotateZ(point, angleDeg) { const rad (angleDeg * Math.PI) / 180; const cos Math.cos(rad); const sin Math.sin(rad); return [ point[0] * cos - point[1] * sin, point[0] * sin point[1] * cos, point[2] ]; }然后在makeFaceMesh里对每个off先做rotateZ再乘size最后加中心点。这样每个 box 可以独立旋转而不用改动任何面表定义。缩放其实已经通过size参数实现了。如果你想做非等比缩放也就是长宽高不一样可以在面表里拆成width, height, depth三个参数分别乘以对应轴的分量。这个改造很简单你只要把off[0] * size改成off[0] * widthoff[1] * size改成off[1] * heightoff[2] * size改成off[2] * depth即可。5. 当 box 数量上涨时从 6N 个 Graphic 到按颜色合并5.1 先把慢在哪里判断清楚我一开始觉得一个 box 才 24 个顶点几千个也不算什么。实际加到 300 个 box 后旋转视图明显掉帧。原因不是顶点太多而是 draw call 太多每个 Graphic 在渲染时都是一个独立的绘制单位300 个 box 等于 1800 个 Graphic浏览器要在每一帧里提交 1800 次绘制调用性能自然成问题。判断方法很简单打开浏览器开发者工具的 Performance 面板录制一段相机旋转看 Rendering 时间占比。如果单个顶点数只有几万、但 Graphic 数量上千那么瓶颈几乎一定在 draw call而不是几何复杂度。5.2 按颜色分组合并 Mesh 的完整实现性能优化的第一招是合并相同颜色的面。因为很多业务的颜色是按方位固定的所有 box 的前面都是红色、顶面都是黄色。那就没必要给每个 box 建 6 个 Graphic而是把所有红色面合并成一个Mesh所有黄色面合并成一个Mesh。这样 300 个 box 从 1800 个 Graphic 降到 6 个 Graphic性能提升非常明显。先写网格合并函数function mergeMeshes(meshes) { if (!meshes.length) return null; const sr meshes[0].spatialReference; const positions []; const uvs []; const faces []; let vertexOffset 0; meshes.forEach((mesh) { const attr mesh.vertexAttributes; positions.push(...attr.position); if (attr.uv) { uvs.push(...attr.uv); } Array.from(mesh.faces).forEach((index) { faces.push(index vertexOffset); }); vertexOffset attr.position.length / 3; }); return new Mesh({ spatialReference: sr, vertexAttributes: { position: positions, ...(uvs.length ? { uv: uvs } : {}) }, faces: faces }); }核心逻辑就是先把所有position拼接起来然后每个Mesh的faces里的索引都要加上自己顶点数组的起始偏移量。比如第一个面有 4 个顶点第二个面的索引 0 实际对应整体数组的第 4 个顶点所以偏移 4。然后按颜色分组const colorGroups new Map(); data.forEach((item) { const center lonLatToWebMercator(item.lon, item.lat, item.z); const graphics buildBoxFaces(center, item.size, sr); graphics.forEach((graphic) { // 这里从 attributes 里取 face 名再从 FACE_DEFS 里取颜色 const faceName graphic.attributes.face; const faceDef FACE_DEFS.find((f) f.name faceName); if (!colorGroups.has(faceDef.color)) { colorGroups.set(faceDef.color, []); } colorGroups.get(faceDef.color).push(graphic.geometry); }); }); const mergedGraphics []; colorGroups.forEach((meshes, color) { const mergedMesh mergeMeshes(meshes); mergedGraphics.push( new Graphic({ geometry: mergedMesh, symbol: { type: mesh-3d, symbolLayers: [ { type: fill, material: { color } } ] } }) ); }); layer.removeAll(); layer.addMany(mergedGraphics);这样整个图层只有 6 个 Graphic每个 Graphic 的几何体里可能包含几百个三角形但 draw call 数量非常低。合并之后旋转视图通常能回到 60 帧。5.3 再往上走的两种选择如果每个 box 的每个面颜色都完全独立没有可合并的颜色Graphic 数量还是会跟 box 数成正比。这时候我通常会考虑两种方向。第一种把 box 几何体导出为 glTF/GLB 模型然后用模型的材质贴图去表达颜色比如一个 6 面的图集纹理每面对应纹理的一个区域。这种方式又回到了 UV 的用武之地纹理的显存开销远比海量 Graphic 小。第二种用ExternalRenderers接管部分渲染逻辑自己把顶点缓冲和索引缓冲一次性传给 WebGL通过批量实例化绘制所有 box。这个方案上限最高几千上万个 box 都没问题但要自己处理相机矩阵、光照、拾取这些事适合已经对 WebGL 很熟的人。对多数项目来说按颜色合并已经能解决 80% 的性能问题我建议先把这步做扎实不要一上来就上 WebGL。6. 我踩过的一些坑和给后来者的建议6.1 空间参考不一致箱子直接消失最早我图省事new Mesh()时不传spatialReference或者传了{ wkid: 4326 }但顶点坐标是 3857 的数值结果就是场景里要么看不到东西要么看到几个色块在坐标原点附近闪。后来我强制所有 Mesh 和 Graphic 都统一使用 3857并为每个 box 使用同一个SpatialReference实例问题就消失了。经验是Mesh的spatialReference必须和顶点坐标的实际坐标系一致还要和 SceneView 的世界坐标系一致。在代码里不要偷懒跳过去显式写出来排查问题时会少很多干扰项。6.2 绕序写反一半面是黑的这个坑我几乎每次手写几何体都会踩一次。JSAPI 在生成默认法线时会根据三角形绕序判断法线朝里还是朝外。如果你把faces写成了顺时针法线就会指向 box 内部从外部看过去就是黑的。光照越强的场景越明显天黑了反而不容易发现。排查方法刚才已经说了加一个单 box 测试页用饱和颜色逐个方向检查。如果确定是绕序问题把每个三角形的第 2、3 个索引交换位置即可。你可以在FACE_DEFS上做一个reverse开关用来快速翻转绕序。6.3 UV 不是给 FillSymbol3DLayer 用的标题里特意写到了 UV但 ArcGIS JSAPI 内置的FillSymbol3DLayer目前确实不会把 UV 渲染成纹理贴图它只取material.color。我在早期项目里加了 UV以为能自动贴图结果发现纹理毫无反应。这不代表 UV 没用当你把手写 Mesh 导出成 glTF、或者接入ExternalRenderers、或者交给支持顶点纹理的渲染管线时UV 就会被读取。所以我的建议是从一开始就规范地把 UV 写进vertexAttributes当成一项标准资产保存不用管当前符号系统用不用。数据结构完整了后续扩展路径会顺很多。6.4 性能监控与工程化提醒最后提醒几件容易被忽略的事批量添加 Graphic 时尽量用addMany而不是循环add能少触发视图刷新当 box 数据是动态变化时不要每个属性变化就removeAll再全部重建那会引发整图层刷新。更聪明的做法是生成每个面的 Graphic 时在attributes里记录业务 ID 和面方向之后用view.hitTest命中到具体 Graphic直接改它的symbol或者只重建那一小部分几何体。我的一个实用技巧是把“每种颜色”做成一个状态对象比如colorMap { front: #e64c4c, top: #f2c856 }。当需求变更时只要改colorMap再重新调用一次分组合并函数就能批量更新所有面颜色而不用碰任何几何坐标。这在项目迭代里非常省心。这套“手写顶点、索引、UV”的能力最初只是为了在项目里画几个彩色体块结果成了我理解 ArcGIS JSAPI 几何模型的一个入口。真正手写过一轮之后再去读 glTF 结构、看模型导入导出工具、处理 WebGL 自定义渲染都会顺畅很多。最后分享一个很小的经验如果只是想让某个盒子的顶面在鼠标悬停时高亮可以在生成每个面时给 Graphic 加attributes: { boxId, face }然后监听view.hitTest找到对应面直接替换那个面的符号这样既不用重算坐标也不会影响其它几千个 box 的性能。