Cesium 加载大雁塔 3D Tiles 实录:从模型结构剖析到局部虚拟化(剖切 + 骨架线)实现

配套资源(大雁塔 3D Tiles 模型 658MB + 完整 HTML 源码 + Python http.server 启动脚本): 链接:夸克网盘分享 提取码:GMT5 文末有备用链接,网盘失效请评论区留言,我看到会补)

最近在做古建筑三维可视化的项目,手上有一套 CesiumLab 从 OSGB 转过来的 3D Tiles 数据------大雁塔景区精细化倾斜模型,63 个 tile 分块、658MB、总尺寸 335×450×85 米。需求分三层递进:加载模型 → 剖切面切开看内部结构 → 鼠标悬停局部置灰 + 骨架线透视

这几个功能单看官方文档都不难,但拼到一起就有一堆坑:Cesium 版本不匹配加载不了、tileset.json 里 transform 矩阵看不懂、剖切面滑块每帧重建 shader 卡顿、CustomShader 顶点签名不兼容直接崩掉、tileset.statistics 返回 undefined、file:// 双击打开被 CORS 拦死......

这篇把完整流程拆开写一遍,包括数据结构剖析、Cesium 版本选型、Viewer 初始化、相机自动定位、ClippingPlanes 数学原理与原地更新优化 、CustomShader 悬停局部变色(避开屏幕空间陷阱)、骨架线渲染、性能面板、部署调试的所有坑。文章末尾有配套 HTML 源码,直接下载就能跑

一、先看清 3D Tiles 数据长啥样

拿到别人转好的 3D Tiles 数据,第一件事不是急着往 Cesium 里塞,而是先扒一扒 tileset.json。这个 JSON 是整个模型的骨架,包含坐标系、包围盒、层级结构、内容文件引用等所有关键信息。

大雁塔这套数据的 tileset.json 长这样(截取关键部分):

java 复制代码
{
  "asset": {
    "generatetool": "osgb2tiles5@www.cesiumlab.com",
    "version": "1.1"
  },
  "extensionsUsed": ["3DTILES_content_gltf"],
  "extensionsRequired": ["3DTILES_content_gltf"],
  "geometricError": 567.7556605064445,
  "refine": "REPLACE",
  "root": {
    "boundingVolume": {
      "box": [-0.035, -1.598, 2.191,
              167.751, 0, 0,
              0, 224.983, 0,
              0, 0, 42.763]
    },
    "transform": [
      -0.9457, -0.3249, 0.0, 0.0,
       0.1827, -0.5319, 0.8269, 0.0,
      -0.2687,  0.7820, 0.5624, 0.0,
      -1715451.5, 4993519.2, 3566870.2, 1.0
    ],
    "geometricError": 567.7556605064445,
    "refine": "REPLACE",
    "children": [ /* 63 个子节点,每个指向 Tile_+XXX_+YYY/Tile_+XXX_+YYY.json */ ]
  }
}

关键字段解读

extensionsRequired: ["3DTILES_content_gltf"] ------ 这是最重要的信号。这个扩展表示 tile 内容是 glTF/GLB 格式(新版 3D Tiles 1.1 标准),而不是老版的 .b3dm / .i3dm / .pntsCesium 从 1.94 版本才开始支持这个扩展 ,如果你还在用 1.70、1.80 这些老版本,加载会直接报 Unsupported extension: 3DTILES_content_gltf

我踩过这个坑:项目原来锁死 Cesium 1.70(因为某个可视域分析插件混淆代码依赖私有 API),换大雁塔模型时死活加载不出来,折腾半天才定位到是扩展版本问题。解决办法要么升级 Cesium,要么让 CesiumLab 转换时勾选"输出 b3dm 兼容格式"重新转一遍。

boundingVolume.box ------ 12 个数字,前 3 个是包围盒中心的局部坐标 [cx, cy, cz],接下来 9 个是三个半轴向量(X 轴、Y 轴、Z 轴各 3 个分量)。大雁塔这里 X 半轴 167.75 米、Y 半轴 224.98 米、Z 半轴 42.76 米,也就是模型长宽约 335×450 米、总高 85 米(含地形起伏)。这个尺寸直接决定后面剖切面滑块的范围。

