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

3次阅读

共计 19505 个字符,预计需要花费 49 分钟才能阅读完成。

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 墨卡托投影的坐标范畴是一个正方形:

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

如何对立示意这两个状态呢?只需在 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 的可见状态,顺便触发相干事件,就这么简略。

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

<div style=”display: grid; place-items: center;”>
<p style=”font-size: 2rem;”>

① update - 更新影像图层的 <code style="font-size: 2rem !important;">show</code> 状态 

</p>
</div>

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 局部要介绍的三个优先级瓦片加载队列,并把一部分调试状态重置。

简略的说,起帧之前:

<div style=”display: grid; place-items: center;”>
<p style=”font-size: 2rem;”>② beginFrame – 清扫洁净屋子好请客 </p>
</div>

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 函数中):

比拟长,有两个 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 的着色器是一套整体,可能专门找工夫学习效果会好些)。

正文完
 0