CesiumJS 2022^ 原理[4] - 最复杂的地球皮肤 影像与地形的渲染与下载过程

API 回顾

在创建 Viewer 时可以直接指定 影像供给器ImageryProvider),官方提供了一个非常简单的例子,即离屏例子(搜 offline):

new Cesium.Viewer("cesiumContainer", {
  imageryProvider: new Cesium.TileMapServiceImageryProvider({
    url: Cesium.buildModuleUrl("Assets/Textures/NaturalEarthII"),
  })
})

这个例子的影像供给器是 TMS 瓦片服务,也就是预制瓦片地图,资源位于 Source/Assets/Textures/NaturalEarthII 文件夹下。

若没有指定 地形供给器(TerrainProvider),Cesium 也有默认策略:

// Globe.js 构造函数内
const terrainProvider = new EllipsoidTerrainProvider({
  ellipsoid: ellipsoid,
})

这就是说,使用 EllipsoidTerrainProvider 来作为默认地形,也就是把椭球面当作地形(因为没有)。

小提示:TileMapServiceImageryProvider 其实是 UrlTemplateImageryProvider 的一个子类,在文章最后一节请求瓦片时会再次提及。

本篇要解决的两大疑问:

  • 椭球体是如何构成的
  • 瓦片是如何从创建到请求,最终到渲染的

这篇比较长,不计代码也有 8000 多字,而且涉及的数据类比较多,但是我觉得能完成上述两个过程的大致讲解,就能沿着思路细化研究下去了。

1. 对象层级关系

其上层下层的主要类从属关系(从 Scene 开始算起)大致是:

Scene
┖ Globe
  ┠ Ellipsoid
  ┖ QuatreePrimitive 
    ┠ GlobeSurfaceTileProvider
    ┖ QuadtreeTile
      ┖ GlobeSurfaceTile
        ┠ TileImagery[]
        ┃ ┖ Imagery
        ┖ TerrainData

我简化了 ImageryLayerImageryProviderImageryLayerCollectionTerrainProvider 与上面这些类的关系,也就没让他们出现在图中。

1.1. Scene 中特殊的物体 - Globe

Scene 下辖的主要三维容器是 PrimitiveCollection,能继续往里套 PrimitiveCollection,也可以单独放类 Primitive

但是 Scene 中有一个三维物体是独立于 Scene 构造函数之外才创建的对象,也就是地球对象 Globe(准确的说是地表),这意味着 Scene 可以脱离 Globe,单独作一个普通三维场景容器使用也没有问题。事实上 Scene 渲染单帧的流程中,也的确如此,会判断 scene.globe 是否存在才继续 Globe 的更新、渲染。

Globe 是伴随着 CesiumWidget 的创建而创建的,优先级仅次于 SceneEllipsoid

GlobeScene 对象管理,伴随着 Scene 的单帧渲染而渲染、请求。本文中最关心的,就是地球表面的影像+地形的瓦片,它是一种稍微做了修改的四叉树数据结构。

这棵四叉树是本文的几乎全部内容,在下下小节会讲。

不过也不能忘了 Globe 的其它作用,这里提一下便带过:

  • 控制地表水面效果;
  • 拥有影像图层容器(ImageryLayerCollection),进而拥有各个影像图层(ImageryLayer),每个图层又收纳着影像供给器(ImageryProvider);
  • 拥有地形供给器(TerrainProvider);
  • 控制地球椭球体的裸色(基础色);
  • 能显示隐藏;
  • 控制瓦片显示精度;
  • 控制是否接收光照;
  • 控制部分大气着色效果;
  • 控制深度检测
  • 控制阴影效果;
  • 控制地形夸张效果;
  • 控制瓦片缓存数量;
  • 最重要的一个:控制瓦片四叉树

1.2. 地球 Globe 与椭球 Ellipsoid

Globe 既然作为地表,支撑起地表的骨架则由 Ellipsoid 定义。

Ellipsoid 定义了地球的形状,也就是数学表面 —— 旋转椭球面,是一个纯粹的数学定义,默认值是 WGS84 椭球。创建一个椭球也很简单:

// WGS84 椭球,Cesium 的默认值
new Ellipsoid(6378137.0, 6378137.0, 6356752.3142451793)

你甚至都可以传递一个其它的星球的参数(假如你有),譬如月球。

CGCS2000 椭球与 WGS84 椭球在参数上极其相似,一般无需修改椭球体定义。

1.3. 瓦片四叉树 - QuadtreePrimitive 及其成员

Globe 特殊就特殊在它维护着一棵状态极其复杂的瓦片四叉树 QuadtreePrimitive,每一帧,这个四叉树对象都要决定:

  • 瓦片下载好了吗?
  • 下载好的瓦片解析了吗?解析时要不要重投影?
  • 瓦片是否当前摄像机可见?解析的瓦片能渲染了吗?
  • 不能渲染的瓦片做好回退方案了吗?

多个异步同步的状态混合起来判断,就显得比单一三维物体的 Primitive、Entity 复杂得多了。