transform ------ 4×4 变换矩阵(列优先展开成 16 个数字),把模型局部坐标变换到 WGS84 地心地固坐标系(ECEF)。矩阵最后一列 [-1715451.5, 4993519.2, 3566870.2] 就是模型原点在地心地固坐标系里的位置。反算经纬度:

java 复制代码
import math
x, y, z = -1715451.5, 4993519.2, 3566870.2
lon = math.degrees(math.atan2(y, x))                      # 108.9594° E
lat = math.degrees(math.atan2(z, math.sqrt(x*x + y*y)))   # 34.2197° N

------正好是西安大雁塔的坐标。这一步可以验证 tileset 数据是否被人为改过原点。

refine: "REPLACE" ------ 精细化策略是"替换式",父 tile 加载后子 tile 会替换掉父 tile;另一种是 ADD(叠加式),子 tile 加到父 tile 之上做细节增强。倾斜模型基本都是 REPLACE,点云数据用 ADD 的多。

geometricError: 567.76 ------ 根节点的几何误差 567 米,意思是当相机距离远到屏幕像素误差超过 567 米对应的像素数时,就不需要精细化。这个数字直接决定 maximumScreenSpaceError 参数的合理范围,一般设成根节点 geometricError 的 1/30 到 1/50 就行。

数据分布

顶层 63 个 tile 按 Tile_+XXX_+YYY 命名,明显是 CesiumLab 按九宫格切分的(X 方向 7 块、Y 方向 9 块),每个 tile 内部还有下一级的 json,递归展开最终叶子节点才是 .glb 二进制模型文件。用 Python 快速统计一下:

java 复制代码
import os
total_files, total_size = 0, 0
for root, _, files in os.walk(r'D:\data\3d模型数据\dayanta'):
    for f in files:
        total_files += 1
        total_size += os.path.getsize(os.path.join(root, f))
print(total_files, total_size / 1024 / 1024)  # 2460 files, ~658 MB

2460 个文件、658MB,全部通过 HTTP 静态服务分发即可。

二、HTML 骨架:Cesium Viewer 初始化

先搭一个能跑的最小骨架。完整 HTML 在文末网盘里,这里贴关键片段。

引入 Cesium 1.120

java 复制代码
<script src="https://cesium.com/downloads/cesiumjs/releases/1.120/Build/Cesium/Cesium.js"></script>
<link href="https://cesium.com/downloads/cesiumjs/releases/1.120/Build/Cesium/Widgets/widgets.css" rel="stylesheet">

Cesium 官方 CDN 保留最近几个大版本,1.120 是 2024 年底的稳定版,兼容 3DTILES_content_gltf 且 CustomShader、ClippingPlanes、debugWireframe 这几个关键 API 都稳定了。如果你项目里已经有本地打包的 Cesium,改成本地路径即可:

java 复制代码
<script src="./Cesium/Cesium.js"></script>

Viewer 初始化:白底简约风

技术博客内嵌截图,深色炫酷的三维球反而抢戏。改成白底、去掉所有装饰控件:

java 复制代码
const viewer = new Cesium.Viewer('cesiumContainer', {
  animation: false, timeline: false, baseLayerPicker: false,
  geocoder: false, homeButton: false, sceneModePicker: false,
  navigationHelpButton: false, fullscreenButton: false,
  infoBox: false, selectionIndicator: false,
  imageryProvider: new Cesium.UrlTemplateImageryProvider({
    url: 'https://services.arcgisonline.com/ArcGIS/rest/services/' +
         'World_Imagery/MapServer/tile/{z}/{y}/{x}',
    maximumLevel: 19
  })
});
​
// 关闭大气、太阳、月亮、星空、雾
viewer.scene.skyAtmosphere.show = false;
viewer.scene.sun.show = false;
viewer.scene.moon.show = false;
viewer.scene.skyBox.show = false;
viewer.scene.fog.enabled = false;
viewer.scene.globe.showGroundAtmosphere = false;
viewer.scene.globe.baseColor = Cesium.Color.fromCssColorString('#f0f2f5');
viewer.scene.backgroundColor = Cesium.Color.fromCssColorString('#ffffff');

底图默认用 ESRI World Imageryservices.arcgisonline.com),CORS 完全开放、国内直连稳定、坐标系是标准 WGS84 无偏移,是 3D Tiles 叠底图最省心的选择。备选是高德卫星影像 webst0{1-4}(style=6),也近似 WGS84,肉眼看无偏移。

