Wgpu圖文詳解(03)緩衝區Buffer

w4ngzhen發表於2024-11-18

在上一篇文章中,我們介紹了Wgpu中的渲染管線與著色器的概念以及基本用法。相信讀者還記得,我們在渲染一個三角形的時候,使用了三角形的三個頂點的索引作為了頂點著色器的輸入,並根據索引值計算了三個幾何頂點在視口中的位置,並透過片元著色器的程式碼邏輯,控制了每一個畫素都用紅色色值,最終渲染了一個紅色三角形:

010

當然,我們不可能一直使用wgpu來渲染這樣的簡單固定的圖形。面對實際的場景,我們有時候需要根據一些上下文來動態的修改渲染圖形的大小形狀。在本文中,我們將開始介紹頂點緩衝區的概念,來為後續實際的場景做一些鋪墊。

認識緩衝區

緩衝區(Buffer)一個可用於 GPU 操作的記憶體塊(又叫“視訊記憶體”)。在wgpu(或其他例如OpenGL等庫)中的緩衝區概念通常指的是 GPU 能讀寫的記憶體區域,與之對應的就是我們常見的CPU記憶體。回想一下常規的軟體執行的過程:程式在啟動後,會在“記憶體”中申請一塊能夠存放資料的區域。在執行的過程中,我們的程式碼指令按照既定的邏輯做著計算,並不斷的讀、寫記憶體區域裡面的資料,以達到期望的程式執行的結果。不嚴謹地講,GPU 與 CPU 是一樣的,它同樣能夠執行計算邏輯,同樣會有資料儲存的區域,這個區域就是 GPU 的緩衝區。

一般來說,我們都在 CPU 直接階段,在記憶體中將一些初始的資料準備好,透過一定的方式傳送給 GPU,並儲存在GPU上的緩衝區中。在執行的過程中,我們可以透過著色器程式碼來讀取緩衝區中的資料:

020

建立頂點緩衝區

為了更好的管理不同型別的資料(比如常見的有頂點資料、頂點索引資料),我們會按照其不同型別來設定不同的緩衝區。在本文中,我們先介紹如何建立並使用頂點緩衝區,對於其他緩衝區我們會在後續的文章中說明。

頂點緩衝區,顧名思義,就是包含了在渲染過程中會使用到的頂點資料的 GPU 視訊記憶體區域。需要注意的是,圖形學中的頂點並不是我們常規意義上的幾何頂點,而是包含了位置座標、顏色資訊、紋理座標以及法線向量等的頂點資料,常規意義上的幾何頂點僅僅是頂點資料中的一部分。

在上一篇文章中,儘管在最後我們成功終繪製了一個三角形,但實際上它的三個頂點位置是透過三個頂點索引(0、1、2)計算而來的。假設我期望繪製一個比較另類的三角形或其他圖形,純粹靠頂點索引是不夠的。這種場景我們一般會按照如下的方式進行:

  1. 準備一些包含自定義位置資訊的頂點資料;
  2. 將頂點資料放置到頂點緩衝區中,並進行一定的配置;
  3. 最後,在著色器程式碼中透過一定的方式讀取這些頂點資料,並交給頂點著色器來使用。

接下來讓我們開始實踐如何透過程式設計方式建立頂點緩衝區。

假設最終我們期望渲染一個由(0, 1)(-0.5, -0.5)(0.5, 0)三個2維頂點構成的三角形:

030

首先,讓我們在基礎專案中增加一個結構體Vertex,用來表達我們的頂點:

040

這個結構體我們現在僅有一個型別為[f32; 3]型別的欄位position,用來表示一個位置座標。

⚠️這裡務必新增Copy派生

引申:關於記憶體佈局

該結構體上的屬性,除了我們常見用來派生CopyClone等trait的derive屬性外,還有一個特殊的屬性:#[repr(C)],在配置該屬性後,Rust 編譯器會強制按照 C 編譯器的編譯方式來安排結構體欄位的順序和對齊方式。假設有如下結構體,在#[repr(C)]的加持下,其記憶體佈局會保持4位元組對齊:

050

上面的結構體中,age欄位的型別是u8,但因為強制使用了#[repr(C)],讓其保持了4位元組的記憶體佈局。我們可以用如下的程式碼來驗證:

060

當然,有的小夥伴會發現即使不新增#[repr(C)],結果也是24bytes,是因為Rust編譯器在某些場景下會進行對齊,不過這樣無法保證是按照和C編譯器一樣的4位元組對齊;此外,Rust編譯器在有時為了記憶體的高效利用,可能會進行佈局壓縮。當然,你還可以使用#[repr(packed)]來禁用記憶體對齊填充:

