ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Cesium加载倾斜摄影模型全攻略:从3D Tiles到性能调优

Cesium加载倾斜摄影模型全攻略:从3D Tiles到性能调优

1. 为什么“加载倾斜摄影模型”成了数字孪生项目的第一个坎

Cesium 加载 3D Tiles 倾斜摄影模型,这句话在需求文档里往往只有一行,但真正落到项目里,牵扯的却是从数据生产、坐标基准、服务发布到前端渲染、性能调优的一整条链路。我第一次接手这类任务时,建模单位给了我一整个目录的数据,里面有 osgb、obj、还有一堆叫不上名的辅助文件,紧接着客户就要求“在网页上能转、能点、能叠业务数据”。我当时的反应是:这东西到底怎么塞进 Cesium?

先说结论:倾斜摄影是当前城市级三维场景里最常用的真实数据来源,而 3D Tiles 是目前把大规模倾斜摄影模型搬到 Web 端最成熟、最通用的容器。这篇文章我不打算只丢一段代码,而是把从拿到倾斜摄影数据到稳定跑在网页上的完整过程拆开讲,包括那些文档里不会写、但实际项目中一定会踩的坑。

1.1 倾斜摄影数据是怎么来的

倾斜摄影并不是什么玄学。你可以把它理解成:无人机挂着一台五镜头相机,垂直镜头向下拍,四个倾斜镜头分别朝前、后、左、右拍。随着飞机沿航线移动,同一个地物会被多个角度、多张照片覆盖到,然后通过空中三角测量解算出每张照片的位置和姿态,再经过密集匹配生成海量点云,重建出三角网,最后把照片纹理映射到三角网上,得到和真实世界几乎一致的三维模型。

这个过程听起来简单,实际产出物却是“分块”的。因为一个城市级别的倾斜摄影模型可能有几十 GB,甚至上百 GB,不可能拼成一个文件输出。数据生产软件通常会把测区切分成很多小块,每一块包含几何、纹理和坐标信息。传统交付格式大多面向桌面软件,比如 osgb、obj,这些格式在 WebGL 环境下没法直接流式加载,因为浏览器不可能等一个几十 GB 的文件下载完再渲染。

1.2 3D Tiles 解决了流式渲染的什么问题

3D Tiles 是面向大规模三维地理空间数据的开放标准,Cesium 对它做了深度支持。它的核心思路和二维地图瓦片很像:把一个完整的模型数据集切分成树状层级结构,根节点是整体粗模,子节点不断细分。加载时根据相机视锥、距离、屏幕空间误差去请求当前视野内需要的瓦片,而不是一次全量下载。

每个 3D Tiles 数据集的入口是一个tileset.json,它描述了根节点的包围盒、几何误差、坐标变换矩阵,以及整个瓦片树的组织方式。瓦片内容以 b3dm、pnts、i3dm 这些格式存在,其中倾斜摄影模型绝大多数打包为 b3dm,本质上是把 glTF/glb 模型做了批量封装,还能携带每个模型的属性信息,便于做拾取和业务查询。

对 Web 端来说,这意味着一个百 GB 的倾斜摄影数据,可以做到“看到哪块就下载哪块”,而不是打开页面就死等。

1.3 什么时候其实不需要它

如果数据量只有几十 MB,元素又少,直接用 glb/gltf 加载也可以。3D Tiles 的调度机制在大场景才显现优势,小场景反而显得繁琐。但实际项目里,倾斜摄影基本都用于园区、城区、工程现场这类大范围场景,所以只要数据面积够大、精度要求高,3D Tiles 基本是绕不开的路线。

2. 动手之前,先确认你的数据、坐标和服务万无一失

很多人在页面里写加载代码只需要五分钟,但卡住几天都出不来模型,问题并不在代码,而在数据本身。我强烈建议先把三件事搞清楚:数据是不是完整的 3D Tiles、坐标系是什么基准、数据服务发布是否正确。

