WebGPU 中消失的 FBO 和 RBO


OpenGL 體系給圖形開發留下了不少的技術積累,其中就有不少的“Buffer”,耳熟能詳的就有頂點緩衝對象(VertexbufferObject,VBO),幀緩衝對象(FramebufferObject,FBO)等。

切換到以三大現代圖形開發技術體系爲基礎的 WebGPU 之後,這些經典的緩衝對象就在 API 中“消失了”。其實,它們的職能被更科學地分散到新的 API 去了。

本篇講一講 FBO 與 RBO,這兩個通常用於離屏渲染邏輯中,以及到了 WebGPU 後爲什麼沒有這兩個 API 了(用什麼作爲了替代)。

1 WebGL 中的 FBO 與 RBO

WebGL 其實更多的角色是一個繪圖 API,所以在 gl.drawArrays 函數發出時,必須確定將數據資源畫到哪裏去。

WebGL 允許 drawArrays 到兩個地方中的任意一個:canvas 或 FramebufferObject. 很多資料都有介紹,canvas 有一個默認的幀緩衝,若不顯式指定自己創建的幀緩衝對象(或者指定爲 null)那就默認繪製到 canvas 的幀緩衝上。

換句話說,只要使用 gl.bindFramebuffer() 函數指定一個自己創建的幀緩衝對象,那麼就不會繪製到 canvas 上。

本篇討論的是 HTMLCanvasElement,不涉及 OffscreenCanvas

1.1 幀緩衝對象(FramebufferObject)

FBO 創建起來簡單,它大多數時候就是一個負責點名的頭兒,出汗水的都是小弟,也即它下轄的兩類附件:

  • 顏色附件(在 WebGL1 中有 1 個,在 WebGL 2 可以有16個)
  • 深度模板附件(可以只用深度,也可以只用模板,也可以兩個一起使用)

關於 MRT 技術(MultiRenderTarget),也就是允許輸出到多個顏色附件的技術,WebGL 1.0 使用 gl.getExtension('WEBGL_draw_buffers') 獲取擴展來使用;而 WebGL 2.0 原生就支持,所以顏色附件的數量上有所區別。

而這兩大類附件則通過如下 API 進行設置:

// 設置 texture 爲 0 號顏色附件
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, color0Texture, 0)
// 設置 rbo 爲 0 號顏色附件
gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.RENDERBUFFER, color0Rbo)

// 設置 texture 爲 僅深度附件
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, depthTexture, 0)
// 設置 rbo 爲 深度模板附件(需要 WebGL2 或 WEBGL_depth_texture)
gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_STENCIL_ATTACHMENT, gl.RENDERBUFFER, depthStencilRbo)

實際上,在需要進行 MRT 時,gl.COLOR_ATTACHMENT0gl.COLOR_ATTACHMENT1 ... 這些屬性只是一個數字,可以通過計算屬性進行顏色附件的位置索引,也可以直接使用明確的數字代替:

console.log(gl.COLOR_ATTACHMENT0) // 36064
console.log(gl.COLOR_ATTACHMENT1) // 36065

let i = 1
console.log(gl[`COLOR_ATTACHMENT${i}`]) // 36065

1.2 顏色附件與深度模板附件的真正載體

顏色附件與深度模板附件是需要明確指定數據載體的。WebGL 若改將繪圖結果繪製到非 canvas 的 FBO,那麼就需要明確指定具體畫在哪。