这个瓦片四叉树,严格来说可能有一棵,也可能有两棵,取决于瓦片的细化规则。

如果用的是 Web 墨卡托投影来做四叉树递归划分,那么只需一棵,因为 Web 墨卡托投影的坐标范围是一个正方形:

CesiumJS 2022^ 原理[4] - 最复杂的地球皮肤 影像与地形的渲染与下载过程_第1张图片

如果 直接使用经纬度范围 作为坐标值域来做四叉树递归划分瓦片,那么就需要左右两棵。

CesiumJS 2022^ 原理[4] - 最复杂的地球皮肤 影像与地形的渲染与下载过程_第2张图片

如何统一表示这两个状态呢?只需在 QuadtreePrimitive 上用个数组存 根瓦片 就好了。它的私有属性 _levelZeroTiles 就是这么一个数组,这个数组只有 0、1、2 三个长度,即 0 代表当前没有根瓦片,1 代表使用 Web 墨卡托投影的范围做四叉树,2 代表使用地理经纬度来做四叉树。

QuadtreeTile
┖ GlobeSurfaceTile
  ┠ TileImagery[]
  ┃ ┖ Imagery
  ┖ *TerrainData

根瓦片乃至任意瓦片都是 QuadtreeTile 类型的。

每个 QuadtreeTile 都有一个 data 成员,代表这棵抽象空间四叉树的任意瓦片上的 数据,类型为 GlobeSurfaceTile;作为一个瓦片的数据,必然有多层影像和单个地形数据构成,瓦片的多层影像数据交由 TileImageryImagery 完成管理,地形数据则由 HeatmapTerrainData 完成管理。

当然,地形供给器有多种类型,自然就还有其它的地形数据类,譬如 QuantizedMeshTerrainDataGoogleEarthEnterpriseTerrainData 等。

GlobeSurfaceTileProvider 则是抽象瓦片对象 QuadtreeTile 和具体瓦片数据,或者叫数据瓦片 GlobeSurfaceTile 的中间人,负责一系列计算。

这些对象的创建,分散在第 2、3、4、5 节中。

2. 瓦片四叉树单帧四个流程

Scene 原型链上的 render 函数中不难找到 Globe 在一帧内的更新、渲染步骤:

// 步骤:
update ~ beginFrame ~ render ~ endFrame

[Module Scene.js]
Scene.prototype.render()
  fn prePassesUpdate()
    [Module Globe.js]
    Globe.prototype.update() // ①
  fn render()
    Globe.prototype.beginFrame() // ②
    fn updateAndExecuteCommands()
      fn executeCommandsInViewport()
        fn updateAndRenderPrimitives()
          [Module Globe.js]
          Globe.prototype.render() // ③
    [Module Globe.js]
    Globe.prototype.endFrame() // ④

Globe 的这 4 个步骤,实际上都是由 QuadtreePrimitive 同名的方法完成的:

Globe.prototype.update()
  [Module QuadtreePrimitive.js]
  QuadtreePrimitive.prototype.update()
  
Globe.prototype.beginFrame()
  [Module QuadtreePrimitive.js]
  QuadtreePrimitive.prototype.beginFrame()

Globe.prototype.render()
  [Module QuadtreePrimitive.js]
  QuadtreePrimitive.prototype.render()
  
Globe.prototype.endFrame()
  [Module QuadtreePrimitive.js]
  QuadtreePrimitive.prototype.endFrame()

接下来就是对这 4 个步骤分步解析。

3. 更新与起帧

更新和起帧比较简单,主要是控制图层可见、初始化各种对象的状态的,没什么复杂的行为,所以在第 3 节中一起讲了。

3.1. 更新过程 - Globe 的 update

Globe 原型链上的 update 函数开始看,它是 Scene 渲染单帧时,对 Globe 作的第一个大操作。

Globe.prototype.update()
  [Module QuadtreePrimitive.js]
  QuadtreePrimitive.prototype.update()
    [Module GlobeSurfaceTileProvider.js]
    GlobeSurfaceTileProvider.prototype.update()
      [Module ImageryLayerCollection.js]
      ImageryLayerCollection.prototype._update()

抽离主干,发现主要线索指向的是 ImageryLayerCollection 原型链上的私有方法 _update,这个瓦片图层容器,就是创建 Globe 时实例化的那一个:

function Globe(ellipsoid) {
  // ...
  const imageryLayerCollection = new ImageryLayerCollection();
  this._imageryLayerCollection = imageryLayerCollection;
  // ...
  this._surface = new QuadtreePrimitive({
    tileProvider: new GlobeSurfaceTileProvider({
      terrainProvider: terrainProvider,
      imageryLayers: imageryLayerCollection,
      surfaceShaderSet: this._surfaceShaderSet,
    }),
  });
}

它会直接传递给 GlobeSurfaceTileProvider,并在其被 QuadtreePrimitive 更新时,一同更新,也就是直接执行 ImageryLayerCollection 原型链上的私有更新函数:

GlobeSurfaceTileProvider.prototype.update = function (frameState) {
  this._imageryLayers._update();
};

影像图层容器的私有更新函数 _update 做了什么呢?这个函数只有三十多行,更新容器内所有 ImageryLayer 的可见状态,顺便触发相关事件,就这么简单。

所以说,第一道过程“更新”,实际上只是:


① update - 更新影像图层的 show 状态


3.2. 起帧过程 - Globe 的 beginFrame

起帧的大致流程,几乎就是发生在 QuadtreePrimitive.js 模块内的:

Globe.prototype.beginFrame()
  [Module QuadtreePrimitive.js]
  QuadtreePrimitive.prototype.beginFrame()
    fn invalidateAllTiles()
    [Module GlobeSurfaceTileProvider.js]
    GlobeSurfaceTileProvider.prototype.initialize()
    fn clearTileLoadQueue()

解读上面这个流程。

起帧是由 Globe 原型链上的 beginFrame 方法出发,会先判断是否有水面效果,有的话继续判断是否有水面法线贴图的相关资源,没有则会创建纹理对象。判断水面这里不涉及太多复杂的作用域跳转,不过多介绍了。该方法会作通道判断,若为渲染通道,才设置 GlobeSurfaceTileProvider 的一系列状态。

接下来才是起帧的重点:

  • 无效化全部瓦片(跟重置状态一个意思)
  • 重新初始化 GlobeSurfaceTileProvider
  • 清除瓦片四叉树内的加载队列

这 3 个分步骤,由 QuadtreePrimitive.js 模块内的两个函数 invalidateAllTiles()clearTileLoadQueue() 以及 GlobeSurfaceTileProvider 原型链上的 initialize 方法按上述流程中的顺序依次执行。

下面是文字版解析。

  • 函数 invalidateAllTiles 调用条件及作用

    • 条件:当 GlobeSurfaceTileProvider 改变了它的 TerrainProvider 时,会要求下一次起帧时 QuadtreePrimitive 重设全部的瓦片
    • 作用:先调用 clearTileLoadQueue 函数(QuadtreePrimitive.js 模块内函数),清除瓦片加载队列;随后,若存在零级根瓦片(数组成员 _levelZeroTiles),那么就调用它们的 freeResources 方法(QuadtreeTile 类型),释放掉所有瓦片上的数据以及子瓦片递归释放
  • 方法 GlobeSurfaceTileProvider.prototype.initialize 的作用:

    • 作用①是判断影像图层是否顺序有变化,有则对瓦片四叉树的每个 QuadtreeTile 的 data 成员上的数据瓦片重排列
    • 作用②是释放掉 GlobeSurfaceTileProvider 上上一帧遗留下来待销毁的 VertexArray
  • 函数 clearTileLoadQueue 作用更简单,清空了 QuadtreePrimitive 对象上三个私有数组成员,即在第 5 部分要介绍的三个优先级瓦片加载队列,并把一部分调试状态重置。

简单的说,起帧之前:


② beginFrame - 打扫干净屋子好请客


4. 瓦片的渲染 - Globe 的 render

这个阶段做两件事:

  • 选择要渲染的瓦片
  • 创建绘制指令

瓦片四叉树的类名是 QuadtreePrimitive,其实它也有普通 Primitive 类似的功能。

Primitive 在它原型链的 update 方法中创建了绘图指令(DrawCommand),添加到帧状态对象中。

QuadtreePrimitive 则把创建指令并添加到帧状态对象的过程拉得很长,而且作为一个在场景中非常特殊、复杂的对象,这么做是合理的;不过,它创建绘图指令的过程不是 update 方法了,而是源自上层 Globe 对象的 render 方法。

大致流程:

Globe.prototype.render()
  [Module QuadtreePrimitive.js]
  QuadtreePrimitive.prototype.render()
    [Module GlobeSurfaceTileProvider.js]
    GlobeSurfaceTileProvider.prototype.beginUpdate()
    fn selectTilesForRendering()
      fn visitIfVisible()
        [Module GlobeSurfaceTileProvider.js]
        GlobeSurfaceTileProvider.prototype.computeTileVisibility()
        fn visitTile()
    fn createRenderCommandsForSelectedTiles()
      [Module GlobeSurfaceTileProvider.js]
      GlobeSurfaceTileProvider.prototype.showTileThisFrame()
    [Module GlobeSurfaceTileProvider.js]
    GlobeSurfaceTileProvider.prototype.endUpdate()
      fn addDrawCommandsForTile()

在 Chrome 开发者工具中也能截到类似的过程(断点设在 GlobeSurfaceTileProvider.js 模块的 addDrawCommandsForTile 函数中):

CesiumJS 2022^ 原理[4] - 最复杂的地球皮肤 影像与地形的渲染与下载过程_第3张图片

比较长,有两个 QuadtreePrimitive.js 模块内的函数比较重要:

  • selectTilesForRendering()
  • createRenderCommandsForSelectedTiles()

对应就是刚刚提到的两件事:选择瓦片、创建指令。

