天天看點

CesiumJS 2022^ 源碼解讀[4] - 最複雜的地球皮膚 影像與地形的渲染與下載下傳過程

目錄

  • API 回顧
  • 1. 對象層級關系
    • 1.1. Scene 中特殊的物體 - Globe
    • 1.2. 地球 Globe 與橢球 Ellipsoid
    • 1.3. 瓦片四叉樹 - QuadtreePrimitive 及其成員
  • 2. 瓦片四叉樹單幀四個流程
  • 3. 更新與起幀
    • 3.1. 更新過程 - Globe 的 update
    • 3.2. 起幀過程 - Globe 的 beginFrame
  • 4. 瓦片的渲染 - Globe 的 render
    • 4.1. 選擇要被渲染的瓦片 - selectTilesForRendering
      • 步驟① 清除待渲染瓦片的數組容器 - _tilesToRender
      • 步驟② 判斷零級瓦片的狀态 - _levelZeroTiles
      • 步驟③ 遞歸周遊零級瓦片 - visitTile
    • 4.2. 建立指令前的準備操作 - showTileThisFrame
    • 4.3. 為選擇的瓦片建立繪制指令 - addDrawCommandsForTile
  • 5. 瓦片資料的請求與處理 - Globe 的 endFrame
    • 5.1. 回顧瓦片對象層級關系
    • 5.2. 地形瓦片(TerrainData)的下載下傳
    • 5.3. 影像瓦片(Imagery)的下載下傳
    • 5.4. 小結
  • 6. 總結
    • 6.1. 好基友 QuadtreePrimitive 和 GlobeSurfaceTileProvider
    • 6.2. 不能顧及的其它細節

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
           

我簡化了

ImageryLayer

ImageryProvider

ImageryLayerCollection

TerrainProvider

與上面這些類的關系,也就沒讓他們出現在圖中。

1.1. Scene 中特殊的物體 - Globe

Scene

下轄的主要三維容器是

PrimitiveCollection

,能繼續往裡套

PrimitiveCollection

,也可以單獨放類

Primitive

但是

Scene

中有一個三維物體是獨立于

Scene

構造函數之外才建立的對象,也就是地球對象

Globe

(準确的說是地表),這意味着

Scene

可以脫離

Globe

,單獨作一個普通三維場景容器使用也沒有問題。事實上

Scene

渲染單幀的流程中,也的确如此,會判斷

scene.globe

是否存在才繼續

Globe

的更新、渲染。

Globe

是伴随着

CesiumWidget

的建立而建立的,優先級僅次于

Scene

Ellipsoid

Globe

Scene

對象管理,伴随着

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] - 最複雜的地球皮膚 影像與地形的渲染與下載下傳過程

如果 直接使用經緯度範圍 作為坐标值域來做四叉樹遞歸劃分瓦片,那麼就需要左右兩棵。

CesiumJS 2022^ 源碼解讀[4] - 最複雜的地球皮膚 影像與地形的渲染與下載下傳過程

如何統一表示這兩個狀态呢?隻需在

QuadtreePrimitive

上用個數組存 根瓦片 就好了。它的私有屬性

_levelZeroTiles

就是這麼一個數組,這個數組隻有 0、1、2 三個長度,即 0 代表目前沒有根瓦片,1 代表使用 Web 墨卡托投影的範圍做四叉樹,2 代表使用地理經緯度來做四叉樹。

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

根瓦片乃至任意瓦片都是

QuadtreeTile

類型的。

每個

QuadtreeTile

都有一個

data

成員,代表這棵抽象空間四叉樹的任意瓦片上的 資料,類型為

GlobeSurfaceTile

;作為一個瓦片的資料,必然有多層影像和單個地形資料構成,瓦片的多層影像資料交由

TileImagery

Imagery

完成管理,地形資料則由

HeatmapTerrainData

完成管理。

當然,地形供給器有多種類型,自然就還有其它的地形資料類,譬如

QuantizedMeshTerrainData

GoogleEarthEnterpriseTerrainData

等。

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] - 最複雜的地球皮膚 影像與地形的渲染與下載下傳過程

比較長,有兩個

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

方法會統計傳進來的

QuadtreeTile

data