070

好了,讓我們迴歸正文。此時我們已經編寫了一個Vertex結構體,也理解了#[repr(C)]的意義。接下來,我們建立一個陣列切片來存放三個頂點的資料:

// 表示三角形三個頂點的頂點列表
pub const VERTEX_LIST: &[Vertex] = &[
    Vertex { position: [0.0, 1.0, 0.0] },
    Vertex { position: [-0.5, -0.5, 0.0] },
    Vertex { position: [0.5, 0.0, 0.0] },
];

在編寫了三個頂點的資料後,我們更進一步,將頂點緩衝區建立出來消費頂點資料。

首先,讓我們在async_new方法中的合適位置透過呼叫Device例項的create_buffer_init方法建立一個頂點緩衝區物件:

080

contents欄位需要我們提供&[u8]型別的資料,即位元組陣列的切片引用,這裡我們先傳空,待會兒會講到如何將我們的VERTEX_LIST資料轉為&[u8]型別的資料;usage欄位我們現在傳入 wgpu::BufferUsages::VERTEX這個列舉,表明我們要建立的是一個頂點緩衝區,而不是其他的緩衝區。

接下來,我們嘗試將前面建立的VERTEX_LIST資料轉換為&[u8]位元組資料。這裡我們使用一個工具庫bytemuck,該庫可以方便的將我們的一些資料結構轉為記憶體中的位元組資料。其具體方式如下:

  1. 在依賴中新增bytemuck
  2. 修改Vertex結構體的內容:

090

  1. 在建立緩衝區的地方新增如下的轉換程式碼:

100

呼叫bytemuck的cast_slice方法,將原始資料轉為u8的切片,並作為contents欄位的值傳入

  1. 修改WgpuCtx結構體,儲存我們本次建立的頂點緩衝區例項:

110

總結一下,為了建立一個頂點緩衝區,我們經歷如下幾步:

  1. 定義一個結構體(Vertex)來表示一個頂點資料,該結構體除開配置#[derive(Copy, Clone)]屬性外,還需要使用#[repr(C)]來保證該結構體在編譯後的記憶體佈局及對齊位元組資料保持和C編譯器一樣;以及,讓結構體實現來自bytemuck庫提供的PodZeroable兩個trait,以供後續透過bytemuck的提供的API來將資料轉為&[u8]
  2. 完成Vertex結構體的定義後,我們又根據最終想要渲染的三角形的幾何結構,使用VERTEX_LIST來儲存了三個頂點資料。
  3. 使用bytemuck提供的API將VERTEX_LIST透過將其轉為了u8位元組陣列切片位元組資料。
  4. 呼叫Device提供的APIcreate_buffer_init,傳入頂點陣列位元組資料,以建立一個頂點緩衝區例項。
  5. 將頂點緩衝區例項儲存到WgpuCtx例項,以供後續消費使用。

至此,對於建立頂點緩衝區部分的介紹就到此為止。接下來我們需要介紹另一個同樣重要的內容:頂點緩衝區佈局(VertexBufferLayout)

建立頂點緩衝區佈局

首先,我們需要明白為什麼會有緩衝區佈局這一東西。假設現在在 GPU 視訊記憶體中有如下的一段資料:

120

在沒有其他上下文的情況下,我們無法理解這段記憶體中的資料有何意義。同樣的,如果我們單是把先前建立的頂點資料放入頂點緩衝區中,在實際渲染的過程中,GPU 也無法理解這一堆的二進位制資料應該如何使用。此時,我們就需要用一些配置上下文來解釋頂點緩衝區中的資料的具體意義。

還是以上圖資料為例,如果現在告訴你這是一段包含了3個頂點資料的記憶體佈局,其步進(stride)是3位元組(即每三個位元組就算做一個頂點資料);同時,單看每一份頂點資料,按照從其偏移位元組為0的地方開始是一份位置資料,其型別為3個float32(32bits,即4bytes)資料,現在對於這段記憶體中的資料的佈局結構是不是變的比較清晰了呢:

130

有了上述的說明,再回過頭來就不難理解緩衝區佈局的意義了。接下來就讓我們透過程式碼實踐來定義一個頂點緩衝區佈局例項。

首先,我們依然在vertex.rs檔案中增加一個方法,用來返回一個頂點緩衝區佈局例項:

140

對於該方法的實現,我們就是返回瞭如下的一個結構體:

wgpu::VertexBufferLayout {
    array_stride: size_of::<Vertex>() as wgpu::BufferAddress,
    step_mode: wgpu::VertexStepMode::Vertex,
    attributes: &[
        wgpu::VertexAttribute {
            offset: 0,
            shader_location: 0,
            format: wgpu::VertexFormat::Float32x3,
        },
    ],
}
  • 欄位array_stride表示的就是每一份頂點資料在記憶體中的步進長度,在本例中,一個Vertex結構體在#[repr(C)]的屬性配置下能夠確保是12bytes。

  • 欄位step_mode我們暫時不詳細介紹,讀者可以簡單理解為告訴渲染管線每一份資料代表的是一個頂點資料(),這裡預設使用該列舉值VertexStepMode::Vertex即可。

  • 欄位attributes是一個陣列切片引用,在這裡我們只傳遞了一個VertexAttribute資料,表示就目前而言,我們一份頂點資料中,只有一份有意義的“子資料”。對於這份“子資料”,我們配置了offsetshader_location以及format欄位。這三個欄位整體表達了這樣一個事實:在一份頂點資料中,從offset = 0開始有一段格式為Float32x3(float32 = 32bits = 4bytes, 乘以3就等於12bytes)的資料,這段資料在shader著色器上的location為0的位置。相信讀者對offset和format應該能夠理解,但是對於“這段資料在shader著色器上的location為0的位置”這句話還有些難以理解,彆著急,我們後面會講到的。

消費緩衝區及佈局

總結下現狀,我們首先建立了頂點緩衝區並將其作為vertex_buffer存放到了WgpuCtx例項中;同時,我們還編寫一個名為create_vertex_buffer_layout的方法用來構造一個頂點緩衝區佈局例項,接下來我們會使用到上面準備工作的成果了。

首先,讓我們在WgpuCtxdraw方法中適當修改程式碼來消費頂點緩衝區:

150

在呼叫渲染通道(RenderPass)例項的draw方法前,我們先呼叫set_vertex_buffer方法。該方法接受兩個引數,第一個引數slot指的是我們要把頂點緩衝區中的資料放置到視訊記憶體內部的頂點緩衝區域的哪個索引位置,這裡我們設定為0,表示我們會設定到預設0的位置;第二個引數使用的緩衝區的資料片段,這裡我們直接消費整個頂點資料,因此程式碼編寫為slice(..)

然後,修改draw的引數傳遞。將原來固定的0..3(即3個頂點)修改為動態的,根據我們建立的VERTEX_LIST的實際長度,這樣在將來我們會建立更多的頂點的時候,就能夠正確對應頂點數量。

完成消費頂點緩衝區的程式碼編寫以後,接下來我們就需要再適當的位置建立緩衝區佈局例項並消費它,其具體做法是:

呼叫create_vertex_buffer_layout方法得到緩衝區佈局例項物件;把該例項物件傳遞給如下VertexStatebuffers欄位:

160

這個地方叫做buffers,但是實際上是要傳buffer佈局,maybe命名有點讓人誤導。

到目前為止內容偏多,讓我們透過下圖做一個簡單的總結:

170

修改頂點著色器程式

上面的實踐過程,我們僅僅是建立並消費了頂點緩衝區以及頂點緩衝區佈局例項。然而,如果在此時執行程式程式碼,讀者會發現視窗中依然是先前的一個撐滿視窗的紅色三角形。很顯然,我們需要適當的修改著色器程式的程式碼,才能真正消費到我們在上面產生的有關頂點資料。讓我們對shader.wgsl做出如下的修改:

180

首先,我們在著色器程式碼中定義了一個結構體VertexInput,這個結構體包含有一個position欄位,其型別為vec3f

值得注意的是,這個欄位有一個前置的註解@location(0)。還記得前面我們說過:“這段資料在shader著色器上的location為0的位置”這句話嗎?其實這裡的location(0)對應匹配的就是前面在定義頂點緩衝區佈局的shader_location配置:

190

對於頂點資料、頂點緩衝區佈局配置以及著色器中VertexInput的結構定義,我們就可以用下圖來解釋它們的關係了:

200

再看頂點著色器vs_main的部分,其入參由原來的@builtin(vertex_index) in_vertex_index: u32修改為了vertex_in: VertexInput。在每次頂點著色器執行的時候,渲染管線會結合頂點緩衝區佈局配置以及每一份記憶體中的頂點資料,為我們構建一個VertexInput結構體例項,並傳入該頂點著色器方法中。在這裡我們就可以直接讀取到對應的position位置欄位資料並直接返回了。