这两个函数是夹在 GlobeSurfaceTileProvider 原型链上 beginUpdate()endUpdate() 方法之间的,其中,endUpdate() 方法将创建好的 绘图指令 添加到帧状态对象(FrameState)中。

4.1. 选择要被渲染的瓦片 - selectTilesForRendering

QuadtreePrimitive 原型链上的 render 方法开始,我们直接进入渲染通道的分支(拾取通道给需要调试学习的人研究吧):

// QuadtreePrimitive.prototype.render 中
if (passes.render) {
  tileProvider.beginUpdate(frameState);

  selectTilesForRendering(this, frameState);
  createRenderCommandsForSelectedTiles(this, frameState);

  tileProvider.endUpdate(frameState); // 4.2 小节介绍
}

首先是 GlobeSurfaceTileProvider 对象的 beginUpdate 方法被调用,它会清空这个对象上的已经被渲染过的 QuadtreeTile 数组 _tilesToRenderByTextureCount,并更新裁剪平面(_clippingPlanes),然后才是重中之重的瓦片对象选择函数 selectTilesForRendering()

一进入 selectTilesForRendering() 函数,复杂且漫长的瓦片可见性、是否被选择的计算就开始了。这些瓦片就像是养殖场待选的鱼一样,浮出来的,也许就被捞走了。

下面用三个小节简单介绍这个选择函数的步骤,不涉及具体算法实现。

步骤① 清除待渲染瓦片的数组容器 - _tilesToRender

selectTilesForRendering() 函数会立即清除瓦片四叉树类(QuadtreePrimitive)上的 待渲染瓦片数组 _tileToRender(每个元素是 QuadtreeTile):

const tilesToRender = primitive._tilesToRender;
tilesToRender.length = 0;

这一步不难理解。它这个行为,侧面反映出 Scene 渲染一帧会完全清空上一帧要渲染的四叉树瓦片。

步骤② 判断零级瓦片的状态 - _levelZeroTiles

上一步结束后立刻会判断瓦片四叉树上的零级瓦片是否存在,不存在则要创建出来。零级瓦片在上文 1.3 小节提过,是一个数组对象 _levelZeroTiles

GlobeSurfaceTileProvider 不存在是无法创建零级瓦片的。

const tileProvider = primitive._tileProvider;
if (!defined(primitive._levelZeroTiles)) {
  if (tileProvider.ready) {
    const tilingScheme = tileProvider.tilingScheme;
    primitive._levelZeroTiles = QuadtreeTile.createLevelZeroTiles(
      tilingScheme
    );
    // ...
  } else {
    return;
  }
}

QuadtreeTile 的静态方法createLevelZeroTiles() 使用瓦片四叉树上的瓦片分割模式(tilingScheme)来创建零级瓦片。其实就是判断是 WebMercator 的正方形区域还是经纬度长方形区域,用一个简单的两层循环创建 QuadtreeTile

步骤③ 递归遍历零级瓦片 - visitTile

上一步若能进一步向下执行,那零级瓦片数组必定存在零级瓦片,在 selectTilesForRender() 函数中的最后使用一个 for 循环来遍历它们,会执行深度优先遍历。

这个循环之前还有一些简单的相机运算,状态、数据运算,比较简单,就不展开了
for (i = 0, len = levelZeroTiles.length; i < len; ++i) {
  // ...忽略分支逻辑层级
  visitIfVisible(/* ... */);
  // ...
}

循环内先判断瓦片对象是否可以渲染,不能则代表此四叉树瓦片还没下载完数据,将它放入高优先加载数组等第 5 节的终帧过程下载;

循环这一步,还能向下延伸两层函数,第一个就是 visitIfVisible() 函数,第二个是 visitTile() 函数:

function visitIfVisible(/* ... */) {
  if (
    tileProvider.computeTileVisibility(tile, frameState, occluders) !==
    Visibility.NONE
  ) {
    return visitTile(
      primitive,
      frameState,
      tile,
      ancestorMeetsSse,
      traversalDetails
    );
  }
    
  // ...
}

GlobeSurfaceTileProvider 原型链上的 computeTileVisibility 方法会计算瓦片的可见性(Visibility),对于不是不可见的瓦片,立即进入递归访问瓦片的函数 visitTile()

visitTile 函数的计算量比较大,接近 300 行的数学计算量,这就是 CesiumJS 剔除瓦片,甚至是瓦片调度的核心算法。

算法以后有兴趣可以展开细讲,但是这篇文章介绍的并不是算法,就省略这些算法实现了。

既然是四叉树结构,本级瓦片与子一级的四个瓦片的判断就需要慎重设计。因此,在 visitTile 函数内有一个比较长的分支,是判断到本级瓦片可被细分的状态时要进行的:

if (tileProvider.canRefine(tile)) {
  // ... 140+ 行
}

visitTile 函数中,涉及对本级、子一级瓦片各种状态(屏幕空间误差、瓦片数据加载情况、父子替代性优化等)的判断,剩下的活儿就是把合适的瓦片添加至瓦片四叉树上的 _tilesToRender 数组,并再次发起加载级别高优先的瓦片数组的加载行为:

addTileToRenderList(primitive, tile);
queueTileLoad(primitive, primitive._tileLoadQueueHigh, tile, frameState);

queueTileLoad 函数比较简单,省略细节;那么 addTileToRenderList 函数就是下一节要重点介绍的了,它把经过是否可见、数据是否加载完毕、父子判断后还存活的 QuadtreeTile 生成 DrawCommand,相当于 Primitive 中的 update 方法,会向帧状态添加绘图指令(也叫绘制指令)。

有人可能会好奇,数据都没通过 HTTP 请求下载下来,这怎么就到生成 DrawCommand 了呢?是这样的,CesiumJS 是一个 WebGL 可视化运行时,渲染当然是第一任务。所以在 4.1 这一节中会有大量的“瓦片是否加载好”的判断,能拿去创建绘制指令的瓦片,必须数据是已经准备好的,而没准备好的,在第 5 小节会请求、下载、创建瓦片等动作。

4.2. 创建指令前的准备操作 - showTileThisFrame

瓦片经过复杂的选择后,QuadtreePrimitive 类就开始为这些摆放到 _tilesToRender 数组中的 QuadtreeTile 生成当前帧的 绘图指令(DrawCommand)。不过,在创建绘图指令之前,还需要对数组内的 QuadtreeTile 对象们做一下是否真的能被填充到瓦片上的判断,也就是 createRenderCommandsForSelectedTiles 函数的调用:

// QuadtreePrimitive.js 中
function createRenderCommandsForSelectedTiles(primitive, frameState) {
  const tileProvider = primitive._tileProvider;
  const tilesToRender = primitive._tilesToRender;

  for (let i = 0, len = tilesToRender.length; i < len; ++i) {
    const tile = tilesToRender[i];
    tileProvider.showTileThisFrame(tile, frameState);
  }
}

这个函数比较短。它遍历的是瓦片四叉树对象上的 _tilesToRender 数组,这个数组是什么?上一小节第 ③ 步的 visitTile 函数最后会把选到的瓦片通过 addTileToRenderList 函数,把选出来的瓦片添加到这个数组中。

遍历这个数组干嘛呢?做创建 DrawCommand 前的最后一道判断,调用 GlobeSurfaceTileProvider 原型链上的 showTileThisFrame 方法。

这个 showTileThisFrame 方法会统计传进来的 QuadtreeTiledata 成员( TileImagery 类型) 上的 imagery 成员(Imagery[] 类型)有多少个是准备好的,条件有二:

  • Imagery 数据对象是准备好的
  • Imagery 对象对应的 ImageryLayer 不是全透明的

然后,使用这个“准备好的瓦片的个数”作为键,在 GlobeSurfaceTileProvider 上重新初始化待渲染瓦片的数组:

let tileSet = this._tilesToRenderByTextureCount[readyTextureCount];
if (!defined(tileSet)) {
  tileSet = [];
  this._tilesToRenderByTextureCount[readyTextureCount] = tileSet;
}

并将这个 QuadtreeTile 添加到这个 tileSet

tileSet.push(tile);

这个 showTileThisFrame 方法还要判断一下 GlobeSurfaceTile 对象上的 VertexArray 是否准备好了,如果准备好了,那么就标记 GlobeSurfaceTileProvider_hasFillTilesThisFrame 为 true,即当前帧已被填充数据;否则就标记 _hasLoadedTilesThisFrame 为 true,即当前帧已加载数据但未生成 VertexArray

事已至此,终于完成了一个瓦片的判断,上战场的时刻到了。

4.3. 为选择的瓦片创建绘制指令 - addDrawCommandsForTile

最后的 GlobeSurfaceTileProvider 对象的 endUpdate 方法才会真正完成指令的创建。

GlobeSurfaceTileProvider.prototype.endUpdate 方法有三个行为:

  • 混合可填充瓦片和已加载但未填充的瓦片,使用 TerrainFillMesh.updateFillTiles 静态方法
  • 更新地形夸大效果
  • 使用双层循环遍历上一步判断已准备好的 QuadtreeTile,调用 addDrawCommandsForTile 创建 DrawCommand

重点也就是最后一个行为,创建绘图指令才是真正的终点,也就是 addDrawCommandsForTile 函数的调用。

它综合了 Globe 上所有的行为、数据对象的效果,主要责任就是把 QuadtreeTile 上的各种资料转换为 DrawCommand,细分一下责任:

  • 判断 VertexArray
  • 判断 TerrainData
  • 判断水面纹理
  • 创建 DrawCommand 所需的各种资源(ShaderProgram、UniformMap、RenderState 等),并最终创建 DrawCommand

这个函数相当长,接近 700 行,但是创建指令的代码(new DrawCommand)在这个模块文件中也只有一处,不过如此:

// GlobeSurfaceTileProvider.js 模块内函数
function addDrawCommandsForTile(tileProvider, tile, frameState) {
  // ...省略层级
  if (tileProvider._drawCommands.length <= tileProvider._usedDrawCommands)   {
    command = new DrawCommand()
    command.owner = tile
    command.cull = false
    command.boundingVolume = new BoundingSphere()
    command.orientedBoundingBox = undefined
  } else {
    /* ... */
  }
  
  // ...
  pushCommand(command, frameState) // 将指令对象添加到帧状态上
  // ...
}

其中,pushCommand 这个模块内的函数就是把指令经过简单判断后就添加到帧状态对象中,大功告成。

绘图指令创建完毕,并移交给帧状态对象后,地球渲染地表瓦片的全程,就结束了。但是你一定会有一个问题:

QuadtreeTile 上的数据哪来的?

这就不得不说到第四个过程了,也就是后置在渲染过程后的终帧过程,它就负责把待加载(下载、解析)的瓦片完成网络数据请求、解析。

劳烦看下一节:

5. 瓦片数据的请求与处理 - Globe 的 endFrame

终帧其实发生了很多事情,包括数据的下载、解析成纹理等对象,甚至瓦片的重投影。

Globe.prototype.endFrame()
  [Module QuadtreePrimitive.js]
  QuadtreePrimitive.prototype.endFrame()
    fn processTileLoadQueue()
    fn updateHeights()
    fn updateTileLoadProgress()

这一道流程做了优化,在相机飞行等过程中是不会进行的,以保证动画性能。

5.1. 回顾瓦片对象层级关系

QuadtreeTile
┖ GlobeSurfaceTile
  ┠ TileImagery[]
  ┃ ┖ Imagery → ImageryLayer → *ImageryProvider
  ┖ (Heightmap|QuantizedMesh|GoogleEarthEnterprise)TerrainData

瓦片四叉树,从抽象的角度来看,必然有一个四叉树对象,也就是 QuadtreePrimitive,它每一个节点即 QuadtreeTile,也就是树结构上的一个元素。

QuadtreePrimitiveQuadtreeTile 并不负责数据管理,它们的作用是数据结构方面的调度,比如根据四叉树瓦片的索引计算其空间范围、可见性、渲染状态等,兢兢业业地提供着四叉树这种数据结构带来的索引性能提升。

四叉树瓦片对象有一个 data 成员属性,类型是 GlobeSurfaceTile,这个才是瓦片的数据本身。GlobeSurfaceTile 对象收纳着影像服务在该四叉树瓦片位置上的影像,以及地形数据。GlobeSurfaceTile 对象有一个 imagery 成员,它是 TileImagery 类型的数组,每一个 TileImagery 就代表一个影像图层在该瓦片处的瓦片图像。

由于 TileImagery 是与瓦片四叉树这一脉相关联的,属于数据模型一层,而真正对服务端的影像服务发起请求的是 ImageryLayer 拥有的各种 ImageryProvider,所以 TileImagery 就用 readyImageryloadingImagery 两个类型均为 Imagery 的成员负责与 ImageryLayer 相关联。这个 Imagery 就是由 ImageryLayer 中的某种 ImageryProvider 在下载数据之后创建的 单个影像瓦片,在 Imagery 上就有用瓦片图像生成的 Texture 对象。

关于 GlobeSurfaceTile 的形状,也就是地形数据,在 CesiumJS 中有多种地形数据可供选择,这里不细细展开了,常用的有高度图(HeightmapTerrainData)、STK(QuantizedMeshTerrainData)等,取决于使用的地形提供器(如 EllipsoidTerrainProvider)。有兴趣的可以去学习一下 fuckgiser 的相关博客。

5.2. 地形瓦片(TerrainData)的下载

瓦片的外观是由影像部分负责的,瓦片的形状则由地形服务负责。在本节最开始的代码简易流程中,QuadtreePrimitiveendFrame 函数首先会执行 processTileLoadQueue 函数,这个函数实际上就是取 QuadtreePrimitive 这棵四叉树对象上的三个瓦片加载队列,按顺序进行瓦片加载:

// QuadtreePrimitive.js 中
function processTileLoadQueue(primitive, frameState) {
  const tileLoadQueueHigh = primitive._tileLoadQueueHigh;
  const tileLoadQueueMedium = primitive._tileLoadQueueMedium;
  const tileLoadQueueLow = primitive._tileLoadQueueLow;
  
  // ...
  
  let didSomeLoading = processSinglePriorityLoadQueue(/* ... */);
  didSomeLoading = processSinglePriorityLoadQueue(/* ... */);
  processSinglePriorityLoadQueue(/* ... */);
}

processSinglePriorityLoadQueue 这个模块内的函数会处理单个加载队列,一个一个来,高优先级的 _tileLoadQueueHigh 数组先被这个函数处理,然后是中优先级、低优先级,按顺序。它的代码主要就是一个 for 循环,使用 tileProvider 这个传入的参数(GlobeSurfaceTileProvider 类型)的 loadTile 方法,加载每个被遍历到的 QuadtreeTile

