上一节我们介绍了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的效果:


带有 LOD 的3D Tiles

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


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

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

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


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对象,它由以下属性组成。如下所示:


tile.png

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

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

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

REPLACE 方式

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,仅包围切片中的渲染模型。

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

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

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。


tilesets.png

Cesium加载3D Tiles

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

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

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

  • 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);