WebGL 與 WebGPU比對[5] – 渲染計算的過程


前兩篇文章介紹了 WebGL 和 WebGPU 是如何準備頂點和數字型 Uniform 數據的(紋理留到下一篇),當渲染所需的原材料準備完成後,就要進入邏輯組裝的過程。

WebGL 在這方面通過指定「WebGLProgram」,最終觸發「drawArrays」或「drawElements」來啟動渲染/計算。全局狀態為特徵的 WebGL 顯然做多步驟渲染來說會麻煩一些,WebGPU 改善了渲染計算過程的介面設計,允許開發者組裝更複雜的渲染、計算流程。

以所有的「draw」函數調用為分界線,調用後,就認為 CPU 端的任務已經完成,開始移交準備好的渲染、計算原材料(數據與著色器程式)至 GPU,進而運行起渲染管線,直至輸出到幀緩衝/Canvas,我稱 draw 這個行為是「一個通道」。

WebGPU 的出現,除了渲染的功能,還出現了通用計算功能,draw 也有了兄弟概念:dispatch(調度),下文會對比介紹。

1. WebGL

1.1. 使用 WebGLProgram 表示一個計算過程

WebGL 的整個渲染管線(雖然沒有管線 API)中,能介入編程的就兩處:頂點著色階段片元著色階段,分別使用頂點著色器和片元著色器完成渲染過程的訂製。

很多書或入門教程都會說,頂點著色器和片元著色器是成對出現的,而能管理這兩個著色器的上層容器對象,就叫做程式對象(介面 WebGLProgram)。

const vertexShader = gl.createShader(gl.VERTEX_SHADER) // WebGLShader
gl.shaderSource(vertexShader, vertexShaderSource)
gl.compileShader(vertexShader)

const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER) // WebGLShader
gl.shaderSource(fragmentShader, fragmentShaderSource)
gl.compileShader(fragmentShader)

const program = gl.createProgram() // WebGLProgram
gl.attachShader(program, vertexShader)
gl.attachShader(program, fragmentShader)
gl.linkProgram(program)

其實,真正的渲染管線是有很多步驟的,頂點著色和片元著色只是比較有代表性:

  • 頂點著色器大多數時候負責取色、圖形變換
  • 片元著色大多數時候負責計算並輸出螢幕空間的片元顏色

既然 WebGL 只能訂製這兩個階段,又因為這倆 WebGLShader 是被程式對象(WebGLProgram)管理的,所以,一個程式對象所代表的那個「管線」,通常用於執行一個通道的計算。

在複雜的 Web 三維開發中,一個通道還不足以將想要的一幀畫面渲染完成,這個時候要切換著色器程式,再進行 drawArrays/drawElements,繪製下一個通道,這樣組合多個通道的繪製結果,就能在一個 requestAnimationFrame 中完成想要的渲染。

1.2. WebGL 沒有通道 API

上文提及,在一幀的渲染過程中,有可能需要多個通道共同完成渲染。最後一次 gl.drawXXX 的調用會使用一個繪製到目標幀緩衝的 WebGLProgram,這麼說可能很抽象,不妨考慮這樣一幀的渲染過程:

  • 渲染法線、漫反射資訊到 FBO1 中;
  • 渲染光照資訊到 FBO2 中;
  • 使用 FBO1 和 FBO2,把最後結果渲染到 Canvas 上。

每一步都需要自己的 WebGLProgram,而且每一步都要全局切換各種 Buffer、Texture、Uniform 的綁定,這樣就需要一個封裝對象來完成這些狀態的切換,可惜的是 WebGL 並沒有這種對象,大多數時候是第三方庫使用類似的類完成的。

因此,如果你不用第三方庫(ThreeJS等),那麼你就要考慮設計自己的通道類來管理通道了。

當然,隨著現代 GPU 的特性挖掘,一個通道不一定是為了繪製一張「畫」,因為有通用計算技術的出現,所以我更樂意稱一個通道為「一個計算集合,由一系列計算過程有邏輯地構成」。在 WebGPU 也就是下面要介紹的內容中會提及計算通道,那個就是為通用計算準備的。

