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 中去了,這樣就可以在幀運算中獨立出行爲對象。

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