简介:面向地理信息可视化与 WebGL 地图开发者的 Mapbox 加载 3DTiles 倾斜摄影模型示例,聚焦在 Mapbox 底图上展示大规模倾斜摄影数据这一常见需求,适合需要快速验证技术路线或搭建演示原型的初中级开发者参考。压缩包共 5 个文件,以 2 个 JSON 配置、2 个 JavaScript 逻辑和 1 个 HTML 入口组成,整体约 81KB,结构轻量,便于直接查阅依赖配置、页面挂载与地图渲染之间的组织关系。已有 4901 人学习,说明该方向具备较高关注度。内容围绕 Mapbox GL JS 与 deck.gl、CesiumIon3DTileLayer 的协同展开,可帮助读者理解 3DTiles 分块加载、地图交互与倾斜摄影模型呈现的基本链路,并为进一步优化性能、扩展查询交互或集成更多 GIS 能力提供可复用的参考。
1. Mapbox 加载 3D Tiles:原生不认 tileset.json,得自己搭一层
拿到一份倾斜摄影的 OSGB 成果,兴冲冲想塞进 Mapbox 里看,第一步就撞墙——Mapbox GL JS 的图层体系里压根没有 3D Tiles 这个类型,addSource传tileset.json的地址不报错,但也永远不会出图。这个 demo 要补的就是这段空白:把 Cesium 生态里的 mapbox 3dtiles 倾斜摄影模型挂到 Mapbox 底图上,让它跟着地图一起平移、旋转、俯仰,而不是另开一个独立的三维窗口。
适合谁:手上已经有转换好的tileset.json,底图是 Mapbox,又不愿意为了一个模型把整个前端切到 Cesium 的项目。读完你应该能把模型落到指定经纬度、把 LOD 参数调顺,并且在「模型不显示」和「模型飘到天上」这两类最常见的翻车里,半小时内定位到原因。
2. 三条技术路线:custom layer、deck.gl Tile3DLayer 与格式转换的取舍
在动手写代码之前,先把路线定死。选错了后面全是白工,尤其是数据量上到几十上百 GB 的倾斜摄影,性能和交互体验的差距会非常明显。
2.1 Mapbox GL JS 原生能力的边界在哪
先说清楚 Mapbox 自己能干什么。它在三维方向上的原生能力大概是三块:fill-extrusion做建筑体块、v3 版本里的model图层加载单个 glTF、以及custom图层对外暴露 WebGL 上下文。前两个跟倾斜摄影基本无关——fill-extrusion是挤出多边形,出不来纹理;model图层虽然能吃 glTF,但它是一个模型一次性加载,没有分块调度、没有 LOD 层级、没有视锥剔除。
3D Tiles 的规范核心恰恰就在调度上:tileset.json描述一棵空间索引树,每个节点带geometricError,渲染器根据相机距离和屏幕误差决定加载哪一层、卸载哪一层。整片倾斜摄影动辄几千个b3dm,不靠这套调度直接全量加载,显存会先垮。
所以结论很直接:Mapbox 缺的不是「渲染三维」的能力,是「解析 3D Tiles 并做 LOD 调度」的能力。这一层必须自己补,补法有三种。
2.2 three.js custom layer:控制力最强的一条路
custom图层是 Mapbox 留的正经后门,接口只有三个回调:
{ id: '3d-tiles', type: 'custom', renderingMode: '3d', // 关键,让 Mapbox 用 3D 管线做深度处理 onAdd(map, gl) {}, render(gl, matrix) {}, onRemove(map, gl) {} }render(gl, matrix)的第二个参数是个 4x4 的Float32Array,含义是「把 Mercator 坐标(x、y、z 都在 0~1 区间)变换到裁剪空间」的矩阵。它同时包含了投影和视图信息,直接把它塞给 three.js 相机的projectionMatrix,相机自身矩阵置单位阵,两套坐标系就对齐了。
3D Tiles 的解析交给 three.js 生态里的3d-tiles-renderer:它负责请求tileset.json、解析b3dm、管理 LOD 队列和内存释放,对外只暴露一个group让你挂到场景里。这条路的代价是你要自己写坐标变换——ECEF 到 Mercator 的那套矩阵得自己推;好处是所有参数都在手里,模型在哪个经纬度、朝哪个方向、缩放多少,全都能控。
2.3 deck.gl Tile3DLayer 与「转成 glTF」的适用场景
deck.gl的Tile3DLayer配合Tiles3DLoader也能读 3D Tiles,代码量小很多,几行就能出图。它的问题在交互层:deck.gl 自己维护一套相机,和 Mapbox 的transform是两套东西,俯仰角度大或者 zoom 很高的时候,容易出现模型和底图对不齐、拖拽时轻微延迟的情况。做数据看板、做大屏展示够用,做需要精确贴地的测绘类应用就得反复调。
第三条路是把模型转成 glTF 或者 3D 瓦片以外的东西,再用model图层加。这条路只在模型很小的时候成立——单栋楼、单个桥墩、单个设备构件,几十 MB 以内,转了也就转了。整片倾斜摄影转成单个 glTF 会变成一个几 GB 的文件,浏览器解析阶段就崩了。
| 路线 | 实现成本 | LOD 调度 | 贴地精度 | 适用体量 |
|---|---|---|---|---|
| three.js custom layer | 高(要写坐标变换) | 完整支持 | 可控,能做到米级 | 几 GB 到几十 GB |
| deck.gl Tile3DLayer | 低 | 完整支持 | 大俯仰角下需调试 | 中等体量 |
| 转 glTF + model 图层 | 低 | 无 | 取决于转换精度 | 几十 MB 以内 |
我一般会先问一句:这模型要跟底图严格对齐吗?要,就走 custom layer;只是展示,deck.gl 更省事。
3. 倾斜摄影数据准备:OSGB 到 3D Tiles 的目录结构与 tileset.json 体检
拿到手的往往是一堆Tile_+000_+000.osgb和几个metadata.xml。这套东西浏览器不会解析,得先变成tileset.json加一堆b3dm。转换本身是一次性的活,但转换出来的东西合不合格,决定了后面调试要花多久。
3.1 OSGB 空三成果到 b3dm 的产物形态
空三软件导出的倾斜摄影成果典型结构是分块的:每一块放在Data/Tile_+xxx_+xxx/下,里面有若干.osgb和.jpg纹理,根目录一个metadata.xml记录坐标系和范围。坐标一般是工程坐标系或者带带号的投影坐标,转换工具通常需要你指定一个「中心点」,把它换算成经纬度,作为 ECEF 到局部坐标的锚点。
转换完成后应该得到这样的结构:
tiles/ ├── tileset.json ├── 0/ │ ├── 0.b3dm │ ├── 1.b3dm │ └── ... └── 1/ └── ...几个必须确认的点:b3dm的纹理是内嵌的还是外挂的,不同转换工具做法不一样,外挂的在部署时容易漏拷;tileset.json的根节点有没有transform字段;geometricError的量级是不是米制(常见值是几十到几百,如果看到0.0001这种就该警觉,说明单位换算错了)。
3.2 tileset.json 里必须逐项确认的四个字段
打开tileset.json,前两百行足够判断数据能不能用。
| 字段 | 期望值 | 不对会怎样 |
|---|---|---|
asset.version | "1.0"或"1.1" | 版本过高时解析器可能拒绝加载 |
geometricError(根) | 米制,通常 50~500 | 过小会一次性拉满所有瓦片;过大则永远只加载最粗层 |
root.transform | 16 个数的数组,列主序,ECEF 单位 | 缺失说明数据在局部坐标,需要自己补锚点 |
root.boundingVolume | region或box | 只有sphere时部分渲染器剔除不准,会出现「转个角度模型消失」 |
transform字段最容易出问题。3D Tiles 规范里它是列主序存储的 4x4 矩阵,[0..3]是第一列而不是第一行。做矩阵乘法的时候如果按行主序拼接,后面的坐标变换就会整段失效。
提示:想看根节点有没有
transform,用一行命令就够,别手动翻文件。
node -e "const j=require('./tiles/tileset.json');console.log('geometricError:', j.geometricError);console.log('transform:', j.root.transform ? 'yes' : 'no');console.log('boundingVolume:', Object.keys(j.root.boundingVolume).join(','))"输出里geometricError是根节点级别的,它决定了整个模型的初始精度;root.geometricError是叶子层级的最小误差,两个值不能颠倒。如果transform: no,说明所有b3dm顶点都是相对某个局部原点的,那你必须在代码里自己补一个 ECEF 变换,否则模型会跑到地心去。
3.3 用脚本做一次瓦片完整性体检
转换为失败得很隐蔽——tileset.json生成成功,但某些分块的b3dm没输出全,前端表现就是飞到一个角度突然缺一块。上百个节点手动点不现实,写个脚本走一遍。
// check-tileset.mjs import fs from 'node:fs'; import path from 'node:path'; const root = process.argv[2] || './tiles'; const json = JSON.parse(fs.readFileSync(path.join(root, 'tileset.json'), 'utf8')); let nodes = 0; let missing = 0; let maxDepth = 0; function walk(node, depth) { nodes++; maxDepth = Math.max(maxDepth, depth); // content.uri 可能带 URL 编码,先解码再判断 const uri = node.content?.uri; if (uri) { const file = path.join(root, decodeURIComponent(uri)); if (!fs.existsSync(file)) { missing++; if (missing <= 10) console.log('缺失:', uri); } } (node.children || []).forEach((c) => walk(c, depth + 1)); } walk(json.root, 0); console.log(`节点总数 ${nodes},最大层级 ${maxDepth},缺失内容 ${missing}`);脚本做三件事:递归遍历整棵索引树、统计节点数和最大深度、逐个校验content.uri指向的文件是否真实存在。参数root传 tileset 所在目录,默认./tiles。输出里的「最大层级」很有参考价值——超过 20 层的树,LOD 调度器在移动端基本跑不动,得考虑砍层级;缺失数不为 0 的话,别急着写前端,先回去补转。
跑完确认没问题,再用 HTTP 服务起起来。注意这里不能用file://直接打开 HTML,fetch会被同源策略拦住。
# Python 起一个带 CORS 的静态服务,方便本地调试 python3 -m http.server 8080 --directory ./tiles--directory指定根目录,这样tileset.json里的相对路径0/0.b3dm就能被正确解析。如果模型放在别的域名下,服务端必须带Access-Control-Allow-Origin,否则浏览器控制台会刷一片 CORS 错误,而页面本身什么都不显示——这是新手最容易卡住的地方。
4. Custom Layer 落地:从 ECEF 到 Mercator 的矩阵与 LOD 相机
这一章是核心。坐标系这套东西一旦错了,表现往往不是报错,而是模型不见、模型变形、模型疯狂闪烁,全靠猜。所以我把矩阵推导和双相机结构拆开写。
4.1 初始化:复用 Mapbox 的 WebGL 上下文
Mapbox 已经把 WebGL 上下文建好了,three.js 必须复用它,不能再new一个 canvas。
import * as THREE from 'three'; import { TilesRenderer } from '3d-tiles-renderer'; const MODEL_ORIGIN = [118.123456, 32.654321]; // 模型中心经纬度,按实际数据填 const MODEL_HEIGHT = 0; // 模型底部相对椭球面的高度,单位米 class TilesCustomLayer { constructor(map, tilesetUrl) { this.map = map; this.url = tilesetUrl; this.camera = new THREE.PerspectiveCamera(); this.lodCamera = new THREE.PerspectiveCamera(); this.scene = new THREE.Scene(); this.renderer = null; this.tiles = null; } onAdd(map, gl) { this.renderer = new THREE.WebGLRenderer({ canvas: map.getCanvas(), context: gl, antialias: true, }); // 共享上下文时不能让 three.js 自己清屏,会擦掉 Mapbox 的底图 this.renderer.autoClear = false; this.renderer.setPixelRatio(1); // 渲染相机不参与矩阵计算,只负责投影 this.camera.matrixAutoUpdate = false; // 场景加一点点环境光,b3dm 的材质一般是 PBR,没有光会漆黑 this.scene.add(new THREE.AmbientLight(0xffffff, 1.6)); const dir = new THREE.DirectionalLight(0xffffff, 1.2); dir.position.set(1, 1, 1); this.scene.add(dir); this.tiles = new TilesRenderer(this.url); this.tiles.errorTarget = 12; // 屏幕空间误差阈值,越小越精细 this.tiles.maxDepth = 18; // 防止深层瓦片把内存吃光 this.tiles.setCamera(this.lodCamera); this.scene.add(this.tiles.group); // 加载完成后统一算一次矩阵,避免每帧重算 this.tiles.onLoadTileset = () => this.buildTransform(); } buildTransform() { // 见 4.2 } render(gl, matrix) { // 见 4.3 / 4.4 } }几个参数值得说清楚。errorTarget是屏幕空间误差的像素阈值,调小模型更精细但加载更慢,调大则相反,倾斜摄影场景一般 8~16 之间手感比较平衡。maxDepth是硬性层级上限,用来兜底——有些转换工具生成的树会一直往下钻,不限制的话显存增长很快。
renderer.autoClear = false这行不能省。three.js 默认每帧自己清一次颜色和深度缓冲,共享上下文时这一清就把 Mapbox 已经画好的底图抹掉了,表现是「只有模型,底图全黑」。
4.2 坐标链:ECEF → ENU → Mercator 的矩阵拼装
3D Tiles 的顶点数据经过根节点transform之后,落在 ECEF(地心地固坐标系)里,原点在地球质心,单位是米。而 Mapbox 的render(gl, matrix)期待的是 Mercator 坐标,原点在西南角,x、y、z 都在 0~1 之间,单位是无量纲的。
中间要过一道 ENU(东-北-天)局部坐标系:以模型中心为原点,x 指向东、y 指向北、z 指向天顶。这一步既把平移归零,也把「米」换算成 Mercator 单位。而 Mercator 的 z 轴也是
本文还有配套的精品资源,点击获取