WebGPU緩衝區更新最佳實踐

2023-10-12 12:00:33

介紹

在WebGPU中,GPUBuffer是您將要操作的主要物件之一。它與GPUTextures一同代表了您的應用程式向GPU傳遞用於渲染的大部分資料。在WebGPU中,緩衝區用於頂點和索引資料、uniforms、計算和片段著色器的通用儲存,以及作為紋理資料的臨時儲存區域。

本檔案專注於找到將資料有效地輸入這些緩衝區的最佳方法,而不考慮其最終用途。

緩衝區資料流

在深入探討設定緩衝區資料的機制之前,讓我們先談談它在底層是什麼樣子。

總體而言,您可以將WebGPU視為使用兩種型別的記憶體:GPU可存取的記憶體和CPU可存取且能夠高效複製到GPU可存取記憶體的記憶體。每當您想要從著色器(頂點、片段或計算)中存取資料時,它必須在GPU可存取記憶體中;每當您想要從JavaScript中存取資料時,它必須在CPU可存取記憶體中。緩衝區可以是GPU或CPU可存取的,但不能同時是兩者,而紋理始終只能是GPU可存取的。

在某些裝置上,比如手機,實際上它們可能是同一記憶體池。在另一些裝置上,比如帶有獨立顯示卡的個人電腦,它們可能位於不同的物理板上,並且只能通過PCIe匯流排或類似方式進行通訊。由於我們正在為Web開發,我們希望能夠編寫一個單一的程式碼路徑,可以在最廣泛的裝置上執行。因此,WebGPU在處理這些記憶體設定時不像Vulkan那樣區分它們。一切都被視為具有獨立的CPU和GPU記憶體池,而由WebGPU實現負責在可能的情況下進行特定架構的優化。

這意味著進入GPU可存取記憶體的所有資料將大致採用相同的路徑:

  1. 建立一個使用CPU可存取記憶體的「臨時」緩衝區,該緩衝區可用於寫入和複製。 (usage: GPUBufferUsage.MAP_WRITE | GPUBufferUsage.COPY_SRC)
  2. 對「臨時」緩衝區進行對映以進行寫入(通過mapAsync()),這使得其記憶體可以作為JavaScript ArrayBuffer進行寫入。
  3. 將資料放入陣列緩衝區。
  4. 解除對「臨時」緩衝區的對映。
  5. 使用複製命令(例如copyBufferToBuffer()或copyBufferToTexture())將資料從「臨時」緩衝區複製到GPU可存取的目標中。

類似的路徑用於從GPU可存取記憶體中讀取資料:

  1. 建立一個使用CPU可存取記憶體的「臨時」緩衝區,該緩衝區可用於複製和讀取。 (usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST)
  2. 使用複製命令(例如copyBufferToBuffer()或copyTextureToBuffer())將資料從GPU可存取的目標複製到「臨時」緩衝區。
  3. 對「臨時」緩衝區進行對映以進行讀取(通過mapAsync()),這使得其記憶體可以作為JavaScript ArrayBuffer進行讀取。
  4. 從陣列緩衝區中讀取資料。
  5. 解除對「臨時」緩衝區的對映。

正如您將看到的,下面的一些方法通過使這些步驟成為隱式的方式來隱藏它們,但在大多數情況下,您可以假定這正是發生的事情。

 

當有疑慮時,使用writeBuffer()!

首先要明確的是,如果您對將資料有效輸入特定緩衝區的最佳方法有任何疑問,writeBuffer()方法始終是一個安全的後備選擇,幾乎沒有太多缺點。

writeBuffer()是GPUQueue上的一個便捷方法,它將ArrayBuffer中的值複製到GPUBuffer中,以使用者代理認為最佳的方式進行。通常,這將是一條相當高效的路徑,在某些情況下甚至可能是最高效的路徑!(在大多數情況下,當您呼叫writeBuffer()時,使用者代理將為您管理一個隱式的「臨時」緩衝區,但在某些體系結構上,它有可能跳過該步驟。)

具體來說,如果您正在從WASM程式碼中使用WebGPU,那麼writeBuffer()是首選路徑。這是因為當您使用對映緩衝區時,WASM應用程式需要執行從WASM堆複製的額外步驟。

總的來說,使用writeBuffer()的優勢有:

  1. 對於WASM應用程式來說是首選路徑。
  2. 總體程式碼複雜度最低。
  3. 立即設定緩衝區資料。
  4. 如果資料已經在ArrayBuffer中,避免分配/複製對映ArrayBuffer。
  5. 在返回之前無需將對映緩衝區的陣列內容設定為零。
  6. 允許使用者代理選擇上傳資料到GPU的(可能是最佳的)模式。

實際上,並沒有明顯的不利之處。根據確切的使用模式,您可能能夠編寫一個更客製化的緩衝區管理系統,在某種情況下獲得更好的效能,但writeBuffer()是一個非常可靠的通用解決方案,用於設定緩衝區資料。

