CesiumJS 2022^ 源碼解讀[8] - 資源封裝與多線程


CesiumJS 對需要網絡請求的一切資源都進行了統一的封裝,也就是 Resource 類。

在 XHR 技術橫行的年代,就出現過 ajax 這種神器,但是 Cesium 團隊選擇了自己封裝 XHR。後來 ES6 出現了 Promise API,axios 再次封裝了 XHR,但是 Cesium 團隊對這種底層的改動非常敏感,也是最近一年(2021~2022年)才把 var 改爲了 const/let,把 when.js 改爲了原生 Promise,把 ""/'' 字符串部分改爲了 `` 這種反引號字符串,因此自封裝的 XHR 就沒有改動。

所以,雖然可能不太常用,我認爲還是可以瞭解瞭解這套 Resource API 的。

1. 資源封裝與請求封裝

Resource 集成了一些通用的請求方法,以及一些輔助的函數,譬如判斷 blob 的支持、處理 Url(修改QueryString、獲取基地址等)等。不過,真正發起請求,還是得從 RequestRequestScheduler 這兩個類說起。

1.1. 請求的封裝 - Request 與其調度器

Request 代表一個具體的請求,RequestScheduler 則是調度器。有人說爲什麼要整個調度類和調度器類,直接讓 Resource 發起 XHR 請求不就行了嗎?

這與 CesiumJS 的數據調度算法有關,有的請求並不是馬上隨更新過程就發出的,有的是需要延遲請求的(優先級不同),這時候請求調度器 RequestScheduler 就凸顯了作用。

Request 類一般以 Resource 對象字段存在:

function Resource(options) {
  /* ... */
  this.request = defaultValue(options.request, new Request());
  /* ... */
}

在需要使用的時候,會把 Resource 對象上的信息交給 Request 對象:

Resource.prototype.fetch = function (options) {
  options = defaultClone(options, {});
  options.method = "GET";

  return this._makeRequest(options);
};

Resource.prototype._makeRequest = function (options) {
  /* ... */
  request.url = resource.url;
  request.requestFunction = function () {/* ... */};
  const promise = RequestScheduler.request(request);
  /* ...返回請求的數據... */
};

1.2. 資源類 - Resource

你可以用很多東西來實例化一個 Resource,你也可以在公開的文檔中看到很多參數是“Resource”類型的,例如幾個很常見的數據類:

/**
 * @param {Resource|String|Promise<Resource>|Promise<String>} options.url The url to a tileset JSON file.
 */
function Cesium3DTileset(options) {/* ... */}

/**
 * @param {String|Resource} options.url The url to the .gltf or .glb file.
 */
ModelExperimental.fromGltf = function (options) {/* ... */}

/**
 * @param {Resource|String|Object} data A url, GeoJSON object, or TopoJSON object to be loaded.
 */
GeoJsonDataSource.load = function (data, options) {/* ... */}

你用這些信息實例化一個 Resource

  • 資源的網絡相對/絕對路徑
  • Resource 實例本身
  • base64 字符串(DataUri) / blob 字符串

在各種數據的 API 中也允許你傳入不同的參數,例如 glTF 數據允許你傳遞文件路徑、glTF JSON 本身甚至是自己請求下來的 gltf/glb 文件的二進制流,詳見本系列文章的第 6 篇。

Resource 類有很多個發起請求的方法,有實例上的,也有靜態的,在 1.4 小節會列舉。靜態方法會 new 一個 Resource 實例,然後調用其對應的實例方法:

Resource.fetchJson = function (options) {
  const resource = new Resource(options);
  return resource.fetchJson(); // 返回 Promise<object>
};

在現在 axios 或瀏覽器原生 Fetch API 已經如此通用的環境下,已經很少有需要創建 Resource 對象的需求了。源碼中一般會使用 Resource.createIfNeeded() 來創建資源對象,在測試用例中,創建 ktx2 文件資源的代碼如下:

const resource = Resource.createIfNeeded("./Data/Images/Green4x4.ktx2");