而對於片元著色器,我們暫時沒有任何改動。因此,在一切準備工作結束以後,讓我們執行程式,會發現最終渲染的三角形確實如我們所期望的結構那樣展示了:

210

給頂點資料加入更多的資訊

在本文中,由於我們的頂點資料結構體Vertex中只包含了一個型別為[f32; 3]的位置資料欄位,因此在設定VertexBufferLayoutattributes欄位的時候,我們只傳入了一個VertexAttribute配置,並且其offset為0,代表了我們的一份在記憶體中的頂點資料,只包含一份屬性資料,且是從偏移位元組為0開始的。當然,正如前面提到的,頂點資料並非只會有位置資料,通常伴隨著的還會有顏色資訊、法線資訊等。在這裡,我們嘗試給頂點加入顏色資料,好在著色器處理階段能夠定製三角形的顏色。

首先,讓我們嘗試修改Vertex結構體,加入一個顏色欄位:

220

完成以後,可以想象到,把VERTEX_LIST資料轉為位元組資料放到頂點緩衝區以後,其記憶體佈局會是如下形式:

230

如果讀者理解了前面提到的offset的含義,那麼就不難想到,為了讓頂點著色器能夠訪問到顏色資訊。我們需要將頂點緩衝區佈局中關於attributes欄位增加一條配置:

240

  1. 在頂點緩衝區佈局物件的attributes欄位,在原有基礎上,再插入一份VertexAttribute配置,代表了要配置顏色資訊;
  2. 對於新加的VertexAttribute,其offset欄位填入的值是偏移過position位元組資料長度;
  3. 將顏色資訊資料指定為著色器中的location為1的地方。

接下來,我們只需要修改著色器程式碼VertexInput結構體,增加一個color欄位,

250

此時,渲染管線在構造這個VertexInput例項的時候,就能知道除了原有position欄位資料外,還會把視訊記憶體中的一份頂點資料的後面float32x3的大小資料對映到@location(1) color: vec3f上了。

當然,僅僅給VertexInput增加color欄位,對於我們最終的渲染效果目前來說是沒有任何影響的,因為我們壓根兒沒有消費這個color欄位。為了消費這個欄位,並讓最終渲染的三角形的顏色產生變化,接下來就讓我們關注一下著色器程式碼,看看還需要做什麼。

首先,我們之前講到過,對於頂點著色器的方法,我們返回的是@builtin(position) vec4<f32>,這意味著每次頂點著色器執行以後,會得到一個頂點的位置資料。在本例中,在所有頂點都執行以後,我們會得到3個頂點位置,渲染管線會拿著這3個位置構建一個三角形,並進行柵格化,再呼叫片元著色器,然後我們會再片元著色器中為每一個畫素指定顏色。那麼這裡有一個問題:我們只能夠在頂點著色器中返回每個頂點的位置嗎?答案當然是否定的。除了直接返回一個@builtin(position)修飾的資料型別,我們還可以返回一個結構體,只要這個結構體中有一個欄位用@bultiin(position)修飾即可:

260

這段修改後的程式碼的效果其實和之前是一樣的,只不過我們用過了結構體來包裹。在返回結構體的形式下,我們可以在結構體中加入一些其他的欄位,並且,在片元著色器節點還可以訪問到頂點著色器輸入結構體資料:

270

上述的著色器程式碼編寫完成以後,理論上執行程式,你會發現如下的效果:

280

wow,一個漸變的三角形!然而,這就結束了嗎?非也!

如何得到顏色

⚠️筆者水平有限,因此後面的內容筆者僅能靠自己目前淺顯的理解進行總結,其中可能會存在一些不到位或不正確的理解,這裡懇請相關專業人士對錯誤的內容批評指出。

如果讀者認真看到現在,並且仔細思考了以後,我相信你會有一些疑問。首先,目前我們只有3個頂點,那麼頂點著色器理論上來講只會被呼叫3次,也就是說,我們總共只會得到3個VertexOutput資料並返回給渲染管線,且這3個VertexOutput例項的color欄位分別只會是(1.0, 0.0, 0.0)(紅色)、(0.0, 1.0, 0.0)(綠色)以及 (0.0, 0.0, 1.0)(藍色)。然而,我們最終渲染的三角形是一個漸變三角形。根據片元著色器的作用,它會在每一個畫素處理階段被呼叫,這是否能夠表明一件事:片元著色器程式碼中的消費的color欄位,和前面的VertexOutputcolor其實不是一個東西?答案確實如此。