踩坑提醒 :高德 webrd01-04(style=8,矢量图)是 GCJ-02 火星坐标,叠 WGS84 的 3D Tiles 会偏移几百米。做 demo 千万别用矢量版,卫星版才行。

三、加载 3D Tiles 并自动定位相机

加载 tileset

Cesium 1.104 之后 Cesium3DTileset 的构造函数改成异步了,老写法 new Cesium.Cesium3DTileset({url: '...'}) 已经废弃,必须用 fromUrl

java 复制代码
Cesium.Cesium3DTileset.fromUrl('./tileset.json', {
  maximumScreenSpaceError: 16,
  maximumMemoryUsage: 1024,
  skipLevelOfDetail: true,
  immediatelyLoadDesiredLevelOfDetail: false,
  cullWithChildrenBounds: true,
  dynamicScreenSpaceError: true,
  dynamicScreenSpaceErrorDensity: 0.00278,
  dynamicScreenSpaceErrorFactor: 4.0,
  preloadWhenHidden: false
}).then(tileset => {
  viewer.scene.primitives.add(tileset);
  // ...挂载 clippingPlanes、customShader
}).catch(err => {
  console.error('加载失败:', err);
});

关键参数解读

maximumScreenSpaceError: 16 ------ 屏幕空间误差阈值,单位是像素。数字越小越精细但性能开销越大。经验值:桌面端 8-16 视觉接近无损,移动端 24-32 性能优先,大场景(一个城市)32-64 粗粒度浏览。大雁塔这种单塔模型,用 16 就够,帧率能稳定在 60FPS。

skipLevelOfDetail: true ------ 跳级加载,不严格按 LOD 层级从粗到细加载,而是直接跳到当前视角最合适的层级。首次加载速度显著提升,代价是可能看到短暂的模型跳变。倾斜模型建议开。

dynamicScreenSpaceError: true ------ 动态屏幕空间误差。远处(地平线附近)的 tile 允许更大误差,把加载资源集中在近处。三个 dynamicScreenSpaceErrorXxx 参数是官方推荐值,直接抄。

maximumMemoryUsage: 1024 ------ 显存上限 1GB。老 GPU 或者移动端调小到 512 甚至 256,Cesium 会主动淘汰远处 tile 释放显存。

相机定位:别写死经纬度

一开始我是这么写的:

java 复制代码
viewer.camera.flyTo({
  destination: Cesium.Cartesian3.fromDegrees(108.9615, 34.2178, 260),
  orientation: { heading: ..., pitch: ..., roll: 0 }
});

结果模型加载完画面一片空白------相机在模型下方。原因是 tileset 的椭球高不等于 0,大雁塔模型原点在椭球高约 400 米(西安海拔),我写死 260 米等于把相机塞到了模型底下。

正确姿势是读 tileset 的包围球,让相机自动定位

java 复制代码
const bs = tileset.boundingSphere;
viewer.camera.flyToBoundingSphere(bs, {
  offset: new Cesium.HeadingPitchRange(
    Cesium.Math.toRadians(-35),   // heading:从东南方向看
    Cesium.Math.toRadians(-32),   // pitch:俯角 32°
    bs.radius * 2.2               // range:距离模型中心 2.2 倍半径
  ),
  duration: 2.0
});

flyToBoundingSphere 会自动把包围球塞进视锥,无论模型在什么椭球高度、多大尺寸都能对准。这个坑很隐蔽,因为控制台不报错、tileset 也加载成功,就是画面里啥都看不到------第一反应以为是模型丢了,其实是相机跑偏。

四、核心之一:ClippingPlanes 剖切面

数学原理

Cesium 的 ClippingPlane 本质是一个平面方程:

java 复制代码
n · p + d = 0

其中 n = (nx, ny, nz) 是平面法向量(单位向量),d 是平面到原点的有符号距离。对于空间中任意一点 p

  • n · p + d > 0:点在法向量指向的一侧

  • n · p + d < 0:点在法向量反方向一侧

  • n · p + d = 0:点在平面上

Cesium 默认切掉 n · p + d > 0 一侧 (即法向量指向的那半边),保留另一半。多个 ClippingPlane 组合时,默认取交集 ------所有剖切面保留区域的交集才是最终可见部分;如果设 unionClippingRegions: true,就变成并集------任意一个剖切面保留即算保留。