2.1 手头的倾斜摄影数据是不是“能直接用的 3D Tiles”

拿到数据后先别急着写页面。到数据目录里看一眼,正常的 3D Tiles 结构至少包含一个tileset.json和若干瓦片文件。如果目录里全是 osgb、obj,那就是还没有转成 3D Tiles,需要先在数据生产阶段完成格式转换。

tileset.json是整个数据集的入口,直接用文本编辑器打开,或者用脚本读取里面的关键信息。我习惯先用一个很小的 Python 脚本做检查:

import json with open("tileset.json", "r", encoding="utf-8") as f: tileset = json.load(f) root = tileset.get("root", {}) print("3D Tiles 版本:", tileset.get("asset", {}).get("version")) print("transform 矩阵:", root.get("transform", "无")) print("包围盒:", root.get("boundingVolume", {}).get("box", "无")) print("几何误差:", root.get("geometricError", "无")) print("子节点数量:", len(root.get("children", [])))

如果transform存在,说明数据生产时已经把局部坐标转换到了 WGS84/ECEF 地心地固坐标系。如果transform为空,那么加载到 Cesium 后大概率不会出现在正确位置,需要后面手动给modelMatrix做定位。

顺便检查一下子节点数量和包围盒范围。只一个根节点没有任何children,基本可以断定切片不完整,加载出来也是一个稀碎的模型壳。

2.2 坐标基准:最常见的三个翻车点

坐标问题是倾斜摄影加载里最折磨人的一类问题,我遇到的无非这三种情况。

第一种是数据本身使用独立坐标系,tileset.json也没有 transform。加载后模型不在目标位置,甚至跑到世界坐标系原点附近,场景里只能看到一个孤零零的模型站在海里。

第二种是tileset.json带了 transform,但数据生产时的地理参考没对准,导致模型出现在距离实际区域几十公里外的地方。这种情况比第一种隐蔽,因为 transform 存在,Cesium 不会报错,但模型就是“长在别人家”。

第三种是高程基准不一致。很多本地项目使用的是基于大地水准面的高程系统,而 WGS84 椭球高之间会有一个几米到几十米的差值。结果模型整体悬浮在空中,或者陷入地下。你盯着代码看半天也发现不了问题,因为经纬度是对的,唯独高度差了一点。

排查姿势很简单:加载后把tileset.boundingSphere.center转成经纬度打印出来看。

const carto = Cesium.Cartographic.fromCartesian(tileset.boundingSphere.center); console.log( "模型中心经度:", Cesium.Math.toDegrees(carto.longitude), "纬度:", Cesium.Math.toDegrees(carto.latitude), "高度:", carto.height );

如果发现差得远,优先联系建模单位确认坐标系。千万不要指望在浏览器里通过反复调整矩阵来“硬救”,那不是长久之计。页面里的矩阵修正只是补救,真正的根治要在数据生产/转换阶段完成地理参考标定。

2.3 数据服务怎么发布才稳

3D Tiles 的加载依赖 HTTP 请求,请求的是tileset.json和大量瓦片文件,所以不能用浏览器直接打开本地文件的方式测试,必须起一个静态文件服务。临时调试我用 Python 自带的服务:

cd /data/3dtiles python3 -m http.server 8080

然后在浏览器访问http://localhost:8080/tileset.json,能正常返回 JSON 内容,再在 Cesium 里填这个地址。

正式环境建议用 nginx 这类静态服务器,并注意几个点。第一是 CORS 跨域头,如果 Cesium 页面运行在http://a.com,数据服务在http://b.com,必须给b.com配置允许跨域访问的响应头:

add_header Access-Control-Allow-Origin *;

第二是 Content-Type。tileset.json要能以application/json返回,有些服务器默认把.json当文本,也能用,但容易出现编码问题。b3dm 这类二进制文件没有标准 MIME 类型,一般建议配置为application/octet-stream,避免某些代理服务器擅自做字符集转换或断点处理。

