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_ATTACHMENT0
、gl.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>
爲當前的 RBOgl.renderbufferStorage(gl.RENDERBUFFER, <rbo_format>, width, height)
:設置當前綁定的 RBO 的數據格式以及長寬
下面是三個創建的方法:
gl.createFramebuffer()
gl.createRenderbuffer()
gl.createTexture()
順帶回顧一下紋理的參數設置、紋理綁定與數據傳遞函數:
gl.texParameteri()
:設置當前綁定的紋理對象的參數gl.bindTexture()
:綁定紋理對象爲當前作用紋理gl.texImage2D()
:向當前綁定的紋理對象傳遞數據,最後一個參數即數據
2 WebGPU 中的對等概念
WebGPU 已經沒有 WebGLFramebuffer
和 WebGLRenderbuffer
這種類似的 API 了,也就是說,你找不到 WebGPUFramebuffer
和 WebGPURenderbuffer
這倆類。
但是,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.drawArrays
或 gl.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_DST
和 MAP_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 離屏渲染,最常見的還是做效果。