关键:剖切面的坐标系是 tileset 的局部坐标系 (应用完 transform 之后),不是 WGS84 经纬度,也不是 ECEF。所以法向量 (1, 0, 0) 指的是模型局部 X 轴正方向。大雁塔这套数据 X 轴大致指向东偏北,Y 轴大致指向南偏东,Z 轴是重力反方向(向上)。

三向剖切面实现:创建一次 + 原地更新

用 X、Y、Z 三个方向的剖切面组合,可以切出 1/8 象限的效果。这里有个非常关键的性能优化 :不要每次滑块动都 new ClippingPlaneCollection,那样每帧都触发 shader 重编译,拖动滑块会明显卡顿,甚至有时候整个剖切"失灵"------因为 glTF 内容对频繁重建 collection 响应不稳定。

正确做法是创建一次、原地更新

java 复制代码
let clippingCollection = null;
let clipPlanes = [];   // 保持引用便于原地更新
​
function createClippingPlanes() {
  clipPlanes = [
    new Cesium.ClippingPlane(new Cesium.Cartesian3(state.x.dir, 0, 0), state.x.pos),
    new Cesium.ClippingPlane(new Cesium.Cartesian3(0, state.y.dir, 0), state.y.pos),
    new Cesium.ClippingPlane(new Cesium.Cartesian3(0, 0, state.z.dir), state.z.pos)
  ];
  clippingCollection = new Cesium.ClippingPlaneCollection({
    planes: clipPlanes,
    unionClippingRegions: state.unionRegions,
    edgeWidth: state.showEdge ? 1.5 : 0.0,
    edgeColor: Cesium.Color.fromCssColorString('#409eff')
  });
  return clippingCollection;
}
​
// 加载 tileset 时挂一次
tileset.clippingPlanes = createClippingPlanes();
​
// 滑块响应:只改属性 + 标脏
function refresh() {
  if (!tileset || clipPlanes.length < 3) return;
  const en = state.enabled;
  const OFF = 99999;   // 关闭剖切时把 distance 推远,不移除集合
  clipPlanes[0].normal   = new Cesium.Cartesian3(state.x.dir, 0, 0);
  clipPlanes[0].distance = en ? state.x.pos : OFF;
  clipPlanes[1].normal   = new Cesium.Cartesian3(0, state.y.dir, 0);
  clipPlanes[1].distance = en ? state.y.pos : OFF;
  clipPlanes[2].normal   = new Cesium.Cartesian3(0, 0, state.z.dir);
  clipPlanes[2].distance = en ? state.z.pos : OFF;
  clippingCollection.unionClippingRegions = state.unionRegions;
  clippingCollection.edgeWidth = (state.showEdge && en) ? 1.5 : 0.0;
  clippingCollection._dirty = true;   // 关键:手动标脏触发下一次渲染重编译
}

_dirty = true 是私有 API,但 Cesium 社区通用做法,比重新赋值稳定得多。实测大雁塔模型拖动滑块能稳 60 FPS,没有卡顿。

方向翻转 :想切掉模型的西半而不是东半,只需把法向量改成 (-1, 0, 0),或者保持 (1, 0, 0) 但把 xPos 设成负值。我在 UI 上做的是切换法向量方向,语义更直观。

edgeWidthedgeColor ------ 剖切面边缘的描边宽度和颜色,用来高亮显示切割位置。做技术截图时把 edgeWidth 调到 2-3、颜色用亮蓝或红色,视觉效果最直观;生产环境可以调成 0 关闭描边。

合并 vs 相交剖切

unionClippingRegions 这个开关非常关键,很多人搞混:

  • false(默认,交集):三个剖切面共同决定保留区域,只有同时满足"在 X 面保留侧、Y 面保留侧、Z 面保留侧"的像素才可见。效果是切掉模型的一个角(1/8 象限)。

  • true(并集):任意一个剖切面判定保留就保留。效果是切掉模型的三条边(相互独立)。

做技术展示时交集更直观(能看到清晰的"四分之一"或"八分之一"剖切),并集适合做特殊效果(比如同时展示三个方向的剖面)。

五、核心之二:CustomShader 悬停局部变色

需求:鼠标放到模型上时,光标周围一定半径内的塔身变灰、看起来像"被虚拟化"了。

为什么不能用 PostProcessStage

