简介:这是一份面向GIS开发与Web地图可视化方向的前端工程师、三维可视化学习者的实战示例,聚焦于在Mapbox地图上加载并展示3DTiles倾斜摄影模型这一常见需求。资源通过结合Mapbox GL JS与deck.gl的CesiumIon3DTileLayer,演示了如何将Cesium定义的3DTiles数据接入Mapbox,实现按需分块加载、降低内存占用并提升页面响应速度,适用于城市规划、房地产展示、环境监测等三维场景。压缩包共5个文件,约81KB,包含2个json依赖与配置文件、2个js脚本和1个html入口,分别承担依赖管理、地图初始化与3DTiles加载逻辑、页面结构搭建等职责,结构精简便于快速理解与二次开发。目前已有4898人学习下载,读者可从中掌握地图初始化、deck.gl视口设置、3DTiles数据源指定及鼠标交互查询等关键实现思路,为后续性能优化与GIS功能扩展提供可复用的参考。
1. mapbox加载3dtiles倾斜摄影模型demo:为什么你的模型总是飘在半空
做过三维 GIS 的同行大概率都遇到过这个场景:手里拿到一份倾斜摄影导出的 3dtiles 数据,满心欢喜想往 mapbox 里塞,结果模型要么悬在半空,要么沉到地下,要么干脆整个白模一片,连纹理都加载不出来。mapbox加载3dtiles倾斜摄影模型demo 这个方向,本质上解决的就是「把 OGC 的 3dtiles 瓦片规范,翻译成 mapbox 能理解的渲染指令」这件事。
它适合两类人:一类是手里已经有倾斜摄影成果(OSGB 转出来的 3dtiles),需要在 Web 端做轻量化展示的测绘或智慧城市从业者;另一类是前端出身,被要求「把这个模型放到地图上」但完全没接触过三维瓦片规范的开发者。前者关心坐标和精度,后者关心怎么跑起来。这篇笔记两条线都覆盖,从最小可运行 demo 讲到参数调优和踩坑,读完你应该能自己搭出一个能用的加载器,而不是只会复制一段看不懂的代码。
2. 先搞清楚 3dtiles 和 mapbox 的坐标系怎么对上
2.1 3dtiles 的瓦片结构和 mapbox 的图层模型差在哪
3dtiles 是流式加载的三维瓦片格式,核心文件是tileset.json,它描述了一棵瓦片树:根节点有一个包围盒(boundingVolume)、一个几何误差(geometricError),下面挂着一堆子瓦片,每个子瓦片指向一个.b3dm(Batched 3D Model)或.pnts(点云)文件。倾斜摄影转出来的基本都是 b3dm,里面打包了 glTF 几何和纹理。
mapbox 这边,无论是 mapbox-gl-js 还是 maplibre,图层模型是二维的:source 提供数据,layer 负责渲染。它原生不认识 3dtiles,所以必须有一个中间层,把 3dtiles 的瓦片请求、坐标变换、模型矩阵算好,再以自定义图层(CustomLayer)或者 three.js 的 mesh 形式塞进去。常见做法是用 three.js 做渲染后端,mapbox 只负责底图和相机同步。
这里第一个关键点:3dtiles 用的是地心直角坐标系(ECEF,EPSG:4978),而 mapbox 的相机和场景是墨卡托投影下的平面坐标。两者之间必须做一次矩阵变换,否则模型必然飘。
2.2 坐标变换的三个矩阵:ENU、ECEF 和墨卡托
要把一个 b3dm 里的顶点放到 mapbox 场景里,需要串起三个变换:
第一个是模型自身的局部变换,b3dm 的 feature table 里有一个RTC_CENTER,表示模型顶点相对于某个中心点的偏移,先要把顶点加回这个中心。
第二个是瓦片到 ECEF 的变换,tileset.json的根节点transform是一个 4x4 矩阵,把瓦片局部坐标转到 ECEF。如果没有这个矩阵,就要用boundingVolume.region里的经纬度高程自己算 ENU(东-北-天)坐标系到 ECEF 的旋转矩阵。
第三个是 ECEF 到 mapbox 墨卡托坐标的变换。mapbox 提供了一个MercatorCoordinate.fromLngLat方法,可以把经纬度转成 0 到 1 的墨卡托坐标,再乘以worldSize(通常是 512 的若干倍)得到像素级坐标。
// 把 ECEF 坐标转成 mapbox 墨卡托坐标的核心逻辑 import { MercatorCoordinate } from 'mapbox-gl'; // WGS84 椭球参数 const a = 6378137.0; const f = 1 / 298.257223563; const e2 = 2 * f - f * f; // ECEF 转经纬度高程 function ecefToLngLatAlt(x, y, z) { const lon = Math.atan2(y, x); const p = Math.sqrt(x * x + y * y); let lat = Math.atan2(z, p * (1 - e2)); let alt = 0; // 迭代三次足够收敛 for (let i = 0; i < 3; i++) { const sinLat = Math.sin(lat); const N = a / Math.sqrt(1 - e2 * sinLat * sinLat); alt = p / Math.cos(lat) - N; lat = Math.atan2(z, p * (1 - e2 * N / (N + alt))); } return { lng: lon * 180 / Math.PI, lat: lat * 180 / Math.PI, alt }; } // 再转墨卡托 function ecefToMercator(x, y, z) { const { lng, lat, alt } = ecefToLngLatAlt(x, y, z); const mc = MercatorCoordinate.fromLngLat({ lng, lat }, alt); return mc; }这段代码的逻辑是:先把 ECEF 反算成经纬度和海拔,再用 mapbox 自带的MercatorCoordinate.fromLngLat转墨卡托。参数上,alt一定要带上,否则模型会贴地或者悬空。迭代次数三次是经验值,再多收益很小。
提示:如果你的 tileset 根节点有
transform矩阵,优先用矩阵直接变换,比走 region 反算更准,也更快。
2.3 为什么直接用 three.js 的 GLTFLoader 加载 b3dm 会失败
很多人第一反应是:b3dm 里面不就是 glTF 吗,那我用 GLTFLoader 直接读不就行了。翻车点就在这里。b3dm 的文件头有 28 个字节的 header,然后是 feature table 和 batch table,glTF 的二进制块藏在后面。GLTFLoader 不认识这个头,直接解析会报格式错误。
正确做法是先剥掉 b3dm 的头部,提取出 glTF 的 JSON 和二进制 buffer,再交给 GLTFLoader。常见做法是写一个parseB3dm函数,读 header 里的byteLength字段,定位到 glTF 起始位置。
// 剥离 b3dm 头部,返回 glTF 的 ArrayBuffer function extractGltfFromB3dm(arrayBuffer) { const dataView = new DataView(arrayBuffer); // b3dm header 是 28 字节,magic 是 'b3dm' const magic = String.fromCharCode( dataView.getUint8(0), dataView.getUint8(1), dataView.getUint8(2), dataView.getUint8(3) ); if (magic !== 'b3dm') { throw new Error('不是合法的 b3dm 文件'); } // 第 12 字节开始是 featureTableJSONByteLength 等长度字段 const featureTableJSONByteLength = dataView.getUint32(12, true); const featureTableBinaryByteLength = dataView.getUint32(16, true); const batchTableJSONByteLength = dataView.getUint32(20, true); const batchTableBinaryByteLength = dataView.getUint32(24, true); const headerLength = 28 + featureTableJSONByteLength + featureTableBinaryByteLength + batchTableJSONByteLength + batchTableBinaryByteLength; // 剩下的就是 glTF return arrayBuffer.slice(headerLength); }参数说明:四个长度字段都是小端序(true表示 little-endian),这是 3dtiles 规范定的。headerLength算错一个字节,后面 glTF 解析就会崩。这个函数是加载流程里最容易出 bug 的地方,建议单独写单测。
3. 从零搭一个能跑的加载器:请求、解析、渲染三步
3.1 用 fetch 递归请求 tileset.json 和子瓦片
3dtiles 是懒加载的,不能一次性把所有 b3dm 拉下来。正确姿势是先请求tileset.json,解析出根瓦片,然后根据相机位置和geometricError决定加载哪些子瓦片。
// 递归加载瓦片树 async function loadTileset(url, cameraPosition) { const response = await fetch(url); const tileset = await response.json(); const root = tileset.root; // 计算根瓦片的屏幕空间误差 const sse = computeSSE(root, cameraPosition); // 误差大于阈值才继续往下加载 if (sse > MAX_SCREEN_SPACE_ERROR) { const children = root.children || []; for (const child of children) { // content.uri 指向 b3dm 文件 if (child.content && child.content.uri) { const b3dmUrl = new URL(child.content.uri, url).href; await loadB3dm(b3dmUrl); } // 递归 await loadTileset(url, cameraPosition); } } }逻辑说明:computeSSE是屏幕空间误差计算,核心是geometricError除以相机到瓦片包围盒中心的距离,再乘以一个屏幕高度系数。MAX_SCREEN_SPACE_ERROR一般设 16 到 32,值越小加载越精细,但请求数会暴涨。这个阈值是性能和画质的主要调节旋钮。
3.2 解析 b3dm 并转成 three.js 的 Mesh
拿到 b3dm 的 ArrayBuffer 后,剥头、解析 glTF、应用瓦片变换矩阵,最后生成 three.js 的 Mesh 加到场景里。
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'; const loader = new GLTFLoader(); async function loadB3dm(url, tileTransform) { const res = await fetch(url); const buffer = await res.arrayBuffer(); const gltfBuffer = extractGltfFromB3dm(buffer); return new Promise((resolve, reject) => { loader.parse(gltfBuffer, '', (gltf) => { const mesh = gltf.scene; // 应用瓦片的 ECEF 变换矩阵 if (tileTransform) { const matrix = new THREE.Matrix4().fromArray(tileTransform); mesh.applyMatrix4(matrix); } // 再转到 mapbox 墨卡托坐标系 applyMercatorTransform(mesh); scene.add(mesh); resolve(mesh); }, reject); }); }参数说明:tileTransform是tileset.json里每个瓦片可能带的transform数组,16 个元素按列主序排列。applyMercatorTransform里要做的是把 ECEF 坐标整体平移到 mapbox 原点附近,否则浮点精度会炸,模型会抖动。常见做法是选一个参考点,把所有顶点减去这个参考点的 ECEF 坐标,再统一变换。
3.3 把 three.js 场景和 mapbox 相机同步
最后一步是让 three.js 的相机跟着 mapbox 走。mapbox 的 CustomLayer 接口提供了render(gl, matrix)回调,这个 matrix 就是 mapbox 相机的投影矩阵,直接赋给 three.js 相机即可。
const customLayer = { id: '3dtiles-layer', type: 'custom', renderingMode: '3d', onAdd(map, gl) { this.camera = new THREE.Camera(); this.scene = new THREE.Scene(); this.renderer = new THREE.WebGLRenderer({ canvas: map.getCanvas(), context: gl }); this.renderer.autoClear = false; // 加载瓦片 loadTileset('https://your-server/tileset.json', map.getCenter()); }, render(gl, matrix) { // matrix 是 mapbox 的投影矩阵,直接给 three.js this.camera.projectionMatrix = new THREE.Matrix4().fromArray(matrix); this.renderer.resetState(); this.renderer.render(this.scene, this.camera); map.triggerRepaint(); } }; map.on('load', () => { map.addLayer(customLayer); });逻辑说明:matrix是 mapbox 算好的 MVP 矩阵,包含投影和视图变换。map.triggerRepaint()是必须的,否则 three.js 渲染完 mapbox 不会重绘,画面会卡住。renderingMode: '3d'让 mapbox 允许深度测试,不然模型会被底图盖住。
4. 参数调优:让模型不飘、不抖、不糊
4.1 geometricError 和 maximumScreenSpaceError 怎么设
这两个参数直接决定加载策略。geometricError在 tileset.json 里是每个瓦片自带的,表示这个瓦片简化了多少。maximumScreenSpaceError是你在客户端设的阈值,含义是「屏幕上误差超过多少像素才继续细分」。
经验值:城市级倾斜摄影,maximumScreenSpaceError设 16 比较均衡;如果只做远景展示,设 32 能省一半请求;要做单体化精细查看,设 8 甚至 4。设太小会导致瓦片请求爆炸,浏览器内存直接拉满。
4.2 模型飘在半空或沉到地下怎么排查
这是最高频的问题。排查顺序:
先看tileset.json根节点的transform矩阵是否存在且正确。如果矩阵是单位阵,说明数据导出时没做地理配准,需要手动补。
再看boundingVolume.region里的经纬度高程。region 是[west, south, east, north, minHeight, maxHeight],单位是弧度。如果 minHeight 和 maxHeight 都是 0,说明高程信息丢了,模型会贴地。
最后检查你的 ECEF 到墨卡托变换里,alt有没有正确传递。很多人只传了经纬度,忘了高程,模型就会沉到地心方向。
4.3 纹理模糊和闪烁的两个常见原因
纹理模糊通常是 mipmap 没开或者各向异性过滤等级太低。在 three.js 里给纹理设texture.anisotropy = renderer.capabilities.getMaxAnisotropy(),能明显改善斜视时的清晰度。
闪烁则多半是深度冲突(z-fighting)。倾斜摄影的瓦片之间会有重叠,如果深度测试精度不够,相邻瓦片就会交替闪烁。解决办法是给材质加polygonOffset,或者把renderer.sortObjects打开,让 three.js 按距离排序渲染。
5. 避坑记录:五个让我加班到凌晨的坑
5.1 坑一:模型加载出来是纯白,没有纹理
现象:b3dm 解析成功,mesh 也加进场景了,但模型是纯白色,纹理完全没显示。
原因:b3dm 里的 glTF 纹理是嵌入在二进制 buffer 里的,GLTFLoader 解析时需要正确的images和textures索引。如果剥头时headerLength算错,glTF 的 buffer 视图会偏移,纹理数据读出来就是乱的。
解决:在extractGltfFromB3dm里加日志,打印headerLength和实际 glTF 起始位置的字节,对比 3dtiles 规范里的字段定义逐个核对。另外确认 GLTFLoader 的parse第二个参数传的是空字符串,不是资源路径。
5.2 坑二:相机移动时模型剧烈抖动
现象:地图静止时模型正常,一旦拖动或缩放,模型就抖得像筛糠。
原因:浮点精度问题。ECEF 坐标的数值在百万级别,直接塞进 three.js 的 float32 矩阵会丢精度。
解决:选一个参考点(比如 tileset 根节点的中心),把所有顶点坐标减去这个参考点的 ECEF 坐标,让数值回到几百以内,再变换到墨卡托。这个操作叫「局部原点平移」,是三维渲染的标配。
5.3 坑三:瓦片请求数量爆炸,浏览器卡死
现象:打开页面后网络面板里几百个 b3dm 请求同时发出,页面直接无响应。
原因:maximumScreenSpaceError设得太小,或者递归加载时没有做视锥剔除,把相机看不到的瓦片也请求了。
解决:在loadTileset里加视锥判断,用THREE.Frustum检查瓦片包围盒是否在相机视野内。另外给请求加并发限制,比如同时最多 6 个,用队列控制。
5.4 坑四:模型和底图对不上,偏移几百米
现象:模型位置和地图上的实际地物完全错位。
原因:坐标系搞混了。3dtiles 可能是 CGCS2000 或者地方坐标系,而 mapbox 用的是 WGS84 墨卡托。如果数据导出时没做坐标转换,直接按 WGS84 加载就会偏。
解决:确认 tileset.json 里的asset.gltfUpAxis和asset.version,以及根节点的transform是否包含坐标系转换。如果数据是地方坐标系,需要先用 proj4 之类的库转到 WGS84 再加载。
5.5 坑五:内存持续增长,跑十分钟就崩
现象:页面开着不动,内存曲线一路往上,最后浏览器标签页崩溃。
原因:瓦片卸载逻辑没写。相机移走后,之前加载的 mesh 还挂在场景里,纹理也没释放。
解决:给每个瓦片记录一个「最后可见时间」,定时器扫描,超过阈值(比如 30 秒)没被相机看到的,从场景移除并调用geometry.dispose()和texture.dispose()。这一步不做,长时间运行必崩。
6. 进阶技巧:用 LOD 分级和实例化把性能再压一档
前面跑通之后,如果瓦片数量上到几千,性能还是会吃紧。这时候有两个方向可以继续压:一是 LOD 分级加载,二是实例化渲染。
LOD 分级的核心思路是:根据相机距离,把瓦片分成近、中、远三档,近处加载原始 b3dm,中处加载简化过的低模,远处只加载包围盒或者干脆不加载。3dtiles 规范本身支持refine字段,ADD表示替换,REPLACE表示叠加。倾斜摄影一般用REPLACE,父瓦片被替换掉后要及时卸载。
实例化渲染针对的是同一瓦片内大量重复的几何体,比如路灯、树木。如果 b3dm 里带了 batch table,可以用InstancedMesh把相同几何体的实例合并成一次 draw call。实测在树木密集的场景,draw call 能从几千降到几十。
// 用 InstancedMesh 合并相同几何体 function buildInstancedMesh(geometry, material, matrices) { const mesh = new THREE.InstancedMesh(geometry, material, matrices.length); const matrix = new THREE.Matrix4(); matrices.forEach((m, i) => { matrix.fromArray(m); mesh.setMatrixAt(i, matrix); }); mesh.instanceMatrix.needsUpdate = true; return mesh; }参数说明:matrices是每个实例的 4x4 变换矩阵数组,按列主序。instanceMatrix.needsUpdate = true必须设,否则 GPU 不会更新。这个技巧对静态地物效果最好,动态物体不适用。
验证性能有没有提升,不要靠感觉。打开 Chrome 的 Performance 面板,录一段相机移动的操作,看draw call数量和GPU memory曲线。draw call 降下来了、内存曲线平稳了,才算真的优化到位。
我自己踩过最深的坑是:一开始没做瓦片卸载,测试时开着页面去泡了杯咖啡,回来浏览器就崩了。从那以后,任何三维加载器,我第一件事就是写卸载逻辑,宁可多写五十行,也不给自己留后悔药。希望帮到你。
本文还有配套的精品资源,点击获取