2. WebGPU

2.1. 使用 Pipeline 組裝管線中各個階段

在 WebGPU 中,一個計算過程的任務就交由「管線」完成,也就是我們在各種資料里見得到的「可編程管線」的具象化 API;在 WebGPU 中,可編程管線有兩類:

  • 渲染管線,GPURenderPipeline
  • 計算管線,GPUComputePipeline

管線對象在創建時,會傳遞一個參數對象,用不同的狀態屬性配置不同的管線階段。

回顧,WebGL 是使用 gl.attachShader() 方法配置兩個 WebGLShader 附著到程式對象上的。

對渲染管線來說,除了可以配置頂點著色器、片元著色器之外,還允許使用其它的狀態來配置管線中的其它狀態:

  • 使用 GPUPrimitiveState 對象設置 primitive 狀態,配置圖元的裝配階段和光柵化階段;
  • 使用 GPUDepthStencilState 對象設置 depthStencil 狀態,配置深度、模板測試以及光柵化階段;
  • 使用 GPUMultisampleState 對象設置 multisample 狀態,配置光柵化階段中的多重取樣。

具體內容需要參考 WebGPU 標準的文檔。下面舉個例子:

const renderPipeline = device.createRenderPipeline({
  // --- 布局 ---
  layout: pipelineLayout,
  
  // --- 五大狀態用於配置渲染管線的各個階段
  vertex: {
    module: device.createShaderModule({ /* 頂點著色器參數 */ }),
    // ...
  },
  fragment: {
    module: device.createShaderModule({ /* 片元著色器參數 */ }),
    // ...
  },
  primitive: { /* 設置圖元狀態 */ },
  depthStencil: { /* 設置深度模板狀態 */ },
  multisample: { /* 設置多重取樣狀態 */ }
})

然後再看一個非同步創建計算管線的例子:

const computePipeline = await device.createComputePipelineAsync({
  // --- 布局 ---
  layout: pipelineLayout,
  
  // --- 計算管線只需配置計算狀態 ---
  compute: {
    module: device.createShaderModule({ /* 計算著色器參數 */ }),
    // ...
  }
})

讀者可自行比對 WebGL 中 WebGLProgram + WebGLShader 的組合。

題外話,我在我的另一文還提到過,管線還具備了 WebGL 中的 VAO 的作用,感興趣的可以找找看看。管線的片元狀態還承擔了 MRT 的資訊。

2.2. 使用 PassEncoder 調度管線內的行為

由上一小節可知,管線對象收集了對應管線各個階段所需的參數。這說明了管線是一個具備行為的過程。

光有武林秘籍,沒有人練,武功是體現不出來的。

所以,PassEncoder(通道編碼器)就起了這麼一個作用,它負責記錄 GPU 計算一個通道的前後邏輯,可以對其設置管線、頂點相關的緩衝對象、資源綁定組,最後觸發計算。

計算通道編碼器(GPUComputePassEncoder)的觸發動作是調用 dispatch() 方法,這個方法譯作「調度」;渲染通道編碼器(GPURenderPassEncoder)的觸發動作是它的各個 「draw」 方法,即觸發繪製。

這個時候就體現出面向對象編程的威力了,你可以將一個通道內的行為(即管線)、數據(即資源綁定組和各種緩衝對象)分別創建,獨立於通道編碼器之外,這樣,面對不同的通道計算時,就可以按需選用不同的管線和數據,進而甚至可以實現管線或者資源的共用。

通道編碼器這一小節沒有示例程式碼,示例程式碼在下一小節。

2.3. 使用 CommandEncoder 編碼多個通道

WebGPU 使用現代圖形 API 的思想,將所有 GPU 該做的操作、需要資訊事先編碼至一個叫「CommandBuffer(指令緩衝)」的容器上,最後統一由 CPU 提交至 GPU,GPU 拿到就吭哧吭哧執行。