這裡是使用writeBuffer()的一個範例。您可以看到程式碼非常簡潔:

// At some point during the app startup...
const projectionMatrixBuffer = gpuDevice.createBuffer({
  size: 16 * Float32Array.BYTES_PER_ELEMENT, // Large enough for a 4x4 matrix
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, // COPY_DST is required
});

// Whenever the projection matrix changes (ie: window is resized)...
function updateProjectionMatrixBuffer(projectionMatrix) {
  const projectionMatrixArray = projectionMatrix.getAsFloat32Array();
  gpuDevice.queue.writeBuffer(projectionMatrixBuffer, 0, projectionMatrixArray, 0, 16);
}

不會改變的緩衝區

有許多情況下,您將建立一個緩衝區,其內容在建立時需要被設定一次,然後永遠不再改變。一個簡單的例子是靜態網格的頂點和索引緩衝區:緩衝區本身需要在建立後立即填充網格資料,之後在渲染迴圈中對網格進行任何更改都將使用變換矩陣或可能是在頂點著色器中進行的網格蒙皮。緩衝區內容在初始化設定後唯一更改的時間是在最終銷燬時。

在這種情況下,在呼叫createBuffer()時使用mappedAtCreation標誌是設定緩衝區資料的最佳方法之一。這將在對映狀態下建立緩衝區,以便在建立後立即呼叫getMappedRange()。這提供了一個ArrayBuffer用於填充,之後呼叫unmap()並設定緩衝區資料!實際上,瀏覽器幾乎肯定需要在呼叫unmap()後在後臺對陣列緩衝區內容進行一次複製,但通常可以確保以高效的方式完成。 (就像在writeBuffer()情況下一樣,大多數情況下,使用者代理會為您管理一個隱式的臨時緩衝區。)

這種方法的主要優勢是,如果您的緩衝區資料是動態生成的,您可以通過直接生成資料到對映的緩衝區中,至少可以節省一個CPU端的複製。

這種方法的優勢有:

  1. 立即設定緩衝區資料。
  2. 不需要特定的使用標誌。
  3. 資料可以直接寫入對映的緩衝區,避免CPU端複製。

缺點有:

  1. 僅適用於新建立的緩衝區。
  2. 使用者代理在對映之前必須將緩衝區清零。
  3. 如果資料已經在ArrayBuffer中,則需要進行另一次CPU端的複製。

以下是使用mappedAtCreation設定靜態頂點資料的範例:

// Creates a grid of vertices on the X, Y plane
function createXYPlaneVertexBuffer(width, height) {
  const vertexSize = 3 * Float32Array.BYTES_PER_ELEMENT; // Each vertex is 3 floats (X,Y,Z position)

  const vertexBuffer = gpuDevice.createBuffer({
    size: width * height * vertexSize, // Allocate enough space for all the vertices
    usage: GPUBufferUsage.VERTEX, // COPY_DST is not required!
    mappedAtCreation: true,
  });

  const vertexPositions = new Float32Array(vertexBuffer.getMappedRange()),

  // Build the vertex grid
  for (let y = 0; y < height; ++y) {
    for (let x = 0; x < width; ++x) {
      const vertexIndex = y * width + x;
      const offset = vertexIndex * 3;

      vertexPositions[offset + 0] = x;
      vertexPositions[offset + 1] = y;
      vertexPositions[offset + 2] = 0;
    }
  }

  // Commit the buffer contents to the GPU
  vertexBuffer.unmap();

  return vertexBuffer;
}

經常寫入的緩衝區

如果您有經常更改的緩衝區(例如每幀一次),那麼有效地更新它們略微更加複雜。不過在我們進一步討論之前,應該注意在許多情況下,從效能的角度來看,使用writeBuffer()將是一條完全可以接受的路徑!

然而,希望更明確控制其記憶體使用的應用程式可以使用所謂的「臨時緩衝區環」。這種技術使用一個旋轉的臨時緩衝區集,不斷地向GPU可存取緩衝區「提供」新資料。每次更新資料時,首先檢查之前使用的臨時緩衝區是否已對映並準備好使用,如果是,則將資料寫入其中。如果不是,則建立一個新的臨時緩衝區,將mappedAtCreation設定為true,以便立即填充。在資料在GPU端複製後,臨時緩衝區立即再次對映,一旦對映完成,它就被放入準備使用的緩衝區佇列中。如果緩衝區資料經常更新,這通常會導致一個包含2-3個臨時緩衝區的迴圈列表。

這種方法在緩衝區管理方面是最複雜的,並且在持續記憶體使用方面比其他方法更多。不過,它對GPU的工作流水線有好處,併為您提供了很多控制的能力,可以針對特定情況進行調整。