第一版我用 Cesium.PostProcessStage 写了个屏幕空间的圆形光斑,鼠标位置画个圆,圆内的像素全部置灰。看起来是对的,但有个致命问题:PostProcessStage 是屏幕空间后处理,它不知道哪些像素属于模型、哪些属于底图天空,圆内的底图、远处建筑、天空全部一起变灰,像个手电筒照过去。

用户明确反馈:"要局部变色而不是屏幕空间局部光斑"------只对模型几何生效,底图不受影响

正解:CustomShader 走片元阶段

Cesium 从 1.87 开始提供 Cesium.CustomShader,允许给 3D Tiles / Model 挂自定义 GLSL。它跑在模型渲染管线内部,只影响模型本身的像素,底图和天空完全不动。

java 复制代码
const hoverShader = new Cesium.CustomShader({
  uniforms: {
    u_center: { type: Cesium.UniformType.VEC3,  value: new Cesium.Cartesian3(0,0,0) },
    u_radius: { type: Cesium.UniformType.FLOAT, value: 25 },
    u_gray:   { type: Cesium.UniformType.FLOAT, value: 0.5 },
    u_alpha:  { type: Cesium.UniformType.FLOAT, value: 0.6 },
    u_active: { type: Cesium.UniformType.FLOAT, value: 0.0 },
    u_soft:   { type: Cesium.UniformType.FLOAT, value: 1.0 }
  },
  mode: Cesium.CustomShaderMode.MODIFY_MATERIAL,
  lightingModel: Cesium.LightingModel.PBR,
  fragmentShaderText: `
    void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) {
      if (u_active > 0.5) {
        vec3 worldPos = (czm_model * vec4(fsInput.attributes.positionMC, 1.0)).xyz;
        float d = distance(worldPos, u_center);
        if (d < u_radius) {
          float falloff = (u_soft > 0.5)
            ? (1.0 - smoothstep(u_radius * 0.35, u_radius, d))
            : 1.0;
          float strength = falloff * u_alpha;
          material.diffuse = mix(material.diffuse, vec3(u_gray), strength);
          material.alpha = material.alpha * (1.0 - strength * 0.75);
        }
      }
    }
  `
});
tileset.customShader = hoverShader;

关键设计点:

世界坐标在片元阶段算czm_model * vec4(positionMC, 1.0) 把模型局部坐标变换到 ECEF 世界坐标,然后跟 u_center(也是 ECEF)算欧氏距离。所有单位都是米,u_radius = 25 就是真实的 25 米球。

避开 czm_modelVertex 陷阱:一开始我写了顶点着色器版本,用 varying 把 worldPos 从顶点传到片元:

java 复制代码
void vertexMain(VertexInput vsInput, inout czm_modelVertex vertex) {
  v_worldPos = (czm_model * vec4(vsInput.attributes.positionMC, 1.0)).xyz;
}

结果 Cesium 1.120 编译直接崩:

java 复制代码
RuntimeError: Vertex shader failed to compile.
Compile log: ERROR: 0:1: 'czm_modelVertex' : syntax error

原因是 CustomShader 对 3D Tiles glTF 内容不暴露 czm_modelVertex 这个 struct 类型(它是 ModelExperimental 内部用的)。修法就是干脆去掉顶点着色器,全部逻辑挪到片元里。每个片元多做一次 4×4 矩阵乘法,性能损失可忽略(悬停只在鼠标周围一小片像素生效),而且精度反而更好------片元阶段算的是当前像素的世界位置,不是顶点插值,边界更锐利。

软边过渡smoothstep(u_radius * 0.35, u_radius, d) 让球心 35% 半径内是实心灰,35%-100% 之间平滑衰减,视觉上不生硬。

u_center 从哪来 :鼠标移动时用 viewer.scene.pickPosition(endPosition) 拿到光标处的世界坐标(ECEF),直接推给 shader:

java 复制代码
handler.setInputAction((movement) => {
  const picked = viewer.scene.pick(movement.endPosition);
  if (!isModel(picked)) { hoverShader.setUniform('u_active', 0.0); return; }
  const center = viewer.scene.pickPosition(movement.endPosition);
  hoverShader.setUniform('u_center', center);
  hoverShader.setUniform('u_active', 1.0);
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);

scene.pickPosition 返回的就是 ECEF 世界坐标,和 shader 里 czm_model * positionMC 得到的坐标系一致,不用再转换。

OPAQUE glTF 的透明度陷阱

material.alpha 这行看起来能让模型透明,但如果 glTF 的 alphaMode 是 OPAQUE(不透明),Cesium 会关掉 blending,alpha 值不生效------你只能看到 diffuse 变灰,看不到"透过去"的效果。

CesiumLab 从 OSGB 转出来的 glb 基本都是 OPAQUE,所以悬停效果主要是"变灰","透明"这一半是打折的。想要真透明得改 glTF 的 alphaMode(用 gltf-transform 或者 Blender 重导),或者接受"偏灰但不真透"的视觉效果。做技术演示的话,变灰已经够直观了。

六、核心之三:debugWireframe 骨架线透视

需求:置灰的同时把模型的三角网格骨架线也显示出来,看穿内部结构。

Cesium 3D Tiles 内置 tileset.debugWireframe 属性,一行代码搞定:

java 复制代码
document.getElementById('sw-wire').onclick = function() {
  this.classList.toggle('on');
  const on = this.classList.contains('on');
  tileset.debugWireframe = on;
  // 骨架线模式下关掉背面剔除,否则线框被背面裁掉一半
  tileset.backFaceCulling = !on;
  viewer.scene.requestRender();
};

技术点:

debugWireframe 底层是 polygon mode line :Cesium 会把 glTF 里的每个三角形用 gl.LINES 画出来,而不是 gl.TRIANGLES。这个特性走的是渲染管线的 polygon mode,和 CustomShader 走的 fragment stage 互不干扰------两者可以完美叠加:置灰区域内的线会变灰,区域外的线保持原色。

必须关掉 backFaceCulling:默认状态下 Cesium 会剔除背向相机的三角面,实心渲染时看不出来(因为被前面的面挡住了),但线框模式下背面那些线也会被剔除,导致骨架看起来缺了一半。关掉背面剔除,整个模型的骨架完整露出来。

性能特点:线框模式对 GPU 的 fill rate 压力比实心渲染小得多(不用填充三角形内部像素),但顶点数不变,帧率通常略高于实心模式。大雁塔 658MB 的模型开骨架线后依然能稳 50+ FPS。

视觉密度问题:倾斜模型的三角面数量惊人(一个塔身可能几百万个三角形),全开骨架线画面会非常密。建议配合悬停置灰使用------把悬停半径调到 40-60m,开骨架线,鼠标划过时能清晰看到"半径球内的线框被置灰"的效果,非常适合做技术截图。

七、性能面板:让读者看到"虚拟化"的效果

"局部虚拟化"这个词,本质是让 Cesium 只渲染当前需要的那部分模型数据。除了剖切面之外,视锥剔除(Frustum Culling)、LOD 层级切换、显存淘汰都是虚拟化的一部分。做一个实时状态面板,把这些指标可视化出来,读者一眼就能感受到。

关键陷阱tileset.statistics 这个公开属性在 Cesium 1.104+ 有时候返回 undefined(内部重构过),tileLoad 事件也不稳定。可靠做法是走 scene.postRender + tileset._statistics(私有属性)

java 复制代码
viewer.scene.postRender.addEventListener(() => {
  if (!tileset) return;
  const s = tileset._statistics;
  if (!s) return;
  document.getElementById('st-tiles').textContent   = s.numberOfLoadedTilesTotal || 0;
  document.getElementById('st-visible').textContent = s.numberOfVisibleTiles || 0;
  document.getElementById('st-tri').textContent     = s.numberOfCommands || 0;
});
​
// FPS 单独统计
let frameCount = 0, lastFpsTime = performance.now();
function tick() {
  frameCount++;
  const now = performance.now();
  if (now - lastFpsTime >= 1000) {
    document.getElementById('st-fps').textContent = frameCount;
    frameCount = 0; lastFpsTime = now;
  }
  requestAnimationFrame(tick);
}
requestAnimationFrame(tick);

_statistics 里几个关键字段:

  • numberOfLoadedTilesTotal:已加载到内存的 tile 总数(含不可见但缓存着的)

  • numberOfVisibleTiles:当前视锥内、参与渲染的 tile 数

  • numberOfCommands:GPU draw call 数量,直接反映渲染压力

  • numberOfPendingRequests:正在下载的 tile 请求数

拖动相机时你会明显看到 numberOfVisibleTiles 从 60 多骤降到十几个,这就是视锥剔除在工作。开启剖切后 numberOfCommands 也会下降,因为被剖掉的 tile 不参与绘制。

八、部署:为什么必须走 http.server(还得配 MIME)

这是新手最容易踩的坑:file:// 直接双击打开 HTML 加载不了 3D Tiles,控制台报:

java 复制代码
Access to fetch at 'file:///D:/data/.../tileset.json' from origin 'null'
has been blocked by CORS policy

原因是 Cesium 通过 fetch() 加载 tileset.json 和后续的 .glb 文件,浏览器对 file:// 协议下的 fetch 一律按跨域处理,直接拒绝。

正确做法 :起一个本地 HTTP 服务。Windows 上最简单就是 Python 自带的 http.server

java 复制代码
cd /d D:\data\3d模型数据\dayanta
python -m http.server 8080

默认的 http.server 不认识 .glb 扩展名 ,会返回 application/octet-stream,某些浏览器(尤其是 Chrome 严格模式)会拒绝解析。得自己写一个 serve.py 显式注册 MIME:

java 复制代码
import http.server, socketserver, webbrowser, os
​
class Handler(http.server.SimpleHTTPRequestHandler):
    extensions_map = {
        **http.server.SimpleHTTPRequestHandler.extensions_map,
        '.glb':  'model/gltf-binary',
        '.gltf': 'model/gltf+json',
        '.b3dm': 'application/octet-stream',
        '.json': 'application/json',
    }
    def end_headers(self):
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Cache-Control', 'no-store')
        super().end_headers()
    def log_message(self, fmt, *args):
        # 简化日志,防止某些错误路径下 args[0] 不是字符串导致 split 报错
        try:
            first = args[0] if args else ''
            if isinstance(first, str):
                print('[HTTP]', first.split('?')[0])
        except Exception:
            pass
​
PORT = 8080
with socketserver.TCPServer(('127.0.0.1', PORT), Handler) as httpd:
    print(f'Serving at http://localhost:{PORT}/viewer.html')
    webbrowser.open(f'http://localhost:{PORT}/viewer.html')
    httpd.serve_forever()

配一个 启动服务.bat注意保存为 GBK 编码,cmd.exe 默认按 GBK 读,UTF-8 保存的中文会变成乱码被当成命令执行):

