配套资源(大雁塔 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 / .pnts。Cesium 从 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 Imagery (services.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 上做的是切换法向量方向,语义更直观。
edgeWidth 和 edgeColor ------ 剖切面边缘的描边宽度和颜色,用来高亮显示切割位置。做技术截图时把 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(私有属性),字段名也变了:numberOfLoadedTiles → numberOfLoadedTilesTotal。
坑 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