優勢:

  1. 限制緩衝區建立。
  2. 不等待先前使用的緩衝區對映。
  3. 臨時緩衝區重用意味著初始化成本僅在每個設定中支付一次。
  4. 資料可以直接寫入對映的緩衝區,避免CPU端複製。

缺點:

  1. 比其他方法更復雜。
  2. 更高的持續記憶體使用。
  3. 使用者代理必須在第一次對映時將臨時緩衝區清零。
  4. 如果資料已經在ArrayBuffer中,則需要進行另一次CPU端的複製。

以下是臨時緩衝區環如何工作的範例,設定頂點資料:

const waveGridSize = 1024;
const waveGridBufferSize = waveGridSize * waveGridSize * 3 * Float32Array.BYTES_PER_ELEMENT;
const waveGridVertexBuffer = gpuDevice.createBuffer({
  size: waveGridBufferSize,
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
const waveGridStagingBuffers = [];

// Updates a grid of vertices on the X, Y plane with wave-like motion
function updateWaveGrid(time) {
  // Get a new or re-used staging buffer that's already mapped.
  let stagingBuffer;
  if (waveGridStagingBuffers.length) {
    stagingBuffer = waveGridStagingBuffers.pop();
  } else {
    stagingBuffer = gpuDevice.createBuffer({
      size: waveGridBufferSize,
      usage: GPUBufferUsage.MAP_WRITE | GPUBufferUsage.COPY_SRC,
      mappedAtCreation: true,
    });
  }

  // Fill in the vertex grid values.
  const vertexPositions = new Float32Array(stagingBuffer.getMappedRange()),
  for (let y = 0; y < height; ++y) {
    for (let x = 0; x < width; ++x) {
      const vertexIndex = y * width + x;
      const offset = vertexIndex * 3;

      vertexPositions[offset + 0] = x;
      vertexPositions[offset + 1] = y;
      vertexPositions[offset + 2] = Math.sin(time + (x + y) * 0.1);
    }
  }
  stagingBuffer.unmap();

  // Copy the staging buffer contents to the vertex buffer.
  const commandEncoder = gpuDevice.createCommandEncoder({});
  commandEncoder.copyBufferToBuffer(stagingBuffer, 0, waveGridVertexBuffer, 0, waveGridBufferSize);
  gpuDevice.queue.submit([commandEncoder.finish()]);

  // Immediately after copying, re-map the buffer. Push onto the list of staging buffers when the
  // mapping completes.
  stagingBuffer.mapAsync(GPUMapMode.WRITE).then(() => {
    waveGridStagingBuffers.push(stagingBuffer);
  });
}

數學無處不在,生成在GPU上的資料!

雖然超出了這份檔案的範圍,但如果我不提及一種快速將資料放入緩衝區的終極技術,我會感到遺憾:在GPU上生成它!具體而言,WebGPU的計算著色器是高效填充緩衝區的絕佳工具。這樣做的巨大優勢是不需要任何臨時緩衝區,因此避免了複製的需要。當然,GPU端的緩衝區生成只有在您的資料可以完全通過演演算法計算且不適用於從檔案載入的模型等情況下才真正奏效。

現實世界的例子 如果您想在現實世界中看到這些技術(以及其他一些技術)在實際工作中的效果,您應該檢視我的WebGPU Metaballs演示。使用「metaballMethod」下拉式選單選擇要使用的緩衝區填充方法,儘管不要期望在它們之間看到太大的效能差異(除了計算著色器方法)。您還可以檢視每種技術的程式碼,其中有註釋解釋每種技術。它還詳細說明了這裡沒有涵蓋的另外兩種模式,主要是因為它們在它們是最有效的路徑的情況下相當罕見。

進一步閱讀 如果您想更多地瞭解緩衝區使用的機制,我建議查閱WebGPU Explainer和WebGPU規範的相關部分。特別是規範並不是我所說的「輕鬆閱讀」,但它詳細描述了WebGPU緩衝區的預期行為。

玩得開心,創造出酷炫的東西!WebGPU中用於將資料傳遞到GPU的各種模式可能會使這一領域感到混亂,可能有點令人生畏,但它不必如此!要記住的第一件事是,這種靈活性存在是為了為高階專業應用程式提供一種緊密控制其效能的方式。對於普通的WebGPU開發人員,您可以並且應該從使用最簡單的方法開始:呼叫writeBuffer()來更新緩衝區,也許對於只需要設定一次的緩衝區使用mappedAtCreation。這些不是「簡化的」輔助函數!它們是推薦的,高效能的路徑,碰巧也是最簡單的路徑。只有當您發現向緩衝區寫入是應用程式的瓶頸,並且您能夠確定適合您的用例的替代技術時,才嘗試變得更炫酷。

祝您在即將進行的任何專案中好運,我迫不及待想看到Web社群構建的令人矚目的創意!