上篇结尾我留了这么一句话“下一篇我们换条线——不再画自己的数据而是把别人做好的一整座塔搬进地球。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/我把它直接读出来看了生成工具cesiumlabgltfUpAxis: Z3D Tiles 1.0文件大小tileset.json202 KB189 个内容文件合计14.53 MB面板提示的约 16 MB是含目录的总量瓦片总数190其中189个带内容根包围体是个box有向包围盒 OBB不是球——12 个数的那种根geometricError287.246refineREPLACE根节点自己没有内容把根的transform矩阵乘到包围盒中心上再转成经纬度塔的落点就出来了项实测值经度108.960582° E纬度34.219910° N椭球高445.131 m包围盒全尺寸337.8 × 457.3 × 82.2 m108.96°E、34.22°N——这正是西安大雁塔的位置。西安市区海拔四百多米445 m 的椭球高也对得上。2.2 反直觉一这棵瓦片树其实是一张 7 × 9 的网格我原本以为 3D Tiles 的空间划分是一棵八叉树或四叉树越往下一层切得越碎。把这棵树的分支数统计出来结果完全不是深度瓦片数每个节点的子节点数016316312631363叶子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()constboundingSpheretileset.boundingSphereconstcartographicCartographic.fromCartesian(boundingSphere.center)const{longitude,latitude}cartographicconstsurfaceCartesian3.fromRadians(longitude,latitude,0)constoffsetCartesian3.fromRadians(longitude,latitude,heightOffset.value)consttranslationCartesian3.subtract(offset,surface,newCartesian3())tileset.modelMatrixMatrix4.fromTranslation(translation)注意它没有把模型放到某个高度而是算了两个点的差向量再整体平移。我把这个差向量量了出来heightOffset平移向量长度0 m0.000000 m5 m5.000000 m10 m10.000000 m50 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 m2.4 反直觉三默认那一档压根没有样式applyStyle()里有个很容易滑过去的分支// 源码级剧透Tiles3DDayantaDemo.vue :: applyStyle()constalphaMath.max(0,Math.min(1,alphaValue.value/100))if(alpha1styleColorIndex.value0){tileset.styleundefined// 默认档白色 100%直接把样式撤掉}else{tileset.stylenewCesium3DTileStyle({color:color(${colorName},${alpha})})}tileset.colorBlendModeBLEND_MODES[blendModeIndex.value].value tileset.colorBlendAmountblendAmount.value默认是白色 100% 透明于是style被置为undefined——这一档是不加任何样式模型按自己的原始贴图呈现。只有你把透明度拉到 100% 以下或者换成青/橙/红/绿样式才真正生成。而下面那两行混合模式、混合度不管有没有样式都会被赋值。面板默认给的混合 / 0.50在默认档下没有东西可混。顺带一个实测出来的坑我把Cesium3DTileset的默认值读出来看了用一个最小的 tileset 造出真对象属性Cesium 默认值maximumScreenSpaceError16colorBlendModeHIGHLIGHTcolorBlendAmount0.5styleundefineddynamicScreenSpaceErrortrueskipLevelOfDetailfalse而枚举值的顺序是反直觉的Cesium3DTileColorBlendMode.HIGHLIGHT 0、REPLACE 1、MIX 2——不是 REPLACE 0。demo 面板默认选的是MIX和 Cesium 自己的默认HIGHLIGHT并不一致。2.5 两个同名旋钮靶子完全不同demo 里出现了两个maximumScreenSpaceError旋钮作用对象demo 取值globe.maximumScreenSpaceError地形的 LOD 精度2tileset.maximumScreenSpaceError这个模型的 LOD 精度16滑块 2~64名字一模一样一个管地球、一个管塔。想让塔更精细就拉后者拉前者只是让地形更细塔一点没变。2.6 加载时序地形先到位模型后进场onMounted的顺序是建场景 → 加载 Bing 影像 → 设globe.maximumScreenSpaceError 2→await 加载 World Terrain→ 加载模型 →flyTo。 地形是await的先让地面就位模型才落上去避免塔悬在没加载完的地形上flyTo(model)直接把相机送到模型的包围球上省得自己算视角2.7 塔是怎么站正的gltfUpAxis: Z与那 16 个数的 transformasset里有一行不起眼但很关键的标志gltfUpAxis: Z。glTF 规范默认是Y 轴朝上而这份数据里的瓦片是Z 轴朝上。Cesium 读到这个标志会自己把模型转正不用你手工加一层旋转——但如果你把同一份数据搬进别的引擎、又忘了这个标志模型就会当场躺倒。真正决定塔立在哪的是每个瓦片自己的transform16 个数一个 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()constmodelawaitCesium3DTileset.fromUrl(TILESET_URL)// /dayanta/tileset.jsonmodel.maximumScreenSpaceErrorscreenSpaceError.value viewer.scene.primitives.add(model)tilesetmodelapplyHeight()applyStyle()awaitviewer.flyTo(model,{offset:undefined})fromUrl返回的 tileset 可以直接flyTo它自己带着包围球⚠️await之后不等于内容都下载完了——3D Tiles 是按视野边看边下的#31 详细拆过这件事 顺序有讲究先add再applyHeight/applyStyle否则tileset.boundingSphere还没准备好3.2 高度偏移相对平移不是绝对落位constsurfaceCartesian3.fromRadians(longitude,latitude,0)constoffsetCartesian3.fromRadians(longitude,latitude,heightOffset.value)tileset.modelMatrixMatrix4.fromTranslation(Cartesian3.subtract(offset,surface,newCartesian3()))heightOffset 0→ 平移向量是零向量 →modelMatrix是单位阵模型保持数据自带的位置⚠️它不知道塔基在哪参考点是包围球心所以抬高 10 m对塔尖和塔基的效果是一样的刚性平移⚠️ 想让模型真正贴地得先知道数据自带的高程本例是 445.131 m再算差值——没有一步到位的 API3.3 着色与混合三种模式到底差在哪constBLEND_MODES[{value:Cesium3DTileColorBlendMode.REPLACE,label:替换},// 1{value:Cesium3DTileColorBlendMode.HIGHLIGHT,label:高亮},// 0{value:Cesium3DTileColorBlendMode.MIX,label:混合}// 2]三种模式的差别在片元着色器里只有两行ModelColorStageFS.glslmaterial.diffuse mix(material.diffuse, model_color.rgb, model_colorBlend); float highlight ceil(model_colorBlend); material.diffuse * mix(model_color.rgb, vec3(1.0), highlight);REPLACEblend 1mix取满 → 直接用样式色ceil(1) 1→ 乘白色等于不额外相乘HIGHLIGHTblend 0mix不动原色ceil(0) 0→乘以样式色乘法高亮MIXblend amount按colorBlendAmount线性插值0 是原色、1 是纯色所以colorBlendAmount只在 MIX 模式下有意义——在替换和高亮下它根本不参与运算。3.4 精度与监视器tileset.maximumScreenSpaceErrorscreenSpaceError.value// 2 ~ 64默认 16// 调试面板inspectorWidgetnewCesium3DTilesInspector(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.246refineREPLACE叶子geometricError063 个落点108.960582°E / 34.219910°N / 445.131 m包围盒尺寸337.8 × 457.3 × 82.2 m4.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 2demo 默认选 MIXCesium 自己默认 HIGHLIGHT——两者不一致别把面板默认值当成引擎默认值关于精度tileset.maximumScreenSpaceError模型和globe.maximumScreenSpaceError地形是两个旋钮SSE 调到 2 会把远处也逼成最高精度帧率会明显掉16 是官方默认够用叶子瓦片geometricError 0它们是这条 LOD 链的终点关于refineREPLACE 和 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 年的秋夜与你共探别人做好的东西该如何安放