GlobeSurfaceTileProvider.prototype.loadTile
  GlobeSurfaceTile.processStateMachine
    fn processTerrainStateMachine
      GlobeSurfaceTile 地形状态判断

这个被遍历到的 QuadtreeTile 经过层层传递,到 GlobeSurfaceTile 的静态方法 processStateMachine 之后,交由模块内函数 processTerrainStateMachine 先进行了地形数据的处理(这个函数先处理地形数据,然后才处理影像数据)。

你可以在这个 processTerrainStateMachine 函数内看到并列的几个 if 分支,它们对这个层层传下来的 QuadtreeTile 的数据本体,也就是它的 data 成员(GlobeSurfaceTile 类型)的状态进行判断,满足哪个状态,就进行哪一种处理:

function processTerrainStateMachine(/* 参数 */) {
  const surfaceTile = tile.data;
  // ...
  if (
    surfaceTile.terrainState === TerrainState.FAILED &&
    parent !== undefined
  ) { /* ... */ }
  if (surfaceTile.terrainState === TerrainState.FAILED) { /* ... */ }
  if (surfaceTile.terrainState === TerrainState.UNLOADED) { /* ... */ }
  if (surfaceTile.terrainState === TerrainState.RECEIVED) { /* ... */ }
  if (surfaceTile.terrainState === TerrainState.TRANSFORMED) { /* ... */ }
  if (
    surfaceTile.terrainState >= TerrainState.RECEIVED &&
    surfaceTile.waterMaskTexture === undefined &&
    terrainProvider.hasWaterMask
  ) { /* ... */ }
}

从这 6 个状态判断分支中,可以看到 CesiumJS 是如何设计瓦片地形数据加载的优先级的:

  • 若没加载成功当前瓦片的地形且上级瓦片存在,则判断父级瓦片是否准备好,没准备好则让它继续走 GlobeSurfaceTile.processStateMachine 这个静态函数;
  • 紧随上一步,用父级瓦片向上采样(当前 Tile 没准备好,就用父级的地形)
  • 紧随上一步,若 GlobeSurfaceTile 的地形状态是未加载,那么调用 requestTileGeometry 这个模块内函数,使用对应的地形供给器发起网络数据请求;
  • 若在当前帧中已经接收到了网络请求下来的数据,那么第 4 个分支就去创建网格对象;
  • 若已经处理成网格对象,那么第 5 个分支就会创建 WebGL 所需的资源,即顶点缓冲,这一步会使用 GlobeSurfaceTile 更新瓦片的地形夸张效果状态;
  • 最后一个分支,处理水面效果。

processTerrainStateMachine 函数执行完毕后,紧接着流程作用域会返回到 GlobeSurfaceTile.processStateMachine 静态函数,继续下载影像瓦片。

5.3. 影像瓦片(Imagery)的下载

上一小节(5.2)结束了地形数据的战斗,又立马开始了影像的运作。

这一个过程是由 GlobeSurfaceTile 对象的 processImagery 方法执行的,大致流程如下:

// 上级作用链是 QuadtreePrimitive 对象的 endFrame 方法,一直到 GlobeSurfaceTile 类的 processStateMachine 静态方法

GlobeSurfaceTile.prototype.processImagery
  ImageryLayer.prototype._createTileImagerySkeletons
    new Imagery
    new TileImagery
  TileImagery.prototype.processStateMachine
    Imagery.prototype.processStateMachine
      | ImageryLayer.prototype._requestImagery
          *ImageryProvider.prototype.requestImage
      | ImageryLayer.prototype._createTexture
      | ImageryLayer.prototype._reprojectTexture

首先,先由 ImageryLayerGlobeSurfaceTile 创建 TileImagery & Imagery,并将 Imagery 送入缓存池,这一步参考 ImageryLayer 原型链上的 _createTileImagerySkeletons 方法,这个方法比较长,你可以直接拉到方法末尾找到 new TileImagery,简单的说,就是先要确定装数据篮子存在,没有就创建出来。

待确定篮子存在后,才调用 TileImagery 对象的 processStateMachine 方法,进而调用 Imagery 对象的 processStateMachine 方法,去根据 Imagery 的状态选择不同的处理方法:

Imagery.prototype.processStateMachine = function (
  frameState,
  needGeographicProjection,
  skipLoading
) {
  if (this.state === ImageryState.UNLOADED && !skipLoading) {
    this.state = ImageryState.TRANSITIONING;
    this.imageryLayer._requestImagery(this);
  }

  if (this.state === ImageryState.RECEIVED) {
    this.state = ImageryState.TRANSITIONING;
    this.imageryLayer._createTexture(frameState.context, this);
  }

  const needsReprojection =
    this.state === ImageryState.READY &&
    needGeographicProjection &&
    !this.texture;

  if (this.state === ImageryState.TEXTURE_LOADED || needsReprojection) {
    this.state = ImageryState.TRANSITIONING;
    this.imageryLayer._reprojectTexture(
      frameState,
      this,
      needGeographicProjection
    );
  }
};

