CesiumJS 2022^ 原始碼解讀[7] - 3DTiles 的請求、載入處理流程解析

四季留歌 發表於 2022-07-03


3DTiles 與 I3S 是競爭關係,可是比起生態開放性、資料定義的靈活性與易讀性來說,3DTiles 比 I3S 好太多了。由於資料生產工具的開發者水平參差不齊,且資料並不存在極致的、萬能的優化方法,故 3DTiles 1.0 時代的一些工具可能導致的資料渲染質量問題,讓 3DTiles 的效能、顯示效果頗受爭議。

隨著 CesiumJS 模型新架構的逐漸成型,下一代的 3DTiles 首先以 1.0 的擴充套件項測試使用,待日後時機成熟,會將 1.0 的規範標記為過時,甚至直接廢棄,直接將這些擴充套件項作為 1.1 版本的核心定義使用(估計還挺久的)。

下一代 3DTiles 明確了這套規範的職能,即 更自由的顯示效果可能性更專注地三維空間索引效能更強大的資訊融合能力

嘮叨有點長,開始講解,本文著重介紹的是“3DTiles”在 CesiumJS 中運作流程本身,而不是瓦片檔案的解析(解析有興趣的可以看本系列文章的上一篇,或者看舊版實現)、瓦片的空間排程演算法、3DTiles 著色器設計。

1. 3DTiles 資料集的型別

3DTiles 1.0 規範允許異構資料共存於一個資料集上。3D 瓦片只是空間劃分的單元,並不是該塊三維空域內的具體三維物體。這些三維物體被稱作“瓦片內容”。

1.0 允許存在 7 種瓦片內容,它們的檔案字尾名是:

  • b3dm,批次三維模型,該瓦片檔案內建一個 glTF 模型檔案,應儘可能在資料生產時優化此 glTF 的繪製批次
  • i3dm,例項三維模型,允許內嵌在 i3dm 檔案內的 glTF 模型在 WebGL 種繪製多例項
  • pnts,點雲
  • cmpt,複合格式,即前三者的混合體,合併細碎瓦片內容檔案成一個,減少網路請求
  • vctr,向量瓦片,未正式釋出,本篇不討論
  • json,這種叫做擴充套件資料集(ExternalTileset),即允許瓦片空域內再巢狀一個子 3DTiles
  • 空瓦片,即瓦片無內容

而 1.0 的擴充套件項,也就是下一代標準增加了一種瓦片格式:

  • glb/gltf,也就是直接將 glTF 模型檔案作為瓦片內容檔案

值得注意的是,社群提案中,延申了 geojson 為瓦片內容檔案,也就是說,在未來也許有可能引入更多的瓦片格式,但是能不能成為官方標準還不一定,暫且以 1.0 + 下一代的 glTF 格式為主要解說點。

而 3DTiles 一切的入口,都是從一個 json 檔案開始的,這個檔名稱是隨意的。

2. 建立瓦片樹

Cesium3DTileset 類代表了一個 3DTiles 資料集,每個資料集總是有一個根瓦片(Cesium3DTile)。

Cesium3DTileset 同舊版 Model、新的 ModelExperimental 類一樣,也是一種“似 Primitive(PrimitiveLike)”類,所以允許直接加入 scene.primitives 容器中。

下面給出一些簡單的程式碼:

import {
  Cesium3DTileset
} from 'cesium'

// 最常規的載入
const t1 = viewer.scene.primitives.add(new Cesium3DTileset({
  url: 'http://localhost/static/tilesets/t1/tileset.json'
}))

// 使用新模型架構載入具備下一代資料標準的資料集
const t2 = viewer.scene.primitives.add(new Cesium3DTileset({
  url: 'http://localhost/static/tilesets/t2/entry.json',
  enableModelExperimental: true
}))

2.1. 請求入口檔案

一旦 new 了 Cesium3DTileset,那麼就會穿過接近 1000 行的建構函式(未壓縮),走到最後的非同步請求:

function Cesium3DTileset(options) {
  const that = this;
  let resource;
  this._readyPromise = Promise.resolve(options.url)
    .then(function (url) {
      /* ... */
      resource = Resource.createIfNeeded(url);
      /* ... */
      return Cesium3DTileset.loadJson(resource);
    })
    .then(function (tilesetJson) {
      /* ... */
      return processMetadataExtension(that, tilesetJson);
    })
    .then(function (tilesetJson) {
      /* ... */
      that._root = that.loadTileset(resource, tilesetJson);
      /* ... */
      return that;
    })
}

