ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Three.js加载3dtiles倾斜摄影模型:从原理到实践

2026/10/5 3:38:40 拓冰建站 浏览量
Three.js加载3dtiles倾斜摄影模型:从原理到实践 1. 为什么要在 Three.js 里加载 3dtiles 倾斜摄影数据做 WebGIS 或三维可视化的朋友应该都有这种感受倾斜摄影模型动辄几个 GB数据量大、纹理多、层级复杂传统 OBJ、GLTF 这种整包加载方案根本扛不住。大家脑子里的第一反应往往是 Cesium毕竟 3dtiles 就是 Cesium 社区提出来的标准Cesium 里加载倾斜摄影数据属于“原生支持”成熟稳定。但实际项目里很多场景并不一定需要 Cesium 那种“全球尺度”的地球渲染能力。比如智慧园区、厂区数字化、矿山监测、城市局部建模这些项目通常只需要在一个局部场景里把倾斜摄影模型和业务 UI 结合做鼠标交互、剖切、量测、动态特效甚至和 Three.js 里已有的工业模型、BIM 模型混合渲染。这时候如果再引入 Cesium就得把整个地球坐标系、影像图层、相机控制方式都迁过去项目复杂度直线上升业务代码也被绑得死死的。我的做法是倾斜摄影数据一律先转成 3dtiles然后在 Three.js 里加载。这样既能保留 3dtiles 的 LOD 自动加载、按需渲染特性又能继续使用 Three.js 强大的场景管理、材质系统和交互生态。这条技术路线的好处非常明显Three.js 社区庞大UI 层、渲染层、后期特效库非常丰富做定制化交互比 Cesium 顺手得多。不需要引入重型 GIS 引擎纯前端渲染轻量部署成本低。3dtiles 的 LOD 机制可以保证大场景流畅同时内存不会瞬间爆炸。当然Three.js 本身并不直接支持 3dtiles需要借助第三方解析库或者自己写 loader。我几乎是踩遍了所有能踩的坑之后才把这条链路彻底跑通。今天这篇文章就把完整方案、关键代码、避坑经验一次性写清楚。2. 3dtiles 到底是个什么东西很多刚接触的人会以为 3dtiles 是一种3D模型文件格式比如像 OBJ 那样一个文件搞定。其实不然3dtiles 是一套面向大规模三维地理空间数据的流式传输规范由瓦片集Tileset、瓦片Tile、内容Content三层结构组成核心目标是“按需加载”。2.1 tileset.json 与瓦片树的组织逻辑3dtiles 的入口是一个tileset.json文件它描述了整个数据集的包围盒、root 节点、子节点级联关系、每个瓦片的几何误差、变换矩阵transform等关键信息。瓦片之间呈树状结构从根节点开始根据视点距离动态拆分或合并子节点。也就是说相机离得远的时候只加载粗糙的根节点瓦片离得近的时候再细化到子瓦片。这就是它比传统整包模型强的地方——数据量再大当前可见范围需要渲染的其实只有一小部分。每个瓦片的内容Content可以是不同格式倾斜摄影最常用的是b3dmBatched 3D Model它本质上是一个 glTF 的容器里面可能包含多个三角网格成员每个成员拥有独立的属性信息。还有一些格式比如pnts点云、i3dm实例化模型、cmpt复合格式但倾斜摄影这边基本就是 b3dm 占绝大比例。2.2 坐标系统与 Y 轴朝向问题这是 Three.js 加载 3dtiles 最容易出问题的环节没有之一。3dtiles 的坐标系统采用右手坐标系默认 Y 轴向北Z 轴向上单位是米。而 Three.js 的默认坐标系统虽然是右手坐标系但 Y 轴是向上的Z 轴指向屏幕外侧也就是 Y-up 与 Z-up 的区别。直接把 3dtiles 数据丢进 Three.js 场景里模型一定是躺倒的而且朝向不对。所以加载时必须做一次旋转转换把整个模型绕 X 轴旋转-Math.PI / 2或者说把 Z-up 转成 Y-up。这是第一个必须处理的坑不处理你后面全是白干。还有一个更隐蔽的问题如果倾斜摄影数据是经过地方坐标系或 CGCS2000 投影的原始坐标数值很大比如 X 是 500000 级别的直接放到 Three.js 场景里会导致浮点精度丢失模型抖动、闪烁、裂缝全都会冒出来。解决思路是“局部坐标系”或者叫“中心化”加载时将瓦片树的变换矩阵平移到一个相对原点附近的位置让模型坐标网格保持在浮点精度安全范围内。2.3 数据来源与常见转换途径拿到手的数据通常有几种形态无人机航飞后由建模软件ContextCapture、大疆智图、Smart3D 等直接输出的 OSGB 格式或者通过开源工具转换得到的 3dtiles 数据还有一些是矢量数据shp、fbx 等经处理后生成的 3dtiles。这里顺便回应一下热词里的shp转3dtiles和fbx转3dtiles。这两个需求在日常生活中非常常见shp 转 3dtiles通常是把二维矢量面比如建筑基底挤成白模或带高度的体块再转为 3dtiles 供三维场景使用。工具上可以用 Cesium ion 上传转换或者使用开源方案比如py3dtiles先读 shp 转成带高度的三维数据再切瓦片。fbx 转 3dtiles这个需求经常来自工建、机械、BIM 方向原始模型是 FBX 格式需要转到 Web 端做 GIS 属性挂接。可以先把 FBX 转成 glTF/glb再用3d-tiles-toolsCesium 官方 Node 工具库加工成 b3dm 瓦片。但绝大多数业务场景航测公司交付的倾斜摄影数据已经是 3dtiles 了所以真实项目里最常做的就是“拿到 3dtiles然后想方设法在 Three.js 里渲染”。3. 工具与库的选择自研 loader 还是直接上现成方案我刚做这个需求的时候第一反应是“Three.js 官方是不是已经支持了”翻了半天文档发现官方并没有内置 3dtiles 加载器。于是面临两条路一是自己解析 tileset.json、按 LOD 规则加载 b3dm 并动态实例化网格二是找现成的开源库。最后还是推荐先用现成库跑通全部流程再按需定制别一开始就硬啃规范。3.1 三个主流开源库的横向对比我实测过几个方案简单列一下库名称地址/说明优点不足three-3d-tiles基于 Three.js 的插件加载 tileset.json 后用 BufferGeometry 渲染代码结构清晰支持 LOD适合中轻量场景更新频率一般高级功能少3d-tiles-renderer目前社区活跃度较高的方案包含基于 glTF/b3dm 的渲染器支持瓦片剔除、LOD、点云兼容性不错与最新 Three.js 版本偶尔有兼容问题需要锁版本Cesium 官方解析器思路自己按规范编写可完全定制工作量大调试难实际项目里我主要推荐第二个3d-tiles-renderer。它把 tileset 的解析、瓦片调度、绘制命令都封装好了我们只需要在 Three.js 场景里实例化一个Tileset对象设置好变换、最大误差然后每帧调用更新逻辑即可。3.2 安装与基础引入用 npm 安装3d-tiles-renderer的时候一定要留意版本兼容性。这个库的 API 在几个大版本之间有过调整和 Three.js 的版本耦合比较紧。我的稳妥建议是Three.js 用r160左右3d-tiles-renderer 用最新版本的同时务必查看它 package.json 里的 peerDependencies。npm install three0.160.0 npm install 3d-tiles-renderer如果只支持某种模块体系注意在 vite 项目中配置别名。很多时候加载半天白屏就是因为模块解析错误控制台飘红一片。接下来看一个最简加载代码import * as THREE from three; import { Tileset } from 3d-tiles-renderer; import { OrbitControls } from three/examples/jsm/controls/OrbitControls.js; const scene new THREE.Scene(); const camera new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 2000); camera.position.set(100, 80, 120); const renderer new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); document.body.appendChild(renderer.domElement); const controls new OrbitControls(camera, renderer.domElement); controls.target.set(0, 0, 0); // 关键加载 3dtiles const tileset new Tileset(https://example.com/tileset.json, { // 可以在这里指定渲染精度等参数 maximumScreenSpaceError: 16, }); scene.add(tileset); function animate() { requestAnimationFrame(animate); tileset.update(camera); controls.update(); renderer.render(scene, camera); } animate();这段代码看似简单但里面其实包含了几个非常重要的细节后面会逐一重点说明。4. 实操全流程从零跑通 Three.js 3dtiles4.1 数据准备将倾斜摄影 OSGB 转成 3dtiles很多设备商交付的原始数据是 OSGB 格式。如果你的源数据是 OSGB需要先转换为 3dtiles。常用的免费工具有 ContextCapture 自带的导出功能、Osgb23dtilesGitHub 开源小工具、以及商业软件的支持。转换过程中有三个参数值得注意顶层包围盒的生成策略建议采用平均重心作为原点避免坐标跨越过大。纹理格式尽量保持 JPG/JPEG压缩率高、加载快PNG 纹理在很多手机上内存消耗极大。几何误差GeometricError设置这个值直接决定了 LOD 切换的节奏。设置为越大模型详细程度切换越晚越容易看到模糊大块反之切换越早细节出现越快但请求次数也更多。一般无人机倾斜摄影数据根节点几何误差给到几十到上百叶子节点给到几就行。4.2 坐标归零避免大坐标浮点精度问题这是我项目里最深刻的教训。倾斜摄影数据往往位于地理坐标系下比如设备导出的 3dtiles 中的 transform 矩阵可能带有很大的平移量比如[405980.5, 3487152.6, 35.2]这种量级。Three.js 渲染时如果这些坐标直接参与矩阵运算GPU 的 float32 精度会迅速丢失结果就是模型表面出现“水波纹”抖动、裂缝、甚至整块消失。解决办法是在加载 tileset 之前先读取它的包围盒中心然后把整个 tileset 放在一个THREE.Group中把这个 group 的位置设置为绕过中心点。或者更简单一点手动修改 tileset 模型的变换矩阵把平移量减到一个合理的原点附近。我用3d-tiles-renderer时一般这样处理// 拿到包围盒中心后把 tileset 整体偏移到原点附近 const offset new THREE.Vector3(-405980.5, -3487152.6, -35.2); const group new THREE.Group(); group.add(tileset); group.position.copy(offset); scene.add(group);这样场景坐标保持在零点附近浮点精度问题立刻消失。如果你后续需要做 GPS 坐标反算或者位置匹配只需要在业务层把偏移量加回来即可。4.3 处理 Z-up 到 Y-up 的旋转接着上面说3dtiles 是 Z-up 坐标系Three.js 是 Y-up。所以即使你把模型位置归零了模型依然是“躺”着的。必须在加载后对 tileset 对象做一次旋转。tileset.rotation.x -Math.PI / 2;但注意一个顺序问题先旋转再平移。如果你先平移了再旋转旋转会把平移方向也转掉导致模型不知道飞到哪里去了。建议把旋转和平移都加到同一个 group 上先旋转后平移或者用矩阵乘法处理。在3d-tiles-renderer中tileset 本身继承自Object3D可以直接设置 rotation 和 position但一定要按正确顺序先设置 rotation再设置 position。4.4 LOD 调度与 maximumScreenSpaceError 的调优3dtiles 的核心魅力在于 LOD。而 LOD 切换的“灵敏度”由maximumScreenSpaceError最大屏幕空间误差控制这个值的默认值是 16。数值越大模型细节切换越慢渲染负载越小数值越小模型细节渲染得越快但请求多、draw call 也多。实际项目里不建议直接套默认值。对于园区级别场景我一般设为 8 到 12 之间如果场景相机经常怼近看纹理细节8 左右比较合适如果只是全局浏览设 20 都没问题。除了这个参数3d-tiles-renderer 里的瓦片调度是每帧会遍历瓦片树、计算误差、判断加载和释放。如果瓦片数非常多这个调度计算也会成为性能瓶颈。这时候可以通过限制.tileCacheSize或者最低显示层级来控制。4.5 相机与场景参数设置倾斜摄影模型范围通常在几百米到几公里之间所以相机远裁剪面far建议给到 5000 甚至 10000近裁剪面响应给到 0.1。太小的 far 会导致模型距离稍远就没了。另外一定要开启对数深度缓冲区否则透视精度不足会造成近处模型闪面、远处模型深度冲突。const renderer new THREE.WebGLRenderer({ antialias: true, logarithmicDepthBuffer: true, });开启后画质会改善很多特别是那些起伏地形和边缘锯齿。5. 遇到的坑和排查技巧这个方案踩过的坑比成功案例本身更有参考价值。下面按问题频率整理了一个速查表。5.1 白屏/黑屏加载了但没有画面可能性很多但最常见的是这三个坐标问题模型加载到了离相机非常远的地方。检查方式是打印 tileset 的 boundingSphere 或者 group.position看包围球中心是不是在相机范围内。旋转顺序问题模型被旋转到了背面。把rotation.x -PI/2和position的组合逐个试一遍或者先单独调试出模型再来加业务逻辑。瓦片几何误差过大或者请求被阻塞打开 Network 看tileset.json是否成功返回、后续.b3dm文件是否在持续加载。如果只有 tileset.json没有具体瓦片请求多半是tileset.update(camera)没有每帧调用或者相机没有指向数据包围盒。5.2 模型发黑或一片暗很多情况是法线或光照问题。倾斜摄影数据自带的材质一般是无光照Unlit或者使用了顶点色如果你在 Three.js 场景里添加了AmbientLightDirectionalLight而 3d-tiles-renderer 里的材质又是基于光照计算的可能就会变暗。而更多时候是因为模型纹理没有正确加载。排查纹理问题时打开浏览器控制台看有没有xxx.jpg 404的报错。数据包里 b3dm 的纹理通常是内嵌的不会单独请求。如果出现单独请求纹理说明数据切割时的相对路径出了岔子或者你的服务器没有正确配置静态资源路径。5.3 相机旋转时模型闪烁、裂缝主要原因还是坐标精度问题或者瓦片之间的几何误差过度重叠。处理方式确认包围盒中心的偏移计算正确模型位置是否离开了零点太远。检查瓦片树层级关系如果数据在切割时没有正确设置几何误差多个层级瓦片重叠会导致穿插闪面。试试把maximumScreenSpaceError稍微调大让低层级瓦片更早隐藏。5.4 纹理模糊或“颗粒感”明显这个问题常在数据压缩/抽样阶段产生。倾斜摄影原始影像分辨率很高切割工具如果默认输出低分辨率纹理加载起来当然模糊。此外b3dm 内部 glTF 的纹理采样方式也可能影响显示。在数据生产端输出纹理尺寸尽量保持至少 2048压缩质量选 80% 以上会大大提升视觉效果。5.5 内存占用爆炸倾斜摄影数据量大随着相机移动3dtiles 会不断加载新瓦片并释放旧瓦片。如果释放不及时内存会越来越高。检查tileset是否开启了合理的缓存淘汰策略。以及在每次tileset.update(camera)后可以在开发环境用renderer.info.memory查看 GPU 内存占用情况。如果瓦片数据量确实太大建议对数据进行二次抽稀屋顶、地面这类大面积均匀区域可以把 LOD 的层级数减少在数据生产期就把不可见的面删掉。6. 延伸玩法单体化、shp 数据处理和 FBX 转换6.1 单体化怎么做热词里出现了“cesium 3dtiles 单体化”很多人误以为单体化是 Cesium 专属。其实单体化的本质是在倾斜摄影模型上把每一栋建筑、每一棵树变成可独立选中、高亮、点击并挂接属性的个体。这项工作通常不在 Three.js 运行时做而是在数据生产期做。数据期做法利用建模软件处理 OSGB 时为每个建筑生成一个唯一的 ID并把这个 ID 写入 b3dm 的属性表FeatureTable或者通过batchId保存到 glTF 的顶点属性中。前端在运行时根据点击位置命中三角面读出所在顶点的batchId再通过 ID 关联业务数据表。Three.js 里实现点击拾取单体需要借助射线检测const raycaster new THREE.Raycaster(); const mouse new THREE.Vector2(); function onMouseClick(event) { mouse.x (event.clientX / window.innerWidth) * 2 - 1; mouse.y -(event.clientY / window.innerHeight) * 2 1; raycaster.setFromCamera(mouse, camera); const intersected raycaster.intersectObject(tileset, true); if (intersected.length 0) { // 读出 batchId再到业务表里查找属性 const batchId getBatchIdFromIntersect(intersected[0]); console.log(单体ID:, batchId); } }要能读到 batchId需要在瓦片加载时保留几何对象上的属性信息或者通过b3dm里的 FeatureTable 解析。这个过程自定义程度比较高如果项目不是十分依赖单体化功能也可以退而求其次给每一栋建筑单独切一个独立瓦片用瓦片 id 来替代单体 id但这种方案在数据量大的时候性能会相对差一些。6.2 shp 转 3dtiles 的实用路径如果你手上只有 shp 面数据想快速在三维场景里拉一个白模体块可以走这个低成本路径用 QGIS 或 GDAL 读取 shp遍历每个面要素根据属性字段比如“层高”“地面高度”生成三维棱柱体。把生成的几何体按三角网导出为带坐标的 OBJ 或 glTF。用3d-tiles-tools将 glTF 打包为 b3dm再按四叉树规则组织成 3dtiles。这个流程看着简单但要做好需要理解几何坐标和瓦片层级关系不然很容易做出一个巨大的根节点瓦片加载时直接卡死。如果不想自己写代码可以用 Cesium ion 上传 shp让云服务自动生成 3dtiles然后下载下来。但我个人不建议大量依赖这种服务因为涉及数据安全而且自定义程度太差。6.3 FBX 转 3dtiles组件级模型的 Web 展示FBX 转 3dtiles 最常见的场景是机械装备、管线、室内外装饰这类精细模型。如果你直接在 Three.js 中加载 FBX 大模型浏览器很容易卡爆转换分块为 3dtiles 后能保留模型细节的同时实现按需加载和视角裁剪。推荐流程是FBX 通过 Blender 或 three.js 编辑器导出为 glTF/GLB注意坐标轴一定要选对Blender 里默认是 Z-up导出为 glTF 时通常会自动转为 Y-up。使用gltf-to-3dtiles或3d-tiles-tools将 glTF 转成 b3dm并生成 tileset.json。如果模型超过几千个三角面建议先在建模软件中做分层 LOD 或拆分成多个 glTF 文件再手动组织成多级瓦片树。实际工作里我更喜欢把 FBX 先优化再转换。很多 FBX 文件包含大量历史残留节点、多余的材质球和动画数据转换前清一遍能够节省 30% 以上体积加载速度也能明显提升。6.4 动态效果与业务结合3dtiles 加载成功以后并不意味着万事大吉。真实项目里你还要做各种业务联动点击建筑高亮、设备闪烁、透明度渐变、剖面切割、测量工具、标牌系统等。透明度渐变尤其常用做法是遍历瓦片网格调整材质透明度。但要注意 b3dm 瓦片内部的多个模型共享材质的情况在一次tileset.update后又可能会释放重建材质所以需要监听瓦片加载完成事件再重新设置新的透明度这个过程做不好会导致高亮功能时灵时不灵。7. 性能优化经验总结最后聊几个在项目中实测有效的性能优化手段这部分价值不亚于加载本身。7.1 瓦片缓存与预加载策略3d-tiles-renderer 默认会根据视锥体和距离做瓦片调度但如果你希望用户在进入场景时不至于看到一堆粗糙底模可以为第一个相机朝向区域预取一部分瓦片。做一个很简单的“视野预热”逻辑先设置相机到目标视角调用几次tileset.update(camera)等到瓦片加载完成后再让用户看到画面。另外合理调整tileCacheSize很有用。默认可能很大但在低端手机上会导致内存持续增长后闪退。把它限制在一两百个瓦片能让性能更稳定。7.2 剔除和后处理优化利用 Three.js 视锥体剔除已经很成熟但 3dtiles 自带的数据层级剔除也很关键。如果你的项目只是展示一个固定区域可以在不进入该区域时把 tileset 的 visible 设为 false从根源上减少绘制。开启后处理如 SSAA 抗锯齿、Bloom 效果会额外消耗大量性能移动端尽量少用。倾斜摄影模型本身纹理细节丰富后处理效果并不明显不如把精力花在纹理压缩上。7.3 多细节层次与轻量化改造如果整个模型很大建议在数据生产阶段就做“分层切瓦片”而不是运行时去优化。倾斜摄影建模软件中可设置细节层次数量通常 10 到 15 层已经足够再高的层级对视觉提升有限但文件体积却成倍增长。我见过一个项目为了追求极致清晰倾斜摄影数据切了 22 层结果加载瓦片请求数飙升模型看着还没快多少。合理控制层级一般手机端建议总层级不超过 12 层。7.4 静态化与 CDN 分发3dtiles 的瓦片文件数量极多小文件请求频次高如果服务器不支持 HTTP/2很容易出现连接阻塞。部署时务必开启 HTTP/2 或 HTTP/3并使用 CDN 分发瓦片目录。同时所有瓦片文件建议开启 gzip/brotli 压缩虽然图片纹理压缩率有限但 tileset.json 和部分 glTF 文件压缩效果还是不错的。8. 一套完整的落地方案示例为了让你拿到就能用这里给出一个我在中型园区项目中验证过的完整示例结构。project/ ├── public/ │ └── tiles/ │ ├── tileset.json │ ├── tiles/ │ │ ├── 0.b3dm │ │ ├── 1.b3dm │ │ └── ... ├── src/ │ ├── main.js │ ├── scene.js │ └── tilesetLoader.jstilesetLoader.js核心逻辑import * as THREE from three; import { Tileset } from 3d-tiles-renderer; export function loadTileset(url, scene, camera) { const tileset new Tileset(url); // 1. 先旋转后偏移 tileset.rotation.x -Math.PI / 2; tileset.position.set(0, 0, 0); // 2. 如果坐标数值很大则动态计算中心偏移 tileset.addEventListener(load-tile-set, () { const sphere tileset.boundingSphere; const center sphere.center; tileset.position.x - center.x; tileset.position.z - center.z; }); return tileset; }当然这只是最基本版。在实际项目里我还会把加载进度、错误拦截、瓦片释放后的内存回收一并写进去。8.1 进度加载与友好提示3dtiles 不像普通模型有一个“加载完成”事件因为它是流式的。可以用统计已加载瓦片数的方式区分“首屏完成”和“全部完成”。通常在瓦片树更新时累计当前加载的瓦片数达到一个阈值后就隐藏 loading 遮罩。也可以监听网络上 b3dm 请求数量但维护成本高一些。我在项目里用的是比较朴素的方法先用一个比较小的maximumScreenSpaceError强制模型整体加载到这个程度等根节点和第一层子瓦片全部出现后再恢复正常的maximumScreenSpaceError。这样用户看到画面的等待时间最短。8.2 错误处理与数据校验如果加载的是本地离线瓦片经常会出现某个瓦片文件缺失的情况。这种缺失有时候不会立刻报错而是表现为模型中空一块。我有一个检查习惯先把整个瓦片目录拉到本地用脚本遍历 tileset.json 里引用的所有相对路径逐个验证文件是否存在这种简单粗暴的方式能提前消灭大量线上事故。9. 写在最后这套方案从调研到跑通我前前后后折腾了将近两周。最卡的点其实不是代码而是对 3dtiles 调度机制的理解不够深。一开始我以为只要把 tileset 丢进场景就能自动显示后来才明白瓦片树需要每帧根据相机位置去更新调度决策后来又遇到坐标旋转和精度问题才明白地理数据不只是“模型”那么简单。如果你也在做类似的事我个人的建议是先把一个最小体量的测试数据几兆大小从加载、旋转、平移、调度全部跑通然后再上真实的大场景数据。千万不要一上来就直接怼几个 GB 的倾斜模型不然出问题的时候根本分不清是代码 bug、数据问题还是性能瓶颈。另外现在的 Web 渲染能力越来越强Three.js 和 3dtiles 的组合在小场景里的表现完全不输 Cesium而且业务代码更容易维护。这套方案后续还能往 VR、数字孪生、可视化大屏方向扩展实用价值非常高。