最後說說觸發請求後的調用鏈。

以請求 JSON 爲例:

Resource.prototype.fetchJson
  ┕ Resource.prototype.fetch
     ┕ Resource.prototype._makeRequest
       [Module RequestScheduler.js]
       ┕ RequestScheduler.request
         ┕ fn startRequest
           [Module Request.js]
           ┕ Request.prototype.requestFunction
             [Module Resource.js]
             ┕ Resource._Implementations.loadWithXhr

_makeRequest 方法會爲 Resource 對象的 request 成員註冊請求方法 requestFunction,隨後讓 RequestScheduler 發起請求,一波週轉後,還是會執行這個註冊了的 requestFunction 的,內部會調用 Resource._Implementations.loadWithXhr 這個方法,也就是發起 XHR 請求,返回一個 Promise。

1.3. 延遲請求與最大請求個數限制

按理說,一般是不需要去修改 RequestRequestScheduler 上的信息的,這兩個在 CesiumJS 封裝的請求功能中屬於底層,用 Resource 暴露出來的請求 API 即可。不過,我仍然覺得有兩個地方值得分享。

雖然 RequestScheduler 是公開的 API,在 2019 年一個 PR CesiumGS/cesium#8549 中被公開出來了

一個是 RequestScheduler 上的靜態參數 maximumRequestsmaximumRequestsPerServer,這兩個數值代表的意義是允許開發者設置的最大併發請求數量、每個服務器允許的最大請求數量,默認分別是 50、6。

如果你的服務器允許,那麼你可以稍微把這個數值改大一些(譬如併發用戶數不多的時候,私網環境),在請求 3DTiles 瓦片、地球瓦片時能同時多請求一些資源。

另一個是 RequestScheduler 上的延遲請求機制,允許創建 Request 時指定 throttle 參數爲 true,在使用 RequestScheduler.request 發出請求時,先暫存起來:

RequestScheduler.request = function (request) {
  /* 前面的代碼是非延遲請求的處理 */
  const removedRequest = requestHeap.insert(request);
  return issueRequest(request);
};

此處 requestHeap 是 CesiumJS 自己製作的一個堆數據結構。

然後在 Scene 的渲染函數執行完畢後,在 postPassesUpdate 函數中調用 RequestScheduler.update 將延遲的請求統一再發出:

// Scene.js
function postPassesUpdate(scene) {
  /* ... */
  RequestScheduler.update();
}

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

這樣就實現了延遲請求,減輕大量請求可能造成的主線程壓力 —— 這也就是 RequestScheduler 的職能所在。

想知道哪些數據類具備延遲請求行爲?只需在 Source 文件夾下全代碼搜索 throttle: true 即可,例如 Multiple3DTileContent 在請求內部瓦片內容時,就用了延遲請求;有幾個影像、地形供給器類也用了延遲請求。

1.4. 常用請求方法

Cesium 封裝了 HTTP 常用的請求方法:

  • Resource.fetch(這個對應 GET 請求)
  • Resource.head
  • Resource.patch
  • Resource.post
  • Resource.delete
  • Resource.options
  • Resource.put

同時,對常用的文件/數據格式也做了簡易的封裝,如果你不想用 axios 或 Fetch API,而且能用 async/await 語法,那麼直接 await 它們的執行結果也是不錯的,返回的就是你所需要的數據結果:

  • Resource.fetchJson
  • Resource.fetchXML
  • Resource.fetchImage
  • Resource.fetchJsonp
  • Resource.fetchText
  • Resource.fetchBlob

1.5. 舉例

這裏就不再贅述,給出兩個源代碼中的資源創建、請求例子。

一個是 3DTiles 的 tileset.json 入口文件,用到了 Resource.prototype.fetchJson 方法:

// Cesium3DTileset.js
function Cesium3DTileset(options) {
  this._readyPromise = Promise.resolve(options.url)
    .then(function (url) {
      /* ... */
      resource = Resource.createIfNeeded(url);
      /* ... */
      return Cesium3DTileset.loadJson(resource);
  })
  /* ... */
}