成員(

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

,也就是樹結構上的一個元素。

QuadtreePrimitive

QuadtreeTile

并不負責資料管理,它們的作用是資料結構方面的排程,比如根據四叉樹瓦片的索引計算其空間範圍、可見性、渲染狀态等,兢兢業業地提供着四叉樹這種資料結構帶來的索引性能提升。

四叉樹瓦片對象有一個

data

成員屬性,類型是

GlobeSurfaceTile

,這個才是瓦片的資料本身。

GlobeSurfaceTile

對象收納着影像服務在該四叉樹瓦片位置上的影像,以及地形資料。

GlobeSurfaceTile

對象有一個

imagery

成員,它是

TileImagery

類型的數組,每一個

TileImagery

就代表一個影像圖層在該瓦片處的瓦片圖像。

由于

TileImagery

是與瓦片四叉樹這一脈相關聯的,屬于資料模型一層,而真正對服務端的影像服務發起請求的是

ImageryLayer

擁有的各種

ImageryProvider

,是以

TileImagery

就用

readyImagery

loadingImagery

兩個類型均為

Imagery

的成員負責與

ImageryLayer

相關聯。這個

Imagery

就是由

ImageryLayer

中的某種

ImageryProvider

在下載下傳資料之後建立的 單個影像瓦片,在

Imagery

上就有用瓦片圖像生成的

Texture

對象。

關于

GlobeSurfaceTile

的形狀,也就是地形資料,在 CesiumJS 中有多種地形資料可供選擇,這裡不細細展開了,常用的有高度圖(

HeightmapTerrainData

)、STK(

QuantizedMeshTerrainData

)等,取決于使用的地形提供器(如

EllipsoidTerrainProvider

)。有興趣的可以去學習一下 fuckgiser 的相關部落格。

5.2. 地形瓦片(TerrainData)的下載下傳

瓦片的外觀是由影像部分負責的,瓦片的形狀則由地形服務負責。在本節最開始的代碼簡易流程中,

QuadtreePrimitive

endFrame

函數首先會執行

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
           

首先,先由

ImageryLayer

GlobeSurfaceTile

建立

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

,進而調用

ImageryProvider

requestImage

方法請求瓦片。

ImageryLayer

來自

ImageryLayerCollection

,這個容器對象由

Globe

對外暴露以供開發者添加圖層,對内則從

GlobeSurfaceTileProvider

一直向下傳遞到需要的類上

那麼,瓦片的影像部分就完成了下載下傳、生成紋理。

5.4. 小結

第四道過程,也就是終幀過程結束後,

Scene

中渲染地球對象的全部任務才算完成。

在這一道過程中,主要還是為下一幀準備好地形和影像瓦片資料,期間會使用 WebWorker 技術進行地形資料的處理,還會發起影像瓦片的網絡請求。

我個人認為,這一步理清各種對象之間的關系非常重要。後期考慮畫一下對象關系圖,

Globe

這一支上的類還是蠻多蠻雜的。

資料最終都會記錄在

QuadtreeTile

data

字段(

GlobeSurfaceTile

類型)上,等待

Globe

下一幀渲染時(也就是回到本文第 3 節)取用。

6. 總結

我預料到地球的渲染會比較複雜,但是沒想到這個會比

Primitive API

、比

Entity API

更複雜,是以花了較長時間去研究源碼,是

Entity API

耗時的兩倍多。

實話說,我寫這篇很粗糙,甚至有可能出現前後表述不相接,還請讀者諒解。

Globe

作為

Scene

中較為特殊的一個三維物體,不像

Entity API

那樣用事件機制完成渲染循環的挂載、

Primitive

的生成,最重要的就是它維護的瓦片四叉樹對象,負責渲染(直接建立

DrawCommand

ComputeCommand

等)、網絡請求瓦片資料并解析,計算瓦片可見、可渲染、多效果疊加(也就是所謂的瓦片排程),這就比

Entity API

Primitive API

要複雜得多。

我本以為,一個四叉樹對象,每個節點對象在渲染時用相機視錐體判斷一下可不可見,資料有沒有,就算全部了,沒想到真正由 CesiumJS 實作起來竟然有這麼複雜,資料模型、資料容器、網絡請求等均被各種類分解了,并沒有糅雜在一起

CesiumJS 在瓦片的可見、父子替換計算、地表效果疊加等方面做了很多功夫,因為 3D 的瓦片并不是 2D 瓦片多了一個高度那麼簡單的。基于各種對象的狀态設計,伴随着每一個請求幀流逝,真正做到了“處于什麼狀态就做什麼事情”。

6.1. 好基友 QuadtreePrimitive 和 GlobeSurfaceTileProvider

這倆都有自己的小弟,前者是

QuadtreeTile

,後者是

GlobeSurfaceTile

,一度讓我很好奇為什麼要在資料模型和資料處理上做這兩個類。

後來我想了想,用這兩個角色思考就很容易了解了:項目經理和技術經理。

QuadtreePrimitive

大多數時候負責

QuadtreeTile

的空間算法排程,是一種“排程角色”,而

GlobeSurfaceTileProvider

則負責與各種資料發生器交流,具備建立資料對象的能力,它需要來自

QuadtreePrimitive

的選擇結果,最後交給

GlobeSurfaceTile

完成每個瓦片的資料生成任務。

這兩個好基友就這麼一左一右搭配,扛起了地球的絕大多數職責,

Globe

更多時候是對外的一個狀态視窗,也就是“大老闆”。

6.2. 不能顧及的其它細節

Globe

除了瓦片四叉樹這一脈之外,還有用于效果方面的對象,譬如海水動态法線紋理、地球拾取、深度問題、切片規則、裁剪和限定顯示、大氣層效果、特定材質等,不能一一列舉,但是這些都會随着

GlobeSurfaceTileProvider

addDrawCommandsForTile

函數一并建立出繪圖指令,并交給幀狀态的,而且相對這棵四叉樹來說沒那麼複雜,是以建議有餘力的讀者深入研究。

關于地形瓦片,CesiumJS 使用 ①高度值瓦片、②STK瓦片 兩種格式來表達瓦片的形狀;關于影像瓦片,CesiumJS 則使用使用

TileImagery

管理起多個影像圖層的瓦片。這兩處資料的差異、生成過程,我并沒有介紹,fuckgiser 的部落格已經介紹得很詳細了,資料格式這方面這幾年來很穩定,沒怎麼變化,以後有機會的話也可以寫一寫。

影像瓦片的重投影,我也沒有深入,以後或許考慮單獨寫一個系列,關于影像瓦片的坐标糾正之類的吧。

着色器方面,整套源碼中着色器代碼大小最大的就是

GlobeVS

GlobeFS

這一對了,精力有限,以後繼續讨論(實際上,CesiumJS 的着色器是一套整體,可能專門找時間學習效果會好些)。

繼續閱讀