第三是压缩与缓存。tileset.json、子节点 json 文件建议开启 gzip,传输体积能小不少。瓦片文件如果数据量大,最好加 HTTP 缓存头和版本号,避免重复下载。开了 HTTP/2 之后,大量瓦片并发请求的连接复用会明显改善首屏速度。

提示:本地调试一旦出现瓦片加载 404,先看文件名大小写和中文路径。数据生产导出时生成的索引文件名往往带大小写和特殊符号,Windows 下看不出区别,但 Linux 服务器上就分得很清楚。

3. 一个最小 Cesium 加载 Demo 的完整过程

数据和服务都确认无误后,写代码就是水到渠成的事。这节我从建项目开始,给你一个可以直接复制的完整最小示例。

3.1 准备运行环境和 Cesium 引入方式

我常用 Vite 搭一个原生 JavaScript 项目,干净、启动快,不受模板束缚:

npm create vite@latest cesium-3d-tiles-demo -- --template vanilla cd cesium-3d-tiles-demo npm install npm install cesium

然后全局引入 Cesium 的 JS 和样式:

import * as Cesium from "cesium"; import "cesium/Build/Cesium/Widgets/widgets.css";

要注意 Cesium 的静态资源,包括 Worker、图片、字体等。Vite 项目如果直接打包,需要额外配置把node_modules/cesium/Build/Cesium下的资源正确复制或托管。不想折腾工程配置的话,也可以直接用原生<script>从 CDN 引入,适合临时验证,但正式项目我建议走打包方案,方便做版本管理和资源优化。

初始化 Viewer 时,我习惯关掉大部分默认控件,只留三维地球:

const viewer = new Cesium.Viewer("cesiumContainer", { animation: false, timeline: false, baseLayerPicker: false, geocoder: false, homeButton: false, sceneModePicker: false, navigationHelpButton: false, infoBox: false, scene3DOnly: true }); viewer.scene.globe.enableLighting = true;

3.2 核心加载代码

新版 Cesium 推荐用Cesium3DTileset.fromUrl,返回的是一个 Promise,拿到实例后加入到场景里:

let tileset; try { tileset = await Cesium.Cesium3DTileset.fromUrl( "http://localhost:8080/data/tileset.json" ); viewer.scene.primitives.add(tileset); await viewer.flyTo(tileset); } catch (error) { console.error("3D Tiles 加载失败:", error); }

如果项目还在用旧写法,new Cesium.Cesium3DTileset({ url })也是一样的效果,只是风格老一点。

加载完成后,建议把相机视角飞到模型包围球附近,让用户第一眼看到的就是倾斜摄影模型,而不是茫茫地球。viewer.flyTo(tileset)能自动根据包围盒计算合适的视角位置,比手动写相机坐标靠谱得多。

3.3 加载过程状态监听和错误处理

只贴加载代码不够,实际项目里一定要监听瓦片加载进度和错误事件。我习惯这样写:

tileset.tileLoad.addEventListener(() => { // 每成功加载一个瓦片都会触发 updateLoadStats(); }); tileset.allTilesLoaded.addEventListener(() => { console.log("当前视野内瓦片全部加载完成,已加载瓦片数:", tileset.tilesLoaded); }); tileset.tileFailed.addEventListener((error) => { console.warn("瓦片加载失败:", error.url, error.message); }); viewer.scene.errorEvent.addEventListener((error) => { console.error("场景错误:", error.message); });

tileFailed是定位加载问题最直接的入口,它会告诉我们哪个瓦片文件出问题了。有些问题不是全部瓦片都失败,而是一两个小瓦片 404,这种错误非常隐蔽,只有监听事件才能在控制台里看到。

3.4 模型位置不对时如何手动定位

如果确认数据本身坐标有问题,就需要用modelMatrix把模型搬到目标位置。比如我知道这块区域大致位于经度 110.5、纬度 34.0,高度按 0 给定:

const targetPosition = Cesium.Cartesian3.fromDegrees(110.5, 34.0, 0); const hpr = new Cesium.HeadingPitchRoll(0, 0, 0); const fixedFrame = Cesium.Transforms.headingPitchRollToFixedFrame(targetPosition, hpr); tileset.modelMatrix = fixedFrame;

modelMatrix会在数据原有 transform 基础上再做一次矩阵变换,相当于把整个模型整体平移/旋转到指定位置。注意设置modelMatrix后 Cesium 会重新计算瓦片调度,如果数据量很大,会有一次明显的“闪一下”和重新加载过程。

提示:手动定位是“把模型挪到大概位置”,它不是万能的。如果数据本身的局部坐标和真实地形起伏完全脱节,你只能救位置,救不了建筑的相对高差关系,这种数据必须退回生产环节处理。

4. 加载完成只是开始:四个高频问题与排查记录

代码写通了、模型也能看到了,真正的挑战才刚开始。我把实际项目里反复遇到的四类问题整理出来,每一条都附上我自己的排查思路。

4.1 模型整体漂移、悬浮或陷入地下

这是问得最多的一个问题。现象各不相同,但根因基本都落在坐标变换和高程基准上。

排查顺序我固定为三步。第一步,把tileset.boundingSphere.center转成经纬度看偏离多少。第二步,打开tileset.json确认 transform 是否存在。第三步,确认模型使用的高程基准,最直接的方法是向数据生产方要一个已知点的坐标对照。

如果是整体偏移几十公里级别,基本是 transform 缺失或错误,用modelMatrix做整体定位是常见解法。如果是高度差几米到十几米,那是高程基准不一致,在矩阵里补偿高度即可:

const offsetPosition = Cesium.Cartesian3.fromDegrees(110.5, 34.0, 12.5); const fixedFrame = Cesium.Transforms.headingPitchRollToFixedFrame( offsetPosition, new Cesium.HeadingPitchRoll(0, 0, 0) ); tileset.modelMatrix = fixedFrame;

我踩过的更隐蔽的坑是多级切片时,根节点的 transform 是准的,但中间某个子节点又带了自己的局部 transform,结果加载到一定层级后,一部分建筑出现位置错位。这种问题在tileset.json里逐层检查不现实,最好的办法是拉大视野,看错位区域的分布规律,再联系数据生产方排查切片时的坐标继承逻辑。

4.2 白屏与 Tile failed to load 的完整排查链路

页面打开一片空白,是最容易让人慌的场景。我的排查链路是这样的。

先看控制台有没有Cesium3DTileset failed to load的错误,这个错误说明连tileset.json都没有拿到或解析失败。接着打开 Network 面板,筛选tileset.json的请求,看返回状态码。如果是 404,优先检查 URL 路径、文件名大小写和服务器目录结构。如果是 401/403,基本是数据服务权限问题,有些对象存储服务默认私有读,需要单独配置公开读或签名访问。

如果tileset.json200 正常,但白屏依旧,再找其他瓦片请求。我整理了一张排查表:

控制台报错可能原因处理方向
404 Not Found瓦片路径与索引不一致、文件名大小写核对 tileset.json 中 uri 与实际文件
403 Forbidden文件服务权限不够配通读权限或签名 URL
Access-Control-Allow-Origin 缺失跨域请求被浏览器拦截配置 CORS 响应头
Failed to fetch / NetworkError服务不可达、DNS 错误用浏览器直接访问瓦片 URL 验证
Invalid tile content瓦片文件损坏或内容不是 b3dm检查切片完整性,重新导出该层级

整个链路最关键的一步是“用浏览器直接访问瓦片 URL”。很多问题在 Cesium 里看是玄学,但单独把一个 b3dm 文件地址丢到浏览器里,是 404 还是 403 一目了然,问题归属立刻定位到数据层还是服务层。

4.3 瓦片灰色、黑块、纹理异常

有些瓦片加载出来了,但表面是灰色,过一会儿才变清晰。这个其实是正常的 LOD 调度:Cesium 会优先显示低精度瓦片占位,再请求高精度瓦片替换。只要 Network 里还有续接请求,就不用过度紧张。

比较烦人的是黑块和纹理发花。黑块最常见的成因是瓦片文件本身纹理缺失,比如建模时某块区域拍照覆盖率不够,纹理映射没生成,这种属于数据质量缺陷,前端调参救不回来。另一种情况是纹理贴图过大,显卡或浏览器在压缩纹理时出现问题,表现为贴图花屏或闪烁。

我在处理这类问题时,会先确认是固定区域黑块还是所有瓦片随机闪烁。固定区域黑块,打开原始 b3dm 检查纹理,基本能判断是不是数据问题。所有瓦片随机闪,优先怀疑显卡驱动或 WebGL 上下文问题,也可以试着把viewer.resolutionScale调回 1,关闭某些高级渲染效果后再观察。

还有一个容易忽略的因素:多个 Tileset 叠加时,不同数据集之间可能会互相竞争深度缓冲和透明排序,导致局部模型边缘发虚。如果页面里同时加载了旧模型和新模型,旧模型会偶尔“透”到新模型上面。

4.4 相机越转内存涨得越离谱

性能问题是倾斜摄影项目里最“磨人”的环节。我见过一个数据量只有 5 GB 左右的模型,在浏览器里转了几分钟后,内存稳定在 2 GB 以上,最后整个页面崩溃。

要理解这是为什么,你得知道 Cesium 的瓦片调度机制:它会持续缓存很多瓦片在内存和显存里,避免相机小幅移动就重新请求。这本身是优化,但如果缓存策略不受控,内存就会一路飘。

我能给出的几个实战手段如下。

在瓦片加载完成后,刻意限制缓存上限。以 Cesium 1.107 之后的版本为例,可以对tileset设置缓存字节数:

tileset.cacheBytes = 536870912; // 限制瓦片缓存约 512 MB

设置一个合理的maximumScreenSpaceError,让相机远离模型时不要加载太高精度的瓦片,这比任何缓存限制都管用。监听tileUnload事件,统计实际的缓存变化,能帮你判断参数调整是否生效:

tileset.tileUnload.addEventListener(() => { console.log("瓦片已卸载,当前缓存瓦片数:", tileset.tilesLoaded); });

如果页面里同时存在多个 Tileset,用完的实例要及时销毁,不要只从场景里移除,还应该调用tileset.destroy()释放 GPU 资源。

5. 按需控制加载精度与性能的几组关键参数

倾斜摄影能不能流畅,往往就取决于那几组调度参数。这里我结合自己调优的经验,把最常用的参数和它们背后的逻辑讲透。

5.1 maximumScreenSpaceError 的取舍逻辑

maximumScreenSpaceError是决定“什么时候需要继续加载更精细瓦片”的核心阈值。它表示当前瓦片投影到屏幕上时,几何误差允许达到的像素值。误差小于阈值,就不再细分;大于阈值,就继续往子节点走。

这个值调得越小,模型越精细,客户端加载的瓦片数量越多,请求压力越大。调得越大,画面越粗糙,但更流畅。Cesium 默认是 16,对地形类数据还行,但用在城市级倾斜摄影上,建筑边缘很容易出现“融化”般的变形。

我的做法是分场景设值。只是宏观浏览,设 16 到 24;离地面 300 米以内观察建筑细节,设 8 左右;做构件级检查,设 4。代码就是一行:

tileset.maximumScreenSpaceError = 8;

还可以根据相机高度动态切换。高空俯瞰时用大误差,贴近地面时把误差收紧。不过要小心,频繁修改这个值会触发热点瓦片重调度,最好加点节流逻辑:

let timer = null; viewer.camera.changed.addEventListener(() => { if (timer) return; timer = window.setTimeout(() => { const height = viewer.camera.positionCartographic.height; tileset.maximumScreenSpaceError = height > 1000 ? 24 : 8; timer = null; }, 200); });

5.2 dynamicScreenSpaceError 的动态降载

dynamicScreenSpaceError是另一个实用开关,打开后,Cesium 会对偏离屏幕中心较远的区域使用更大的屏幕空间误差,减少视野边缘的瓦片请求量,把资源让给屏幕中心区域。

我常用的配置是:

tileset.dynamicScreenSpaceError = true; tileset.dynamicScreenSpaceErrorDensity = 0.9; tileset.dynamicScreenSpaceErrorFactor = 4.0;

这个参数不是所有场景都适合。如果用户需要在场景中频繁选中边缘区域的建筑,或者会快速旋转视角观察,过度降载会让边缘模型长时间处于低精度状态,反而影响体验。我的建议是俯瞰漫游模式打开,近景精细操作模式关闭。

5.3 调度策略相关选项

Cesium 还提供了一些调度策略选项,用得更少,但特定场景下很管用。

skipLevelOfDetail默认为 true,表示可以跳过中间层级瓦片。比如根节点下面直接加载较精细的某几个子节点。它能让模型更快地从模糊变清晰,但会有一定概率出现“某个区域突然蹦出建筑”的跳层感。遇到跳层感特别明显的场景,可以把skipLevels调回 1 限制跳层幅度。

preloadWhenHidden控制在相机移动过程中是否预加载视野外的瓦片。我的经验是如果用户操作节奏快,开启后容易造成大量低优先级请求抢占带宽,反而拖慢当前视野加载。大场景默认关掉反而更顺。

5.4 按多边形范围裁剪模型

倾斜摄影模型经常有不规则边界、无关建筑、或者临时遮挡的情况。Cesium 较新的版本提供了ClippingPolygon,可以用多边形直接裁剪瓦片,比较适合基坑展示、区域拆除模拟这类需求:

const positions = Cesium.Cartesian3.fromDegreesArray([ 110.50, 34.00, 110.51, 34.00, 110.51, 34.01, 110.50, 34.01 ]); const clippingPolygon = new Cesium.ClippingPolygon({ polygon: positions }); tileset.clippingPolygons = [clippingPolygon];

裁剪是多边形内部的瓦片会被隐藏,还是外部?不同版本行为有调整,用之前一定要先确认自己所在版本的语义。这种操作在 GPU 端对每个片元做判断,裁剪范围越多性能开销越大,不要一次性挂几十个裁剪体。

5.5 服务端并发与缓存策略

前端的优化到头了,还可以从服务端“偷”一点性能回来。瓦片调度对并发的依赖很强,大量小文件并行请求时,HTTP/1.1 的并发数会被浏览器限制,换成 HTTP/2 后多路复用效果立竿见影。

如果数据量特别大,我建议把瓦片文件放到对象存储或 CDN 上,而不是由应用服务器直接读磁盘。对象存储天然带高并发和缓存能力,还能省下应用服务器的带宽。唯一要记得的是把 CORS 头和缓存规则配好,否则前端加载照样报跨域。

我自己在服务端积累的一个习惯是:把tileset.json和所有瓦片文件的修改时间固定,发布时如果数据没有变化不要重新上传,让 HTTP 缓存命中,避免用户每次打开页面都把基础数据重新拉一遍。

再分享一个我个人的小习惯:每次调整参数,我都会在浏览器任务管理器里盯一下 GPU 进程的内存,同时用 Cesium 自己的调试面板看瓦片数量和请求数。单靠肉眼判断流畅度不够客观,有时候你觉得卡,可能是 GPU 内存压力大,也可能是网络请求堆积,两者处理方向完全不一样。调参前先量化,调参后看变化,这套方法帮我少走了不少弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进