編碼指令緩衝的對象叫做 GPUCommandEncoder,即指令編碼器,它最大的作用就是創建兩種通道編碼器(commandEncoder.begin[Render/Compute]Pass()),以及發出提交動作(commandEncoder.finish()),最終生成這一幀所需的所有指令。

話不多說,這裡直接借用 austin-eng 的例子 ShadowMapping(陰影映射)

// 創建指令編碼器
const commandEncoder = device.createCommandEncoder()

{
  // 陰影通道的編碼過程
  const shadowPass = commandEncoder.beginRenderPass(shadowPassDescriptor)
  
  // 使用陰影渲染管線
  shadowPass.setPipeline(shadowPipeline)
  shadowPass.setBindGroup(0, sceneBindGroupForShadow)
  shadowPass.setBindGroup(1, modelBindGroup)
  shadowPass.setVertexBuffer(0, vertexBuffer)
  shadowPass.setIndexBuffer(indexBuffer, 'uint16')
  shadowPass.drawIndexed(indexCount)
  shadowPass.endPass()
}
{
  // 渲染通道常規操作
  const renderPass = commandEncoder.beginRenderPass(renderPassDescriptor);
  
  // 使用常規渲染管線
  renderPass.setPipeline(pipeline)
  renderPass.setBindGroup(0, sceneBindGroupForRender)
  renderPass.setBindGroup(1, modelBindGroup)
  renderPass.setVertexBuffer(0, vertexBuffer)
  renderPass.setIndexBuffer(indexBuffer, 'uint16')
  renderPass.drawIndexed(indexCount)
  renderPass.endPass()
}
device.queue.submit([commandEncoder.finish()]);

為了完成三維物體的陰影渲染,在陰影映射有關的技術中一般會把陰影資訊使用一個通道先繪製出來,然後把陰影資訊傳給下一個通道進而完成陰影的效果。

在上面的程式碼中,就使用了兩個 RenderPassEncoder 進行陰影的先後步驟渲染。它們在 draw 之前就可以設置不同的渲染材料,包括代表行為的管線,以及代表資源的綁定組、各類緩衝等。

2.4. PassEncoder 和 Pipeline 的關係

WebGPU 中的 Pipeline 被劃分成了多個階段,其中有三個階段是可編程的,其它的階段是可配置的。管線由於在三個可編程階段擁有了著色器模組,所以管線對象更多的是扮演一個「執行者」,它代表的是某個單一計算過程的全部行為,而且是發生在 GPU 上。

而對於 PassEncoder,也就是通道編碼器,它擁有一系列 setXXX 方法,它的角色更多的是「調度者」。

通道編碼器在結束編碼後,整個被編碼的過程就代表了一個 Pass(通道)的計算流程。

3. 總結

多個時間很短的畫面,就構成了動態的渲染結果。這每一個畫面,叫做幀。而每一幀,在實時渲染技術中用多個「通道」,通過圖形學或實時渲染知識有邏輯地組裝在一起共同完成。

通道由行為和數據構成。

行為由著色器程式實現,也就是「你想在這一個通道做什麼計算」,在 WebGL 中使用 WebGLProgram 附著兩個著色器,而在 WebGPU 中使用 GPURenderPipeline/GPUComputePipeline 裝配管線的各個階段狀態。

而數據,則希望讀者去看我寫的 Uniform 和 頂點緩衝文章了。

每一幀,在 WebGL 程式碼中,其實就是不斷切換 WebGLProgram,綁定不同數據,最後發出 draw 動作完成;在 WebGPU 程式碼中,就是創建指令編碼器、開始通道編碼、結束通道編碼、結束指令編碼,最後提交指令緩衝完成。

WebGPU 把 WebGLProgramWebGLShader 的行為職能抽離到 GPU[Render/Compute]PipelineGPUShaderModule 中去了,這樣就可以在幀運算中獨立出行為對象。