Wgpu圖文詳解(02)渲染管線與著色器

w4ngzhen發表於2024-11-07

在本系列的第一篇文章中(《Wgpu圖文詳解(01)視窗與基本渲染》),我們介紹瞭如何基於0.30+版本的winit搭建Wgpu的桌面環境,同時也講解了關於Wgpu一些基本的概念、模組以及架構思路,並基於wgpu庫實現了一個能展示有顏色背景的窗體。而在本篇文章中,我們將開始介紹Wgpu中的渲染管線以及著色器,並透過這兩個基本要素,在原有視窗的基礎上,渲染一個三角形。

⚠️這章的內容很多,相比上一章來說,需要讀者具備更多關於圖形學的理論知識,否則看起來還是會一頭霧水,不過筆者儘可能的將一些內容講的細一點,特別是著色器程式碼與程式碼中的某些配置的關係,旨在讓讀者能夠不那麼“頭暈”。當然,作者的能力有限,所以針對圖形學的內容,讀者可以自行了解熟悉後,後再看本文,當然這裡也毛遂自薦下自己的另一篇文章《關於計算機圖形學的一些介紹(01)基本要素與空間變換》(知乎部落格

⚠️本章節開始的wgpu使用版本為23.0.0,為2024年的最後一個major升級:release/tab/v23.0.0,該版本有break change,請讀者確保版本一致。

基本概念匯入

首先,讓我們先簡單的介紹一下什麼是管線Pipeline。從實際應用的角度看,管線類似於工廠內的生產線:從一端開始,接收基礎原料,隨後,生產線上各工序節點依次對這些原料進行加工處理,逐步形成最終產品。同樣,計算機圖形學工程中的管線的形式也十分類似,我們把一些有關最終要渲染的影像的必要資料作為輸入,透過管線的層層作業,最終得到能夠渲染到螢幕裝置上的圖形、顏色。此外,管線還有一個比較有價值的作用就是能夠將處理資料的分工變的更加明確,同時,每一個步驟也能具備獨立配置、程式設計的能力。

當然,在圖形學工程中的管線是有很多種類的,比如渲染管線RenderPipeline、計算管線ComputePipeline。不同種類的管線負責了不同的工作,但其本質是一樣的:流程化的處理圖形資料。為了透過Wgpu渲染一個三角形,我們至少需要構建一個渲染管線,來達到最終的目的。

在介紹渲染管線的同時,我們就不得不介紹另一個重要的東⻄:著色器Shader。正如上面所說的,渲染管線的本質是一條包含多個環節的作業流水線。為了讓我們能夠更加方便的透過程式來控制每一個作業環節,圖形學工程引入了著色器這一概念。需要強調的是,著色器並不是某種類似上色的功能,而是一段可程式設計的處理程式,能夠讓我們在渲染管線中的某些環節透過程式去控制結果。所以,整體結合來看,我們可以將渲染管線與著色器的關係用一張圖來表達:

000

上面這張圖只是一個概念上的簡單的關係圖。在實際的圖形學工程中,遠遠要比這個複雜的多,不過為了讓讀者有一個感性的認知,可以暫時按照上圖的關係來理解管線和著色器的關係。

既然著色器本質是一段程式,那我們不可避免的需要編寫這樣的程式。在Wgpu中,我們使用wgsl(Web GPU Shading Language)編寫著色器程式。當然,就如同C/C++、Rust等高階程式語言一樣,我們編寫的wgsl只是原始碼,因此,我們還需要將這些原始碼編譯為著色器的二進位制程式,這個過程幾乎不用我們操心,因為Wgpu在執行過程中會去編譯並呼叫這些著色器程式碼。

好了,到目前為止我們對管線與著色器有了一個大體的認識,當然,光有理論知識是不夠了,接下來我們就開始從程式碼工程出發,編寫構建渲染管線的相關程式碼以及著色器程式。

準備階段

本章的程式碼工程專案將會在第一篇文章搭建結果的基礎上進行修改。因此在繼續後面的講解前,請確保你已經充分理解了第一章的內容並搭建好了環境。

首先,讓我們在WgpuCtx這個結構中新增一個新的欄位render_pipeline,其型別為wgpu::RenderPipeline。接著,讓我們準備一個結構無關的方法,其簽名為:

fn create_pipeline() -> wgpu::RenderPipeline;

最後,讓我們在WgpuCtx的new_async方法中的指定位置呼叫上述的create_pipeline方法,並將得到的RenderPipeline交給WgpuCtx存放。

005

010

接下來,讓我們編寫一段著色器程式。在專案目錄下建立一個名為shader.wgsl的檔案,並在其中新增如下wgsl程式碼:

@vertex
fn vs_main(@builtin(vertex_index) in_vertex_index: u32) -> @builtin(position) vec4<f32> {
    let x = f32(i32(in_vertex_index) - 1);
    let y = f32(i32(in_vertex_index & 1u) * 2 - 1);
    return vec4<f32>(x, y, 0.0, 1.0);
}

@fragment
fn fs_main() -> @location(0) vec4<f32> {
    return vec4<f32>(1.0, 0.0, 0.0, 1.0);
}

020

至於這段wgsl程式碼的含義我們先不著急說明,後面我們會詳細的解釋,此時就簡單理解為我們編寫了一份著色器程式原始碼,並讓其在渲染管線中發揮作用。

接下來讓我們修改一下 create_pipeline 的方法簽名,增加兩個入參:

fn create_pipeline(
    device: &wgpu::Device, // <--- 引數1
    swap_chain_format: wgpu::TextureFormat, // 引數2
) -> wgpu::RenderPipeline { 
  //... 
}

對於第一個引數wgpu::Device,看過第一章的讀者應該知道,這個例項是透過adapter呼叫request_device得到的,是對邏輯裝置的抽象的例項:

030

對於第二個引數,wgpu::TextureFormat,則來源於完成配置後的surface_config的format欄位。所以,在呼叫點我們需要做出適當的修改:

040

在準備工作完成以後,我們的專案現在大概長這樣:

050

至此,我們已經準備好了一個建立管線的環境了。接下來就讓我們開始關注於create_pipeline這個方法的具體實現,開始真正的建立渲染管線、著色器以及理解它們。

建立渲染管線

對於create_pipeline的方法體,我們填入如下的內容:

060

透過程式碼註釋,我們可以瞭解到建立一個基礎的渲染管線至少有以下兩步:

  1. 透過wgpu::Device提供的APIcreate_shader_module載入著色器程式模組;
  2. 透過wgpu::Device提供的APIcreate_render_pipeline,結合步驟1中得到的著色器模組例項建立渲染管線。

對於第一步來說,讀者可以直接參考上述程式碼即可,其含義理解起來並不困難,核心就是從載入著色器原始碼內容,並透過一系列構造過程得到一個ShaderModule(著色器程式模組)。

wgpu的很多結構體都會有一個名為label的欄位,這個欄位對於執行時沒有什麼影響,僅僅是作為Debug除錯階段時方便定位資料的。

對於第二步呼叫create_render_pipeline,其具體的內容如下所示:

070

筆者在上圖程式碼中將其標記為了5個部分的配置。其中,第1個和第5個配置本章暫不涉及,按上圖示例程式碼傳入相關預設值即可,這些引數我們會在後續的文章中逐步講解,本文咱不贅述。讓我們重點關注上圖中的第2、3、4個部分。

⚠️接下來的內容,除了有關wgpu本身使用內容以外,還會涉及到計算機圖形學中的一些重要概念。什麼是頂點vertex,什麼是片元fragment,什麼是圖元primitive,這些都是學習計算機圖形學必不可少的知識點。由於本系列文章重點是從工程的角度介紹如何使用wgpu,所以關於圖形學的知識點不會特別介紹,需要讀者自行學習,本文假設讀者已經具備了相關的知識

再次自薦《關於計算機圖形學的一些介紹(01)基本要素與空間變換》(部落格地址知乎地址)。

頂點著色器

讓我們先聚焦第一個部分:

vertex: wgpu::VertexState {
    module: &shader,
    entry_point: Some("vs_main"),
    buffers: &[],
    compilation_options: Default::default(),
},

第一個引數module,表明了我們需要從哪個ShaderModule例項來獲取頂點著色器程式。在前面,我們曾編寫了一份著色器程式碼,並透過create_shader_module建立了一個ShaderModule例項,這裡作為該引數的值傳入即可。

第二個引數entry_point,表明了頂點著色器程式入口點,這個所謂的入口點類似於我們常規程式中的main函式一樣。不過需要注意的是,這裡我們填入的是"vs_main",還記得我們之前編寫的shader.wgsl程式碼嗎?在其中有我們編寫的這樣一段程式碼:

080

在該段程式碼中,我們使用了一個註解@vertex來表明接下來的函式是一個頂點著色器有關的函式,然後,我們給這個方法命名為"vs_main"。相對應的,在上面的Rust程式碼中的entry_point欄位我們對應填入的就是這個vs_main。所以,目前的情況如下:

090

請注意,本文使用的wgpu版本為23.x +,該版本與22.x以及0.2x版本的一個重要break change:關於VertexState以及接下後續介紹的FragmentState的entry_point欄位的型別由舊版本的&'a str該為了Option<&'a str>。因此本文都傳的Some(xxx)

在瞭解了這樣的配置關係以後,我們還需要知道這段頂點著色器程式碼的意義。首先,該方法會在每一次處理頂點的時候被呼叫。假設現在我們場景中提供了n個頂點,那麼渲染管線在頂點處理這一環節的時候,會呼叫n次這個頂點著色器程式。對於這個vs_main方法的引數,首先是入參@builtin(vertex_index) in_vertex_index: u32,每次呼叫該vs_main方法的時候,會傳入一個u32型別的值,該值是wgsl內建的頂點索引值(如果是n個頂點的話,通常是0到n-1)。可以把這段流程想象成如下虛擬碼:

遍歷:0 <= 頂點索引index n-1 {
	頂點處理結果 = 執行vs_main(頂點索引index)
	拿著頂點處理結果乾其他事...
}

同時,該方法每一次呼叫完成以後,會返回一個vec<f32>,同時用@builtin(position),表明該方法返回的是一個內建的位置資料。可能讀者對於這塊還感覺到非常抽象。那讓我們用一個更見實際的例子來解釋。

假設現在有如下的一個三角形:

100

對於這個三角形的三個頂點,按照逆時針方向,其索引依次是0、1、2。在渲染管線的頂點著色器處理的環節,根據我們前面講到的,每一個頂點都呼叫一次vs_main方法,那麼結果如下:

110

值得注意的是,在程式碼中求y值時,程式碼使用的是將資料與1進行二進位制按位與操作,因此,當index = 2時,2 & 1實際上是二進位制10 & 二進位制01,按位與的結果就是二進位制00,即就是0。

對於每個頂點來說,我們求得了其位置座標。但值得注意的是,返回的位置座標是一個4維的,其中前兩個分量分別對應x軸和y軸,同時也是我們根據頂點索引動態得到的;第三個分量是z軸,且均為0.0,表明所有的頂點都處在z軸等於0的平面上;最後一分量是w值,通常都是1.0(對於這個w分量,務必請讀者自行仔細瞭解其數學意義,本文不做贅述)。

對上述結果整理一下,我們可以知道,三個頂點依次處理的結果就是生成了在同一個2維平面上(因為z均為0)的三個點,其座標分別是:(-1.0, -1.0)(0.0, 1.0)(1.0, -1.0)。那麼這些座標在wgpu下的意義是什麼呢?這裡我們直接給一個結論。首先我們知道wgpu渲染的時候,對應物理螢幕上是存在一個視口viewport的(如果忘記了,請在閱讀下本系列的第一章內容),對於這個視口來說,無論其寬高的絕對大小值是多少,總是以中心位原點,視口上下y範圍為1.0到-1.0,以及視口左右x的範圍為-1.0到1.0的座標區域:

120

因此,上述座標的結果就是我們能夠渲染一個如下的頂點剛好頂滿視口的三角形:

130

目前的程式碼進度還無法渲染出上圖的結果,這裡只是為了讓讀者更加直觀瞭解座標與最終渲染的關係

當然,如果我們適當的修改頂點著色器中程式碼,將x、y值分別再乘以0.5,就能看到一個縮小版的三角形了:

140

現在我們已經講解了關於Vertex配置VertexStatemoduleentry_point欄位了,對於剩餘的bufferscompilation_options欄位來說,本章暫時不進行討論,只需要預設即可:

vertex: wgpu::VertexState {
    module: &shader,
    entry_point: Some("vs_main"),
    buffers: &[], // <--- 預設
    compilation_options: Default::default(), // <--- 預設
},

圖元配置

對於圖片的配置如下:

primitive: wgpu::PrimitiveState {
    topology: wgpu::PrimitiveTopology::TriangleList,
    ..Default::default()
},

在本文中筆者僅展示一個核心的欄位配置:topology。對於這個引數,有以下幾個目前能夠支援的配置:

  • PointList:頂點資料是一系列點。每個頂點都是一個新點。也就是說,像上面的我們提供的3個頂點,最終並不會渲染為一個三角形,而是三個獨立的點。

  • LineList:頂點資料是一系列線條。每對頂點組成一條新線。頂點0 1 2 3建立兩條線0 1和2 3。注意,這個列舉值配置下,我們提供的頂點必須要能夠成對出現,像上面我們的3個頂點,最終只會渲染一條線,因為0、1構成一條線,而頂點2沒辦法構成另一條線了。

  • LineStrip:頂點資料是一條直線。每組兩個相鄰的頂點形成一條線。頂點0 1 2 3建立三條線0 1、1 2和2 3。也就是說上面的例子最終不會渲染一個填充了內容的三角形,而是隻有邊線的三角形。

  • TriangleList(預設):頂點資料是一系列三角形。每組3個頂點組成一個新的三角形。頂點0 1 2 3 4 5建立兩個三角形0 1 2和3 4 5。這是我們的預設配置。

  • TriangleStrip:頂點資料是一個三角形條帶。每組三個相鄰頂點形成一個三角形。頂點0 1 2 3 4 5建立四個三角形0 1 2、2 1 3、2 3 4和4 3 5。

透過解釋,相信讀者應該能夠理解上述配置的結果,當然讀者會在後續的文章中用更多的例子來講解這塊的內容。

片元著色器

接下來讓我們關注片元著色器的部分。要了解片元著色器,我們首先要知道什麼是片元,片元是怎麼來的。在前面的的頂點著色器部分我們知道輸入三個頂點索引,能夠透過頂點著色器來計算出三個頂點的座標,再透過圖元的拓撲配置,來表明這三個點構成的是一個三角面(而不是三個點或者三條直線),再透過頂點座標進而控制一個三角面在空間中的位置大小。有了位置大小以後,渲染管線的處理過程中會進行一個步驟:光柵化。光柵化邏輯就是對於幾何圖形上每一個“點”,在螢幕裝置上找到對應的畫素點的過程。

150

對於光柵化的具體實現實現,就不在本文的討論範圍內了,對於這塊感興趣的同學可以自行查閱相關資料進行深入研究。

簡單瞭解完光柵化基本形式和結果後,讓我們回到本節的核心:片元fragment。片元實際上就是一個圖形經過光柵化處理後的一個或多個畫素的樣本。在這有兩點值得注意:

  1. 儘管叫做片元,但通常指的是一個或少許多個畫素大小的單位。也就是說,一個幾何圖形,經過光柵化會被分解為多個片元。
  2. 光柵化後得到的片元只是接近畫素點,但並不完全等於畫素點。片元是與畫素相關的、待處理的資料集合,包括顏色、深度、紋理座標等資訊(深度和紋理座標等先簡單理解為一些額外資料)。

片元並非畫素點,它只是接近畫素點,所以通常來說,我們還會有一個步驟來對片元進行進一步的處理,好讓它最終轉換為螢幕上的畫素點來進行呈現(此時基本就是帶有rgba顏色的點了)。那麼這個步驟實際上就是呼叫片元著色器進行處理。這個過程則是,渲染管線在頂點著色器處理後的某個步驟中,計算得到m個片元;隨後,渲染管線會呼叫片元著色器,並把片元上下文內容透過引數傳入到片元著色器的入口方法中,並返回對應片元在螢幕上的色彩:

160

因此,我們先前在shader.wgsl中編寫的片元著色器的程式碼其實就很容易理解了:

170

上述的程式碼中,首先我們使用@fragment註解標記了這個名為fs_main的方法為片元著色器的入口方法;其次,對於這個方法的實現,非常見簡單,我們總是返回rgba為(1.0, 0.0, 0.0, 1.0)的紅色顏色值。同時,配置方式如下:

180

這裡我們需要關注一個點。在片元著色器中,我們最終返回的型別定義是:@location(0) vec4<f32>,這個vec4<f32>讀者應該理解,就是一個表示rgba的顏色值。那這個@location(0)什麼含義呢?其實上圖的配置過程能夠給到一定的提示。在配置fragment引數時候,我們配置了targets: &[Some(swap_chain_format.into())],這個targets是一個陣列,我們傳入了唯一一個元素Some(swap_chain_format.into()),而片元著色器的返回中配置的@location(0),其意義就是把片元著色器計算得到的顏色“放到”位置索引為0的顏色目標,而這個顏色目標在這裡就是由swap_chain_format.into()轉換得到的顏色目標ColorTargetState

190

至此,我們大體上了解了片元著色器中的基本使用方式。不過在本例中,我們的片元著色器並沒有任何的入參,且始終返回的是一個固定的顏色值。不過在後續文章,我們會透過更多的示例來講解片元著色器。

使用渲染管線

上面的程式碼中,我們僅僅是在構造Wgpu上下文的階段建立了一個渲染管線並將它存放到了WgpuCtx的render_pipeline欄位。那麼我們應該在哪裡去使用這個渲染管線呢?答案就是在之前我們編寫的WgpuCtx的draw方法中去使用它:

200

對於新增的程式碼,第一步中set_pipeline(xxx)很好理解這裡不再贅述;第二步對於呼叫渲染通道(render_pass)的draw方法的引數需要說明一下。該draw方法的第一個引數定義是:vertices: Range<u32>,在這裡我們傳入了一個0..3,其意義就是告訴渲染管線,我提供了0、1、2三個頂點。再回看我們的頂點著色器程式碼,我們在頂點著色器的入口方法定義的引數:@builtin(vertex_index) in_vertex_index: u32,這裡的@builtin(vertex_index)就是想表達這樣一個事實:在頂點著色器程式碼入口給我依次傳入0、1、2的頂點索引,好讓我們能夠透過一些計算得到我期望的三角形的三個幾何頂點的位置。

210對於第2個引數instances: Range<u32>在本章中情況下我們都傳0..1,即只有一個渲染例項。當然,當你需要繪製多個相同或相似的物件時,可以使用例項化渲染。instances 引數指定了要繪製的例項數量。同時,我們還可以在頂點著色器中透過@builtin(instance_index)來得到當前的例項索引。舉個例子,假設現在我們想要繪製兩個三角形。一種方式是提供兩個三角形的頂點(比如,我們傳入0-5共計6個頂點)來表示2個三角形,我們也可以像之前一樣,傳入3個頂點索引,但構造兩個例項:

220

然後,我們修改原先的頂點著色器入口引數,新增對例項索引的訪問:

230

再次執行程式,我們會發現現在視窗中渲染了兩個三角形:

240

寫在最後

至此本章的內容就基本上接近尾聲了,在本文中我們在第一章的基礎上,進一步介紹了渲染管線以及著色器程式碼,並透過程式碼實踐,希望讓讀者更加清晰瞭解整個過程。當然,目前為止我們僅僅在頂點著色器處理階段消費了頂點的索引,以及在片元著色器處理階段返回了固定的顏色值,而實際應用場景下遠沒有如此簡單。因此在接下來的文章我們將介紹新的概念來實現如何更加動態的構建三角形。

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

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

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

相關文章