上一节我们介绍了glTF的主要数据结构以及Cesium是如何对其进行加载的,这一节我们来介绍一下glTF的升级版3D Tiles ,也是目前 Cesium 在加载海量三维模型数据方面必须采用的一种数据格式。

3D Tiles介绍

3D Tiles 是在glTF的基础上,加入了分层LOD的概念(可以把3D Tiles简单地理解为带有 LOD 的 glTF ),专门为流式传输和渲染海量 3D 地理空间数据而设计的,例如倾斜摄影、3D 建筑、BIM/CAD、实例化要素集和点云。它定义了一种数据分层结构和一组切片格式,用于渲染数据内容。3D Tiles 没有为数据的可视化定义明确的规则,客户可以按照自己合适的方式来可视化 3D 空间数据。同时,3D Tiles 也是 OGC 标准规范成员之一,可用于在台式机、Web端和移动应用程序中实现与海量异构3D地理空间数据的共享、可视化、融合以及交互功能。下图的动画则是加入了LOD的效果:


Cesium开发基础篇 | 063D Tiles介绍及加载_json 带有 LOD 的3D Tiles


在 3D Tiles 中,一个瓦片集(Tileset)是由一组瓦片(Tile)按照空间数据结构(树状结构)组织而成的,它至少包含一个用于描述瓦片集的 JSON 文件(包含瓦片集的元数据和瓦片对象),其中每一个瓦片对象可以引用下面的其中一种格式,用于渲染瓦片内容:


Cesium开发基础篇 | 063D Tiles介绍及加载_3d_02


瓦片的内容(瓦片格式的一个单独实例)是一个二进制blob,具有特定于格式的组件,包括要素表(Feature Table)和批处理表(Batch Table)。瓦片内容参考多种要素集特征,例如表示建筑物或树木的 3D 模型或点云中的点。每个要素的位置和外观属性都存储在瓦片要素表中,其他应用于特定程序的属性则存储在批处理表中。客户端可选择在运行时选择要素,并检索其属性以进行可视化或分析。


上面表格中的b3dm 和 i3dm 格式是基于 glTF(一种专为高效传输 3D 内容而设计的开放性规范)构建的,它们的瓦片内容在二进制体中嵌入了 glTF 资源,包含模型的几何和纹理信息,而 pnts 格式却没有嵌入 glTF 资源。

瓦片中的树状组织结合了层次细节模型(Hierarchical Level of Detail,简称HLOD)的概念,以便最佳地渲染空间数据。在树状结构中,每个瓦片都有一个边界范围框属性,该边界范围框在空间中能够完全包围该瓦片和孩子节点的数据。下图为一种 3D Tiles 边界范围框所形成的层次体系示例:


Cesium开发基础篇 | 063D Tiles介绍及加载_3d_03 tree.png


瓦片集可以使用类似于 2D 空间的栅格和矢量瓦片方案(例如Web地图切片服务 WMTS 或 XYZ 方案),其在若干细节级别(或缩放级别)处提供预定义的瓦片。但是,由于瓦片集的内容通常是不一致的,或者可能很难仅在二维上组织,因此树可以是具有空间一致性的任何空间数据结构,包括k-d树,四叉树,八叉树和网格。


3D Tiles 的样式是可选的,可以将其应用于 Tileset 。样式是由可计算的表达式所定义,用于修改每个要素的显示方式。

获取更多关于3D Tiles 的信息可查其GitHub地址:​​https://github.com/CesiumGS/3d-tiles​​​和 OGC 相关规范地址:​​http://docs.opengeospatial.org/cs/18-053r2/18-053r2.html​​。下面主要简单介绍一下最核心的两个概念:Tiles、Tileset。

首先,我从一个简单的3D Tiles数据示例说起。下面代码为一个3D Tiles的主瓦片集JSON 文件(tileset.json)的一部分,也是调用3D Tiles数据的入口文件。为了尽可能少占篇幅,children部分已省略,获取完整tileset.json可查看该地址:​​https://github.com/CesiumGS/3d-tiles/blob/master/examples/tileset.json​​。

{
"asset" : {
"version": "1.0",
"tilesetVersion": "e575c6f1-a45b-420a-b172-6449fa6e0a59",
},
"properties": {
"Height": {
"minimum": 1,
"maximum": 241.6
}
},
"geometricError": 494.50961650991815,
"root": {
"boundingVolume": {
"region": [
-0.0005682966577418737,
0.8987233516605286,
0.00011646582098558159,
0.8990603398325034,
0,
241.6
]
},
"geometricError": 268.37878244706053,
"refine": "ADD",
"content": {
"uri": "0/0/0.b3dm",
"boundingVolume": {
"region": [
-0.0004001690908972599,
0.8988700116775743,
0.00010096729722787196,
0.8989625664878067,
0,
241.6
]
}
},
"children": [..]
}
}