java 复制代码
@echo off
title Dayanta 3D Tiles Server
cd /d "%~dp0"
python serve.py
pause

生产部署的话 Nginx 配一个 location /3dtiles/ 指向模型目录,加上 CORS 头和 MIME:

java 复制代码
location /3dtiles/dayanta/ {
    alias /data/3d-tiles/dayanta/;
    add_header Access-Control-Allow-Origin *;
    add_header Cache-Control "public, max-age=86400";
    types {
        application/json    json;
        model/gltf-binary   glb;
        model/gltf+json     gltf;
        application/octet-stream b3dm;
    }
}

九、常见坑与排查清单

坑 1:Unsupported extension: 3DTILES_content_gltf

原因:Cesium 版本 < 1.94。修复:升级到 1.120,或者让 CesiumLab 重新转成 b3dm 兼容格式(转换面板勾选"输出旧版 3D Tiles")。

坑 2:模型加载成功但画面里啥都看不到

原因:相机写死了 fromDegrees(lon, lat, height),但 tileset 的椭球高不等于 0,相机跑到模型下方或者侧面。修复:改用 viewer.camera.flyToBoundingSphere(tileset.boundingSphere, { offset: HeadingPitchRange(...) }),让 Cesium 自动算合适距离。

坑 3:Vertex shader failed to compile: 'czm_modelVertex' syntax error

原因:Cesium 1.120 的 CustomShader 对 3D Tiles glTF 内容不支持 inout czm_modelVertex vertex 这个顶点签名。修复:去掉顶点着色器,把 czm_model * positionMC 挪到片元着色器里用 fsInput.attributes.positionMC 算,效果一样、兼容性更好、精度反而更高。

坑 4:剖切面滑块拖动卡顿或者失灵

原因:每帧都 tileset.clippingPlanes = new ClippingPlaneCollection(...) 触发 shader 重编译,glTF 内容对频繁重建响应不稳定。修复:创建一次、原地更新 clipPlanes[i].normal / distance,最后手动 clippingCollection._dirty = true。关闭剖切时把 distance 推到 99999,别移除集合。