在第 1 個 then 中,呼叫 Resource 類相關靜態方法,發起網路請求,得到的結果就是 tileset.json 的物件,然後向下傳遞;

第 2 個 then 是處理 3DTILES_metadata 擴充套件用的,這個不是本文的核心內容:3DTiles 在 CesiumJS 中的運作流程,所以略過。

第 3 個 then,呼叫 Cesium3DTileset 例項的 loadTileset 方法,載入整棵 3DTiles 樹,也就是下一小節的內容。

2.2. 建立樹結構

接上一小節,請求到入口檔案並反序列化為 JavaScript 物件後,就由 Cesium3DTileset.prototype.loadTileset 方法開始建立整棵樹了。

我一開始納悶為什麼這種載入方法要作為例項方法,而不是靜態方法,後來看到 ExternalTileset 的載入過程時才知道這樣設計的意圖。

先看程式碼吧:

Cesium3DTileset.prototype.loadTileset = function (/**/) {
  const asset = tilesetJson.asset;
  if (!defined(asset)) {
    throw new RuntimeError("Tileset must have an asset property.");
  }
  if (
    asset.version !== "0.0" &&
    asset.version !== "1.0" &&
    asset.version !== "1.1"
  ) {
    throw new RuntimeError(
      "The tileset must be 3D Tiles version 0.0, 1.0, or 1.1"
    );
  }
  if (defined(tilesetJson.extensionsRequired)) {
    Cesium3DTileset.checkSupportedExtensions(
      tilesetJson.extensionsRequired
    );
  }
  
  /* ... */
  const rootTile = makeTile(this, resource, tilesetJson.root, parentTile);
  
  if (defined(parentTile)) {
    parentTile.children.push(rootTile);
    rootTile._depth = parentTile._depth + 1;
  }
  
  const stack = [];
  stack.push(rootTile);
  while (stack.length > 0) {
    /* ... */
    const children = tile._header.children;
    if (defined(children)) {
      const length = children.length;
      for (let i = 0; i < length; ++i) {
        /* ... */
        const childTile = makeTile(this, resource, childHeader, tile);
        tile.children.push(childTile);
        stack.push(childTile);
      }
    }
    /* ... */
  }
  
  return rootTile;
}

建立樹結構主要有三塊內容:

  • 檢驗資料合法性
  • 建立根瓦片物件
  • 從根瓦片開始廣度優先搜尋整個資料集,建立出所有的 Cesium3DTile

檢驗資料合法性是近一年來加強的,對版本號、擴充套件項做了嚴格的要求。

老實說,這就是 1.0 的效能隱患,如果不使用 ExternalTileset,而把大量的瓦片定義在 tileset.json 上,那麼這個廣度優先搜尋就非常消耗 CPU 的計算資源。下一代的 3DTiles 使用隱式瓦片擴充套件解決了這個效能弱點。

無論是建立根瓦片,還是建立子瓦片物件,都要經過模組內的 makeTile 函式:

function makeTile(tileset, baseResource, tileHeader, parentTile) {
  const hasImplicitTiling =
    defined(tileHeader.implicitTiling) ||
    hasExtension(tileHeader, "3DTILES_implicit_tiling");
  
  if (hasImplicitTiling) {
    /* ... */
  }
  
  return new Cesium3DTile(tileset, baseResource, tileHeader, parentTile);
}

其實這個函式的主要作用還是分辨下一代 3DTiles 的隱式瓦片用的,也就是判斷是否有 3DTILES_implicit_tiling 擴充套件,如果沒有,直接返回 new 出來的 Cesium3DTile 物件即可。

來看看隱式瓦片這個邏輯分支做了什麼:

if (hasImplicitTiling) {
  const implicitTileset = new ImplicitTileset(/* ... */);
  const rootCoordinates = new ImplicitTileCoordinates({/* ... */});
  const contentUri = implicitTileset.subtreeUriTemplate.getDerivedResource(
    templateValues: rootCoordinates.getTemplateValues(),
  ).url;
  
  const deepCopy = true;
  const tileJson = clone(tileHeader, deepCopy);
  tileJson.contents = [
    {
      uri: contentUri,
    },
  ];

  delete tileJson.content;
  delete tileJson.extensions;
  
  const tile = new Cesium3DTile(
    tileset, baseResource, tileJson, parentTile
  );
  tile.implicitTileset = implicitTileset;
  tile.implicitCoordinates = rootCoordinates;
  return tile;
}

其實也是返回一個 Cesium3DTile,但是它多了倆專門用於隱式瓦片擴充套件的欄位,分別是 ImplicitTilesetImplicitCoordinates

待所有瓦片物件建立完畢後,那麼 Cesium3DTileset 物件也就算建立完成了,此時也只有這棵樹的結構,沒有瓦片內容。瓦片內容是根據當前檢視狀態,隨 Scene 的單幀更新過程去選取、下載、解析,進而建立 DrawCommand 的。

2.3. 瓦片快取機制帶來的能力

3DTiles 也是有快取功能的,由 Cesium3DTilesetCache 類完成快取,它的例項是 Cesium3DTileset 的一個成員欄位 _cache

在本文下一節將會講到 3DTiles 的更新過程,有三大步驟,其中有一個叫做“請求瓦片”的步驟,這一步是非同步的,用到了 ES6 Promise API,當瓦片內容檔案請求、解析完成後,在 tile.contentReadyPromise 的 then 鏈中就使用下面這個函式將瓦片物件新增到快取池中:

function handleTileSuccess(tileset, tile) {
  return function (content) {
    /* ... */
    if (!tile.hasTilesetContent && !tile.hasImplicitContent) {
      /* ... */
      tileset._cache.add(tile);
    }
  }
}

這個快取機制有什麼用呢?

翻遍原始碼,能在即將在下面介紹的遍歷器中、解除安裝瓦片的過程中用到這個快取池,這樣就能免於再次搜尋哪些瓦片需要被解除安裝了。

此處的快取機制只是快取瓦片物件的引用。對於在記憶體中瓦片檔案的快取,請看本系列文章的上一篇,我介紹了 ModelExperimental 新架構中的快取機制,那裡快取的 ResourceLoader 上才會有資源資料。

3. 瓦片樹的遍歷更新

3.1. 三個大步驟

伴隨著 Scene 的幀更新過程,Cesium3DTileset 也一起進入更新、建立 DrawCommand 的隊伍中。

很快就從 Cesium3DTileset.prototype.update 方法進入到 Cesium3DTileset.prototype.updateForPass 方法。先點題,updateForPass 方法會進入到模組內的函式 update 內,由如下三個大步驟完成 3DTiles 樹上的瓦片的選擇、請求解析、更新:

image

方法 updateForPass 裡頭有一個值得注意的變數,那就是傳進來的引數:來自 frameState 的 tilesetPassState,型別是 Cesium3DTilePassState,它身上攜帶了一個欄位:

  • pass,是 Cesium3DTilePass 列舉,指示 3DTiles 更新時是哪一道通道的

這個欄位用於在更新時獲取 passOptions

Cesium3DTileset.prototype.updateForPass = function (
  frameState,
  tilesetPassState
) {
  const pass = tilesetPassState.pass;
  /* ... */
  const passOptions = Cesium3DTilePass.getPassOptions(pass);
  /* ... */
}

在普通渲染更新過程中,欄位 pass 的值就是 Cesium3DTilePass.RENDER,此時 passOptions 根據原始碼可以得知:

const Cesium3DTilePass = {
  RENDER: 0,
  PICK: 1,
  SHADOW: 2,
  PRELOAD: 3,
  PRELOAD_FLIGHT: 4,
  REQUEST_RENDER_MODE_DEFER_CHECK: 5,
  MOST_DETAILED_PRELOAD: 6,
  MOST_DETAILED_PICK: 7,
  NUMBER_OF_PASSES: 8,
};

const passOptions = new Array(Cesium3DTilePass.NUMBER_OF_PASSES);

passOptions[Cesium3DTilePass.RENDER] = Object.freeze({
  traversal: Cesium3DTilesetTraversal,
  isRender: true,
  requestTiles: true,
  ignoreCommands: false,
});

/* 其他 passOptions */