上面代码中 root 下面的内容,就是一个Tile,即一个瓦片。

Tiles—瓦片

瓦片包含用于确定是否渲染瓦片的元数据、对渲染内容的引用以及任何子瓦片的数组。切片实际上也是一个JSON对象,它由以下属性组成。如下所示:


Cesium开发基础篇 | 063D Tiles介绍及加载_3d_04 tile.png

1)boundingVolumes(边界范围框)

定义了瓦片的最小边界范围,用于确定在运行时渲染哪个瓦片,有region、box、sphere三种形式。

2)geometricError(几何误差)

是一个非负数,以米为单位定义了不同瓦片层级的几何误差,通过几何误差来计算以像素为单位的屏幕误差(SSE),从而确定不同缩放级别下应该调用哪个层级的瓦片。简单来说,Tile的几何误差是用来确定瓦片切换层级的,即控制LOD的。

3)refine(细化方式)

确定瓦片从低级别(LOD)切换为高级别(LOD)的呈现过程,简单来说就是瓦片是如何切换的,其中包括替换(REPLACE)和添加(ADD)两种方式。替换就是直接把父级的瓦片替换掉,添加则是在父级瓦片的基础增加细节部分。如下图所示,说明了具体的切换方式:

Cesium开发基础篇 | 063D Tiles介绍及加载_数据_05 REPLACE 方式


Cesium开发基础篇 | 063D Tiles介绍及加载_数据_06 ADD 方式

理论上来说,ADD方式是一种非常好的方式,是一种增量的LOD策略,能够减少数据的传输。这里强调一下,refine属性在根节点的Tile中是必须定义的,子节点中是可选的。如果子节点没有定义,则继承父节点的该属性。

4)content(内容)

content属性指定了瓦片实际渲染的内容。content.uri属性可以是一个指定二进制块(b3dm、i3dm、pnts、cmpt)的位置,也可以是指向另一个外部的tileset.json。

content.boundingVolume属性定义了类似 Tile属性boundingVolume的边界范围框,但是content.boundingVolume是一个紧密贴合的边界范围框,仅包含切片的内容。该属性可以用来做视锥体裁剪,只渲染视图范围内的内容,如果该属性没定义,系统也会自动计算。下图是关于Tile.boundingVolumes和content.boundingVolumes 的比较,红色是Tile的boundingVolumes,包围了Tileset的整个区域;蓝色是content的boundingVolumes,仅包围切片中的渲染模型。

Cesium开发基础篇 | 063D Tiles介绍及加载_json_07

5)children(孩子节点)

这个很容易理解,因为3D Tiles是分级别的,所以每个Tile还会有子Tile、子子Tile、子子子Tile ......,分的越多,层级划分的越精细,和下面讲到的Tileset瓦片集root.children是同一个概念。

6)viewerRequestVolume(可选,观察者请求体)

定义了一个边界范围,使用与boundingVolumes相同的模式,只有当观察者处于其定义的范围内时,Tile才显示,从而精细控制了个别瓦片的显示与否。如下图所示,只有相机拉近到某一个距离时,才显示屋内的球。

Cesium开发基础篇 | 063D Tiles介绍及加载_json_08

7)transform(可选,位置变换矩阵)

定义了一个4x4的变换矩阵 ,通过此属性,Tile的坐标就可以是自己的局部坐标系内的坐标,最后通过自己的transform矩阵变换到父节点的坐标系中。它会对Tile的content、boudingVolume、viewerRequestVolume进行转换。详情可查看3D Tiles的规范文档。

Tileset—瓦片集

通常,一个3D Tiles 数据会使用一个主 tileset JSON 文件作为定义 tileset 的入口点,一般是以 tileset.json 文件命名(当然该文件名称可以修改)。从上面示例代码可以看出,tileset JSON 有四个顶级属性:asset、properties、geometricError、root。

1)asset

asset包含整个tileSet的元数据对象。asset.Version属性,用于定义3D Tiles版本,该版本指定tileset的JSON模式和基本的tileset格式。tileVersion属性可选,用于定义特定的应用程序的tileset。

2)properties

properties是一个对象,包含tileset中每个feature属性的对象。上面的例子是一个建筑物的3DTiles,因此每个瓦片都含有三维建筑物模型,每个三维建筑物模型都有高度属性,所以上面的例子中就定义了Height属性。属性中每个对象的名称与每个要素属性的名称相对应(如例子中的Height对应高度),并且包含该属性的最大值和最小值,这些值用于创建样式的颜色渐变非常有用。

3)geometricError