Cesium3DTileset.loadJson = function (tilesetUrl) {
  const resource = Resource.createIfNeeded(tilesetUrl);
  return resource.fetchJson();
};

另一個是 ImageryProvider 的靜態函數,請求影像瓦片圖片,用到了 Resource.prototype.loadImage 方法:

ImageryProvider.loadImage = function (imageryProvider, url) {
  /* ... */

  const resource = Resource.createIfNeeded(url);

  if (ktx2Regex.test(resource.url)) {
    return loadKTX2(resource);
  } else if (
    defined(imageryProvider) &&
    defined(imageryProvider.tileDiscardPolicy)
  ) {
    return resource.fetchImage({
      preferBlob: true,
      preferImageBitmap: true,
      flipY: true,
    });
  }

  return resource.fetchImage({
    preferImageBitmap: true,
    flipY: true,
  });
};

其餘的請讀者自行研究學習。

2. 多線程技術

CesiumJS 使用了 WebWorker 多線程技術。

什麼功能要用到 WebWorker 多線程呢?畢竟子線程與主線程的通信成本、能傳遞的數據類型都是對程序有影響的。WebWorker 技術發佈之後,在實踐中發現比較合適的任務是數據編解碼或有阻礙你主線程效率的任務(尤其是數據解碼,使用 WASM 輔助更佳)。

CesiumJS 有兩類任務需要剝離主線程,不影響主線程的邏輯判斷:數據解碼、幾何數據的處理。數據解碼主要是 basisu 紋理的文件解碼、draco 幾何壓縮緩衝數據的解碼;幾何數據的處理主要是少量在 Primitive API 中用到的部分 Geometry 合併操作。

而管理起這些 WebWorker 的管理器是 TaskProcessor 類。

2.1. 跳轉器

在介紹 TaskProcessor 類之前,要先介紹一個 叫做 cesiumWorkerBootstrapper 的東西,你可能打開瀏覽器看報錯、網絡抓包時,在源代碼頁面看到過這個東西:

image

它是 TaskProcessor 創建的一個最基本的子線程(子 Worker):

// TaskProcessor.js

function getBootstrapperUrl() {
  if (!defined(bootstrapperUrlResult)) {
    bootstrapperUrlResult = getWorkerUrl("Workers/cesiumWorkerBootstrapper.js");
  }
  return bootstrapperUrlResult;
}

function createWorker(processor) {
  const worker = new Worker(getBootstrapperUrl());
  /* ... */

  const bootstrapMessage = {
    loaderConfig: {
      paths: {
        Workers: buildModuleUrl("Workers"),
      },
      baseUrl: buildModuleUrl.getCesiumBaseUrl().url,
    },
    workerModule: processor._workerPath,
  };

  worker.postMessage(bootstrapMessage);
  worker.onmessage = function (event) {
    completeTask(processor, event.data);
  };

  return worker;
}

而這個 Workers/cesiumWorkerBootstrapper.js 文件中的 Worker,直接封裝了一個 RequireJS 庫,使用 require 函數去異步請求了當前 TaskProcessor 需要的真正 Worker,真正的 Worker 的 onmessage 就會替換掉 cesiumWorkerBootstapper 的 onmessage。

RequireJS 是 ESModule 尚未完全定稿前社區提供的一種模塊化方案,即著名的“異步模塊定義”的一種實現,有更高級的封裝庫(如 dojo.js),dojo.js 是 ArcGIS JsAPI 的底層依賴。

簡而言之,這個就是個跳轉器,方便接入 CesiumJS 其它模塊,做個簡單的橋樑。

image

2.2. 基本用法

在需要使用多線程的地方,需要實例化一個 TaskProcessor

const processor = new TaskProcessor(
  "path/to/your-worker.js", // worker 的路徑
  4 // 你希望這個 taskProcessor 最多激活多少個 WebWorker 在運行
);

