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


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 源碼解讀系列已經接近尾聲,還有一篇關於資源處理和網絡請求、多線程的文章,下篇見。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章