geometricError是一个非负数,是通过这个几何误差的值来计算屏幕误差,确定Tileset是否渲染。如果在渲染的过程中,当前屏幕误差大于这里定义的屏幕误差,这个Tileset就不渲染。即根据屏幕误差来控制Tileset中的root是否渲染。

4)root

root 是一个 JSON 对象,定义了最根级的 Tile ,它存储的是真正的Tile 。也就是说,root 的数据组织方式与 Tile 的数据组织方式是一样的。

需要注意的是,root.geometricError 与 tileset 的顶级 geometricError 不同,tileSet的geometricError是根据屏幕误差来控制tileSet中的root是否渲染,而root(tile)中的geometricError则是用来控制tile中的children是否渲染。

root.children 是一个定义子 Tile 的对象数组,每个Tile还会有其children,这样就形成了一种递归定义的树状结构。每个子 Tile 的内容完全由其父 Tile 的boundingVolume 包围,并且通常是其 geometricError 小于其父 Tile 的 geometricError,因为越接近叶子节点,模型越精细,与原模型的几何误差就越小。对于叶子节点的 Tile ,其数组的长度为零,或者是未定义 children 。

当然,为了创建树状结构,tile 的 content.uri 也可以指向外部的 tileset(另一个 tileset 的 JSON 文件)。这样做的一个好处就是,不同的tileset可以分开存储,例如我国的每个城市可单独存储成一个tileset,然后再定义一个包含所有 tileset 的全局 tileset。


Cesium开发基础篇 | 063D Tiles介绍及加载_数据_09 tilesets.png

Cesium加载3D Tiles

Cesium虽然也支持两种方式(Entity和Primitive)加载3D Tiles数据,但因为多数情况下3D Tiles数据都是成片区的数据,数据量比较大,所以为了保证性能,建议使用Primitive方式。

1)Cesium中3D Tiles相关类

我们在Cesium API帮助文档中搜索3dtile关键词,搜出如下结果:

Cesium开发基础篇 | 063D Tiles介绍及加载_数据_10

我把几个非常重要的也非常常用的类用红框标了出来,便于大家记忆。


  • Cesium3Dtileset:用于流式传输大量的异构3D地理空间数据集;
  • Cesium3DTileStyle:瓦片集样式;
  • Cesium3DTile:数据集中的一个瓦片;
  • Cesium3DTileContent:瓦片内容;
  • Cesium3DTileFeature:瓦片集要素,用于访问Tile中批量表中的属性数据,可通过scene.pick方法来获取一个 BATCH,即三维要素。Cesium3DTileFeature.getPropertyNames() 方法获取批量表中所有属性名,Cesium3DTileFeature.getProperty(string Name) 来获取对应属性名的属性值。

2)加载3D Tiles

var viewer = new Cesium.Viewer("cesiumContainer");
// 添加3D Tiles
var tileset = viewer.scene.primitives.add(
new Cesium.Cesium3DTileset({
url: "./data/Cesium3DTiles/Tilesets/Tileset/tileset.json",
// maximumScreenSpaceError: 2, //最大的屏幕空间误差
// maximumNumberOfLoadedTiles: 1000, //最大加载瓦片个数
})
);

3)设置样式

var properties = tileset.properties;
if (Cesium.defined(properties) && Cesium.defined(properties.Height)) {
tileset.style = new Cesium.Cesium3DTileStyle({
color: {
conditions: [
["${Height} >= 83", "color('purple', 0.5)"],
["${Height} >= 80", "color('red')"],
["${Height} >= 70", "color('orange')"],
["${Height} >= 12", "color('yellow')"],
["${Height} >= 7", "color('lime')"],
["${Height} >= 1", "color('cyan')"],
["true", "color('blue')"],
],
},
});
}

4)位置调整

var cartographic = Cesium.Cartographic.fromCartesian(
tileset.boundingSphere.center
);
var surface = Cesium.Cartesian3.fromRadians(
cartographic.longitude,
cartographic.latitude,
0.0
);
var offset = Cesium.Cartesian3.fromRadians(
cartographic.longitude,
cartographic.latitude,
height
);
var translation = Cesium.Cartesian3.subtract(
offset,
surface,
new Cesium.Cartesian3()
);
tileset.modelMatrix = Cesium.Matrix4.fromTranslation(translation);
})
.otherwise(function (error) {
console.log(error);
});

5)拾取要素

var handler = new Cesium.ScreenSpaceEventHandler(viewer.canvas);
handler.setInputAction(function (movement) {
var feature = viewer.scene.pick(movement.position);
if (Cesium.defined(feature) && feature instanceof Cesium.Cesium3DTileFeature) {
var propertyNames = feature.getPropertyNames();
var length = propertyNames.length;
for (var i = 0; i < length; ++i) {
var propertyName = propertyNames[i];
console.log(propertyName + ": " + feature.getProperty(propertyName));
}
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);