抛弃 Unity 之后:CesiumJS 数字孪生从零跑通的完整踩坑记录
配套代码:本仓库(
cesium-demo)。所有结论都是在这台机器上实测出来的,不是抄文档。
0. 缘起
前阵子看到一篇公众号文章《抛弃 Unity !我们的 Web 数字孪生项目反而丝滑了 10 倍》。标题很标题党(全文没有任何性能数据,"丝滑 10 倍"属于玄学),但它讲的选型逻辑我认为是对的:
如果你的场景是地理空间大场景——真经纬度坐标、倾斜摄影、地形起伏、高频时序轨迹—— 那么 CesiumJS 比 Unity/Unreal 或纯手写 Three.js 更对路。
光看文章没意思。我决定按它说的搭一个最小可跑工程验证一下,同时把过程中踩到的坑完整记录下来。
最终产出是一个 Vite + Vue 3 + CesiumJS 的 demo:
- 不需要 Cesium ion token、不需要任何外网瓦片服务就能跑起来
- 包含一套自己生成的 3D Tiles(4 级调度 + 3 级 LOD,449 栋楼,0.53MB)
- 演示区域:武汉市岳家嘴(2km × 2km)
- 附无头浏览器自动化验证脚本
本文是这个过程的完整记录,包括五个真实的坑——其中第一个坑让整个瓦片集一个都不加载,而且控制台一个字都不报。
1. 方案设计:怎么做到"零 token 也能跑"
1.1 先做网络侦察
动手之前先摸清楚这台机器能访问什么。这一步能省掉后面大量的试错:
| 目标 | 结果 | 结论 |
|---|---|---|
| registry.npmmirror.com | ✅ 200 | 用它装依赖 |
| unpkg / jsdelivr | ✅ 200 | 备用 CDN |
| cdnjs | ❌ 超时 | 排除 |
| github.com / raw.githubusercontent.com | ❌ 超时 / 域名不可达 | 排除一切 GitHub raw 数据源 |
| server.arcgisonline.com(Esri 影像) | ✅ 200 | 选它当免 key 底图 |
| tile.openstreetmap.org | ❌ 超时 | OSM 官方瓦片不可用 |
| ion.cesium.com | ✅ 200 | ion 这条路可行 |
| OSM Overpass API | ❌ 406(被网络中间层挡) | 真实矢量数据要另想办法 |
1.2 底图:找一个不需要 key 的影像源
CesiumJS 的 Viewer 默认底图走 Cesium ion,没 token 就是黑球。绕开的办法是显式给它一个 baseLayer:
const viewer = new Cesium.Viewer(container, {
baseLayer: new Cesium.ImageryLayer(
new Cesium.UrlTemplateImageryProvider({
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
maximumLevel: 19,
credit: 'Esri · Maxar · Earthstar Geographics',
})
),
terrainProvider: new Cesium.EllipsoidTerrainProvider(), // 不请求 ion 地形
baseLayerPicker: false, // 内置底图选择器里的每个源都要 ion
geocoder: false, // 地理编码也要 ion
})
注意:baseLayerPicker 和 geocoder 必须关掉,否则 Cesium 仍然会去请求 ion,控制台会刷错误。
1.3 3D Tiles 数据从哪来:自己造
这是最关键的决策点。要演示"海量 3D Tiles 流式调度",就得有一套瓦片数据。可选的路:
- 真实倾斜摄影:几十 GB,且要下载(GitHub 不通)
- Cesium ion 官方数据:要 token
- 自己写生成器造一套:零依赖、离线、完全可控
我选了第三条。tools/gen-tiles.mjs 是一个零依赖的 Node 脚本,手写 GLB 二进制 + tileset.json,输出一套结构完整的 3D Tiles。
2. 自己造一套 3D Tiles
2.1 GLB 容器长什么样
GLB 就是 glTF 的二进制打包格式,结构非常简单:
┌────────────┬──────────────┬─────────────────┬──────────────┬────────────┐
│ header(12) │ chunk0 头(8) │ JSON chunk │ chunk1 头(8) │ BIN chunk │
│ magic/ver/ │ 长度/类型 │ 补空格到 4 字节 │ 长度/类型 │ 补 0 到 4 │
│ 总长度 │ 'JSON' │ │ 'BIN\0' │ │
└────────────┴──────────────┴─────────────────┴──────────────┴────────────┘
核心代码就几十行:
const header = Buffer.alloc(12)
header.writeUInt32LE(0x46546c67, 0) // 'glTF'
header.writeUInt32LE(2, 4) // version
header.writeUInt32LE(total, 8) // 总长度
glTF 的 JSON 部分描述 accessor / bufferView / mesh / material; BIN 部分就是顶点数据:POSITION、NORMAL、COLOR_0、indices。
两个必须注意的点:
POSITION这个 accessor 必须带min/max——3D Tiles 靠它算内容包围盒,不给的话 Cesium 会报错或者不渲染。- JSON chunk 用空格(
0x20)补齐,BIN chunk 用0x00补齐,都补齐到 4 字节对齐。
用顶点色(COLOR_0)的好处是:几百栋楼可以塞进一个 primitive,不用为了每种颜色建一个 material。
2.2 坐标系:最容易写反的地方
这个是重点。glTF 是 Y 轴向上,3D Tiles 瓦片局部坐标系是 Z 轴向上,Cesium 加载瓦片内容时会自动做一次变换。
我没有靠猜,而是直接去源码里找答案:
// @cesium/engine/Source/Scene/Model/ModelUtility.js
ModelUtility.getAxisCorrectionMatrix = function (upAxis, forwardAxis, result) {
result = Matrix4.clone(Matrix4.IDENTITY, result);
if (upAxis === Axis.Y) {
result = Matrix4.clone(Axis.Y_UP_TO_Z_UP, result); // ← 关键
}
...
if (forwardAxis === Axis.Z) {
result = Matrix4.multiplyTransformation(result, Axis.Z_UP_TO_X_UP, result);
}
return result;
};
再确认两个默认值:
Cesium3DTileset的modelForwardAxis默认是Axis.X(源码options.modelForwardAxis ?? Axis.X),所以不会追加第二个旋转modelUpAxis取tileset.json里asset.gltfUpAxis,缺省是Y
然后在 Node 里直接把矩阵算出来验证:
const m = Cesium.Axis.Y_UP_TO_Z_UP
// gltf(1,0,0) -> tile(1, 0, 0)
// gltf(0,1,0) -> tile(0, 0, 1)
// gltf(0,0,1) -> tile(0,-1, 0)
配合瓦片根节点的 transform(东-北-天 ENU → ECEF),结论就是:
| glTF 轴 | 3D Tiles 瓦片轴 | 地理含义 |
|---|---|---|
x |
x |
东(east) |
y |
z |
高(up) |
z |
-y |
南(即 z = -north) |
写反了会怎样?楼会躺下,或者南北镜像。前者一眼能看出来,后者很隐蔽——所以在生成器里把这个映射写成了注释加辅助函数,而不是散落在各处。
2.3 LOD 树的设计
生成器输出的结构:
root(无内容,只做调度,geometricError = 300)
├── quad_0_0.glb(LOD1 粗模,geometricError = 100)
│ ├── cell_0_0.glb(LOD2 细模,geometricError = 0)
│ ├── cell_0_1.glb
│ └── ...
├── quad_1_0.glb
└── ...
- root 不带内容是合法且常见的写法,Cesium 会因为
hasEmptyContent继续向子节点遍历 geometricError决定"什么时候该细化":屏幕空间误差超过maximumScreenSpaceError(默认 16)就往下走一层- 想让默认视角看到细模,LOD1 的
geometricError不能太小。我一开始设 60,4km 视距下算出来的 SSE 只有 11.7,永远不细化;调到 100 之后默认视角就是细模了
对应的 SSE 估算公式(fov 为垂直视场角):
SSE = geometricError × 屏幕高度 / (距离 × 2 × tan(fov/2))
3. 坑一:一个瓦片都不加载,而且不报错 ⭐️
这是整件事里最值得写的一个坑。
现象
tileset.json 明明加载成功了,tileset.statistics.numberOfTilesTotal 也能读到 21,但是:
numberOfTilesWithContentReady : 0 ← 一个内容瓦片都没就绪
numberOfPendingRequests : 0 ← 也没有任何请求在飞
tileset.tilesLoaded : true ← Cesium 认为"全加载完了"
控制台零报错。 场景里就是一片光秃秃的地球。
定位过程
既然没报错,就只能把内部状态 dump 出来看。我写了个脚本在页面里读 tileset.boundingSphere:
const bs = tileset.boundingSphere
console.log(Cesium.Cartographic.fromCartesian(bs.center)) // → 经纬度完全正确
console.log(bs.radius) // → NaN !!!
中心点经纬度完全正确,但半径是 NaN。 包围球半径 NaN → 视锥剔除判定"不可见" → 一个瓦片都不选。
根因
顺着包围球往回找,问题出在包围盒的解析上。Cesium 源码 Cesium3DTile.js:
let halfAxes = Matrix3.fromArray(box, 3, scratchHalfAxes);
Matrix3.fromArray(array, startingIndex) 会从第 3 个元素开始硬读 9 个数当作半轴矩阵。
而 3D Tiles 规范里 box 有两种写法,我用了短的这种:
// 我写的(6 个数)—— 后 6 位是 undefined
"box": [0, 0, 80, 2000, 2000, 240]
// 正确的(12 个数):center(3) + x/y/z 三个半轴向量(9)
"box": [0, 0, 80, 2000,0,0, 0,2000,0, 0,0,240]
后 6 位是 undefined → 半轴矩阵变 NaN → 包围球半径 NaN → 整个瓦片集被剔除。
修法
加一个辅助函数,从源头上杜绝:
/**
* 3D Tiles 的 box 必须写满 12 个数:center(3) + x/y/z 三个半轴向量(9)。
* 千万别用 6 元素简写:Cesium 用 Matrix3.fromArray(box, 3) 硬读 9 个数,
* 越界成 NaN → 包围球半径 NaN → 整个瓦片集被视锥剔除(一个瓦片都不加载)。
*/
function obb(cx, cy, cz, hx, hy, hz) {
return [cx, cy, cz, hx, 0, 0, 0, hy, 0, 0, 0, hz]
}
教训
"没报错"不等于"没问题"。 3D 场景的很多失败是静默的——被剔除、被遮挡、在视野外,都是没有异常、没有日志的。这时候只能靠 dump 内部状态。这套方法后面还救了我一次(见第 7 节)。
4. 坑二:Cesium 的静态资源怎么和 Vite 配合
Cesium 的 Worker、着色器、贴图不是打包进 JS 的,运行时要用 CESIUM_BASE_URL 去拼 URL 加载。官方教程推荐用 vite-plugin-static-copy 拷贝,我照做了,然后连踩两个坑。
4.1 vite-plugin-static-copy@4 的 dest 语义变了
我的配置:
{ src: 'node_modules/cesium/Build/Cesium/Workers', dest: 'cesiumStatic' }
期望输出 cesiumStatic/Workers/...,实际输出:
dist/cesiumStatic/node_modules/cesium/Build/Cesium/Workers/...
翻了下插件源码 collectCopyTargets:
const { base, dir } = path.parse(relativeMatchedPath);
destDir = path.join(dest, dirClean); // ← 保留了相对 root 的完整目录
v4 会把源目录相对项目根的全部路径都带上。这不算 bug,但和旧版行为不一致,很容易踩。
4.2 fs.cpSync 直接把 Node 干崩了
更离谱的是这个。为了绕开上面的问题,我决定自己写拷贝,第一版用了 fs.cpSync:
node -e "fs.cpSync('node_modules/cesium/Build/Cesium/Workers', 'dst', {recursive:true})"
# 没有任何输出,退出码 -1073740791 (0xC0000409)
Node 22.19 在 Windows 上拷 Cesium/Workers 会直接崩掉进程,连异常都抛不出来。这也导致 vite build 在 closeBundle 阶段整个挂掉。
修法是手写递归拷贝:
function copyDir(from, to) {
fs.mkdirSync(to, { recursive: true })
let count = 0
for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
const src = path.join(from, entry.name)
const dest = path.join(to, entry.name)
if (entry.isDirectory()) count += copyDir(src, dest)
else if (entry.isSymbolicLink()) { /* ... */ }
else { fs.copyFileSync(src, dest); count++ }
}
return count
}
342 个文件,270ms,稳定。
4.3 最终方案:40 行的内联插件
与其跟插件的语义变化较劲,不如自己写。这个插件同时解决 dev 和 build:
function cesiumStaticAssets() {
let outDir = 'dist'
return {
name: 'cesium-static-assets',
configResolved(config) { outDir = config.build.outDir },
// dev:中间件直接从 node_modules 读,不复制文件,启动快、仓库干净
configureServer(server) {
const prefix = '/cesiumStatic/'
server.middlewares.use((req, res, next) => {
const url = (req.url || '').split('?')[0]
if (!url.startsWith(prefix)) return next()
const file = path.join(CESIUM_BUILD_DIR, decodeURIComponent(url.slice(prefix.length)))
if (!file.startsWith(CESIUM_BUILD_DIR)) return next() // 防目录穿越
fs.stat(file, (err, stat) => {
if (err || !stat.isFile()) return next()
res.setHeader('Content-Type', MIME[path.extname(file).toLowerCase()] ?? 'application/octet-stream')
fs.createReadStream(file).pipe(res)
})
})
},
// build:打包结束后拷进 dist
closeBundle() {
for (const dir of ['Workers', 'ThirdParty', 'Assets', 'Widgets']) {
copyDir(path.join(CESIUM_BUILD_DIR, dir), path.join(root, outDir, 'cesiumStatic', dir))
}
},
}
}
配套的 define:
define: {
// define 做的是文本替换,Cesium 源码里会读这个全局标识符
CESIUM_BASE_URL: JSON.stringify('/cesiumStatic/'),
}
5. 怎么"看见"看不见的东西
做 3D 开发最难受的是:没有眼睛。我在命令行里,怎么知道场景渲染对不对?
答案是 puppeteer-core + 本机 Chrome 无头模式。不用下载浏览器(puppeteer-core 只有几 MB),关键是它能做三件事:
- 截图——我能"看"到画面
- 收集控制台 / 网络错误
- 在页面里执行任意 JS——这是最有价值的,可以直接 dump Cesium 的内部状态
无头环境跑 WebGL 要加参数:
const browser = await puppeteer.launch({
executablePath: 'C:/Program Files/Google/Chrome/Application/chrome.exe',
headless: true,
args: [
'--enable-unsafe-swiftshader', // 无头下用软件渲染跑 WebGL
'--use-angle=swiftshader',
'--hide-scrollbars',
],
})
代价是 FPS 只有 1~4(软件渲染),但功能验证完全够用——我们验证的是"对不对",不是"快不快"。
这个脚本后来成了固定工具(tools/verify.mjs),每次改完代码跑一遍,输出长这样:
===== 诊断 =====
{
"errors": [],
"camera": { "lon": 114, "lat": 30, "height": 1500 },
"tileset": {
"inScene": true,
"stats": { "total": 21, "contentReady": 20, "pending": 0 },
"boundingSphere": { "lon": 114, "lat": 30, "radius": 1434.4 }
}
}
第 3 节那个"半径 NaN"就是这么抓出来的。
6. 换成真实建筑:ion + OSM
假数据毕竟是假的。演示跑通后,自然想换成真实建筑轮廓。
我先试了免费数据源,全军覆没:
| 数据源 | 结果 |
|---|---|
| OSM Overpass API | ❌ 406,被网络中间层挡 |
| OSM 官方 map API | ❌ 域名不可达 |
| 微软 Global ML Building Footprints | ❌ 全球索引文件过大,流式过滤 90 秒未命中中国分片 |
最后走 Cesium ion 的 OSM Buildings(资产 96188):轮廓来自 OpenStreetMap,全球覆盖,跟影像对齐。
Cesium.Ion.defaultAccessToken = token
viewer.terrainProvider = await Cesium.createWorldTerrainAsync()
const osm = await Cesium.createOsmBuildingsAsync()
viewer.scene.primitives.add(osm)
两个细节:
- 1.146 里
createOsmBuildings()已经被移除了,只剩createOsmBuildingsAsync() - 开启真实建筑后要把自己那套合成模型关掉,否则两套楼会叠在一起
顺带一个 API 坑
想给瓦片集加垂直偏移时,我写了:
const up = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(site, new Cesium.Cartesian3())
跑起来直接抛 DeveloperError: cartesian has a NaN component。
查了半天才反应过来——new Cesium.Cartesian3() 本身就是错的。Cesium 的构造函数里有 Check.typeOf.number('x', x),npm 包是 debug 构建,这些检查全都生效,不给参数就抛异常。new Cesium.Matrix4() 同理。
正确写法是用工厂方法拿实例:
const up = Cesium.Cartesian3.normalize(origin, Cesium.Cartesian3.clone(Cesium.Cartesian3.ZERO))
7. 坑三:真实建筑被地形埋了 23.6 米
换成 ion 真实建筑后,画面是这样的:楼是有的,但看着像一片"贴在地上的白板",而且很多地方是空的。
第一反应是"没加载完",于是采样统计:
t=53s tiles=25 pending=0 featSel=21230 tris=240258
t=68s tiles=25 pending=0 featSel=21230 tris=240258
...
t=158s tiles=25 pending=0 featSel=21230 tris=240258
pending 从第 53 秒起就是 0,21230 个建筑要素全部渲染中,两分钟后一个数都没变。排除"没加载完"。
那就只能怀疑几何本身。我量了一下园区的地形高度:
const sampled = await Cesium.sampleTerrainMostDetailed(viewer.terrainProvider, [site])
// sampled[0].height = 23.577
viewer.scene.globe.getHeight(site) // 23.549,两种方式互验一致
地形高 23.58 米,而 OSM 建筑的基准高度是椭球面 0 米。
也就是说:每栋楼的下半截 23.6 米全埋在地里。六层以下的楼(约 18m)完全看不见,高层楼只露出个屋顶——从斜上方看,就是一片贴地的白色平板。
验证方法
做个对照实验:同一机位,把地形换成椭球面再截一张。
viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider()
地形一关,楼立刻"长高"了,墙面全出来了。假设成立。
修法
采样园区中心的地形高度,把整个瓦片集沿该点的"上"方向平移同样的距离:
export async function alignTilesetToTerrain(viewer, tileset, lon, lat) {
const carto = Cesium.Cartographic.fromDegrees(lon, lat)
const sampled = await Cesium.sampleTerrainMostDetailed(viewer.terrainProvider, [carto])
const height = sampled[0]?.height
if (!Number.isFinite(height) || Math.abs(height) < 0.5) return 0
const origin = Cesium.Cartesian3.fromDegrees(lon, lat, 0)
const up = Cesium.Cartesian3.normalize(origin, Cesium.Cartesian3.clone(Cesium.Cartesian3.ZERO))
const translation = Cesium.Cartesian3.multiplyByScalar(up, height, Cesium.Cartesian3.clone(Cesium.Cartesian3.ZERO))
tileset.modelMatrix = Cesium.Matrix4.fromTranslation(translation, Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY))
return height
}
怎么验证它真的生效了? 读根瓦片的计算变换,看平移量对不对:
const t = tileset.root.computedTransform
Cesium.Matrix4.getTranslation(t, Cesium.Cartesian3.clone(Cesium.Cartesian3.ZERO))
// → [-8, 19, 12]
// up × 23.6 的理论值:(-0.356, 0.784, 0.509) × 23.6 = (-8.4, 18.5, 12.0) ✅
对得上,说明平移真的应用了。
局限:这是局部补偿——用一个点的高度代表整片 2km 区域。城市范围内高差一般只有几米,够用;跨城市就会不准。
8. 坑四:OSM 在国内的覆盖本身就很不全
修完地形偏移,楼坐正了,但"很多地方是空的"依然存在。
排除法做到这一步,只剩下一个可能:数据本身就没有。为了验证,我把影像底图关掉,只渲染模型本体——一眼就清楚了:
楼是立体的、尺寸正常,但大片街区压根没有数据。
数据侧的佐证:
tilesReady: 28 pending: 0 featSelected: 18057 triangles: 206321
全部加载完毕、1.8 万个要素都在渲染——这不是加载问题,是 OpenStreetMap 在武汉岳家嘴这一带的建筑数据本身就非常稀疏,目测覆盖率不到一成。
这是国内 OSM 的普遍现状:成熟城区也就零星画了一些楼。
结论
ion + OSM 建筑的定位是"能跑通、能对齐、快速看效果",不适合当正式交付的数据源。 真要交付,还是得用自家或采购的倾斜摄影 / BIM 瓦片。
9. 几个小坑
NODE_ENV=production:这台机器的环境变量里有这个,npm install会默认跳过 devDependencies,vite根本装不上。要写npm install --include=dev。- PowerShell 抓中文网页乱码:
Invoke-WebRequest的.Content在 PS 5.1 下按 GBK 解码 UTF-8 内容。改用WebClient.DownloadData+ 显式Encoding.UTF8.GetString。 Invoke-WebRequest非交互模式报错:加-UseBasicParsing。- 微信文章抓取被拦:
web_fetch会撞上验证页,换Invoke-WebRequest+ 微信 UA 可以拿到。
10. 总结
关于 CesiumJS 的选型
文章的观点我验证下来是成立的:地理空间大场景,CesiumJS 是对的。3D Tiles 的流式调度、LOD、时序轨迹、空间分析都是开箱即用的,自己从零实现这些工作量巨大。
但有几个认知需要修正:
- 它不是"替代 Unity",两者不是互斥关系。Cesium 强在"地理空间数据流式渲染",Unity 强在"高保真交互仿真"。很多项目实际是 Cesium 打底 + 其他引擎做叠加。
- "丝滑 10 倍"没有依据。渲染性能取决于数据量、减面质量、LOD 策略,跟引擎选型只是部分相关。
- 真正的成本在数据,不在引擎。CesiumJS 是免费的,但一套像样的倾斜摄影数据不便宜,加工处理更贵。
关于调试方法
这次最值钱的收获不是某个 API 用法,而是调试 3D 场景的方法论:
- 别信"没报错"。3D 场景大量失败是静默的(剔除、遮挡、视野外)。坑一和坑三都是"没有任何错误信息"。
- dump 内部状态。
boundingSphere.radius === NaN这一个数字,比看一百行日志都有用。 - 做受控对照实验。地形开 / 关、偏移加 / 不加,一次只变一个变量,结论才可靠。
- 量化的优先于观感的。
pending=0、featSelected=18057这种数字才能排除"没加载完"这类猜测。 - 自动化截图。
puppeteer-core+ 本机 Chrome,几 MB 的依赖,换来"能看见"。
最后
写完这篇回头看,最有意思的是:两个最耗时的坑(box 12 元素、地形埋楼),现象都是"东西没出来",而且都不报错。 如果没有那套 dump 内部状态 + 对照实验的方法,光靠猜是很难收敛的。
希望这份记录能帮你少走点弯路。
本文所有代码都在配套仓库里,包括 3D Tiles 生成器和无头验证脚本。
posted on 2026-10-08 19:30 fox_charon 阅读(17) 评论(0) 收藏 举报
浙公网安备 33010602011771号