AIGC标识 抛弃 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。

两个必须注意的点:

  1. POSITION 这个 accessor 必须带 min / max——3D Tiles 靠它算内容包围盒,不给的话 Cesium 会报错或者不渲染。
  2. 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),关键是它能做三件事:

  1. 截图——我能"看"到画面
  2. 收集控制台 / 网络错误
  3. 在页面里执行任意 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、时序轨迹、空间分析都是开箱即用的,自己从零实现这些工作量巨大。

但有几个认知需要修正:

  1. 它不是"替代 Unity",两者不是互斥关系。Cesium 强在"地理空间数据流式渲染",Unity 强在"高保真交互仿真"。很多项目实际是 Cesium 打底 + 其他引擎做叠加。
  2. "丝滑 10 倍"没有依据。渲染性能取决于数据量、减面质量、LOD 策略,跟引擎选型只是部分相关。
  3. 真正的成本在数据,不在引擎。CesiumJS 是免费的,但一套像样的倾斜摄影数据不便宜,加工处理更贵。

关于调试方法

这次最值钱的收获不是某个 API 用法,而是调试 3D 场景的方法论:

  1. 别信"没报错"。3D 场景大量失败是静默的(剔除、遮挡、视野外)。坑一和坑三都是"没有任何错误信息"。
  2. dump 内部状态。boundingSphere.radius === NaN 这一个数字,比看一百行日志都有用。
  3. 做受控对照实验。地形开 / 关、偏移加 / 不加,一次只变一个变量,结论才可靠。
  4. 量化的优先于观感的。pending=0、featSelected=18057 这种数字才能排除"没加载完"这类猜测。
  5. 自动化截图。puppeteer-core + 本机 Chrome,几 MB 的依赖,换来"能看见"。

最后

写完这篇回头看,最有意思的是:两个最耗时的坑(box 12 元素、地形埋楼),现象都是"东西没出来",而且都不报错。 如果没有那套 dump 内部状态 + 对照实验的方法,光靠猜是很难收敛的。

希望这份记录能帮你少走点弯路。


本文所有代码都在配套仓库里,包括 3D Tiles 生成器和无头验证脚本。

posted on 2026-10-08 19:30  fox_charon  阅读(17)  评论(0)    收藏  举报

导航