上篇结尾我留了这么一句话:
"下一篇我们换条线------不再画自己的数据,而是把别人做好的一整座塔搬进地球。3DTiles 的大雁塔模型,我们下一篇见。"
从 #59 的面、#60 的线、#61 的体到 #62 的文字,我们一路都在干同一件事:自己造图元。顶点怎么排、贴图怎么合、实例怎么批,每一笔都是我们亲手算出来的。四篇算下来,顶点数从 3 涨到 24,账单从 108 字节涨到 961 MB。
可真实项目里最常遇到的,往往不是"我造的东西怎么画得动",而是"别人做好的东西怎么放进去 "------一座 BIM 模型、一片倾斜摄影、一整个城市的白模。它们不是顶点数组,是一个已经切好片、编好目录的数据集。
今天换个方向。
大家好,我是 Cesium 酱(也可以叫我"本猿"),一名在 WebGIS 领域摸爬滚打多年的前端开发者。
写这篇前我又干了一件傻事:拿到模型第一件事就去改 tileset.modelMatrix,想把它"放到 0 米"。结果塔没落地,反而飘到了半空。那一座飘着的塔,就是这篇的开场白。
本文目标:把仓库里那个真实的大雁塔 3D Tiles 数据集拆开看------它到底切成什么样,"抬高 10 米"到底抬的是哪个方向,以及面板上那几个默认值为什么会骗人。💪
💡 本文涉及的全部代码均已开源,完整的仓库地址见文末,可自行取用、随意魔改。
一、痛点与场景:为什么"别人的模型"是另一门学问
🎯 背景现状 :自己造图元时,所有参数都在你手里;加载别人的模型时,你拿到的是一个已经切好片的黑盒 ------切片方式、坐标系、LOD 层级、包围体,全在 tileset.json 里定死了,你能动的只有外围那几个旋钮。
典型场景里,它要对付的是这些:
- 🏙️ 城市白模 / 倾斜摄影:一整片城区,几十 GB,必须靠切片与 LOD 才能放进浏览器
- 🏛️ 文物古建:大雁塔这类单体文物的精细重建,要能贴到真实地理位置上
- 🏗️ BIM / 管网:一栋楼的所有构件,要能按构件着色、按楼层筛选
- 🛰️ 大规模点云:激光扫描成果,同样靠 3D Tiles 分块调度
- 🗺️ 跨源融合:影像、地形、模型三层要严丝合缝地叠在一起
🤦 传统痛点:这次翻车的姿势,和"自己造图元"完全不沾边。
| 方案 | 问题 |
|---|---|
| 整个 glTF / OBJ 一把梭 | 大模型几百 MB 一次下载,浏览器直接卡死;没有 LOD,远处也按最高精度画 |
| 自己写分块逻辑 | 要自己实现包围体、LOD 选择、内存淘汰------等于重写一个 3D Tiles 引擎 |
直接改 tileset.modelMatrix 想"落地" |
矩阵是平移 不是落位,模型会整体飘走(本篇开头的傻事) |
不读 tileset.json 就调参 |
着色要靠要素属性、LOD 靠 geometricError,两者都得先知道数据长什么样 |
💡 本文方案 :Cesium3DTileset.fromUrl() 加载本地数据集,再用四个外围旋钮调它:
1️⃣ 高度 :沿包围球心处的椭球法线做刚性平移
2️⃣ 着色与混合 :Cesium3DTileStyle 给整塔上色,再用 colorBlendMode 决定与原贴图怎么合
3️⃣ 精度 :maximumScreenSpaceError 控制 LOD 取舍
4️⃣ 调试 :Cesium3DTilesInspector 把内部状态摊开看
✨ 一句话总结:模型是别人切的,你能动的只有"往哪挪、染什么色、看多细"------而这三个旋钮,每一个的默认行为都和你以为的不太一样。
二、核心技术原理:拆开这座塔
2.1 先看这塔到底是什么
仓库里就带着这份数据(public/dayanta/),我把它直接读出来看了:
- 生成工具 :
cesiumlab,gltfUpAxis: Z,3D Tiles 1.0 - 文件大小 :
tileset.json202 KB,189 个内容文件合计 14.53 MB(面板提示的"约 16 MB"是含目录的总量) - 瓦片总数 :190 ,其中 189 个带内容
- 根包围体 :是个 box(有向包围盒 OBB),不是球------12 个数的那种
- 根
geometricError:287.246;refine:REPLACE ;根节点自己没有内容
把根的 transform 矩阵乘到包围盒中心上,再转成经纬度,塔的落点就出来了:
| 项 | 实测值 |
|---|---|
| 经度 | 108.960582° E |
| 纬度 | 34.219910° N |
| 椭球高 | 445.131 m |
| 包围盒全尺寸 | 337.8 × 457.3 × 82.2 m |
108.96°E、34.22°N------这正是西安大雁塔的位置。西安市区海拔四百多米,445 m 的椭球高也对得上。
2.2 反直觉一:这棵"瓦片树"其实是一张 7 × 9 的网格
我原本以为 3D Tiles 的空间划分是一棵八叉树(或四叉树),越往下一层切得越碎。把这棵树的分支数统计出来,结果完全不是:
| 深度 | 瓦片数 | 每个节点的子节点数 |
|---|---|---|
| 0 | 1 | 63 |
| 1 | 63 | 1 |
| 2 | 63 | 1 |
| 3 | 63(叶子) | 0 |
根节点一口气分出 63 个 孩子,然后每个孩子各自拖着一条单传的链:深度 1 → 2 → 3,每一层都只有一个孩子。
再把这 63 个孩子的落点算出来(子节点的 transform 要乘上父节点的,因为 3D Tiles 的变换是逐层继承的),规律就露出来了:
- 内容路径是
000/000.b3dm一路排到006/008.b3dm------7 个目录 × 9 个文件 = 63 - 把 63 个块心投影到东向、北向两个轴上排序,正好落成 7 列 × 9 行
- 列间距约 46 m 、行间距约 48 m ;每块自身的包围盒约 48.8 × 54.2 × 7.1 m
所以它既不是八叉树,也不是"63 个随机对象",而是一张 7 × 9 的规则网格,每一格挂一条 3 级 LOD 单链 (geometricError 16 → 8 → 0)。63 × 3 = 189 块内容瓦片,根自己没有内容,189 + 1 正好是 190。
这也解释了 refine: REPLACE 在这里的语义:因为是单链,替换就是"用同一格 的下一级精度换掉当前级",不会出现"兄弟同时显示"的情况。顺带一个细节:63 个叶子的 geometricError 全是 0 ,而且它们连 refine 字段都没有------叶子不需要再往下细化。