其实,就三个状态:

  • 没加载且不忽略加载时,由 ImageryLayer 对象发起网络请求
  • 数据接收后,由 ImageryLayer 对象创建 Texture 对象
  • 纹理创建好后,由 ImageryLayer 进行重投影

我们这篇文章就不展开纹理对象的介绍和重投影的介绍了,重点还是影像瓦片的下载:调用 ImageryLayer 的数据请求方法 _requestImagery,进而调用 ImageryProviderrequestImage 方法请求瓦片。

ImageryLayer 来自 ImageryLayerCollection,这个容器对象由 Globe 对外暴露以供开发者添加图层,对内则从 GlobeSurfaceTileProvider 一直向下传递到需要的类上

那么,瓦片的影像部分就完成了下载、生成纹理。

5.4. 小结

第四道过程,也就是终帧过程结束后,Scene 中渲染地球对象的全部任务才算完成。

在这一道过程中,主要还是为下一帧准备好地形和影像瓦片数据,期间会使用 WebWorker 技术进行地形数据的处理,还会发起影像瓦片的网络请求。

我个人认为,这一步理清各种对象之间的关系非常重要。后期考虑画一下对象关系图,Globe 这一支上的类还是蛮多蛮杂的。

数据最终都会记录在 QuadtreeTiledata 字段(GlobeSurfaceTile 类型)上,等待 Globe 下一帧渲染时(也就是回到本文第 3 节)取用。

6. 总结

我预料到地球的渲染会比较复杂,但是没想到这个会比 Primitive API、比 Entity API 更复杂,所以花了较长时间去研究源码,是 Entity API 耗时的两倍多。

实话说,我写这篇很粗糙,甚至有可能出现前后表述不相接,还请读者谅解。

Globe 作为 Scene 中较为特殊的一个三维物体,不像 Entity API 那样用事件机制完成渲染循环的挂载、Primitive 的生成,最重要的就是它维护的瓦片四叉树对象,负责渲染(直接创建 DrawCommandComputeCommand 等)、网络请求瓦片数据并解析,计算瓦片可见、可渲染、多效果叠加(也就是所谓的瓦片调度),这就比 Entity APIPrimitive API 要复杂得多。

我本以为,一个四叉树对象,每个节点对象在渲染时用相机视锥体判断一下可不可见,数据有没有,就算全部了,没想到真正由 CesiumJS 实现起来竟然有这么复杂,数据模型、数据容器、网络请求等均被各种类分解了,并没有糅杂在一起

CesiumJS 在瓦片的可见、父子替换计算、地表效果叠加等方面做了很多功夫,因为 3D 的瓦片并不是 2D 瓦片多了一个高度那么简单的。基于各种对象的状态设计,伴随着每一个请求帧流逝,真正做到了“处于什么状态就做什么事情”。

6.1. 好基友 QuadtreePrimitive 和 GlobeSurfaceTileProvider

这俩都有自己的小弟,前者是 QuadtreeTile,后者是 GlobeSurfaceTile,一度让我很好奇为什么要在数据模型和数据处理上做这两个类。

后来我想了想,用这两个角色思考就很容易理解了:项目经理和技术经理。

QuadtreePrimitive 大多数时候负责 QuadtreeTile 的空间算法调度,是一种“调度角色”,而 GlobeSurfaceTileProvider 则负责与各种数据发生器交流,具备创建数据对象的能力,它需要来自 QuadtreePrimitive 的选择结果,最后交给 GlobeSurfaceTile 完成每个瓦片的数据生成任务。

这两个好基友就这么一左一右搭配,扛起了地球的绝大多数职责,Globe 更多时候是对外的一个状态窗口,也就是“大老板”。

6.2. 不能顾及的其它细节

Globe 除了瓦片四叉树这一脉之外,还有用于效果方面的对象,譬如海水动态法线纹理、地球拾取、深度问题、切片规则、裁剪和限定显示、大气层效果、特定材质等,不能一一列举,但是这些都会随着 GlobeSurfaceTileProvideraddDrawCommandsForTile 函数一并创建出绘图指令,并交给帧状态的,而且相对这棵四叉树来说没那么复杂,所以建议有余力的读者深入研究。

关于地形瓦片,CesiumJS 使用 ①高度值瓦片、②STK瓦片 两种格式来表达瓦片的形状;关于影像瓦片,CesiumJS 则使用使用 TileImagery 管理起多个影像图层的瓦片。这两处数据的差异、生成过程,我并没有介绍,fuckgiser 的博客已经介绍得很详细了,数据格式这方面这几年来很稳定,没怎么变化,以后有机会的话也可以写一写。

影像瓦片的重投影,我也没有深入,以后或许考虑单独写一个系列,关于影像瓦片的坐标纠正之类的吧。

着色器方面,整套源码中着色器代码大小最大的就是 GlobeVSGlobeFS 这一对了,精力有限,以后继续讨论(实际上,CesiumJS 的着色器是一套整体,可能专门找时间学习效果会好些)。

你可能感兴趣的