Cesium3DTilePass.getPassOptions = function (pass) {
  return passOptions[pass];
};

passOptions 會透過 Cesium3DTileset.js 模組內的函式 update,一直傳遞到瓦片內容的選擇、請求、更新這幾個流程:

// Cesium3DTileset.prototype.updateForPass 中
if (this.show || ignoreCommands) {
  this._pass = pass;
  tilesetPassState.ready = update(
    this,
    frameState,
    passStatistics,
    passOptions
  );
}

這個 update 函式大致可以分這 3 個流程,圖已經在本小節開頭給到了:

function update(tileset, frameState, passStatistics, passOptions) {
  /* ... */
  const ready = passOptions.traversal.selectTiles(tileset, frameState);
  if (passOptions.requestTiles) {
    requestTiles(tileset);
  }

  updateTiles(tileset, frameState, passOptions);
  /* ... */
  return ready;
}

不過,在講這 3 個流程進行講解之前,還得提一下 passOptions 上的 traversal 成員。

3.2. 遍歷器

上一小節的 passOptions 來自 Cesium3DTilePass.js 模組,內部定義的若干個 passOption 中,只有兩種 traversal 的值,即:

  • Cesium3DTilesetTraversal
  • Cesium3DTilesetMostDetailedTraversal

這兩個靜態類作用於 update 函式的第一個重要步驟,也就是選擇瓦片。

image

passOptions 上這個 traveral 被稱作“遍歷器”。設計這兩個類,是因為 3DTiles 瓦片的空間排程選擇較為複雜,獨立到一個類中。

我對全程式碼進行了搜尋,發現用到 Cesium3DTilesetMostDetailedTraversal 的邏輯分支條件是使用射線求交拾取相關的 API 時,才會用到這個“詳盡遍歷器”,大多數時候用的還是普通的遍歷器 Cesium3DTilesetTraversal

3.3. 選擇瓦片

現在,把視線從遍歷器上返回 Cesium3DTileset.js 模組內的 update 函式中,一句簡單的程式碼就啟動了瓦片的選擇:

// Cesium3DTileset.js
function update(/* ... */) {
  /* ... */
  const ready = passOptions.traversal.selectTiles(tileset, frameState);
  /* ... */
  return ready;
}

現在明確瓦片選擇的目的:把符合當前 3DTiles 資料集上各種優化引數的前提下,選出當前視角下要用於載入、解析(如果未載入和未解析),並繼續沿著更新流程建立 DrawCommand 的 Cesium3DTile,掛載到 Cesium3DTileset 物件的 _requestTiles 這個陣列成員上。

3.2 小節指出了大多數時候 passOptions.traversalCesium3DTilesetTraversal。呼叫 traversal.selectTiles() 方法的主要流程可由下面的流程示意圖給出:

image

更新瓦片資訊是第一步,此更新非“更新瓦片的內容資料”,只是更新瓦片物件(Cesium3DTile)的狀態資訊,主要是可見性計算。

第二步是依據前一步更新的狀態,進行從根瓦片到底的遍歷(此處有三個邏輯分岔,見原始碼),這一步就是最核心的排程演算法;

第三步就是為選出來的瓦片計算其優先值,優先值越高的,越先被載入、渲染。

由於排程演算法並不是本文的目的,所以止步到這一層我認為已足夠,感興趣如何計算 Tile 的可見性、如何被選擇,優先順序如何計算的讀者,可以按這一層繼續往下追蹤原始碼。

3.4. 請求並解析瓦片內容

接上一步,被選中的瓦片已經存至 Cesium3DTileset 物件的 _requestTiles 陣列成員上了,並計算了優先值,即 Cesium3DTile 物件的 _priority 私有成員上,是一個普通的數字。

緊接著,作用域回到 Cesium3DTileset.js 模組內的 update 函式裡頭,繼續執行模組內的 requestTiles 函式:

// Cesium3DTileset.js -> function update()
if (passOptions.requestTiles) {
  requestTiles(tileset);
}

這個 requestTiles 函式只做了兩件事:根據優先值排序,並請求瓦片內容:

function requestTiles(tileset, isAsync) {
  const requestedTiles = tileset._requestedTiles;
  const length = requestedTiles.length;
  requestedTiles.sort(sortRequestByPriority);
  for (let i = 0; i < length; ++i) {
    requestContent(tileset, requestedTiles[i]);
  }
}