2.3 反直觉二:「抬 10 米」抬的是包围球心那条法线
面板上有个"高度偏移(m)"输入框,代码是这样的:
ts
// 源码级剧透:Tiles3DDayantaDemo.vue :: applyHeight()
const boundingSphere = tileset.boundingSphere
const cartographic = Cartographic.fromCartesian(boundingSphere.center)
const { longitude, latitude } = cartographic
const surface = Cartesian3.fromRadians(longitude, latitude, 0)
const offset = Cartesian3.fromRadians(longitude, latitude, heightOffset.value)
const translation = Cartesian3.subtract(offset, surface, new Cartesian3())
tileset.modelMatrix = Matrix4.fromTranslation(translation)
注意它没有 把模型"放到"某个高度,而是算了两个点的差向量再整体平移。我把这个差向量量了出来:
heightOffset |
平移向量长度 |
|---|---|
| 0 m | 0.000000 m |
| 5 m | 5.000000 m |
| 10 m | 10.000000 m |
| 50 m | 50.000000 m |
长度精确等于输入值------因为椭球高本来就是沿椭球法线 量的,P(h) − P(0) 就是 h × 单位法线。
但"法线"和"垂直向上(地心径向)"不是同一条线。在塔所在的纬度 34.2199°,我实测这两个方向的夹角是 0.1787°。
- 🔑 所以"抬 10 米"实际是:沿法线抬 10 m,其中水平分量
10 × sin(0.1787°)≈ 0.031 m - 🔑 参考点取的是包围球的球心经纬度,也就是模型的几何中心,不是塔基
- ⚠️ 它是刚性平移 (
Matrix4.fromTranslation),所以heightOffset = 0时modelMatrix是单位阵------模型不会"落地",它只是待在数据自带的位置上(445 m)