290

細心的讀者會發現,在fs_main入參,儘管型別是VertexOutput,但我刻意的避免了使用vertex_output作為名稱,而是使用了data,其實就在暗示從頂點著色器vs_main返回的VertexOutput跟這裡片元著色器fs_main得到的輸入VertexOutput例項並不是一個東西。讓我們開拓下思維,結構體的本質是什麼?實際上,結構體只是一種對記憶體資料的具名錶達而已,在這裡我們僅僅透過了VertexOutput這個具名的描述記憶體資料形態的標識作為了頂點著色器和片元著色器的橋樑而已。

300

換句話說,如果我們改成下面的程式碼,我們的程式同樣能夠正確的執行:

310

那頂點著色器輸出的位置和顏色資訊,最終是如何影響到片元著色器的輸入的呢?對於渲染管線來說,在頂點著色器執行以後,它會得到三個頂點資料,而其中就有透過@builtin(position)標識的,能夠表達位置資訊的資料。很顯然,有了三個點的位置資訊,在圖元裝配結合光柵化以後,我們能夠得到一個最終圖形的上的任意一個畫素點位置資訊:

320

每一個頂點中我們都增加了一份資料用來表示顏色(color欄位)。那麼,對於三角面中任意一個畫素的點位置的color欄位資料,實際上是三個頂點顏色資料在此位置上的演算法疊加:

330

即,我們可以用一個方法來表達三角面上任意一個點的顏色:

fn color((v1_pos, v1_color), (v2_pos, v2_color), (v3_pos, v3_color), any_pos) -> color

透過輸入三個頂點的位置和顏色,以及任何一個三角面上的點位置,就能算出該點的顏色資料。但值得注意的是,在我們的場景中,我們對@location(0)位置的資料取名為了color,表明用該欄位作為顏色欄位,但是在記憶體中,管線只知道這裡有一份型別為vec3f的資料罷了。所以,對應的更加通用的公式應該是:

fn get_data((v1_pos, v1_data), (v2_pos, v2_data), (v3_pos, v3_data), any_input_pos) -> data_in_pos

那麼關於這個的具體實現在本文中不再細講,但最簡單的方式應該就是線性疊加,讀者可以自行深入這塊的內容。

關於@location

另外我們還需要著色器程式碼解釋另一個東西。仔細觀察程式碼,無論是VertexOutput還是FragmentInput結構體,我們都在color這個欄位使用了註解@location(0)。在前面的VertexInput結構體的中的color欄位我們使用了同樣的註解,其含義是我們把一份記憶體中對應位置的資料設定為了著色器中一個結構體中location = 0的位置的欄位。那麼這裡是不是也是同樣的意義呢?答案確實是如此的。

讀者可以這樣理解,在光柵化後,每一次片元著色器的輸入也是一份記憶體資料,這份記憶體資料我們同樣可以使用一個結構體來訪問(因為結構體是記憶體中的資料的可讀性表達),但是結構體中的欄位可以有很多個,每個欄位究竟是記憶體中哪一塊的資料,需要有一個明確的指明:

340

在上圖中,無論是VertexOutput結構體還是FragmentInput結構體,其記憶體的佈局是一致的,因此在片元著色器執行的時候,渲染管線提供的資料我們用上述兩種結構體夠可以表達對應的記憶體資料。再想的遠一點,這個location只能是0嗎?其實也不是,因為本質上講,它是一段資料的標識,只是本例中,我們使用了0這個位置標識而已,如果你樂意,你還可以編寫如下標識:

350

甚至,你還可以不編寫任何的結構體作為輸入,而是直接使用@location來定位:

360

對於最後的一種使用方式,請讀者自己揣摩一下~

寫在最後

本文的內容較多引申了不少額外的內容,讀者可以慢慢閱讀消化,希望能夠對認識wgpu以及圖形學工程有更進一步的理解和認識。在接下來的內容,我們將會認識wgpu中有關於以及圖形學工程相關的更多的內容,敬請期待!

本章的程式碼倉庫在這裡:

https://github.com/w4ngzhen/wgpu_winit_example/tree/main/ch03_buffer

後續文章的相關程式碼也會在該倉庫中新增,所以感興趣的讀者可以點個star,謝謝你們的支援!

相關文章