那麼進入到 requestContent 函式中,主要的程式碼就這幾個:

function requestContent(tileset, tile) {
  /* ... */
  
  const attemptedRequests = tile.requestContent();
  
  /* ... */
  
  tile.contentReadyToProcessPromise
    .then(addToProcessingQueue(tileset, tile))
    .catch(/* ... */);
  tile.contentReadyPromise
    .then(handleTileSuccess(tileset, tile))
    .catch(/* ... */);
}

即發起內容請求,併為瓦片物件上的 contentReadyToProcessPromisecontentReadyPromise 這兩個 Promise 註冊 resolve 和 reject 的回撥函式。

至此,當前幀的大流程已經基本完成,即選擇瓦片、發出請求瓦片內容。有人說請求完了應該要解析啊?是要解析沒錯,ES6 Promise API 又派上了用場。

從上面的兩個 Promise 可以看出,瓦片內容 - 也就是 glTF 瓦片、b3dm/i3dm/pnts/cmpt 等瓦片資料檔案請求下來後,還要經過 contentReadyToProcessPromise 中的程式碼進行處理的。

那麼 contentReadyToProcessPromise 又是什麼時候,由誰建立的呢?順著 Cesium3DTile.js 模組中的 requestSingleContent 函式或者 requestMultipleContents 函式,你可以看到這個 Promise 的建立(以其一舉例):

function requestSingleContent(tile) {
  const resource = tile._contentResource.clone();
  /* ... */
  const promise = resource.fetchArrayBuffer();
  const contentReadyToProcessPromise = promise.then(function (arrayBuffer) {
    /* ... */
    const content = makeContent(tile, arrayBuffer);
    /* ... */
    tile._content = content;
    tile._contentState = Cesium3DTileContentState.PROCESSING;
    return content;
  });
  tile._contentReadyToProcessPromise = contentReadyToProcessPromise;
  tile._contentReadyPromise = contentReadyToProcessPromise
    .then(function (content) {/**/})
    .then(function (content) {/**/})
    .catch(/**/);
  
  return 0;
}

可見,請求到 ArrayBuffer 後,進入 makeContent 函式進一步處理瓦片內容,生成對應的 Content 類例項。requestMultipleContents 函式就稍微複雜一些。

用程式碼流程圖來說明,比程式碼文字好一些:

image

可見,CesiumJS 對下一代 3DTiles 中“多內容瓦片(1.0 版本中即 3DTILES_multiple_contents 擴充套件)”還是做了分叉的,對每一個 content 發起請求,留下其請求 Promise,然後使用 Promise.all 併發處理;當每個 Promise resolve 後,呼叫 createInnerContent 函式,對請求下來的 ArrayBuffer 資料,進入簡單工廠 Cesium3DTileContentFactory 分支,建立具體的瓦片內容物件。

如果是單個瓦片內容,如上文所述,走的是 requestSingleContent 函式,當內容檔案的請求 Promise resolve 後,呼叫 makeContent 方法,同樣進入 Cesium3DTileContentFactory 工廠中對應的分支,建立具體的瓦片內容物件。

如果是 glb/gltf,且開啟了 tileset.enableModelExperimentaltrue,那麼就能看到上一篇熟悉的 ModelExperimental 的建立了。b3dmi3dm 等也同理。

subtsubtreeJson 是下一代 3DTiles 中隱式瓦片(1.0 版本中的 3DTILES_implicit_tiling 擴充套件)中子樹的可見性資料,詳見 3DTiles 相關資料。

別忘了,不管是單內容瓦片執行 makeContent 後,還是多內容瓦片執行 createInnerContent 後,也就是請求到瓦片內容檔案、解析成具體內容類後,都會留下 contentReadyToProcessPromisecontentReadyPromise 兩個 Promise,供進一步處理。

進一步處理什麼呢?此時該選擇的瓦片選好了,該請求的瓦片請求到了,也解析了,當然就是要 處理成 DrawCommand,供 Renderer 模組去渲染。在說建立 DrawCommand 之前,我先把本節講解的“三大步驟”中最後一個步驟,也就是 updateTiles 函式講完,隨後再說是如何建立每個被選中的瓦片的 DrawCommand 的。