// 當你需要用的時候,以傳遞 ArrayBuffer 的所有權情況爲例
const needToDecodeData = new Float32Array(/* ... */)
processor
  .scheduleTask(
    needToDecodeData,
    [needToDecodeData.buffer]
  )
  .then(result => {
    // result 上就有 taskProcessor 處理後的數據
  })

路徑如果相對於 Cesium 運行時的 Worker 目錄,如果你所處的環境能用 async/await 會更直觀:

const result = await processor.scheduleTask(
  typedArrayData, [typedArrayData.buffer]
)

TaskProcessor.prototype.scheduleTask 就是觸發 Worker 執行的方法。

2.3. 使用 WebAssembly

TaskProcessor 有一個方法用來初始化 WebAssembly 模塊:

processor.initWebAssemblyModule({
  modulePath: "ThirdParty/Workers/basis_transcoder.js",
  wasmBinaryFile: "ThirdParty/basis_transcoder.wasm",
}) // 返回	

有一些任務,例如 draco 壓縮數據的解碼或者 basisu 紋理的解碼,需要依賴 wasm 模塊,必須等待 wasm 解析、創建完成才能執行 WebWorker,所以一般 TaskProcessor.prototype.initWebAssemblyModule 方法執行之後,纔會調用 TaskProcessor.prototype.scheduleTask,見下面 Draco 解析的例子。

實際上,現在要使用 wasm 解析的數據也不過是 basisu 紋理與 draco 壓縮幾何數據而已。

① 例:解碼 draco 壓縮的幾何數據

Draco 壓縮幾何數據是一片以字符串 "DRACO" 起頭的二進制數據,Draco 則是由 Google 開源的一套使用 C++ 開發的幾何數據壓縮庫,利用了熵編碼相關的算法。

glTF 2.0 允許使用 Draco 擴展,3DTiles 的點雲格式也允許使用這個擴展。

解碼 Draco 數據是由 DracoLoader.js 模塊導出的“靜態類”DracoLoader 完成的。DracoLoader 上有幾個 decode 方法供使用。

如果是 glTF 中的 draco 數據,那麼是由 ResourceCache.loadDraco 觸發的:

ResourceCache.loadDraco = function (options) {
  let dracoLoader = ResourceCache.get(cacheKey);
  
  dracoLoader = new GltfDracoLoader(/* ... */);

  ResourceCache.load({
    resourceLoader: dracoLoader,
  });
  /* ... */
};

// ========= 

GltfDracoLoader.prototype.load = function () {
  /* ... */
  
  // 層級較深,多重 Promise,見源碼,這行不代表真實縮進
  const decodePromise = DracoLoader.decodeBufferView(decodeOptions);
  
  /* ... */
};

如果是 3DTiles 的點雲格式,則是由 PntsLoader.prototype.load 方法觸發的:

function decodeDraco(loader, context) {
  const parsedContent = loader._parsedContent;
  const draco = parsedContent.draco;
  
  let decodePromise;
  /* ... */
  decodePromise = DracoLoader.decodePointCloud(draco, context);
  /* ... */
  
  /* ...後續處理... */
}

PntsLoader.prototype.load = function () {
  /* ... */
  const loader = this;
  this._promise = new Promise(function (resolve, reject) {
    /* ... */
    decodeDraco(loader, frameState.context).then(resolve).catch(reject);
    /* ... */
  });
};

以常見的 glTF 解析爲例,也就是從 glTF 的 bufferView 中獲取的 draco 壓縮數據:

DracoLoader.decodeBufferView = function (options) {
  const decoderTaskProcessor = DracoLoader._getDecoderTaskProcessor();
  if (!DracoLoader._taskProcessorReady) {
    // The task processor is not ready to schedule tasks
    return;
  }

  return decoderTaskProcessor.scheduleTask(options, [options.array.buffer]);
};