坑 5:tileset.statistics 返回 undefined

原因:Cesium 1.104+ 内部重构,公开属性不稳定。修复:走 scene.postRender 事件 + tileset._statistics(私有属性),字段名也变了:numberOfLoadedTilesnumberOfLoadedTilesTotal

坑 6:悬停置灰看起来像手电筒(连底图一起变灰)

原因:用了 PostProcessStage,那是屏幕空间后处理,不区分模型和底图。修复:改用 Cesium.CustomShader,走模型渲染管线内部,只影响模型本身的像素。

坑 7:material.alpha 设了但模型不透明

原因:glTF 的 alphaMode 是 OPAQUE,Cesium 关掉了 blending。修复:接受"变灰但不真透"的效果,或者用 gltf-transform 改 alphaMode 为 BLEND 重新导出。

坑 8:中文路径 404

原因:Cesium 内部对 tile uri 做 URL 编码,中文路径编码不匹配导致请求 404。修复:把模型放到英文路径下,比如 D:\data\3d-tiles\dayanta\。我原来的路径 D:\data\3d模型数据\dayanta 就出过问题,改成英文再没遇到。

坑 9:GPU 显存爆了浏览器崩溃

现象:加载一会儿后 Chrome 报 "Aw, Snap!",或者整个页面变白。原因:maximumMemoryUsage 默认 512MB,但如果模型超大 + 相机在低空来回飞,Cesium 会短时间加载过多 tile 撑爆显存。修复:调低 maximumMemoryUsage 到 256,同时调高 maximumScreenSpaceError 到 24-32 减少加载精度。

坑 10:骨架线只显示一半

原因:backFaceCulling 默认开启,背向相机的三角面被剔除。修复:开 debugWireframe 时同步关掉 tileset.backFaceCulling = false

十、写在最后

Cesium 加载 3D Tiles 本身不难,难的是把整个链路走通:数据结构剖析、版本兼容、坐标系对齐、相机自动定位、剖切面数学与原地更新、CustomShader 局部变色、骨架线透视、性能优化、部署调试,每一步都有坑。这篇文章把大雁塔这套数据的完整实现拆开了写,配套的 HTML 源码放在夸克网盘,下载解压后:

java 复制代码
cd dayanta
双击 启动服务.bat

浏览器会自动打开 http://localhost:8080/viewer.html,拖拖滑块就能看到剖切、悬停、骨架线三重效果。模型 + HTML 完整包体积约 660MB,其中 HTML 源码只有 34KB、Python 服务脚本 4KB,改起来很方便。

链接:夸克网盘分享 提取码:GMT5

相关推荐
FOORIR 客流统计3 小时前
智能客流系统未来趋势:3D视觉、AI识别与数据分析的融合架构
人工智能·3d·数据分析
3D小将12 小时前
3D格式转换之Rhino转换为IGS格式技术
3d·stp模型·igs模型·glb模型
PhotonixBay1 天前
InGaAs微透镜3D形貌怎么测?共聚焦显微镜在光通信的应用实践
功能测试·测试工具·3d·测试标准
学习星球1 天前
AI 一句话生成 3D 游戏世界:腾讯 HY-World 2.0 开源深度解析与本地实战
开发语言·人工智能·游戏·3d·ai·课程设计·ai编程
ai小陈2 天前
Hunyuan3D-2云端部署实战:图生3D、文生3D怎么跑更稳
人工智能·科技·3d·ai·音视频·gpu算力
独立开发之道2 天前
Three.js 创建 VR 内容:四步把一个普通 3D 场景送进头显
javascript·3d·vr
k4m7v2pz3 天前
Bevy 0.18.1 雨雪天气粒子系统排障实录:四个“想当然“如何让 3D/2D 粒子全部不可见
3d·游戏开发·3d渲染·bevy·天气粒子·2d渲染
戴西软件3 天前
戴西iDWS.3DViz Suite数据轻量化可视化软件,从传统桌面软件向云端协同的重大突破
大数据·运维·网络·人工智能·机器学习·3d
Jmyd01233 天前
实训室的 3D 模型涉及肖像文物,数据安全与合规怎么做?
安全·3d·数据安全·虚拟实训