3.5. 更新瓦片並建立 DrawCommand

三大步驟中的最後一個步驟:

// Cesium3DTileset.js

function update(tileset, frameState, passStatistics, passOptions) {
  /* ... */
  updateTiles(tileset, frameState, passOptions);
  /* ... */
}

function updateTiles(tileset, frameState, passOptions) {
  /* ... */
  const selectedTiles = tileset._selectedTiles;
  const selectedLength = selectedTiles.length;
  /* ... */
  
  let i;
  let tile;
  
  for (i = 0; i < selectedLength; ++i) {
    /* ... */
    tile.update(tileset, frameState, passOptions);
    /* ... */
  }
  
  /* ... */
}

這個步驟沒有太多很關鍵的行為,只是更新一些狀態資訊、可有可無的效果(例如裁剪面、除錯資訊等),最終呼叫 tile._contentupdate 方法,進而建立 DrawCommand 或其它的 Command,總之很常規。

至此,3DTiles 三大步驟,從選擇瓦片,到發起請求解析瓦片內容,到更新瓦片狀態並隨之建立內容在當前幀的 DrawCommand,一切都順利。期間,ES6 Promise API 配合各個資料物件上的狀態機制,來判斷在當前幀是否該做什麼 —— 譬如某個資料狀態得是 READY,才有資格建立 DrawCommand。

3.6. prePassesUpdate 也能建立 DrawCommand

承 3.4 小節,瓦片內容檔案請求下來、解析後,相關 Promise 的 then 鏈最終扔給 Cesium3DTile 物件的 _contentReadyToProcessPromise 成員,隨後繼續第三個大步驟,更新瓦片的狀態後建立 DrawCommand,結束當前幀的戰鬥。

但是,我發現 Cesium3DTileset 這貨有那麼一丟丟不一樣,它有兩條路線可以建立 DrawCommand。

回憶本系列文章第二篇的內容,也就是 Scene 渲染 Primitive、建立出 DrawCommand 的內容,建立 DrawCommand 是在 下面這段函式呼叫中執行的:

function updateAndRenderPrimitives(scene) {
  const frameState = scene._frameState;
  /* ... */
  scene._primitives.update(frameState);
  /* ... */
}

也就是 PrimitiveCollection 會觸發 Primitive 或似 Primitive(例如 Cesium3DTilesetModel 等)的更新,進而建立出 DrawCommand。

注意,Scene 會在 render 之前走一遍 prePassesUpdate

Scene.prototype.render = function (time) {
  /* ... */
  tryAndCatchError(this, prePassesUpdate);
  if (shouldRender) {
    /* ... */
    tryAndCatchError(this, render);
  }
  /* ... */
}

function prePassesUpdate(scene) {
  /* ... */
  const primitives = scene.primitives;
  primitives.prePassesUpdate(frameState);
  /* ... */
}

也就是會呼叫 Cesium3DTileset.prototype.prePassesUpdate,最終會呼叫 Cesium3DTile.prototype.process 方法:

Cesium3DTile.prototype.process = function (tileset, frameState) {
  /* ... */
  this._content.update(tileset, frameState);
  /* ... */
};

這時,對 Tile 上的 content 進行 update,也就是繼續進入建立 DrawCommand 的過程,與 3.5 小節的最終目的一致了。

建立 DrawCommand 並不一定是 Primitive.prototype.update 發起的。更新 Primitive 可能是 Scene.js 模組下的 render 函式發起的,也有可能是 prePassesUpdate 函式發起的。所以,似 Primitive 的 postPassesUpdate 方法也有可能建立 DrawCommand。當然,目前也只有 Cesium3DTileset 擁有 postPassesUpdate,可見 3DTiles 的渲染優先順序之高。

3.7. 自定義著色器

自定義著色器是 ModelExperimental 新架構帶來的 API,即 CustomShader API,在發文時,Cesium 1.95 還是需要顯式指定使用新架構,才能使用這個自定義著色器,官方沙盒中也有相關的程式碼。

這裡簡單提一下它的作用過程。

自定義著色器雖然定義在 Cesium3DTileset 例項上,但是作用卻是在 ModelExperimental 上,見 ModelExperimental3DTileContent