DracoLoader._getDecoderTaskProcessor = function () {
  if (!defined(DracoLoader._decoderTaskProcessor)) {
    const processor = new TaskProcessor(
      "decodeDraco",
      DracoLoader._maxDecodingConcurrency
    );
    processor
      .initWebAssemblyModule({
        modulePath: "ThirdParty/Workers/draco_decoder_nodejs.js",
        wasmBinaryFile: "ThirdParty/draco_decoder.wasm",
      })
      .then(function () {
        DracoLoader._taskProcessorReady = true;
      });
    DracoLoader._decoderTaskProcessor = processor;
  }
  return DracoLoader._decoderTaskProcessor;
};

總會有那麼一幀,DracoLoader 的靜態字段 _taskProcessorReady 會在 wasm 模塊創建完成後被標記爲 true,進而觸發 decoderTaskProcessor.scheduleTask 方法,啓動 draco_decoder_nodejs.js Worker 的解碼任務。

② 例:處理幾何數據

Primitive API 中的幾何體與 Globe/QuadtreePrimitive API 用到的地形網格(由高程瓦片採樣計算成幾何網格)都用到了 TaskProcessor 進行多線程處理幾何數據。

在幾個地形數據模塊中,可以看到 TaskProcessor 的使用,例如經典的 QuantizedMeshTerrainData

// 模塊作用域下
const createMeshTaskName = "createVerticesFromQuantizedTerrainMesh";
const createMeshTaskProcessorNoThrottle = new TaskProcessor(createMeshTaskName);
const createMeshTaskProcessorThrottle = new TaskProcessor(
  createMeshTaskName,
  TerrainData.maximumAsynchronousTasks
);

QuantizedMeshTerrainData.prototype.createMesh = function (options) {
  /* ... */
  
  const createMeshTaskProcessor = throttle
    ? createMeshTaskProcessorThrottle
    : createMeshTaskProcessorNoThrottle;
  const verticesPromise = createMeshTaskProcessor.scheduleTask(/* ... */)
  /* ...進一步處理多線程處理後的數據... */
};

Primitive API 的更新過程也用到了:

Primitive.prototype.update = function (frameState) {
  /* ... */
  if (this.asynchronous) {
    loadAsynchronous(this, frameState);
  } else { /* ... */ }
  /* ... */
};

let createGeometryTaskProcessors;
const combineGeometryTaskProcessor = new TaskProcessor("combineGeometry");
  
function loadAsynchronous(primitive, frameState) {
  if (primitive._state === PrimitiveState.READY) {
    if (!defined(createGeometryTaskProcessors)) {
      createGeometryTaskProcessors = new Array(numberOfCreationWorkers);
      for (i = 0; i < numberOfCreationWorkers; i++) {
        createGeometryTaskProcessors[i] = new TaskProcessor("createGeometry");
      }
    }
    /* ...進一步調用 taskProcessor 的 scheduleTask 方法... */
  } else if (primitive._state === PrimitiveState.CREATED) {
    /* ... */
    const promise = combineGeometryTaskProcessor.scheduleTask(/* ... */);
    /* ... */
  }
};

loadAsynchronous 這個函數中,會調用 Geometry 所需的 _workerName 創建 TaskProcessor

function loadAsynchronous(primitive, frameState) {
  /* ... */
  if (primitive._state === PrimitiveState.READY) {
    /* ... */
    let subTasks = [];
    for (i = 0; i < length; ++i) {
      /* ... */
      subTasks.push({
        moduleName: geometry._workerName,
        geometry: geometry,
      });
    }
    /* ...之後就會使用 subTasks 數組併發啓動 TaskProcessor...  */
  } /* ... */
}

每一種 Geometry 都有一個自己的私有字段 _workerName,指向運行時 WebWorker 目錄下的 ${_workerName}.js 文件,例如:

function PolygonGeometry(options) {
  /* ... */
  this._workerName = "createPolygonGeometry";
  /* ... */
}

這裏對多線程的介紹僅此一斑,但是差不多也講到了應用的大致方面,希望對讀者有所指引。

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