2.4 反直觉三:默认那一档,压根没有样式
applyStyle() 里有个很容易滑过去的分支:
ts
// 源码级剧透:Tiles3DDayantaDemo.vue :: applyStyle()
const alpha = Math.max(0, Math.min(1, alphaValue.value / 100))
if (alpha >= 1 && styleColorIndex.value === 0) {
tileset.style = undefined // 🔑 默认档(白色 + 100%)直接把样式撤掉
} else {
tileset.style = new Cesium3DTileStyle({ color: `color('${colorName}', ${alpha})` })
}
tileset.colorBlendMode = BLEND_MODES[blendModeIndex.value].value
tileset.colorBlendAmount = blendAmount.value
默认是"白色 + 100% 透明",于是 style 被置为 undefined------这一档是"不加任何样式",模型按自己的原始贴图呈现。只有你把透明度拉到 100% 以下,或者换成青/橙/红/绿,样式才真正生成。
而下面那两行(混合模式、混合度)不管有没有样式都会被赋值。面板默认给的"混合 / 0.50",在默认档下没有东西可混。
顺带一个实测出来的坑:我把 Cesium3DTileset 的默认值读出来看了(用一个最小的 tileset 造出真对象):
| 属性 | Cesium 默认值 |
|---|---|
maximumScreenSpaceError |
16 |
colorBlendMode |
HIGHLIGHT |
colorBlendAmount |
0.5 |
style |
undefined |
dynamicScreenSpaceError |
true |
skipLevelOfDetail |
false |
而枚举值的顺序是反直觉的:Cesium3DTileColorBlendMode.HIGHLIGHT = 0、REPLACE = 1、MIX = 2------不是 REPLACE = 0 。demo 面板默认选的是 MIX ,和 Cesium 自己的默认 HIGHLIGHT 并不一致。
2.5 两个同名旋钮,靶子完全不同
demo 里出现了两个 maximumScreenSpaceError:
| 旋钮 | 作用对象 | demo 取值 |
|---|---|---|
globe.maximumScreenSpaceError |
地形的 LOD 精度 | 2 |
tileset.maximumScreenSpaceError |
这个模型的 LOD 精度 | 16(滑块 2~64) |
名字一模一样,一个管地球、一个管塔。想让塔更精细就拉后者;拉前者只是让地形更细,塔一点没变。
2.6 加载时序:地形先到位,模型后进场
onMounted 的顺序是:建场景 → 加载 Bing 影像 → 设 globe.maximumScreenSpaceError = 2 → await 加载 World Terrain → 加载模型 → flyTo。
- 🔑 地形是
await的:先让地面就位,模型才落上去,避免"塔悬在没加载完的地形上" - 🔑
flyTo(model)直接把相机送到模型的包围球上,省得自己算视角
2.7 塔是怎么"站正"的:gltfUpAxis: Z 与那 16 个数的 transform
asset 里有一行不起眼但很关键的标志:gltfUpAxis: Z。
glTF 规范默认是 Y 轴朝上 ,而这份数据里的瓦片是 Z 轴朝上。Cesium 读到这个标志会自己把模型转正,不用你手工加一层旋转------但如果你把同一份数据搬进别的引擎、又忘了这个标志,模型就会当场躺倒。
真正决定"塔立在哪"的,是每个瓦片自己的 transform(16 个数,一个 4×4 矩阵)。我把根节点那个矩阵拆开看了:
- 左上 3×3 是旋转(把局部坐标轴旋到 ECEF 的三个方向上)
- 最后一列是平移:ECEF 坐标
(-1715331, 4993175, 3566615)
把这个平移列转成经纬高,得到 108.959400°E / 34.219600°N / 高度 0.000 m ------也就是说,局部坐标系的原点正好扎在大雁塔脚下的椭球面上。
而根包围盒中心在局部坐标里是 (108.944, 35.799, 445.021):向东 108.9 m、向北 35.8 m、向上 445.0 m 。模型是贴着 445 米的地面建的,所以它的局部高度本来就是 445 米------这也正是 2.3 节里"抬高 10 米"不能图省事写成 fromDegrees(lon, lat, 10) 的原因:那样算出来的是相对椭球面的高度,而模型站在 445 米上。
顺带一提,变换是逐层继承的 :子节点的 transform 是相对父节点的,要一层层乘下来才是最终的世界位置。我算那 63 个块心的时候,就是拿根矩阵去乘每个子矩阵------只乘一层,位置会全错。
三、代码实战:关键实现拆解
⚠️ 温馨提示 :本文 demo 用的是仓库内置的本地数据集(/dayanta/tileset.json)。换成你自己的 3D Tiles 时,树形结构、geometricError、包围体都会变,但"加载 → 挪位 → 着色 → 调精度"这条主线一字不差。
3.1 加载与定位
ts
// Tiles3DDayantaDemo.vue :: addModel()
const model = await Cesium3DTileset.fromUrl(TILESET_URL) // '/dayanta/tileset.json'
model.maximumScreenSpaceError = screenSpaceError.value
viewer.scene.primitives.add(model)
tileset = model
applyHeight()
applyStyle()
await viewer.flyTo(model, { offset: undefined })
- 🔑
fromUrl返回的 tileset 可以直接flyTo:它自己带着包围球 - ⚠️
await之后不等于内容都下载完了------3D Tiles 是按视野边看边下的(#31 详细拆过这件事) - 🔑 顺序有讲究:先
add再applyHeight/applyStyle,否则tileset.boundingSphere还没准备好
3.2 高度偏移:相对平移,不是绝对落位
ts
const surface = Cartesian3.fromRadians(longitude, latitude, 0)
const offset = Cartesian3.fromRadians(longitude, latitude, heightOffset.value)
tileset.modelMatrix = Matrix4.fromTranslation(
Cartesian3.subtract(offset, surface, new Cartesian3())
)
- 🔑
heightOffset = 0→ 平移向量是零向量 →modelMatrix是单位阵:模型保持数据自带的位置 - ⚠️ 它不知道塔基在哪:参考点是包围球心,所以"抬高 10 m"对塔尖和塔基的效果是一样的(刚性平移)
- ⚠️ 想让模型真正"贴地",得先知道数据自带的高程(本例是 445.131 m),再算差值------没有一步到位的 API
3.3 着色与混合:三种模式到底差在哪
ts
const BLEND_MODES = [
{ value: Cesium3DTileColorBlendMode.REPLACE, label: '替换' }, // = 1
{ value: Cesium3DTileColorBlendMode.HIGHLIGHT, label: '高亮' }, // = 0
{ value: Cesium3DTileColorBlendMode.MIX, label: '混合' } // = 2
]
三种模式的差别,在片元着色器里只有两行(ModelColorStageFS.glsl):
glsl
material.diffuse = mix(material.diffuse, model_color.rgb, model_colorBlend);
float highlight = ceil(model_colorBlend);
material.diffuse *= mix(model_color.rgb, vec3(1.0), highlight);
- REPLACE (blend = 1):
mix取满 → 直接用样式色;ceil(1) = 1→ 乘白色,等于不额外相乘 - HIGHLIGHT (blend = 0):
mix不动原色;ceil(0) = 0→ 乘以样式色(乘法高亮) - MIX (blend = amount):按
colorBlendAmount线性插值,0 是原色、1 是纯色
所以 colorBlendAmount 只在 MIX 模式下有意义------在替换和高亮下它根本不参与运算。

3.4 精度与监视器
ts
tileset.maximumScreenSpaceError = screenSpaceError.value // 2 ~ 64,默认 16
// 调试面板
inspectorWidget = new Cesium3DTilesInspector(inspectorContainer, viewer.scene)
- 🔑 SSE 越小越精细、越慢:16 是 Cesium 默认;往 2 拉会把远处瓦片也逼到最高精度
- 🔑
Cesium3DTilesInspector是官方调试器:能直接看到当前绘制了多少瓦片、内存占用、各 LOD 状态------调参时比自己打日志快得多
四、功能亮点与性能优势

4.1 九个可调旋钮
- 显示模型 :
tileset.show - 高度偏移(m):沿包围球心法线的刚性平移,步长 5
- 透明度:0~100%,映射成样式的 alpha
- 着色:white / cyan / orange / red / green
- 混合模式:替换 / 高亮 / 混合
- 混合度:0~1,只在"混合"模式下生效
- 屏幕空间误差:2~64(模型的 LOD 精度)
- 场景光照 :
globe.enableLighting(作用于地球,不是模型) - 深度测试 :
globe.depthTestAgainstTerrain
外加两个按钮:定位模型 (再次 flyTo)、重置(移除后重新加载并把所有参数复位)。
4.2 数据体量实测
| 项 | 实测值 |
|---|---|
tileset.json |
202 KB |
| 内容文件(189 个) | 14.53 MB |
| 瓦片总数 / 带内容 | 190 / 189 |
| 树的最大深度 | 3(根 + 3 层) |
根 geometricError |
287.246 |
refine |
REPLACE |
叶子 geometricError |
0(63 个) |
| 落点 | 108.960582°E / 34.219910°N / 445.131 m |
| 包围盒尺寸 | 337.8 × 457.3 × 82.2 m |
4.3 核心优势
- 🚀 大模型靠切片与 LOD 调度:14.53 MB 的数据集不用一次性下载完
- 🎨 着色不改模型 :
Cesium3DTileStyle在渲染期染色,原始文件一个字节都不用动 - 🔧 参数全热调:高度、透明度、着色、精度都能实时改,不用重新加载
- 🩺 有官方调试器 :
Cesium3DTilesInspector直接摊开内部状态

五、小贴士:避坑指南
📐 关于高度偏移
- ⚠️ 重要提示 :它是相对平移 ,不是"设置绝对高度"。
heightOffset = 0意味着"不动",不是"放到 0 米" - 方向是椭球法线 ,与地心径向在 34.2199° 处差 0.1787° ;抬 10 m 会带来约 0.031 m 的水平位移
- 参考点是包围球心 的经纬度,不是塔基。想精确贴地,先读
tileset.boundingSphere.center的高程再算差值
🎨 关于着色与混合
- ⚠️ 重要提示 :默认档(白色 + 100%)代码主动把
style撤成undefined,此时模型按原贴图呈现,面板上的"着色/混合度"没有参与对象 colorBlendAmount只在 MIX 模式下有意义(REPLACE / HIGHLIGHT 的着色器分支不读它)- 枚举值顺序反直觉:
HIGHLIGHT = 0、REPLACE = 1、MIX = 2 - demo 默认选 MIX,Cesium 自己默认 HIGHLIGHT------两者不一致,别把面板默认值当成引擎默认值
🔍 关于精度
tileset.maximumScreenSpaceError(模型)和globe.maximumScreenSpaceError(地形)是两个旋钮- SSE 调到 2 会把远处也逼成最高精度,帧率会明显掉;16 是官方默认,够用
- 叶子瓦片
geometricError = 0,它们是这条 LOD 链的终点
🧱 关于 refine:REPLACE 和 ADD 差在哪
refine 只有两个取值,但它决定了整棵树在渲染时是"换掉"还是"叠上去":
- REPLACE(本例) :子瓦片替换父瓦片------画了孩子就不画父亲。屏幕上同一格同时只会出现一条链上的一级
- ADD :子瓦片叠加在父瓦片之上------父瓦片继续显示,孩子往上补细节。适合"先给整体轮廓、再逐块加细节"的渐进式加载
本例是 7 × 9 的单链网格,配 REPLACE 刚刚好:相机转到哪一格、离得多近,就在那一格里换一档精度,不会出现两层模型互相穿模。⚠️ 反过来,如果你的数据是 ADD,却按 REPLACE 的思路去想象它,很容易误判"细节怎么一直没出来"。
🔚 关于那 63 个叶子的 geometricError = 0
这个 0 不是笔误。geometricError 的语义是"用当前这一级代表真实几何时,最多差多少米",0 就意味着"这一级已经是真值、不需要再细化了"。
- 🔑 按屏幕空间误差的判定逻辑,几何误差为 0 的瓦片算出来的误差也恒为 0,永远不会被判成"需要细化"------它就是这条链天然的终点
- 🔑 反过来,如果哪天你看到某个非叶子 节点的
geometricError是 0,那多半是导出工具算错了:它等于在说"整体都已经精确到不能再精确",却还挂着孩子
⚙️ 关于那两个"全局"开关
面板上的"场景光照"和"深度测试"改的都是 globe,不是模型:
globe.enableLighting------ 影响的是地球有没有昼夜光影globe.depthTestAgainstTerrain------ 影响的是地形参不参与深度测试
⚠️ 别指望它们让塔本身变亮或改变塔的遮挡关系;模型的着色要走 Cesium3DTileStyle 或光源设置。
🩺 关于监视器
Cesium3DTilesInspector 会往 viewer.container 里塞一个 div。demo 在关闭时同时 destroy() 了控件并 remove() 了容器------卸载时两件事都要做,否则会留下一个孤儿面板。
坦诚局限:这个 demo 只做了"整塔统一着色",没有按构件筛选(那需要要素属性与 featureId);高度偏移也只用了一个标量。真实的 BIM 集成要做分层、分构件、按属性查询,那是另一个量级的工程。
六、写在最后
Cesium 是头优雅的大象。
我们曾经以为,加载模型不过是"给个 URL、等它出现";
今天才发现,那 190 块瓦片的排布方式、那条被当作"垂直"的法线、那组悄悄不生效的默认值------每一个细节,都是数据作者在替我们做决定。
从 #59 到 #62,我们一直在问"我造的东西怎么画得动 ";今天换了个问题:"别人造的东西,我到底能动它什么"。答案有点泄气,也很实在:能动的只有往哪挪、染什么色、看多细------而且每一个旋钮的默认行为,都得先读一遍源码才敢用。🎉
这个案例还不完美:高度偏移只是刚性平移、着色只到整塔一级、没有按构件筛选。但它把"一个真实 3D Tiles 数据集长什么样、三个旋钮各自改了什么"这件事讲清楚了,这就够了。
下一篇我们往回收一步------不再折腾模型本身,而是回到"页面打开那一刻,控件该怎么到位"。初始化加载控件,我们下一篇见。
如果你也接过别人的 3D Tiles 模型,欢迎留言说说你踩过最狠的一次坑------是坐标系对不上,还是根本不知道该往哪个方向抬?
🌟 项目已开源,欢迎 Star!(你的 Star ⭐ 就是对我最大的支持!)
仓库地址:https://github.com/jianlei-wang/CESIUM-TREASURE-BOX
代码在 src/cases/tiles-3d-dayanta/ 目录下,数据自带在 public/dayanta/(14.53 MB),复制进你的项目、把路径换成自己的 tileset 就能跑。
Cesium酱の百宝箱 · 第 63 篇
塔不是我造的,我能动的只有往哪挪、染什么色
2026 年的秋夜,与你共探"别人做好的东西该如何安放"