ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

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

第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.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 的空间划分是一棵八叉树(或四叉树),越往下一层切得越碎。把这棵树的分支数统计出来,结果完全不是:

深度瓦片数每个节点的子节点数
0163
1631
2631
363(叶子)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 单链(geometricError16 → 8 → 0)。63 × 3 = 189 块内容瓦片,根自己没有内容,189 + 1 正好是 190。

这也解释了refine: REPLACE在这里的语义:因为是单链,替换就是"用同一格的下一级精度换掉当前级",不会出现"兄弟同时显示"的情况。顺带一个细节:63 个叶子的geometricError全是 0,而且它们连refine字段都没有——叶子不需要再往下细化。

2.3 反直觉二:「抬 10 米」抬的是包围球心那条法线

面板上有个"高度偏移(m)"输入框,代码是这样的:

// 源码级剧透:Tiles3DDayantaDemo.vue :: applyHeight()constboundingSphere=tileset.boundingSphereconstcartographic=Cartographic.fromCartesian(boundingSphere.center)const{longitude,latitude}=cartographicconstsurface=Cartesian3.fromRadians(longitude,latitude,0)constoffset=Cartesian3.fromRadians(longitude,latitude,heightOffset.value)consttranslation=Cartesian3.subtract(offset,surface,newCartesian3())tileset.modelMatrix=Matrix4.fromTranslation(translation)

注意它没有把模型"放到"某个高度,而是算了两个点的差向量再整体平移。我把这个差向量量了出来:

heightOffset平移向量长度
0 m0.000000 m
5 m5.000000 m
10 m10.000000 m
50 m50.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()里有个很容易滑过去的分支:

// 源码级剧透:Tiles3DDayantaDemo.vue :: applyStyle()constalpha=Math.max(0,Math.min(1,alphaValue.value/100))if(alpha>=1&&styleColorIndex.value===0){tileset.style=undefined// 🔑 默认档(白色 + 100%)直接把样式撤掉}else{tileset.style=newCesium3DTileStyle({color:`color('${colorName}',${alpha})`})}tileset.colorBlendMode=BLEND_MODES[blendModeIndex.value].value tileset.colorBlendAmount=blendAmount.value

默认是"白色 + 100% 透明",于是style被置为undefined——这一档是"不加任何样式",模型按自己的原始贴图呈现。只有你把透明度拉到 100% 以下,或者换成青/橙/红/绿,样式才真正生成。

而下面那两行(混合模式、混合度)不管有没有样式都会被赋值。面板默认给的"混合 / 0.50",在默认档下没有东西可混。

顺带一个实测出来的坑:我把Cesium3DTileset的默认值读出来看了(用一个最小的 tileset 造出真对象):

属性Cesium 默认值
maximumScreenSpaceError16
colorBlendModeHIGHLIGHT
colorBlendAmount0.5
styleundefined
dynamicScreenSpaceErrortrue
skipLevelOfDetailfalse

而枚举值的顺序是反直觉的: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 加载与定位

// Tiles3DDayantaDemo.vue :: addModel()constmodel=awaitCesium3DTileset.fromUrl(TILESET_URL)// '/dayanta/tileset.json'model.maximumScreenSpaceError=screenSpaceError.value viewer.scene.primitives.add(model)tileset=modelapplyHeight()applyStyle()awaitviewer.flyTo(model,{offset:undefined})
  • 🔑fromUrl返回的 tileset 可以直接flyTo:它自己带着包围球
  • ⚠️await之后不等于内容都下载完了——3D Tiles 是按视野边看边下的(#31 详细拆过这件事)
  • 🔑 顺序有讲究:先add再applyHeight/applyStyle,否则tileset.boundingSphere还没准备好

3.2 高度偏移:相对平移,不是绝对落位

constsurface=Cartesian3.fromRadians(longitude,latitude,0)constoffset=Cartesian3.fromRadians(longitude,latitude,heightOffset.value)tileset.modelMatrix=Matrix4.fromTranslation(Cartesian3.subtract(offset,surface,newCartesian3()))
  • 🔑heightOffset = 0→ 平移向量是零向量 →modelMatrix是单位阵:模型保持数据自带的位置
  • ⚠️它不知道塔基在哪:参考点是包围球心,所以"抬高 10 m"对塔尖和塔基的效果是一样的(刚性平移)
  • ⚠️ 想让模型真正"贴地",得先知道数据自带的高程(本例是 445.131 m),再算差值——没有一步到位的 API

3.3 着色与混合:三种模式到底差在哪

constBLEND_MODES=[{value:Cesium3DTileColorBlendMode.REPLACE,label:'替换'},// = 1{value:Cesium3DTileColorBlendMode.HIGHLIGHT,label:'高亮'},// = 0{value:Cesium3DTileColorBlendMode.MIX,label:'混合'}// = 2]

三种模式的差别,在片元着色器里只有两行(ModelColorStageFS.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 精度与监视器

tileset.maximumScreenSpaceError=screenSpaceError.value// 2 ~ 64,默认 16// 调试面板inspectorWidget=newCesium3DTilesInspector(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.json202 KB
内容文件(189 个)14.53 MB
瓦片总数 / 带内容190 /189
树的最大深度3(根 + 3 层)
根geometricError287.246
refineREPLACE
叶子geometricError0(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 年的秋夜,与你共探"别人做好的东西该如何安放"

RELATED READING

延伸阅读

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