如 1.1 小節的示例代碼所示,每個附件都可以選擇如下二者之一作爲真正的數據載體容器:

  • 渲染緩衝對象(WebGLRenderbuffer
  • 紋理對象(WebGLTexture

有前輩在博客中指出,渲染緩衝對象會比紋理對象稍好,但是要具體問題具體分析。

實際上,在大多數現代 GPU 以及顯卡驅動程序上,這些性能差異沒那麼重要。

簡單的說,如果離屏繪製的結果不需要再進行下一個繪製中作爲紋理貼圖使用,用 RBO 就可以,因爲只有紋理對象能向着色器傳遞。

關於 RBO 和紋理作爲兩類附件的區別的資料就沒那麼多了,而且這篇主要是比對 WebGL 和 WebGPU 二者的不同,就不再展開了。

1.3 FBO/RBO/WebGLTexture 相關方法收集

  • gl.framebufferTexture2D(gl.FRAMEBUFFER, <attachment_type>, <texture_type>, <texture>, <mip_level>):將 WebGLTexture 關聯到 FBO 的某個附件上
  • gl.framebufferRenderbuffer(gl.FRAMEBUFFER, <attachment_type>, gl.RENDERBUFFER, <rbo>):將 RBO 關聯到 FBO 的某個附件上
  • gl.bindFramebuffer(gl.FRAMEBUFFER, <fbo | null>):設置幀緩衝對象爲當前渲染目標
  • gl.bindRenderbuffer(gl.RENDERBUFFER, <rbo>):綁定 <rbo> 爲當前的 RBO
  • gl.renderbufferStorage(gl.RENDERBUFFER, <rbo_format>, width, height):設置當前綁定的 RBO 的數據格式以及長寬

下面是三個創建的方法:

  • gl.createFramebuffer()
  • gl.createRenderbuffer()
  • gl.createTexture()

順帶回顧一下紋理的參數設置、紋理綁定與數據傳遞函數:

  • gl.texParameteri():設置當前綁定的紋理對象的參數
  • gl.bindTexture():綁定紋理對象爲當前作用紋理
  • gl.texImage2D():向當前綁定的紋理對象傳遞數據,最後一個參數即數據

2 WebGPU 中的對等概念

WebGPU 已經沒有 WebGLFramebufferWebGLRenderbuffer 這種類似的 API 了,也就是說,你找不到 WebGPUFramebufferWebGPURenderbuffer 這倆類。

但是,gl.drawArray 的對等操作還是有的,那就是渲染通道編碼器(令其爲 renderPassEncoder)發出的 renderPassEncoder.draw 動作。

2.1 渲染通道編碼器(GPURenderPassEncoder)承擔 FBO 的職能

WebGPU 的繪製目標在哪呢?由於 WebGPU 與 canvas 元素不是強關聯的,所以必須顯式指定繪製到哪裏去。

通過學習可編程通道以及指令編碼等概念,瞭解到 WebGPU 是通過一些指令緩衝來向 GPU 傳遞“我將要幹啥”的信息的,而指令緩衝(Command Buffer)則由指令編碼器(也即 GPUCommandEncoder)完成創建。指令緩衝由若干個 Pass(通道)構成,繪製相關的通道,叫做渲染通道。

渲染通道則是由渲染通道編碼器來設置的,一個渲染通道就設定了這個通道的繪製結果要置於何處(這個描述就類比了 WebGL 要繪製到哪兒)。具體到代碼中,其實就是創建 renderPassEncoder 時,傳遞的 GPURenderPassDescriptor 參數對象裏的 colorAttachments 屬性:

const renderPassEncoder = commandEncoder.beginRenderPass({
  // 是一個數組,可以設置多個顏色附件
  colorAttachments: [
    {
      view: textureView,
      loadValue: { r: 0.0, g: 0.0, b: 0.0, a: 1.0 },
      storeOp: 'store',
    }
  ]
})

注意到,colorAttachments[0].view 是一個 textureView,也即 GPUTextureView,換言之,意味着這個渲染通道要繪製到某個紋理對象上。

通常情況下,如果你不需要離屏繪製或者使用 msaa,那麼應該是畫到 canvas 上的,從 canvas 中獲取其配置好的紋理對象如下操作:

const context = canvas.getContext('webgpu')
context.configure({
  gpuDevice,
  format: presentationFormat, // 此參數可以使用畫布的客戶端長寬 × 設備像素縮放比例得到,是一個兩個元素的數組
  size: presentationSize, // 此參數可以調用 context.getPreferredFormat(gpuAdapter) 獲取
})

const textureView = context.getCurrentTexture().createView()

上述代碼片段完成了渲染通道與屏幕 canvas 的關聯,即把 canvas 視作一塊 GPUTexture,使用其 GPUTextureView 與渲染通道的關聯。

其實,更嚴謹的說法是 渲染通道 承擔了 FBO 的部分職能(因爲渲染通道還有發出其它動作的職能,例如設置管線等),因爲沒有 GPURenderPass 這個 API,所以只能委屈 GPURenderPassEncoder 代替一下了。

2.2 多目標渲染

爲了進行多目標渲染,也即片元着色器要輸出多個結果的情況(代碼中表現爲返回一個結構體),就意味着要多個顏色附件來承載渲染的輸出。

此時,要配置渲染管線的片元着色階段(fragment)的 targets 屬性。

相關的從創建紋理、創建管線、指令編碼例子代碼如下所示,用到兩個紋理對象來充當顏色附件的容器:

// 一、創建渲染目標紋理 1 和 2,以及其對應的紋理視圖對象
const renderTargetTexture1 = device.createTexture({
  size: [/* 略 */],
  usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
  format: 'rgba32float',
})
const renderTargetTexture2 = device.createTexture({
  size: [/* 略 */],
  usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
  format: 'bgra8unorm',
})
const renderTargetTextureView1 = renderTargetTexture1.createView()
const renderTargetTextureView2 = renderTargetTexture2.createView()

// 二,創建管線,配置片元着色階段的多個對應目標的紋素輸出格式
const pipeline = device.createRenderPipeline({
  fragment: {
    targets: [
      {
        format: 'rgba32float'
      },
      {
        format: 'bgra8unorm'
      }
    ]
    // ... 其它屬性省略
  },
  // ... 其它階段省略
})

const renderPassEncoder = commandEncoder.beginRenderPass({
  colorAttachments: [
    {
      view: renderTargetTextureView1,
      // ... 其它參數
    },
    {
      view: renderTargetTextureView2,
      // ... 其它參數
    }
  ]
})

這樣,兩個顏色附件分別用上了兩個紋理視圖對象作爲渲染目標,而且在管線對象的片元着色階段也明確指定了兩個 target 的格式。

於是,你可以在片元着色器代碼中指定輸出結構:

struct FragmentStageOutput {
  @location(0) something: vec4<f32>;
  @location(1) another: vec4<f32>;
}

@stage(fragment)
fn main(/* 省略輸入 */) -> FragmentStageOutput {
  var output: FragmentStageOutput;
  // 隨便寫倆數字,沒什麼意義
  output.something = vec4<f32>(0.156);
  output.another = vec4<f32>(0.67);
  
  return output;
}

這樣,位於 location 0 的 something 這個 f32 型四維向量就寫入了 renderTargetTexture1 的一個紋素,而位於 location 1 的 another 這個 f32 型四維向量則寫入了 renderTargetTexture2 的一個紋素。

儘管,在 pipeline 的片元階段中 target 指定的 format 略有不一樣,即 renderTargetTexture2 指定爲 'bgra8unorm',而着色器代碼中結構體的 1 號 location 數據類型是 vec4<f32>,WebGPU 會幫你把 f32 這個 [0.0f, 1.0f] 範圍內的輸出映射到 [0, 255] 這個 8bit 整數區間上的。

事實上,如果沒有多輸出(也即多目標渲染),WebGPU 中大部分片元着色器的返回類型就是一個單一的 vec4<f32>,而最常見的 canvas 最佳紋理格式是 bgra8unorm,總歸要發生 [0.0f, 1.0f] 通過放大 255 倍再取整到 [0, 255] 這個映射過程的。

2.3 深度附件與模板附件

GPURenderPassDescriptor 還支持傳入 depthStencilAttachment,作爲深度模板附件,代碼舉例如下:

const renderPassDescriptor = {
  // 顏色附件設置略
  depthStencilAttachment: {
    view: depthTexture.createView(),
    depthLoadValue: 1.0,
    depthStoreOp: 'store',
    stencilLoadValue: 0,
    stencilStoreOp: 'store',
  }
}

與單個顏色附件類似,也需要一個紋理對象的視圖對象爲 view,需要特別注意的是,作爲深度或模板附件,一定要設置與深度、模板有關的紋理格式。

若對深度、模板的紋理格式在額外的設備功能(Device feature)中,在請求設備對象時一定要加上對應的 feature 來請求,例如有 "depth24unorm-stencil8" 這個功能才能用 "depth24unorm-stencil8" 這種紋理格式。

深度模板的計算,還需要注意渲染管線中深度模板階段參數對象的配置,例如:

const renderPipeline = device.createRenderPipeline({
  // ...
  depthStencil: {
    depthWriteEnabled: true,
    depthCompare: 'less',
    format: 'depth24plus',
  }
})

2.4 非 canvas 的紋理對象作爲兩種附件的注意點

除了深度模板附件裏提及的紋理格式、請求設備的 feature 之外,還需要注意非 canvas 的紋理若作爲某種附件,那它的 usage 一定包含 RENDER_ATTACHMENT 這一項。

const depthTexture = device.createTexture({
  size: presentationSize,
  format: 'depth24plus',
  usage: GPUTextureUsage.RENDER_ATTACHMENT,
})

const renderColorTexture = device.createTexture({
  size: presentationSize,
  format: presentationFormat,
  usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
})

3 讀取數據

3.1 從 FBO 中讀像素值

從 FBO 讀像素值,實際上就是讀顏色附件的顏色數據到 TypedArray 中,想讀取當前 fbo(或 canvas 的幀緩衝)的結果,只需調用 gl.readPixels 方法即可。

//#region 創建 fbo 並將其設爲渲染目標容器
const fb = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
//#endregion

//#region 創建離屏繪製的容器:紋理對象,並綁定它成爲當前要處理的紋理對象
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);

// -- 若不需要作爲紋理再次被着色器採樣,其實這裏可以用 RBO 代替
//#endregion

//#region 綁定紋理對象到 0 號顏色附件
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
//#endregion

// ... gl.drawArrays 進行渲染

//#region 讀取到 TypedArray
const pixels = new Uint8Array(imageWidth * imageHeight * 4);
gl.readPixels(0, 0, imageWiebdth, imageHeight, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
//#endregion

gl.readPixels() 方法是把當前綁定的 FBO 及當前綁定的顏色附件的像素值讀取到 TypedArray 中,無論載體是 WebGLRenderbuffer 還是 WebGLTexture.

唯一需要注意的是,如果你在寫引擎,那麼讀像素的操作得在繪製指令(一般指 gl.drawArraysgl.drawElements)發出後的代碼中編寫,否則可能會讀不到值。

3.2 WebGPU 讀 GPUTexture 中的數據

在 WebGPU 中將渲染目標,也即紋理中訪問像素是比較簡單的,使用到指令編碼器的 copyTextureToBuffer 方法,將紋理對象的數據讀取到 GPUBuffer,然後通過解映射、讀範圍的方式獲取 ArrayBuffer.

//#region 創建顏色附件關聯的紋理對象
const colorAttachment0Texture = device.createTexture({ /* ... */ })
//#endregion

//#region 創建用於保存紋理數據的緩衝對象
const readPixelsResultBuffer = device.createBuffer({
  usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
  size: 4 * textureWidth * textureHeight,
})
//#endregion

//#region 圖像拷貝操作,將 GPUTexture 拷貝到 GPUBuffer
const encoder = device.createCommandEncoder()
encoder.copyTextureToBuffer(
  { texture: colorAttachment0Texture },
  { buffer: readPixelsResultBuffer },
  [textureWidth, textureHeight],
)
device.queue.submit([encoder.finish()])
//#endregion

//#region 讀像素
await readPixelsResultBuffer.mapAsync()
const pixels = new Uint8Array(readPixelsResultBuffer.getMappedRange())
//#endregion

要額外注意,如果要拷貝到 GPUBuffer 並且要交給 CPU 端(也就是 JavaScript)來讀取,那這塊 GPUBuffer 的 usage 一定要有 COPY_DSTMAP_READ 這兩項;而且,這個紋理對象的 usage 也必須要有 COPY_SRC 這一項(作爲顏色附件的關聯紋理,它還得有 RENDER_ATTACHMENT 這一個 usage)。

4 總結

從 WebGL(也即 OpenGL ES 體系)到 WebGPU,離屏繪製技術、多目標渲染技術都有了接口和用法上的升級。

首先是取消了 RBO 這個概念,一律使用 Texture 作爲繪製目標。

其次,更替了 FBO 的職權至 RenderPass,由 GPURenderPassEncoder 負責承載原來 FBO 的兩類附件。

因爲取消了 RBO 概念,所以 RTT(RenderToTexture)RTR(RenderToRenderbuffer) 就不再存在了,但是離屏繪製技術依舊是存在的,你在 WebGPU 中可以使用多個 RenderPass 完成多個繪製成果,Texture 作爲繪製載體可以自由地經過資源綁定組穿梭在不同的 RenderPass 的某個 RenderPipeline 中。

關於如何從 GPU 的紋理中讀取像素(顏色值),第 3 節也有粗淺的討論,這部分大多數用途是 GPU Picking;而關於 FBO 這個遺留概念,現在即 RenderPass 離屏渲染,最常見的還是做效果。

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