第63篇:3DTiles-大雁塔模型——190 块瓦片不是八叉树,抬高的却是球心那条法线

上篇结尾我留了这么一句话:

"下一篇我们换条线------不再画自己的数据,而是把别人做好的一整座塔搬进地球。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.json 202 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 年的秋夜,与你共探"别人做好的东西该如何安放"

相关推荐
data analyse 4563 小时前
服务端统计时区切换的日期归并
大数据·前端·数据库·人工智能·数据分析
roman_日积跬步-终至千里3 小时前
【数据Agent论文·表格事实与生成|15】LogicNLG:数值抄对了,逻辑结论就对了吗?
人工智能·深度学习·自然语言处理·数据分析
派小心.4 小时前
DAU和MAU怎么计算?先确定活跃事件、用户键和统计窗口
大数据·服务器·前端·人工智能·数据分析
roman_日积跬步-终至千里4 小时前
【数据Agent论文·查询与语义|09】Spider 2.0:从生成SQL走向完成企业工作流
数据库·sql·数据分析
茗创科技5 小时前
静息态fMRI预处理没有一刀切:RESTplus参数选择与常见误区
神经网络·数据分析·脑网络·神经科学
xiaomuzuirenzhen5 小时前
2027秋招准备:统计学专业投商业分析、战略分析,除了SQL还要补什么?
人工智能·数据分析
Data analyse45618 小时前
rnweb 回放数据量太大怎么办?分片存储与断点续传的实现思路
前端·网络·算法·数据分析
注塑机数据采集联网18 小时前
注塑机数据采集网关对接MES系统数字化技术方案
大数据·网络·数据库·物联网·数据挖掘·数据分析
梅如你19 小时前
全国逐年 1KM 分辨率 NDVI 植被指数数据集(2000-2024)
arcgis·gis·遥感