ModelExperimental3DTileContent.prototype.update = function (
  tileset,
  frameState
) {
  const model = this._model;
  /* ... */
  model.customShader = tileset.customShader;
  model.update(frameState);
};

於是,你就能在 ModelExperimental.prototype.update 方法中看到自定義著色器是如何更新場景圖結構物件的了:

function updateCustomShader(model, frameState) {
  if (defined(model._customShader)) {
    model._customShader.update(frameState);
  }
}

ModelExperimental.prototype.update = function (frameState) {
  /* ... */
  // A custom shader may have to load texture uniforms.
  updateCustomShader(this, frameState);
  /* ... */
};

具體的內容還是得到 CustomShader.prototype.update 方法裡看。在著色器方面的實現上,用的就是上一篇提到的“階段”技術,選擇性地在著色器程式碼中增加的。

// ModelExperimentalVS.glsl
void main() 
{
    // ...
    Metadata metadata;
    metadataStage(metadata, attributes);

    #ifdef HAS_CUSTOM_VERTEX_SHADER
    czm_modelVertexOutput vsOutput = defaultVertexOutput(attributes.positionMC);
    customShaderStage(vsOutput, attributes, featureIds, metadata);
    #endif
  
    // ...
}

片元著色器上也有類似的,customShaderStage 函式在 CustomShaderStageVS/FS.glsl 檔案中。

3.8. 樣式引擎

CesiumJS 使用 Cesium3DTileStyle 相關 API 來實現 3DTiles 的樣式化。用法不贅述,有專門的文件:[點我](https://github.com/CesiumGS/3d-tiles/tree/main/specification/Styling|3D Tiles Styling language)

在這裡列出,主要是明確它的作用方式:

// Cesium3DTileset.js

function updateTiles(tileset, frameState, passOptions) {
  tileset._styleEngine.applyStyle(tileset);
  /* ... */
}

顯然,是在三大步驟的最後一個步驟應用的樣式。

Cesium3DTileStyleEngine.prototype.applyStyle = function (tileset) {
  const tiles = styleDirty
    ? tileset._selectedTiles
    : tileset._selectedTilesToStyle;
  const length = tiles.length;
  for (let i = 0; i < length; ++i) {
    const tile = tiles[i];
    if (tile.lastStyleTime !== lastStyleTime) {
      const content = tile.content;
      /* ... */
      content.applyStyle(this._style);
      /* ... */
    }
  }
};

content.applyStyle 只是簡單地將 _style 傳遞給 content 例項,最終還是隨 content 例項的 update 方法應用到 DrawCommand 上的。

Cesium3DTileStyle 既可以應用於 3DTiles,也可以應用於 ModelExperimental。它條件樣式語言的作用前提是,在 3DTiles /模型中存在 3D 要素表,這是在製作資料時就必須寫入的。

3.9. 其它

篇幅原因,有一些相對簡單又零碎,或者不屬於本文關注的內容,例如事件機制、裁剪平面、幾何誤差等就不再展開了,以後可以出一些單文來講。

4. 本文總結

其實本文沒寫什麼很深入的內容,只把建立樹、處理瓦片的全流程,以及一些零碎點提煉了出來,希望對讀者有幫助。

截至發文,3DTiles 已經應用了有六七年了,也看到了 Cesium 團隊為此付出的努力。

不好看?卡頓?確實有點,但是已經在努力了。下一代的 3DTiles 真的值得期待!

我認為,3DTiles 規範只是一種大規模空間三維資料的組織指導資料。它本身沒有指導你怎麼製作 LOD,也沒有告訴你該如何把你的業務需求(分層分戶、單體化、點選查詢)如何塞到瓦片裡,這都需要資料生產開發者的不懈努力,把 GPUPicking、Batch、資料調優手段都用起來,那麼 3DTiles 與 glTF 才能煥發出強大的能力,CesiumJS 作為一個前端執行時,它在調優上已經做得很不錯了。

簡單總結如下:

  • 亮點:快取機制
  • 難點:選擇排程演算法
  • 架構設計優點:平穩的接入了下一代 3DTiles 的同時還相容了 1.0 版本

至此,CesiumJS 原始碼解讀系列已經接近尾聲,還有一篇關於資源處理和網路請求、多執行緒的文章,下篇見。