(WIP)The rendering pipeline in OpenGL
The rendering pipeline in OpenGL
Background 與 big picture
本文使用的符號與寫法
後面的 rendering pipeline 會同時使用幾何名稱、位置向量、矩陣、純量與程式識別字。 為了讓符號的外觀直接反映它代表的資料種類,本文採用下列慣例:
、 、 與 等大寫斜體字母:用來命名幾何點或頂點。 頂點序列需要編號時,則使用 、 等名稱- 粗體小寫的
:表示位置向量。 例如 是頂點 的位置, 是相機的位置。 小節已經固定座標空間時可以省略空間下標,需要追蹤同一個位置如何轉換時,則使用 與 等名稱 - 其他粗體小寫字母:表示一般向量。 符號上方的帽號表示它已正規化成單位向量,例如
是相機的單位 gaze direction 、 、 與 等大寫字母:表示矩陣。 表示完整的 model matrix, 、 與 則分別表示組成它的平移、旋轉與縮放矩陣 、 、 與 等普通斜體字母:表示座標分量、距離、參數或權重等純量。object、clip、ndc與left等完整描述詞放入數學下標時使用直立體,例如 與 與 :表示頂點 攜帶的 RGBA 色彩與 UV。 這類完整名稱使用直立體,下標中的 仍是前述頂點名稱- 矩陣與二維網格的方向:由上往下排列的一組元素稱為行(column),由左往右排列的一組元素稱為列(row)。 分量由上往下排列的向量稱為行向量(column vector)
gl_Position、reciprocal_w與hipegl_draw_desc等程式識別字:使用等寬字體。 公式若直接引用程式欄位,也會沿用相同寫法
因此,P 是 projection 的縮寫,兩者代表不同概念
座標轉換需要的數學背景:齊次座標與矩陣
幾何資料在不同座標系統之間移動時,會反覆經過縮放、旋轉與平移。 如果每一種操作都採用不同的表示方式,會使我們難以組合轉換順序,也不容易算出返回原座標系統的操作。 齊次座標會替位置增加一個分量,讓我們能把這些轉換都寫成矩陣乘法。 後續只要沿著矩陣的乘法順序,就能追蹤一個點轉換的過程了
平移需要超出線性轉換的表示能力
縮放、反射、斜切(shear)與繞原點旋轉等操作都可以寫成二維矩陣乘上一個位置的形式。 我們需要先確認這種表示法能涵蓋哪些操作,才能看出平移為何需要另一種表示方式。 假設點的位置為
其中線性轉換會把原點留在原點,因為:
平移則會把每個點加上同一個位移向量
因此只要
然而,任何
為了讓平移也能進入矩陣乘法,我們會替二維位置增加第三個分量。 這種表示方式稱為齊次座標(homogeneous coordinates)
齊次座標以 區分點與向量
增加第三個分量後,我們需要保留點與方向向量的差異。 平移應該改變點的位置,但不應改變只描述方向與長度的向量。 齊次座標利用了
本文使用行向量,因此矩陣會乘在點或向量的左側。 平移矩陣作用於點時,最右行會加入
同一個矩陣作用於向量時,
另外,齊次點的
就會對應到下列二維位置:
當
將三個分量都除以
因此,它代表的二維位置是
這個設計也讓點與向量的基本運算能保留著正確的資料種類。 若點使用
也就是說:
- 向量 + 向量:仍然是向量
- 點 - 點:結果為向量
- 點 + 向量:結果為點
一般仿射幾何並未將「點加點」定義成合法運算。 若要由多個位置算出另一個位置,較清楚的寫法是使用仿射組合(affine combination)。 仿射組合會替每個輸入位置指定一個權重,而且依定義,所有權重的總和必須是
以下兩個例子都使用相同的輸入位置:
第一個例子的兩個權重分別是
因此,輸出位置是
第二個例子的兩個權重分別是
因此,輸出位置是
另外,前面提到的齊次座標相加,也可以用第一個例子來理解。 兩筆
將所有分量除以結果的
affine transformation:統一表示線性轉換與平移
有了齊次座標後,我們可以把原本分開的線性轉換與平移放進同一個運算,這被稱為仿射轉換(affine transformation),其會先對位置套用線性轉換,再加入位移向量:
在齊次座標中,這樣的操作可以直接寫成一個
矩陣左上角的
採用行向量與逆時針正角度時,繞原點旋轉的矩陣為:
平移矩陣則為:
這三種矩陣具有相同尺寸,因此可以直接相乘。 反射與斜切也只需要替換左上角的線性轉換區塊即可
inverse transformation:沿相反方向轉換座標
前面介紹的縮放矩陣
圖形管線有時也需要沿相反方向轉換座標,例如後面的 view transformation 就會用到這項能力。 此時場景記錄的是相機位於何處以及面向哪裡,但算繪時我們需要反過來求出其他位置相對於相機的座標。 這種取消原轉換並沿相反方向還原座標的操作稱為逆轉換(inverse transformation)。 此時我們擁有的是轉換後的位置
若一項轉換沒有遺失位置資訊,而且每個輸出位置都能唯一對應回一個輸入位置,我們就稱其矩陣可逆。 能夠取消
其中
也就是說,先用
平移的逆操作是朝相反方向平移,因此:
例如位移向量為
點
旋轉矩陣中的各個行向量彼此正交且長度為
也就是說,沿用本文的二維齊次座標,旋轉
將旋轉角度改成
縮放只有在每個縮放係數都不等於
transformation composition:合併多個轉換並保留操作順序
前面分別介紹了縮放、旋轉與平移,但實際擺放一個物件時,通常需要讓同一個位置連續經過多項轉換。 為了看清楚要如何組合這些矩陣,本節先設定一項具體任務:將物件調整成指定大小、轉成指定朝向,最後放到指定位置。 這三個動作依序對應到了縮放矩陣
第一步先縮放原始位置:
第二步將縮放後的位置交給旋轉矩陣:
第三步再平移旋轉後的位置:
將前兩步的輸出依序代入下一步,就會得到:
由於本文使用行向量,並將矩陣寫在向量左側,因此最靠近
更一般地說,若一個位置依序經過
矩陣乘法通常不具有交換律,交換兩個矩陣往往會得到不同結果。 下面的例子要把左側圖形旋轉

如果先平移再旋轉,位移後的位置也會繞原點旋轉。 對行向量而言,這個順序會寫成

因此如果要得到目標位置,我們得先讓圖形繞原點旋轉,再沿 x 軸向右平移。 這個順序會寫成

三張圖顯示了矩陣順序會直接改變幾何結果。 兩種順序都能預先合併成單一矩陣,但合併後的矩陣內容並不相同
旋轉矩陣預設會以原點為中心。 若要讓圖形繞指定位置

依行向量的右至左作用順序,完整矩陣為:
最右側的
三維齊次座標與 transformation matrices
三維幾何還要處理 z 座標,因此需要把相同的點、向量、平移與矩陣組合規則延伸到三維。 三維點
一般的齊次位置
三維縮放需要分別指定三個軸的倍率:
三維平移會把位移向量
一般的三維仿射轉換會把任意
最後還需要分別繞三個座標軸旋轉。 在右手座標系統與行向量的慣例下,正角度依右手定則決定。 繞 x 軸旋轉的矩陣為:
繞 y 軸旋轉的矩陣為:
繞 z 軸旋轉的矩陣為:
這些
後面我們在講 model、view 與 projection transformations 時會直接使用這套表示法。 Model 與 view 會使用前述的仿射矩陣,projection 則會利用齊次位置的
一筆 draw call 如何走過 rendering pipeline
前面的軟體分層我們說明了一筆 draw call 會由哪些元件接手,但這筆 draw call 內的資料要如何從頂點變成像素,則需要由 rendering pipeline 來定義。 這裡我們會採用教學上的邏輯順序來寫,但實務上 driver 與 GPU 可以自行合併或重排部分工作,只要最後 OpenGL 對外的外顯行為正確即可
完整的管線如下,每個階段都會接收前一階段的結果,並補上下一階段所需的資訊:
[一般 rendering pipeline:從頂點資料到畫面緩衝區]
=================================================
頂點/索引資料
│
↓
頂點處理(vertex processing)
├─ 將 object-space 座標轉成 clip-space 座標
├─ 產生色彩、UV 等頂點屬性
│
↓
圖元組合(primitive assembly)
├─ 依圖元類型將頂點組成三角形
│
↓
裁切(clipping)
├─ 留下可見範圍內的幾何形狀
│
↓
透視除法(clip-space → NDC)
├─ 以 clip-space w 計算正規化裝置座標
│
↓
視埠轉換(NDC → screen-space 座標)
├─ 產生 screen-space x/y 與深度 z
│
↓
三角形覆蓋判定(triangle coverage)
├─ 找出三角形會影響的像素
│
↓
重心座標內插(barycentric interpolation)
├─ 算出每個像素相對於三個頂點的權重
│
↓
片段著色(fragment shading)
├─ 由頂點色彩或紋理產生候選顏色
│
↓
逐片段操作(per-fragment operations)
├─ 執行深度測試等輸出合併規則
│
↓
畫面緩衝區寫入(framebuffer write)
├─ 保存最後通過測試的色彩與深度
│
↓
RGB/深度影像上面的 callgraph 提供了階段順序。 下圖則將圖形管線概括成了五個大階段,並在相鄰階段之間標出了資料流的形態:

其中:
Vertex Processing:接收 Application 提供的頂點資料,決定本次要處理的頂點,再逐一執行頂點著色器,最後交出包含 clip-space 位置與頂點屬性的 vertex streamTriangle / Primitive Processing:從 vertex stream 取得頂點,依輸入模式組成圖元,接著完成 clipping、perspective divide 與 viewport transform,再交出位於 screen-space 的 primitive streamRasterization:判斷每個三角形在各像素內涵蓋哪些 sample,並為每個至少涵蓋一個 sample 的像素產生一筆 fragment。 若像素內有多個 samples,這筆 fragment 會以 coverage mask 的每個 bit 記錄一個 sample 的涵蓋結果Fragment Processing:使用內插後的屬性與紋理等輸入計算片段顏色,交出 shaded fragmentsFramebuffer Operations:對著色完成的片段執行深度、模板與混色等操作,最後更新輸出影像中的像素
本文沿用了這五個大階段來作為 H3,並將前面 callgraph 中的細部步驟放進了對應的 H4:
Vertex Processing:包含頂點/索引資料與頂點處理,最後產生 clip-space 位置及頂點的 RGBA、UV 等資料Triangle / Primitive Processing:包含圖元組合、裁切、透視除法與視埠轉換,最後產生位於 screen-space 的圖元Rasterization:包含三角形覆蓋判定與重心座標內插,將 screen-space 圖元轉換成片段資料與內插權重Fragment Processing:對應片段著色,負責產生片段的候選顏色Framebuffer Operations:包含逐片段的操作與畫面緩衝區的寫入,負責測試片段並更新最後的色彩與深度
圖中使用「Vertices positioned in screen space」概括了頂點處理後的結果。 但我們後續的解說會明確保留 clip-space 這個中間座標系統,再於 Triangle / Primitive Processing 中依序說明裁切、透視除法與視埠轉換。 兩種畫法採用的分組只是粒度不同,其底層描述的是同一條圖形管線
我們後面會用 11 個 H4 來追蹤一筆索引式三角形的 draw call。 這個三角形的其中一個頂點會落在右裁切平面外,因此我們可以一路觀察頂點與索引如何形成圖元、MVP transformation 如何產生 clip-space 位置、clipping 如何建立新頂點,以及同一批資料如何繼續變成 NDC、screen-space 座標、片段、候選顏色與深度。 後半段我們會使用
Vertex Processing:從頂點輸入產生 clip-space 資料
Vertex Processing 周圍的工作會先決定這筆 draw call 要取用哪些頂點,以及每筆頂點記錄中的欄位要如何解讀。 接著我們會對取出的頂點逐一執行頂點著色器內的操作,將 object-space 位置轉換成 clip-space 位置,並產生後續階段需要的色彩與 UV

下面兩個 H4 會分別展開輸入資料與逐頂點的運算
vertex / index data:決定要處理哪些頂點
Application 提交 draw call 前,會先準備幾何資源與解讀規則。 圖形 API 整理這些狀態後,圖形管線會取得兩類輸入:
- 頂點緩衝區及其欄位格式:用來找出每個頂點的位置、色彩與 UV
- 頂點緩衝區:保存的是一串原始位元組
- 欄位格式:是一組解讀規則。 它會描述 position、color 與 UV 各有幾個分量、每個分量使用什麼資料型態、是否需要正規化、欄位在一筆頂點記錄中的 byte offset,以及相鄰兩筆記錄之間的 stride
- 索引緩衝區及 draw call 參數:用來決定這次要取用哪些頂點,以及如何將它們組成圖元
- 索引緩衝區:保存的是一串頂點編號。 圖形管線會依序讀取這些編號,再到頂點緩衝區取得對應的頂點資料,因此同一筆頂點資料可以被多個圖元重複使用
- draw call 的參數:會指定圖元輸入模式、索引數量、每個索引的資料型態,以及從索引緩衝區哪一個 byte offset 開始讀取。 圖形管線會依照這些參數解讀索引,並將取出的頂點組成點、線段或三角形
先看第一類輸入。 一筆頂點記錄代表的是「其中一個頂點所對應到的一筆資料」,具體排列可能會像這樣:
struct ExampleVertex {
float position[3]; // 3 × 4 bytes
float color[4]; // 4 × 4 bytes
float uv[2]; // 2 × 4 bytes
};ExampleVertex 以 position、color 與 uv 分別保存位置、RGBA 色彩與紋理座標。 圖形 API 還需要替每個欄位描述 shader input location、分量數、資料型態、正規化方式、stride 與 byte offset 的資訊,圖形管線才能把原始位元組還原成正確的數值

這張圖強調欄位格式是讀取原始位元組時的解讀規則。 以上例來說,三個欄位可整理成:
position:位於 location 0,包含 3 個 float32 分量,byte offset 為 0color:位於 location 1,包含 4 個 float32 分量,byte offset 為 12uv:位於 location 2,包含 2 個 float32 分量,byte offset 為 28
三個欄位共用了 36-byte 的 stride,表示相鄰兩筆頂點記錄的起點相隔 36 bytes
OpenGL 可以用 glVertexAttribPointer() 提供這些規則,其他圖形 API 也會提供等價的頂點輸入描述。 這些欄位設定會把頂點緩衝區中的原始位元組對應到位置、顏色與 UV。 因此若要讀取第 i 筆頂點,可以先算:
再加上各欄位的 offset 來讀取對應的資料:
到這裡我們已經說明了該如何解讀第一類輸入中的頂點資料,接著來看第二類輸入會如何決定這次要取用哪些資料。 假設索引緩衝區保存以下六個 uint32_t 頂點編號:
const uint32_t indices[] = {0, 1, 2, 2, 1, 3};Application 可以使用下列 draw call 來提交這六個索引:
glDrawElements(GL_TRIANGLES, index_count,
GL_UNSIGNED_INT, nullptr);四個參數用來補齊解讀索引所需的資訊:
GL_TRIANGLES:指定三角形輸入模式,圖元組合階段會將每三個索引所對應的頂點組成一個三角形index_count:指定要讀取多少個索引,在這個例子中是6GL_UNSIGNED_INT:指定每個索引是 32-bit 無號整數,與索引緩衝區中的uint32_t相符nullptr:表示從目前索引緩衝區的 byte offset 0 開始讀取
圖形管線因此會按照 vertex 0 → 1 → 2 → 2 → 1 → 3 的順序取得頂點記錄,圖元組合階段再將它們組成兩個三角形:

上例的頂點 1 與頂點 2 同時屬於兩個三角形。 索引緩衝區只會保存頂點編號,不會複製整筆頂點資料。 因此它只需要再次引用相同編號,就能重複使用既有的頂點記錄了。 接下來的共用範例會將相同的讀取規則套用到一個三角形
共用範例:頂點與索引指出原始三角形
我們的共用範例會從三筆 ExampleVertex 的記錄開始。 頂點
const ExampleVertex vertices[] = {
{{-1.0f, -1.0f, 0.0f}, {1.0f, 0.0f, 0.0f, 1.0f}, {0.0f, 0.0f}}, // A
{{ 3.0f, -1.0f, 0.0f}, {0.0f, 1.0f, 0.0f, 1.0f}, {1.0f, 0.0f}}, // B
{{ 0.0f, 3.0f, -1.0f}, {0.0f, 0.0f, 1.0f, 1.0f}, {0.0f, 1.0f}}, // C
};
const uint32_t indices[] = {0, 1, 2};
glDrawElements(GL_TRIANGLES, 3, GL_UNSIGNED_INT, nullptr);GL_TRIANGLES 與索引序列 [0, 1, 2] 表示這筆 draw call 只會建立一個三角形。 輸入階段會先依索引取回
ExampleVertex 的 position 原本只有
vertex processing:執行 vertex shader,產生 clip-space 頂點
上一個管線階段我們已經依索引順序取回了頂點
如一開始所述,整個 rendering pipeline 的目標是將 3D 模型轉成 2D 影像。 過程中頂點處理階段會先將每筆頂點的位置轉成 clip-space 座標,後續的圖元處理再執行裁切、透視除法與視埠轉換。 本節我們先專注在第一段,並說清楚頂點處理與頂點著色器分別代表什麼
vertex processing 是管線階段,vertex shader 是其中執行的程式
在本文的管線劃分中,頂點處理(vertex processing)是一個邏輯階段,主要工作是針對每個被取用的頂點執行一次使用者提供的頂點著色器(vertex shader)
圖形管線實作會在頂點處理階段,依目前綁定的頂點資料與 program state,準備頂點著色器所需的輸入、啟動每次頂點著色器執行,並接收它產生的 gl_Position、色彩與 UV 等資料。 而頂點著色器內部會去描述單一頂點要如何使用這些輸入來計算輸出
頂點著色器在執行時會讀取兩種輸入資料:
- 頂點屬性:輸入階段會依 vertex buffer、頂點欄位格式與索引,找出目前頂點記錄內的 position、RGBA 與 UV,再依 input location 將這些數值送到頂點著色器對應的輸入變數
- 註:不同頂點通常會取得不同的屬性值
- uniform 參數:由 Application 負責設定這些值,OpenGL 會將它們保存在提交 draw call 時由
glUseProgram()綁定的 OpenGL program object 中- 在同一筆 draw call 中,頂點著色器每次執行時都會讀取相同的 uniform 值,例如 model、view 與 projection matrices
GLSL 預先定義了名為 gl_Position 的 vec4 輸出變數。 頂點著色器會將目前頂點的 homogeneous clip-space 位置
例如,下列頂點著色器會將輸入位置乘上 MVP matrix,再把得到的 clip-space 位置寫入 gl_Position:
layout(location = 0) in vec3 in_position;
uniform mat4 mvp;
void main()
{
gl_Position = mvp * vec4(in_position, 1.0);
}其中 in_position 與 mvp 都是使用者撰寫 shader 時自行定義的變數:
in_position的值來自 vertex buffer 中的 position 欄位,Application 會透過glVertexAttribPointer()將這個欄位接到頂點著色器的 input locationmvp的值則是 Application 計算好的 MVP matrix,並透過glUniformMatrix4fv()傳入頂點著色器
下面是一個簡單的 Application 端範例,示範如何將這兩類資料傳入頂點著色器。 這裡沿用了前一個 H4 的 ExampleVertex 與 vertex buffer,只保留和 in_position、mvp 直接相關的呼叫:
glBindBuffer(GL_ARRAY_BUFFER, vertex_buffer);
glVertexAttribPointer(
0, 3, GL_FLOAT, GL_FALSE,
sizeof(ExampleVertex),
reinterpret_cast<void *>(
offsetof(ExampleVertex, position)));
glEnableVertexAttribArray(0);
const glm::mat4 mvp = projection * view * model;
glUseProgram(program);
const GLint mvp_location =
glGetUniformLocation(program, "mvp");
glUniformMatrix4fv(mvp_location, 1, GL_FALSE,
glm::value_ptr(mvp));可以看到,glVertexAttribPointer() 會利用 location 0,將 ExampleVertex::position 對應到 shader 的 in_position
Application 接著會將 model、view 與 projection matrices 相乘,算出 mvp,再以 glGetUniformLocation() 找到同名的 uniform,並透過 glUniformMatrix4fv() 傳入矩陣內容
頂點著色器執行後,會將 mvp 與 in_position 相乘,再把得到的四個分量寫入 gl_Position
MVP transformation 是產生 gl_Position 的常見方式。 OpenGL 的 API 契約只有要求最後一個啟用的頂點處理著色器要交出合法的 clip-space 位置,不會自動替 Application 建立或套用 model、view 與 projection matrices
本文範例採用了常見的做法。 Application 會先建立 model、view 與 projection matrices,將三者相乘成一個 MVP matrix,再以 uniform 傳入頂點著色器。 頂點著色器接著會使用這個矩陣轉換輸入位置
另一種做法是由 Application 將三個矩陣分別傳入頂點著色器,再由頂點著色器按照 projection、view 與 model 的順序完成矩陣乘法:
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
void main()
{
gl_Position = projection * view * model
* vec4(in_position, 1.0);
}這兩種做法的差別,在於三個矩陣是在 Application 端先合併,還是在頂點著色器中保持分開。 它們最後都會產生 homogeneous clip-space 位置,並寫入 gl_Position
最後,完整的 OpenGL 管線還能另外再啟用曲面細分著色器(tessellation shader)與幾何著色器(geometry shader)。 前者能把由多個控制點描述的曲面區塊切成較小圖元; 後者則能以整個輸入圖元為單位來產生、修改或捨棄圖元。 但這些暫時不在本文的討論範圍內
從 object-space 走到 clip-space
MVP transformation 會讓同一個頂點依序經過四個座標空間:
- object-space:以模型自身的原點與座標軸為基準,用來描述頂點位於模型內的哪個位置
- world-space:讓所有模型與其他物件共用同一套座標基準,因此可以表示彼此的相對位置
- view-space:以相機的位置與朝向為基準,表示頂點位於相機的哪個方向,以及離相機多遠
- clip-space:保存 projection transformation 產生的四維齊次位置
,供後續 clipping 與 perspective divide 使用
前面我們已經說明了如何把 3D 點寫成四維齊次座標,以及如何組合
下圖將這四個空間與後續的 NDC、screen-space 座標一起放了進來。 本節的頂點著色器只負責了圖中的前四個座標空間,輸出會停在 clip-space

圖中的後兩個座標空間要等後續管線階段才會產生:
- NDC:perspective divide 會將 clip-space 的
、 與 分別除以 ,把可見範圍轉成與實際畫面大小無關的標準座標- 在 OpenGL 的 NDC 中,位於可見範圍內的
、 與 都會處於 之間
- 在 OpenGL 的 NDC 中,位於可見範圍內的
- screen-space 座標:viewport transform 會將 NDC 映射到指定視埠,產生以畫面位置表示的
、 ,以及供深度測試使用的 screen-space 深度
因此,頂點處理的位置輸出是
model transformation:決定模型在世界中的大小、朝向與位置
模型檔案通常是以自己的原點與座標軸來保存頂點資訊的,但一個 3D 世界內可能同時會放入桌子、機器手臂與多個物件。 若每個模型都停留在自己的 object-space,我們就無法用同一套座標來比較它們的位置了
因此整個轉換的第一步是 Model transformation,它用來算出模型在共同的 world-space 中有多大、朝向哪裡,以及放在哪裡
本文我們會讓 object-space 中的點依序經過縮放、旋轉與平移來作為例子。 首先我們先做縮放:
再繞模型原點旋轉:
最後平移到 world-space 中的指定位置:
將前兩式逐步代入最後一式,就能得到:
因此我們可得:
前面我們已經講過三維縮放、旋轉與平移矩陣的形式了。 將其代入後,合併後的 model matrix 會有下列形式:
左上角的
下圖以同一個三角形顯示了三個操作的分工:縮放改變它相對於模型原點的大小,旋轉改變朝向,平移最後決定它位於 world-space 的哪裡

由於本文將行向量放在最右邊,矩陣會由右往左作用。 因此圖中的
下列 GLM 程式會先用不改變輸入座標的單位矩陣(identity matrix)來初始化 matrix,再逐次把平移、繞 x/y/z 軸旋轉與縮放矩陣等操作接到右側。 因此,matrix 的內容會按照下列順序累積:
因此函式最後傳回的 model matrix 為
換句話說,雖然程式碼組合矩陣的順序是平移、
下面的程式碼用來展示上述矩陣組合順序。 它會讀取模型的 position、rotationDeg 與 scale,從單位矩陣開始依序組合
glm::mat4 GameObject::calculateTransformMatrix_() const
{
glm::mat4 matrix{1.0f};
matrix = glm::translate(matrix, position);
matrix = glm::rotate(matrix, glm::radians(rotationDeg.x),
glm::vec3(1, 0, 0));
matrix = glm::rotate(matrix, glm::radians(rotationDeg.y),
glm::vec3(0, 1, 0));
matrix = glm::rotate(matrix, glm::radians(rotationDeg.z),
glm::vec3(0, 0, 1));
matrix = glm::scale(matrix, scale);
return matrix;
}view transformation:建立以相機為基準的座標系統
前面 Model transformation 已經幫我們把所有的物件都放進了共同的 world-space,而下一個階段我們想知道的是「從目前相機看出去,每個頂點位於哪個方向與距離」。 因此該階段做的 View transformation 會把 world-space 的位置改寫成 view-space 中的位置
建立這項轉換需要知道相機的位置與朝向。 Application 中負責管理相機的程式碼可能直接保存方向向量,也可能保存相機的注視目標或旋轉資訊。 無論採用哪一種表示方式,在建立 View matrix 前都需要取得本文使用的三項資料:
- 相機位置向量
:指出相機位於 world-space 中的哪裡 - gaze direction
:是相機向前看的單位向量 - up direction
:是相機朝上的單位向量,並與 gaze direction 垂直。 兩者會一起描述相機的朝向,並決定畫面如何傾斜
由於只要相機與物體的相對位置相同,最後看到的結果就會相同,因此 View transformation 不必真的在數學中搬動相機,它可以固定相機,再把整個世界朝相反方向移動與旋轉。 本文採用

上圖左側展示了兩種等價的配置。 雖然相機與物體在 world-space 中的位置不同,但兩者的相對位置與方向保持不變,因此從相機看見的畫面也會相同。 而 View transformation 會利用這項關係,把 world-space 的頂點轉換到右側所示的 standard view-space,使相機位於原點、看向
要做到這件事,我們需要算出能將相機移到原點並對齊指定方向的平移與旋轉操作,並將它們組合成 View matrix,再用這個矩陣轉換所有的 world-space 頂點,讓其進到 view-space
假設相機位於
接著我們需要把相機原本的座標軸對齊到 standard view-space。 下圖左側的
因此 View transformation 具體來說會將

一開始 Application 會先從相機狀態取得或算出 gaze direction
在推導旋轉矩陣
接下來我們希望用一個矩陣等式同時表示上方的三項轉換,達到乘上
為了讓合併後的矩陣等式更容易求解,我們會先讓第三個輸出也對應 standard view-space 的正向座標軸。 由於 standard view-space 的相機朝向
接著,為了將這三個等式合併成一個矩陣等式,我們可以先將三個輸入方向
當
同時由於它是單位矩陣,所以我們可以知道:
- 這同時也是將
、 與 三個標準座標軸排成直行後自然得到的結果 是 的逆矩陣:
接著我們需要實際寫出
運算結果
這三個結果顯示,矩陣的第一、第二與第三個直行,分別是
接著我們來看
把前面說明的直行規則套回
也就是說,
若要由這個等式求出左側第一個矩陣的九個元素,就需要在等式兩側從右邊乘上
由於
如果直接將三個已知方向填入
而這剛好是從三個標準軸方向來轉回三個相機方向的操作,恰好與
接下來我們要把這個反方向的旋轉寫成作用於齊次座標的
在齊次座標中,方向向量的第四個分量是
這個矩陣只負責旋轉,不會平移原點,因此還需要滿足:
上面四個輸入都只有一個分量是
其中由於
最後,我們要把前面的平移與旋轉合併成 View matrix。 本文使用直行向量,因此最靠近頂點的
因此完整的 View matrix 為:
前三列分別算出了世界位置沿相機右方、上方與後方座標軸的分量,最右行則抵消了相機在 world-space 中的位置。 套用後便可以得到 view-space 中的位置了:
程式也常會預先計算好
projection transformation:準備可以裁切與投影的齊次位置
前面 View transformation 的過程我們已經把所有的頂點位置改寫成了以相機為基準的 view-space 座標,而下一個階段我們想要做的事是「將相機可以看見的 3D 範圍投影到 2D 畫面,同時保留各頂點的前後深度」。 這會由 Projection transformation 負責
Projection transformation 會把 view-space 的位置轉換成四維的 clip-space 位置,讓後續管線能利用這四個分量先執行 clipping,再透過 perspective divide 得到投影後的座標
投影方式取決於我們希望距離要如何影響畫面中物體的大小。 下圖比較了正交投影與透視投影對相同立方體產生的視覺結果

- 正交投影(orthographic projection):投影射線互相平行,物體大小不隨距離改變,原本平行的線在投影後仍會保持平行
- 透視投影(perspective projection):投影射線從相機位置向外展開,較近的物體看起來較大,延伸到遠方的平行線也可能在影像上逐漸靠攏
兩種方式都會保留深度,也都會輸出供後續裁切時使用的
現在讓我們先從正交投影開始。 當我們不希望物體投影後的大小隨距離改變時,會使用正交投影。 這項轉換會接收 view-space 的位置,並把相機能看見的長方體範圍映射到 OpenGL 的標準裁切立方體內:
前面的 View transformation 已經將相機移至原點並看向
我們希望正交投影矩陣完成下列邊界映射:
因此,只要輸入頂點位於原始長方體內,輸出的
為了做出這項映射,我們會先將長方體中心平移到原點,再將三個邊的範圍縮放到

長方體的中心是:
因此平移矩陣為:
平移後,長方體在 x、y 與 z 三個方向的長度分別是
其中 z 軸需要另外反轉方向。 在 view-space 中,近平面與遠平面的 z 座標分別是
此時近平面的 z 是正值,遠平面的 z 則是負值。 為了將它們依序映射到 clip-space 的
由於正交投影會讓
將前面的平移與縮放合併後可得:
實際使用時,Application 會先決定正交相機要保留的 view-space 長方體,再將它的六個邊界傳給矩陣函式。 例如,下列程式碼將左右邊界設為
const glm::mat4 projection = glm::ortho(
-4.0f, 4.0f, // l, r
-3.0f, 3.0f, // b, t
1.0f, 9.0f); // n, f相機在 view-space 中看向
將這些數值代入剛才推導的公式,可以得到:
因此像是近平面的右上角與遠平面的左下角會分別映射到標準裁切範圍的兩個角落:
現在讓我們接著來看透視投影。 當我們希望畫面呈現與真實相機相似的近大遠小效果時,會使用透視投影。 這項轉換會接收 view-space 的位置,並把相機能看見的 frustum 映射到 OpenGL 的標準裁切範圍內:
與正交投影不同,透視投影會把頂點的 view-space 深度資訊編碼到
透視投影的可見範圍會從相機向外展開成 frustum,超出這個範圍的部分則會在下一個 clipping 階段處理。 為了建立投影矩陣,我們需要先用參數描述這個 frustum 的形狀,常見的表示方式有下列兩種
第一種是直接使用
frustum 的四個側面會從相機原點延伸並穿過這個矩形的四條邊。 因此,根據相似三角形,遠平面在
這種形式能直接指定不對稱的 frustum。 例如,
第二種是較接近一般相機設定的對稱形式,使用了垂直視角
其中垂直視角(vertical field of view,vertical FOV)
下圖是 vertical FOV 與 aspect ratio 的示意圖:

我們可以把透視投影理解成兩個動作:
- 把 frustum 擠壓成與正交投影相同的長方體
- 使用
將長方體正規化

擠壓時需要維持三項關係:
- 近平面上的 x 與 y 不變
- 所有深度切面中心的 x、y 不變
- 近平面與遠平面的 z 仍分別是
與
前兩項會形成 x、y 方向的限制條件,我們可以用相似三角形推導對應的縮放關係。 第三項則會形成 z 方向的兩個邊界條件,後面會用來推導控制深度的係數
現在我們先來看 x 與 y 的部分,找出 frustum 中不同深度的截面應該如何縮放。 假設 view-space 中有一個位於相機前方的齊次位置:
從原點的相機出發,穿過這個 3D 點的射線可以寫成:
因為近平面位於
由於
將這個比例乘回原本的 x 與 y,可以先算出交點的前兩個分量:
由於交點位於近平面,它的第三個分量必須是
下圖從側面畫出了相機、原本的點與近平面交點:

例如,令近平面距離為
射線抵達近平面時的縮放比例與交點為:
因此,原本位於
現在我們要把這項幾何關係寫成矩陣。 剛才推導時我們把
因為
以前面的數值為例,這項等價關係為:
在這組表示方式中,前兩個分量保存了還原原本 x 與 y 所需的分子,第四個分量則保存了共同的分母
因此這裡我們只會沿用第一、第二與第四個分量的結果,而第三個分量我們會將其改寫成
於是擠壓的操作(squeeze matrix)可以寫成:
驗證一下,將它乘上 view-space 的位置後,可以得到:
將這組中間齊次座標除以
也就是說:
其中 x 與 y 正好符合前面由近平面交點得到的結果,整個向量的最後一個也回到了
接下來我們需要利用深度邊界來求出第三個分量中的
將遠平面的
將兩個等式相減,可以消去
因為
再把
因此,完整的 squeeze matrix 為:
這個矩陣輸出的中間齊次座標除以
因此各個深度截面的 x、y 範圍會縮到近平面矩形的大小,近平面與遠平面的 z 則會繼續保持為
例如,若沿用
將前三個分量除以最後的
至此,我們就成功把 frustum 擠壓成一個長方體了:

但是現在這個長方體仍使用著原本的
前面為了確認
但實際計算時,
所以矩陣乘法的線性關係會保留這個共同的
因此,雖然時我們的步驟是「先擠壓,再做正交投影」。 但實際使用時,我們會預先計算好兩個矩陣的乘積。 頂點著色器只需使用組合完成的
完成矩陣乘法後,我們就得到了 OpenGL 的一般透視投影矩陣:
這邊我們直接使用了
其中,近平面位於
接下來我們要利用 vertical FOV

從這個垂直截面觀察時,相機位置、近平面中心與上邊界中點會形成直角三角形。 其中鄰邊是近平
等式兩邊同乘
下邊界和上邊界以 x 軸對稱,因此:
接著處理水平方向。 Aspect ratio
將對稱關係
因此右邊界與左邊界分別是:
至此,四個邊界就都能由 vertical FOV、aspect ratio 與近平面距離求出來了:
現在回到一般透視投影矩陣中。 它的 x 與 y 縮放係數中會重複出現
根據剛才的
對稱 frustum 的高度為
寬度則是
一般矩陣中還有兩個用來處理不對稱邊界的偏移項。 由於對稱 frustum 具有
將這些結果代回一般形式後,projection matrix 可以簡化成程式庫常見的形式:
這個矩陣乘上 view-space 位置
因此,
正交投影矩陣的最後一列則會讓
組合 MVP matrix,並由 vertex shader 交出結果
到這裡,Model、view 與 projection transformations 已分別回答了「模型位於世界何處」、「相機如何觀察世界」,以及「可見的 3D 範圍如何轉成 clip-space」。 實際使用時,
由於矩陣會從右往左作用,所以 object-space 的點會先進入 world-space,再進入 view-space,最後才得到 clip-space 的位置:
Application 可以在提交 draw call 前先建立好三個矩陣,將它們相乘後再上傳給 OpenGL program。 下例的 GLM 片段示範了實際使用時會如何傳遞相機與投影等參數:
const glm::mat4 model = object.calculateTransformMatrix_();
const glm::mat4 view = glm::lookAt(
cameraPosition,
cameraPosition + gazeDirection,
upDirection);
const glm::mat4 projection = glm::perspective(
glm::radians(fovYDegrees),
static_cast<float>(viewportWidth) /
static_cast<float>(viewportHeight),
nearDistance,
farDistance);
const glm::mat4 mvp = projection * view * model;
glUniformMatrix4fv(mvpLocation, 1, GL_FALSE,
glm::value_ptr(mvp));glm::lookAt() 會從相機位置、目標位置與 up direction 建立 glm::perspective() 則由垂直 FOV、aspect ratio、near 與 far distances 建立 viewportWidth / viewportHeight 必須使用浮點除法,才能得到正確的 aspect ratio
下例的頂點著色器除了計算 clip-space 位置,也會算出目前頂點的 RGBA 色彩與 UV:
#version 330 core
layout(location = 0) in vec3 in_position;
layout(location = 1) in vec4 in_color;
layout(location = 2) in vec2 in_uv;
uniform mat4 mvp;
out vec4 vertex_color;
out vec2 vertex_uv;
void main()
{
gl_Position = mvp * vec4(in_position, 1.0);
vertex_color = in_color;
vertex_uv = in_uv;
}gl_Position 是這個頂點的 clip-space 位置:
其中 vertex_color 與 vertex_uv 分別保存了頂點著色器為目前頂點算出的 RGBA 色彩與 UV
等光柵化器開始處理由三個頂點組成的三角形時,它會為每個片段內插這些值,再將內插的結果交給片段著色器。 這類從頂點著色器傳往後續著色階段的資料稱為 varyings
頂點著色器每次只會處理一筆頂點。 執行完成後,每個頂點就都填好了 clip-space 位置、RGBA 色彩與 UV 等資料,圖形管線的下一階段會依 draw call 指定的輸入模式將多筆頂點組成圖元
共用範例:MVP 產生三筆 clip-space 頂點
現在讓我們回到前面的共用範例,vertex / index data 階段已經依照索引 [0, 1, 2],取回了 ExampleVertex 記錄。 目前準備交給頂點著色器的資料為:
:object-space 位置為 ,RGBA 為紅色 ,UV 為 :object-space 位置為 ,RGBA 為綠色 ,UV 為 :object-space 位置為 ,RGBA 為藍色 ,UV 為
另外,在目前的頂點處理階段,頂點著色器除了每個頂點各自的位置、RGBA 與 UV 以外,還會需要這筆 draw call 內三個頂點共用的 MVP matrix,才能將每個頂點的 object-space 位置轉換成 clip-space 位置
為了建立這個 MVP matrix,共用範例會把模型沿 world-space 的
Projection 使用垂直 FOV
將這三個矩陣預先相乘,可以得到這次頂點著色器使用的 MVP matrix:
三筆 object-space 位置會依序經過 world-space、view-space 與 clip-space,得到下列座標:
圖中前兩個圖框的 x-y 輪廓相同,z 則從 object-space 座標改成在 view-space 中沿相機座標軸量出的帶符號座標。 本文相機朝
這個頂點著色器只轉換位置,RGBA 與 UV 會直接傳到後續階段。 頂點處理因此交出三筆 clip-space 位置、各自原有的紅綠藍色彩,以及
Triangle / Primitive Processing:把 clip-space 頂點轉成可供光柵化的 screen-space 圖元
上一節我們從三筆 object-space 頂點的位置、RGBA 與 UV,以及這筆 draw call 共用的 MVP matrix 出發,並將這些資料交給頂點處理階段。 頂點處理階段執行頂點著色器後,我們便成功將各頂點的位置轉換成 clip-space 座標,並將 RGBA 與 UV 一同傳給後續階段了
接下來我們要把這些處理完成但仍彼此分離的頂點,逐步轉成光柵化器能夠處理的 screen-space 圖元
圖元組合(primitive assembly)會先依 draw call 指定的輸入模式,決定哪些頂點共同形成一個點、線段或三角形。 組成圖元後,我們才有完整的幾何形狀可以判斷它是否落在相機的可見範圍內。 如果圖元穿過了可見範圍的邊界,clipping 會在 homogeneous clip-space 中把不可見的外側部分裁掉,並在交界處建立新的頂點
完成 clipping 後,為了讓光柵化器知道圖元位於畫面中的什麼位置,我們需要將留下的 clip-space 頂點轉成 screen-space 座標。 但此時 clip-space 的位置仍使用四維齊次座標,無法直接對應實際視埠中的位置。 所以管線會先透過 perspective divide 將它們轉成 NDC,使可見範圍落入標準座標區間,再由 viewport transform 根據視埠的位置與大小映射到 screen-space
完成這四個步驟後,這個階段會輸出位於連續 screen-space 座標中的點、線段或三角形,本文接下來會固定追蹤三角形路徑。 每筆輸出頂點都會包含 screen-space 的 x、y 與深度 z,以及對應的 RGBA 與 UV。 管線在執行 perspective divide 時也會保留每個頂點的
許多以三角形為主的架構圖會將這一連串工作標成 Triangle Processing,更一般的名稱則是 Primitive Processing。 下面四個 H4 我們會依 primitive assembly、clipping、perspective divide 與 viewport transform 的順序,分別說明各階段會接收什麼資料、如何處理,以及會把什麼結果交給下一個階段
primitive assembly:把頂點連成三角形
上一階段我們已經分別算出了每個頂點的 clip-space 位置、RGBA 色彩與 UV。 接下來我們要依 draw call 指定的輸入模式,判斷哪些頂點共同描述了同一個幾何單位,並保留這些頂點的先後順序。 圖元組合階段會根據輸入模式解讀頂點序列,建立類型與頂點順序都明確的圖元
圖元(primitive)是這項分組工作交出的基本幾何單位。 可以直接進入 rasterization 的基本圖元包括點、線段與三角形。 其內部會保留著頂點原有的 clip-space 位置、RGBA 與 UV
以本文前面使用的 glDrawElements() 為例,第一個引數會替這筆 draw call 指定輸入模式,告訴圖元組合階段該如何解讀索引。 下圖上方先用六個索引展示 triangle list 每三個索引組成一個三角形的規則,下方再把固定的索引序列 [0, 1, 2, 3] 套用到七種 OpenGL 輸入模式,比較同一批頂點如何形成不同的點、線段或三角形圖元

圖中下方七個圖框都從相同的 [0, 1, 2, 3] 索引順序出發。 輸入模式只會改變分組與頂點重用方式,索引所指向的 position、RGBA 與 UV 不會跟著改變
最上方我們先以 triangle list 介紹了最直接的三角形輸入方式,在 OpenGL 中這會對應到 GL_TRIANGLES。 由於本文使用索引式的 draw call,圖元組合階段會依序讀取索引,每三個索引組成一個三角形
以第一個三角形為例,索引 0、1 與 2 會取得三筆已完成頂點著色的資料。 圖元組合會將它們依這個順序放在一起,以形成第一個三角形圖元。 其中圖元類型 triangle 來自這筆 draw call 的輸入模式,圖元的頂點順序則由三筆頂點的先後次序來決定
在 triangle list 中,每一組三個索引都會獨立形成一個圖元。 這裡的「獨立」表示下一個圖元會從新的一組索引開始,不會自動沿用前一組的頂點。 不同組仍然可以重複使用相同的索引,藉此共用同一筆頂點資料
管線還需要保留圖元中的頂點順序,讓後續階段能夠判斷三角形的正面與背面,並以一致的方向計算各條邊。 例如 vertex 0 → vertex 1 → vertex 2 與 vertex 0 → vertex 2 → vertex 1 使用了相同的三筆頂點,卻具有了相反的環繞方向(winding)。 因此,圖元組合不能只保存三個頂點形成的集合,還必須保留它們的先後順序
三角形在概念上還具有三條邊與內部區域,但通常我們不需要另外保存這些資訊。 圖元組合完成時,三角形圖元直接提供的資訊只有:
- 目前的圖元類型
- 有順序的三筆頂點
- 每筆頂點的 clip-space 位置、RGBA 色彩與 UV
圖中的幾種常見模式會將同一串索引組合成下列結果:
GL_POINTS:將每個索引對應的頂點各自建立成一個點圖元- 結果:
point 0、point 1、point 2與point 3
- 結果:
GL_LINES:每兩個頂點建立一條互相獨立的線段- 結果:
line (0, 1)與line (2, 3),兩條線段之間不會自動相連
- 結果:
GL_LINE_STRIP:依序連接相鄰頂點,讓下一條線段重複使用上一條線段的終點- 結果:
line (0, 1)、line (1, 2)與line (2, 3),三條線段會形成一條連續折線
- 結果:
GL_LINE_LOOP:先採用 line strip 的方式連接相鄰頂點,再補上最後一個頂點到第一個頂點的線段- 結果:
line (0, 1)、line (1, 2)、line (2, 3)與line (3, 0),最後會形成封閉線圈
- 結果:
GL_TRIANGLE_STRIP:先用前三個頂點建立第一個三角形,之後每增加一個頂點,就和前面的兩個頂點建立下一個三角形- 結果:
triangle (0, 1, 2)與triangle (2, 1, 3)。 第二個三角形會調整前兩個頂點的順序,使相鄰三角形維持一致的環繞方向
- 結果:
GL_TRIANGLE_FAN:固定以第一個頂點作為共用錨點,再將後續每一對相鄰頂點與這個錨點組成三角形- 結果:
triangle (0, 1, 2)與triangle (0, 2, 3),頂點0會出現在每個三角形中。 另外雖然頂點2在這個範例中也被兩個相鄰三角形共用了,但加入更多頂點後,後續三角形仍會固定使用頂點0,不會繼續使用頂點2
- 結果:
OpenGL 也提供了 GL_PATCHES,能將一組控制點(control points)交給曲面細分(tessellation)階段。 曲面細分可以依著色器規則增加頂點並建立更細緻的幾何形狀,最後同樣會產生點、線段或三角形,供後續 rasterization 使用。 不過本文沒有啟用 tessellation,因此接下來我們只會追蹤由上述輸入模式直接建立的基本圖元
這些輸入模式會使用不同的頂點分組與重用規則,組合完成後則統一交出點、線段或三角形等基本圖元。 一筆 draw call 只會指定一種輸入模式,Application 若要改用另一種模式,就需要再提交一筆 draw call
本文後面會固定沿用 GL_TRIANGLES 的例子。 因此圖元組合完成後,每個三角形都帶有三筆頂點的 clip-space 位置、RGBA 色彩與 UV。 下一個裁切階段會利用這些資料來判斷三角形有多少部分位於可見範圍內
共用範例:三筆頂點組成一個三角形圖元
這個範例用來展示圖元組合要如何將頂點處理交出的個別頂點,轉成 clipping 可以接收的三角形圖元。 共用範例的輸入模式是 GL_TRIANGLES,索引序列則是 [0, 1, 2]。 圖元組合會依這個順序,將三筆頂點資料建立成三角形

圖中的三條有向邊只用來標示
clipping:留下可見範圍內的三角形
上一階段我們已經建立了三角形圖元,每個頂點都帶有 clip-space 位置、RGBA 色彩與 UV。 接下來為了讓可見的幾何形狀安全地進入透視除法,我們需要找出三角形位於相機可見範圍內的部分。 有些三角形完全位於範圍內或範圍外,有些則會穿過可見範圍的邊界。 裁切(clipping)會在做透視除法之前分辨這些情況,並保留可見的部分
當三角形穿過邊界時,我們仍要保留位於可見範圍內的區域。 假設其中兩個頂點位於範圍內、第三個頂點位於範圍外,如果我們只丟掉了外側頂點,那就會遺失兩條邊與邊界相交的位置。 因此,裁切階段會在這些交界處建立新頂點,再以原頂點與新頂點圍出可見部分:

圖中的藍線是上裁切平面。
OpenGL 會在 homogeneous clip-space 中執行這項工作,目標是找出「完成透視除法後仍位於可見範圍內的部分」。 要理解這個目標,我們需要先知道下一階段使用的座標系統
透視除法的目的是把 clip-space 位置轉成正規化裝置座標(normalized device coordinates,NDC)。 由於 NDC 尚未使用畫面緩衝區中的實際像素座標,所以透視除法會先以相同的標準範圍來表示可見位置,與後續視埠的具體寬度與高度無關
本文固定採用 OpenGL 預設的 NDC 深度範圍慣例(negative-one-to-one depth convention),也就是 NDC 的 z 可見範圍和 x、y 一樣都是
但在目前的裁切階段,頂點仍位於 homogeneous clip-space,其位置具有四個分量:
在下一階段我們會以
這裡我們先只用透視除法來說明裁切條件中的
例如
由於每一組不等式都有較小值與較大值兩個邊界,因此三個座標軸總共會形成六個裁切平面:
為了讓六個平面共用同一套內外判定方式,並讓判定值之後能沿三角形的邊內插來求交點,我們要把每個平面的內側條件都改寫成「某個判定值大於或等於
因此我們可以定義
因此右平面的判定值是
這裡的
下圖彙整了剛才推導的六個平面條件與六個判定值。 左側列出每個平面的公式,右側則固定一個正的

圖中右側的立方體是固定某個
以
- 當
時, 且 ,所以頂點同時位於左右平面的內側 - 當
時, ,所以頂點正好位於左平面上 - 當
時, ,所以頂點位於左平面外側 - 當
時, ,所以頂點位於右平面外側
一個頂點必須對六個平面都得到
因此,裁切階段我們可以先快速判斷三角形:
- 若三個頂點對六個平面的判定值都大於或等於
,則保留原三角形 - 若三個頂點對同一個平面的判定值都小於
,則捨棄整個三角形 - 其餘情況表示三角形可能穿過邊界,不能直接保留或捨棄
對於穿過邊界的情況,裁切階段會先把三角形視為由三個頂點組成的多邊形,再依序以六個平面裁切目前留下的多邊形。 為了建立邊界上的新頂點,我們需要先算出多邊形的邊與裁切平面的交點
以下使用大寫的
當
其中
如此一來,我們就可以用
: 位於裁切平面的內側 : 位於裁切平面的外側 : 正好位於裁切平面上
而由於交點正好位於裁切平面上,因此我們要找出使
將
本文的 vertex_color 與 vertex_uv 採用 GLSL 的預設內插規則。 在這項規則下,
其他內插規則會使用不同方式處理裁切產生的新頂點。 為了先專注在交點本身,本節後續公式與共用範例都會沿用上述預設規則。 各種內插規則的名稱、目的與裁切行為會在 barycentric interpolation H4 一節中一併說明
例如一條邊穿過了右裁切平面
將
新的交點
算出邊界交點後,我們還要拿每條有向邊的原頂點與新交點組合成新的多邊形。 對某一個平面處理多邊形的有向邊
與 都在內側時,輸出 在內側、 在外側時,只輸出交點 在外側、 在內側時,先輸出交點,再輸出 與 都在外側時,不輸出任何頂點
每處理完一個平面,輸出的頂點序列都會成為下一個平面的輸入。 六個平面都處理完成後,結果可能是:
- 空集合
- 未改動的原三角形
- 由原頂點與新交點構成,但仍只有三個頂點的三角形
- 具有四個以上頂點的可見多邊形
處理完六個平面後,我們還需要把留下的多邊形交回三角形路徑。 每個裁切平面只會保留平面內側的空間。 由於三角形是凸形,而凸形和這類半空間的交集仍會是凸形,因此非空結果會是一個凸多邊形。 這裡的「凸」表示任取多邊形內的兩個位置,連接它們的整條線段都不會離開多邊形。 這項性質也能確保我們從其中一個頂點向其他非相鄰頂點連線時,分割線仍留在多邊形內,因而可以安全地將結果重新拆成三角形
空集合表示整個圖元不可見。 非空結果若只有三個頂點,可以直接作為三角形繼續處理。 頂點超過三個時,後續 rasterization 必須能處理這個凸多邊形,常見作法之一是重新拆成三角形。 為了讓本文繼續沿用三角形路徑追蹤資料,以下會採用這種作法
在幾何形狀剛好落在邊界上的退化情況中,結果也可能只剩點或線段。 這類結果的面積為
為了具體說明重新三角化,本文選擇以第一個頂點為共用頂點,使用 triangle fan 將四邊形拆成:
裁切完成後,每個輸出的三角形都會位於 clip-space 的可見範圍內,新增的頂點也會具有對應的 clip-space 位置、RGBA 色彩與 UV。 下一階段我們會對這些位置執行透視除法
共用範例:右裁切平面產生兩個交點與兩個三角形
這個範例我們要找出三角形 [0, 1, 2] 指向的三筆頂點組成了有順序的三角形圖元
- 圖元類型與頂點順序:三角形
- 頂點
:clip-space 位置為 ,RGBA 為紅色 ,UV 為 - 頂點
:clip-space 位置為 ,RGBA 為綠色 ,UV 為 - 頂點
:clip-space 位置為 ,RGBA 為藍色 ,UV 為
下圖同時呈現原三角形、兩個交點、裁切後的四邊形與重新三角化結果,方便核對新頂點的順序:

從這三筆 clip-space 位置可以看出,原三角形只有頂點
沿
將相同的
沿
第二個交點
兩個交點都符合
若依序將四筆不重複的頂點編為 [0, 1, 2, 0, 2, 3] 來表示這兩個三角形。 這四筆 clip-space 位置及其 RGBA 與 UV,會一起交給透視除法
perspective divide:把 clip-space 轉成 NDC
裁切階段我們已經算出位於可見範圍內的 clip-space 三角形,而且每個頂點都保留著
因為不可見的部分已經在前一階段被裁掉了,所以留下的頂點經過透視除法後,
其中 x 與 y 表示頂點在標準可見範圍內的水平與垂直位置,z 則用來在後續深度比較時表達前後關係。 對透視投影而言,除以
為了先只觀察水平方向的變化,下圖從四維 clip-space 位置中取出了

同樣的
這個階段會產生尚未套用 viewport 寬度、高度與像素座標的 NDC。 在將這批位置交給視埠轉換前,我們另外再補充一下為何裁切會位於透視除法之前
為什麼不能直接先做完 perspective divide 再做 clipping?
這是我當初在看時想到的疑問:你做 clipping 時明明就是用 NDC 座標在判斷要裁切的範圍的,那為什麼不先做完 perspective divide 再做 clipping?
前一節從 NDC 的標準可見範圍推導出了六個 clip-space 裁切條件。 兩組條件描述的是同一個可見邊界,而裁切階段實際接收的是圖元的 clip-space 位置
若要先執行 perspective divide,再於 NDC 中裁切,裁切階段仍然需要完成兩項工作:
- 找出圖元與 NDC 可見邊界的交點位置
- 為每個交點建立完整的新頂點,包含位置以及對應的 UV 等頂點資料
第一項工作可以直接使用 NDC 位置。 第二項工作還需要知道交點位於原 clip-space 邊上的比例,才能按照原本的內插規則算出 UV 等資料
由於透視除法產生的 NDC 位置只包含
以下用一組具體數值來觀察這項差異。 假設兩個端點的
右裁切平面滿足
因此交點在 clip-space 邊上的比例是:
如果先對兩個端點執行透視除法,它們的 NDC x 座標則會是
可以看見同一個交點在 clip-space 邊上的比例是
若改用 NDC 線段上的
但如果管線另外保留了邊上兩端的
將
不過一般 rendering pipeline 還必須處理穿過
如果
透視除法無法在
在本文使用的標準透視投影矩陣中,
由於相機位於 view-space 原點並看向
、 :頂點位於相機前方 、 :頂點位於相機所在的平面 、 :頂點位於相機後方
例如一條邊的兩端分別具有
而相較之下,此時的 clip-space 位置與平面判定值仍然有限,管線可以先在 homogeneous clip-space 中截斷圖元,再讓留下且具有有效
前面我們提的一般 rendering pipeline 的做法會先在 homogeneous clip-space 中裁切。 這個順序能在除法前處理好穿過
簡單來說,這兩種順序的差別是:
- 先在 homogeneous clip-space 中裁切:可以直接處理穿過
的圖元,並使用 clip-space 邊上的比例建立完整的新頂點 - 先轉成 NDC 再裁切:圖元必須位於
的區域,而且管線需要在 NDC 的階段額外再保留原本的 ,以利用它換算交點的頂點資料
共用範例:四筆 clip-space 頂點轉成 NDC
前一節的裁切範例中我們移除了三角形 [0, 1, 2, 0, 2, 3],四筆頂點資料則是:
- 頂點
:clip-space 位置為 ,RGBA 為 ,UV 為 - 頂點
:clip-space 位置為 ,RGBA 為 ,UV 為 - 頂點
:clip-space 位置為 ,RGBA 為 ,UV 為 - 頂點
:clip-space 位置為 ,RGBA 為 ,UV 為
接下來為了把這四筆頂點交給 viewport transform,我們需要將每筆 clip-space 位置轉成 NDC 位置。 透視除法會將每筆位置的 x、y 與 z 分別除以該頂點自己的
例如
由於

圖中我們以精確分數銜接了裁切與視埠轉換。 實作時選用的浮點數、定點數與捨入方式,都需要維持這裡描述的座標關係並產生符合圖形 API 的結果
viewport transform:把 NDC 映射到 screen-space 座標
Perspective divide 階段的輸出為 NDC 位置,以及仍與頂點相連的
在這個階段,OpenGL context 所設定的 viewport 矩形會提供輸出區域的起點、寬度與高度,depth range 則會指定後續 depth test 使用的深度範圍。 視埠轉換(viewport transform)會依這兩項狀態,將 NDC 的 x、y 與 z 轉成連續的 screen-space x、y 與深度
為了用一個運算表示上述映射,我們會將視埠轉換寫成一個
接下來我們要推導
其中 clipping 會以整個圖元為單位來判斷可見範圍。
為了展開 x 與 y 的映射公式,我們先確定兩邊使用的座標範圍。 NDC 以固定的標準範圍表示左、右、上與下,screen-space 則以實際視埠建立連續座標系。 本文沿用了 OpenGL 的左下角原點慣例,向右是
假設視埠左下角是
現在我們要將 NDC 的 x 與 y 從無單位的
要推導這項映射,我們先將 x 方向的轉換寫成「縮放後再平移」的一次函數:
NDC 的左邊界
將第二式減去第一式,可以解出縮放係數
y 方向使用相同的方法,將
深度方向則要把
將這三組縮放係數與平移量分別放入矩陣的前三列,便能得到我們的
把矩陣的前兩列展開後,就是所謂的視埠轉換公式:
假設視埠從
下圖我們以

將這組數值代入視埠轉換公式,可以得到:
綜上所述,完成視埠轉換後,我們會輸出具有 screen-space x、y 與深度 z 的頂點。 每個頂點原有的
共用範例:NDC 頂點映射到 viewport
前一節執行完透視除法後,我們成功將裁切後的四筆頂點
頂點順序仍由索引 [0, 1, 2, 0, 2, 3] 指定,對應
接下來,我們要讓 rasterizer 知道這兩個三角形位於輸出影像中的哪個位置,因此需要將 NDC 位置映射到共用範例的 viewport。 這個 viewport 的左下角為
這個階段會使用前面推導完的視埠轉換公式:
將
以頂點
將三個分量分別代入視埠轉換公式後,可以得到:
因此,頂點
對其餘三筆頂點也套用上相同的方法來計算,結果為:
到這裡,我們就取得了兩個三角形的 OpenGL screen-space 座標:
視埠轉換會把這四筆描述三角形 [0, 1, 2, 0, 2, 3] 交給 rasterization。 下圖我們將四筆輸出頂點與兩個三角形放進了

Rasterization:從 screen-space 圖元產生片段資料
做完前一節的圖元處理後,我們手上會有位於 screen-space 座標中的三角形,以及每筆頂點的 screen-space 深度、
它的輸出影像會由一格一格的像素(pixel)組成,每個像素都是影像網格中以整數座標
每個像素包含多少個 sample,取決於目前使用的取樣模式。 如果每個像素只有一個 sample,這種設定稱為 single-sample 模式。 如果每個像素具有多個 sample,則稱為 multisample 模式。 後者會分別判斷每個 sample 的取樣位置是否被三角形涵蓋,並將判定結果記錄在對應的 sample 中,因此能更細緻地表示三角形只涵蓋像素一部分的情況
本文的共用範例採用 single-sample,並將唯一一個 sample 的取樣位置設在像素中心。 下圖會先以 OpenGL screen-space 的 y 軸向上座標,解釋像素索引、像素邊界與中心取樣位置的關係:

圖中的每個像素都以一組整數
在檢查各個 sample 的取樣位置以前,管線還可以先使用三個 screen-space 頂點的環繞方向,來判斷目前正在處理的三角形圖元是正面圖元還是背面圖元。 若 Application 不希望繪製某個朝向,就可以啟用面剔除(face culling),讓該方向的三角形不再進入 coverage。 本文的共用範例沿用 OpenGL 預設狀態,不會啟用面剔除,因此兩種朝向都能繼續處理
此外,triangle coverage 的目標是找出哪些 sample 的取樣位置落在三角形的二維內部,因此三個頂點必須能在 screen-space 中圍出具有面積的區域。 三個頂點若發生了共線或重合,圍出的面積便是
找出被三角形涵蓋的 sample 後,我們還需要算出每個 sample 的 RGBA、UV 與候選 screen-space 深度,才能準備後續著色所需的資料。 為此,rasterizer 會接著執行 barycentric interpolation,在目標內插位置算出三個重心權重,再依各項頂點資料的內插規則產生結果
到這裡,rasterizer 已經知道三角形涵蓋了該像素內的哪些 samples,也已經算出了後續處理這些 samples 時要使用的 RGBA、UV 與候選 screen-space 深度。 接著 rasterizer 會把這些資料整理成一筆片段(fragment)。 就本文追蹤的資料而言,每筆片段會包含:
- 片段的 screen-space x、y 位置,以及目前圖元在該像素產生的 coverage mask
- 該位置的候選 screen-space 深度
- 由三筆頂點內插而來的 RGBA 與 UV,這些資料會成為 fragment shader 的輸入
片段是之後可能寫入像素的候選資料,一個三角形在一個像素內只會產生一筆 fragment,而如果有兩個三角形都涵蓋了某個像素,那該像素就會有兩個對應的 fragment,之後我們會再透過一系列的規則來決定最終要用哪個 fragment 的內容來寫入該像素
當同一個三角形在同一個像素內涵蓋多個 samples 時,這些結果會記錄在同一筆 fragment 的 coverage mask 中,其中每個 bit 對應一個 sample。 當多個三角形同時覆蓋同一個像素時,rasterizer 會分別為它們產生各自的片段,因此一個像素可以有多筆候選資料。 光柵化產生這些片段後會再將它們交給後續管線繼續處理,後續的階段會再將其輸出為實際要寫入像素的資料
下面兩個 H4 會依序展開 triangle coverage 與 barycentric interpolation,並明確列出兩者各自使用與交出的資料
triangle coverage:找出三角形會影響的像素
Triangle coverage 的目標是利用三筆頂點的 screen-space x 與 y 座標界定三角形邊界,再按照上一段介紹的取樣位置配置(sample pattern),取得每個 sample 的取樣位置,並判斷其中哪些 sample 被三角形涵蓋。 只有被涵蓋的 sample 才需要繼續做內插與著色
為此,rasterizer 會逐一確認每個 sample 的取樣位置是位於三角形的內部、外部,還是剛好落在邊上。 位於內部時,對應的 sample 會記為被三角形涵蓋。 位於外部時,該 sample 則不會繼續處理。 對於剛好落在邊上的取樣位置,rasterizer 還需要根據邊的方向,決定對應的 sample 是否歸屬於目前的三角形
Application 會透過畫面緩衝區與相關 API 狀態選擇 single-sample 或 multisample,以及每個像素內的 sample 數量。 各個 sample 的取樣位置則由圖形 API 與實作規則決定
Triangle coverage 會判斷同一個像素內的每個 sample 是否有被三角形涵蓋,再將這些布林結果依 sample index 記錄成一筆 coverage mask。 下一個 barycentric interpolation 的階段會逐一處理被涵蓋的 sample,使用對應的取樣位置與三筆頂點資料算出 RGBA、UV 與深度
本文的共用範例只檢查一個 sample,並將它的取樣位置設在像素中心。 因此,triangle coverage 對每個像素要回答的問題是「這個 sample 的取樣位置是否位於三角形內?」
假設目標像素為
接著我們要判斷這個取樣位置是否位於三角形內。 以下將它命名為點
先以有向邊
接著,我們利用這兩個向量的外積來建立一個可供後續比較的三角形法向量。 對任意兩個向量
將
從算式可以直接看到,
接下來我們要以同一條邊
這個測試向量是利用已知的
為了將同向、反向與零向量這三種結果轉成一個可以比較正負號的數值,我們會計算測試向量與基準法向量
兩個向量的內積可以寫成:
其中
如此一來:
:兩個外積向量同向。 這表示從 轉向 和轉向 的方向相同,因此 與 位於直線 的同一側,也就是 位於有向邊 的三角形內側 :兩個外積向量方向不同。 這表示兩次轉向的方向相反,因此 與 位於直線 的不同側,也就是 位於這條邊的三角形外側 : 是零向量,表示 與 平行,因此 位於無限延伸的直線 上
下圖將相同的向量關係套用到了三角形的三條有向邊上,合併起來就可以判斷點

前面我們利用了外積與內積來說明了如何判斷點
先以有向邊
將
與前面一樣,由於外積的 x 與 y 分量都是
邊緣函數會為一條有向邊與一個待判定位置輸出一個帶正負號的純量。 在本文使用的 y-up 座標中,這個數值具有下列意義:
: 位於有向邊 的左側 : 位於有向邊 的右側 : 位於無限延伸的直線 上
邊緣函數的絕對值也和三角形面積有固定關係。 為了看出這項關係,我們可以用
將
前面我們知道
後兩個等號是因為
在 y-up 座標中,
將邊緣函數與

由於
- 如果三個邊緣函數值都與
同號,則取樣位置位於三角形內部 - 如果有一個以上的值與
異號,則取樣位置位於三角形外部 - 如果沒有任何一個值與
異號,但有一個以上的值等於 ,則取樣位置位於三角形邊界上,還需要決定對應的 sample 是否歸屬於目前的三角形
top-left rule:決定共享邊上的 sample 歸屬
兩個相鄰三角形共同使用的線段稱為共享邊。 當某個 sample 的取樣位置剛好落在共享邊上時,我們需要決定它要由哪一個三角形涵蓋。 如果兩個三角形都將它算在內,rasterizer 會為同一個 sample 產生兩筆候選片段。 如果兩邊都不將它算在內,接縫處則不會產生任何片段
要達成這個目標,我們需要讓共享邊兩側的三角形得到相反的涵蓋結果。 然而,當取樣位置位於共享邊上時,兩個三角形對該線段算出的邊緣函數值都會是
接下來我們要把這四種邊直接寫成 rasterizer 可以計算的代數條件。 本文使用 y-up 座標,並將三角形的三個頂點
沿著
我們可以任取一條從
將這兩個分量代入前面的邊緣函數後,可以得到:
現在我們取邊上的一點
由於逆時針三角形的內部滿足

Top-left rule 會納入左邊與上邊,因此如果一個取樣位置要通過某條有向邊的涵蓋判定,必須滿足下面其中一個條件:
- 取樣位置位於有向邊的內側,此時邊緣函數值會大於
: - 取樣位置正好位於有向邊上,而且該邊是 top-left rule 會納入的左邊或上邊:
如果兩個相鄰三角形採用一致的頂點順序,它們會以相反方向走過共享邊:
- 對非水平共享邊而言,其中一個方向的
會小於 ,另一個方向的 則會大於 - 對水平共享邊而言,其中一個方向的
會小於 ,另一個方向的 則會大於
如此一來,共享邊上的 sample 就只會讓其中一個三角形的涵蓋判定成立了
共用範例:兩個三角形覆蓋哪些像素
前一節的視埠轉換已經將
接下來我們要利用邊緣函數與 top-left rule,判斷每個像素中心是否有被這兩個三角形涵蓋,並產生完整的 coverage mask。 首先,將兩組頂點代入有向雙倍面積公式:
兩個結果都大於

我們先以共享線段
這個位置可以由
接下來讓我們把
這條有向邊的
對
過程中可以看見這個方向的
雖然兩個三角形在共享邊上都算出了
本節的共同範例中,判定結果共有二十一個被涵蓋的像素,其中
後續範例我們會固定追蹤圖中以
涵蓋判定階段已經確認像素中心的 sample 被
barycentric interpolation:算出三個頂點的權重
經過三角形涵蓋判定的過程後,我們已經找出了一個被涵蓋的 sample,並輸出了它的取樣位置、三角形的三筆頂點與三個邊緣函數值。 接下來我們要算出這個 sample 應從每個頂點取得多少 RGBA、UV 與深度。 為此,重心座標內插會先將邊緣函數描述的幾何關係正規化成三個重心權重,再依每項資料的內插規則產生片段著色器輸入與預設的候選 screen-space 深度
由邊緣函數算出重心權重
以下會沿用
以頂點
公式中的
這三個除法可以直接用面積比例理解。 下圖把取樣位置與三個頂點相連,將原三角形分成了三個子三角形:

圖中的
由於每個
依內插規則產生 shader inputs
接下來我們要依資料指定的內插規則,使用這三個權重與頂點
RGBA、UV 與材質編號通常會需要不同的計算規則,例如 UV 通常需要沿著投影前的模型表面變化; 用來描述畫面效果的色彩或距離可能要在 screen-space 中線性變化; 材質編號等離散資料則要讓整個圖元共用同一筆值。 GLSL 會在 vertex shader output 與對應的 fragment shader input 前加上內插限定詞(interpolation qualifier),指定 rasterizer 要採用哪一種計算規則
對應的 shader 介面可以寫成:
// Vertex shader outputs
smooth out vec2 surface_uv;
noperspective out vec4 screen_color;
flat out int material_id;// Fragment shader inputs
smooth in vec2 surface_uv;
noperspective in vec4 screen_color;
flat in int material_id;在這個 GLSL 3.30 範例中,每組 output 與 input 都會使用相同的限定詞。 以下會依序說明三種限定詞是如何使用重心權重或頂點資料產生結果的
先從 noperspective 開始。 這項規則的目標是讓資料在 screen-space 內呈線性變化。 由於三個
令
下圖用頂點色彩呈現了這個加權過程。 圖中的

smooth 用於 UV 等要延續模型表面變化的資料。 當三個頂點的
前面的 perspective divide 已經替每筆頂點保留了
為了讓這三個權重再次形成總和為
接著將每個
最後以這三個新權重來混合頂點
這種先以

左側使用了 noperspective,所以格線會沿 screen-space 線性排列。 右側使用預設的是 smooth,格線間距會反映三筆頂點原有的 clip-space
flat 則比較特別,主要用在與材質有關的情境。 材質編號是用來選擇材質資料的離散索引,如果我們直接對不同頂點的材質編號進行加權混合,其結果可能是小數,也可能會指向另一筆無關的材質資料,因此整個圖元內的材質編號必須相同。 GLSL 使用 flat 這個內插限定詞來完成這項工作,套用 flat 後,OpenGL 會從原圖元選出一個頂點作為資料來源,並將該頂點的值提供給這個圖元產生的所有片段。 這個來源頂點被稱為 provoking vertex
假設三角形的頂點順序是 GL_TRIANGLES 而言,OpenGL 預設的 GL_LAST_VERTEX_CONVENTION 會選擇
若要改由第一筆頂點提供資料,可以在繪製前設定:
glProvokingVertex(GL_FIRST_VERTEX_CONVENTION);這項設定會讓
flat 在這裡固定的是材質編號,因此 UV、法向量、頂點色彩與光照計算所需的其他資料仍會依各自的內插規則在三角形內變化,所以使用同一個材質編號的片段還是可以產生不同的色彩。 但如果同一個三角形內需要混合多種材質,那可以由每個頂點另外再提供各材質的混合權重,rasterizer 會在三角形裡面內插這些權重,fragment shader 再依權重混合各材質的參數或紋理結果,以達到目的
multisample 時要在哪個位置計算 shader inputs
在前面的 single-sample 共用範例中,我們將唯一的取樣位置與內插位置都設在像素中心。 但 Multisample 會在同一個像素內配置多個取樣位置,每個位置的 screen-space 座標都不同,代入邊緣函數後也會得到不同的重心權重。 因此在 Coverage 判定找出被三角形涵蓋的取樣位置後,barycentric interpolation 還需要再從像素內選定計算 shader inputs 的位置
這個過程會依序做出兩項決定:
決定在哪個位置計算重心權重。 本文將 coverage 判定所檢查的位置稱為取樣位置(sample position),記為
。 計算 shader inputs 時實際代入邊緣函數的位置則稱為內插位置(interpolation position),記為 。 選定 後,rasterizer 會計算:內插位置改變時,三個邊緣函數值與重心權重也會跟著改變。 GLSL 提供了
centroid與sample這兩個輔助儲存限定詞(auxiliary storage qualifier)來控制 應該要選在哪裡,以及同一個像素要使用幾個內插位置決定如何使用重心權重混合頂點資料。 取得三個
後,noperspective會直接使用它們混合頂點資料。smooth則會先以各頂點的 調整三個 ,再使用重新正規化後的 混合資料:smooth與noperspective控制的是這項加權規則。 因此,像smooth centroid這樣的宣告具有兩層資訊:centroid先決定 ,smooth再決定如何使用該位置算出的三個重心權重
在 multisample 像素的邊界附近,三角形可能只會涵蓋其中一部分取樣位置。 然而只要 Rasterizer 有找到至少一個被涵蓋的取樣位置,就會為這個像素產生片段。 因此其選出的內插位置可能會落在三角形涵蓋範圍之外,使其中一個以上的重心權重小於
為了讓 shader 指定內插位置的選擇方式,GLSL 提供了 centroid 與 sample 這兩個輔助儲存限定詞。 它們會寫在 shader 介面變數的 in 與 out 宣告中,用來控制 multisample 情境下的內插位置與計算頻率。 以下為了方便對照,三組範例都讓 vertex shader output 與 fragment shader input 採用了相同的限定詞:
// 一般內插
smooth out vec2 surface_uv; // Vertex shader
smooth in vec2 surface_uv; // Fragment shader
// centroid 內插
smooth centroid out vec2 surface_uv; // Vertex shader
smooth centroid in vec2 surface_uv; // Fragment shader
// sample 內插(GLSL 4.00 以上)
smooth sample out vec2 surface_uv; // Vertex shader
smooth sample in vec2 surface_uv; // Fragment shader這三組宣告都使用了 smooth 的加權規則,現在讓我們來看一下它們是如何選擇內插位置的。 假設一個像素內有四個取樣位置
smooth:rasterizer 可以在像素範圍內選擇一個 ,並在該處計算三個重心權重。 接著再套用smooth的加權規則,算出一組可供 與 共用的surface_uvsmooth centroid:centroid會將 限制在像素範圍與三角形涵蓋範圍的交集內。 rasterizer 會在該處計算一組重心權重,再套用smooth的加權規則,算出一組可供 與 共用的surface_uvsmooth sample:sample會先將 作為 ,算出第一組重心權重與surface_uv,再將 作為 ,算出第二組重心權重與surface_uv。 fragment shader 也會分別為 與 執行一次
如果宣告改成 noperspective centroid 或 noperspective sample,centroid 與 sample 所選擇的內插位置會相同,但第二個步驟會改為直接使用三個

以重心權重產生候選深度
Barycentric interpolation 除了產生 shader inputs,也會替目前的取樣位置算出後續 depth test 時使用的預設候選深度。 目前三筆頂點的 screen-space z 已經都包含了投影、透視除法與 viewport transform 的結果,因此 rasterizer 會直接使用 screen-space 的重心權重,讓深度沿三角形所在的平面線性變化:
這項計算會為每個被涵蓋的取樣位置準備一筆候選 screen-space 深度
共用範例:算出像素 的內插資料
現在回到共用範例。 為了替像素
- 取樣資料:
- 像素索引為
- 取樣位置與內插位置都是
- coverage mask 為
。 唯一的 bit 對應到像素中心的 sample,其值為 ,表示這個 sample 被 涵蓋
- 像素索引為
- 頂點
:- screen-space 位置為
- 深度為
- RGBA 為
- UV 為
- screen-space 位置為
- 頂點
:- screen-space 位置為
- 深度為
- RGBA 為
- UV 為
- screen-space 位置為
- 頂點
:- screen-space 位置為
- 深度為
- RGBA 為
- UV 為
- screen-space 位置為
- 內插規則:RGBA 與 UV 都採用預設的
smooth
三角形的有向雙倍面積定義為
而三個重心權重的分子分別為:
將這三個邊緣函數值分別除以雙倍面積
下圖整理了三條對邊、邊緣函數值與重心權重:

同時縮放邊緣函數值與雙倍面積並不會改變重心權重,也不會改變有向面積的正負號。 左下角我們用了正比例係數
現在讓我們來看 UV、色彩與深度計算,我們會繼續使用這組重心權重:
首先來計算 fragment shader 實際收到的 UV。 共用範例使用預設的 smooth,因此還需要三筆頂點的 UV 與
依
因此,fragment shader 收到的 UV 為:
頂點色彩也採用了預設的 smooth。 同一組暫時權重會將
最後再使用 screen-space 重心權重產生候選深度。 三筆頂點的 screen-space 深度分別是:
將它們代入候選深度公式,可以得到:
為了將 triangle coverage 與 barycentric interpolation 的結果交給後續管線,rasterizer 會把像素與取樣位置、coverage mask、shader inputs 及候選深度整理成一筆 fragment。 共用範例得到的資料如下:

這筆 fragment 包含:
- 像素索引
- 取樣位置
- coverage mask
- UV
- RGBA
- 候選深度
Rasterizer 會以相同方式處理三角形涵蓋的其他像素,並為各個像素整理出對應的 fragment
Fragment Processing:篩選片段並產生候選顏色與深度
光柵化完成後,我們會拿到一系列 fragments。 每筆 fragment 會對應到一個像素,並記錄「該像素內有哪些 samples 被目標圖元涵蓋了」、「內插完成的 shader inputs」與「光柵化器算出的 screen-space 深度」等資訊
Fragment Processing 的目標是先篩選這些 fragments 中仍需處理的 samples,再替它們算出要交給 Framebuffer Operations 的候選顏色與候選深度。 在執行 fragment shader 前,OpenGL 會依序處理 pixel ownership、scissor 與 multisample fragment operations,藉此決定是否要繼續處理這筆 fragment,以及其中的哪些 samples 資訊是有效的:
- Pixel ownership test:當繪製目標是視窗系統提供的 default framebuffer 時,視窗系統會先判定目前位置是否屬於這個 OpenGL drawable,再將結果提供給 OpenGL
- 如果目標位置不屬於該 drawable,那就不會繼續處理該位置了
- 這可能會發生在目標視窗被另一個視窗遮住之類的情況
- 繪製目標若是 Off-Screen framebuffer object(FBO),這項測試會直接通過
- 如果目標位置不屬於該 drawable,那就不會繼續處理該位置了
- Scissor test:繪製狀態可以指定一個 screen-space 的矩形,將後續處理限制在該矩形內。 啟用這項測試後,OpenGL 會將 fragment 的位置與這個矩形比較,並排除位於該矩形之外的 fragment
- Multisample fragment operations:在 multisample framebuffer 中,coverage mask 的每個位元分別對應到了目標像素內的一個 sample。 這組操作會再依 multisample state 來清除其中不需要繼續處理的位元,形成這筆 fragment 目前的 sample mask
sample mask 與 coverage mask 兩者都以一個 bit 對應像素內的一個 sample,但其描述的是不同階段的結果:
- coverage mask:Triangle coverage 根據幾何邊界算出的結果,記錄哪些 samples 被三角形涵蓋
- sample mask:將 coverage mask 套用 shader 執行前的 multisample state 後,目前仍需交給 fragment shading 的 samples
例如,一個像素有四個 samples:
這代表三角形涵蓋 sample 0 與 sample 1。 接著假設 Application 又提供了下列遮罩:
則逐位元 AND 後我們可以得到:
完成這些判定後,sample mask 中數值為 1 的位元會指出仍需處理的 samples,之後那些至少保留了一個 sample 的 fragment 會繼續進倒 fragment shading 的階段
因此以這個例子來說,這裡最後只會繼續處理 sample 0
fragment shading:產生候選顏色與候選深度
為了依材質、紋理與畫面位置算出 fragment 要提交的顏色,fragment shading 會執行目前選用的 fragment shader。 管線每執行一次 fragment shader,就會建立一次 fragment shader invocation
一次 invocation 負責的 sample 數量會依 framebuffer 的取樣模式與 shader 的執行頻率而定:
- Single-sample framebuffer 的 fragment 只有一個 sample,因此只會執行一次 fragment shader
- Multisample framebuffer 中,一次 invocation 可以負責一個或多個仍有效的 samples,同一次執行算出的輸出會套用到這些 samples 上
- 啟用逐 sample shading 後,管線會為每個仍有效的 sample 分別執行 fragment shader,讓各個 sample 取得自己的輸出
每次 invocation 會取得 rasterizer 內插完成的 interface inputs(RGBA 與 UV)。 它也可以讀取 uniforms 與目前繪製狀態綁定的紋理資料。 而當 Shader 需要目前 invocation 的畫面位置與深度時,則可以直接讀取 GLSL 的內建輸入 gl_FragCoord,其有四個分量:
- x 與 y 是這次 invocation 在 screen-space 中的位置
- 本文沿用了 OpenGL 的預設座標慣例:drawable 的左下角是
,x 向右增加,y 向上增加 - 若像素索引為
,該像素的中心位於 screen-space 座標- 在本文的 single-sample 情境中,
gl_FragCoord.xy就是這個中心位置
- 在本文的 single-sample 情境中,
- 本文沿用了 OpenGL 的預設座標慣例:drawable 的左下角是
- z 是 rasterizer 為這個位置算出的 screen-space 深度
- w 是這個位置的
。 它由三筆頂點的 依 screen-space 重心權重進行仿射內插
對三角形 gl_FragCoord 所在位置的重心權重是
為了將 fragment shader 產生的顏色保存成繪製結果,我們需要一個能依畫面位置記錄色彩值的儲存空間。 在 OpenGL 中,色彩影像會承擔這項工作。 色彩影像、color attachment 與 fragment shader invocation 的關係如下:
- 色彩影像是一張完整的影像儲存空間。 以
的 single-sample 色彩影像為例,它會包含 個像素位置,每個位置保存一筆色彩 - Framebuffer 會透過一個或多個 color attachments 連接色彩影像,並記錄每個 attachment 對應到哪張影像
- 為了將每筆候選顏色送往正確的色彩影像,OpenGL 需要知道 fragment shader 的每筆輸出要送到 framebuffer 的哪個 color attachment
- Fragment shader 會用 output location 編號各筆輸出,而 framebuffer 則可能同時連接著多個 color attachments
- Draw-buffer state 會負責記錄每個 output location 應送往哪個 color attachment
- 每次 fragment shader invocation 會處理目前 fragment 對應的畫面位置,並透過
out變數交出一筆或多筆色彩值。 這些值會成為後續附件更新的輸入,因此本文稱它們為候選顏色
為了讓 fragment shader 將算出的 RGBA 交回管線,GLSL 會使用以 out 宣告的變數保存這筆結果。 下列宣告會建立一個名為 fragment_color 的色彩輸出變數:
layout(location = 0) out vec4 fragment_color;vec4 會保存 4 個浮點數,在這個輸出中分別代表了 R、G、B 與 A。 layout(location = 0) 會將 fragment_color 指定為 fragment color output location 0。 後續的對應關係是:
若同一次的繪製需要更新多張色彩影像,shader 可以宣告多個 out 變數,並替它們指定不同的 output locations。 每個 output location 會依 draw-buffer state 中對應的路由找到一個 color attachment,framebuffer 則會記錄該 attachment 實際連接的影像儲存空間。 另外,如果同一次的 invocation 負責了多個 samples,則這些 samples 會共用這次 invocation 交出的候選顏色
葉片或柵欄等材質常常會用紋理的 alpha 來標示透明部位。 如果 shader 對目標 fragment 做取樣後得到的 alpha 低於了門檻值,那 shader 可以執行 GLSL 的 discard statement,以直接終止目前的 invocation。 如此一來,這次 invocation 的 shader outputs 便不會交給後續的 Framebuffer Operations 了。 如果該 invocation 負責了多個 samples,那這些 samples 也全都不會收到這次 invocation 的 shader outputs
後續的 depth test 會逐 sample 比較新的深度與 depth attachment 已經保存的深度,因此每個要接受測試的 sample 都需要一筆候選深度。 前面的 barycentric interpolation 已經根據目標 sample 的重心權重算出了它的深度。 Fragment shader 可以透過 gl_FragCoord.z 讀取這個值,接著管線也會將它作為預設候選深度。 Shader 若需要依自身的計算改變深度,可以將新值寫入內建輸出 gl_FragDepth
GLSL 會在編譯時去確認 shader 的程式碼中是否存在對 gl_FragDepth 的寫入敘述,如果有,那我們就稱這個 shader 具有「靜態寫入(statically assign)」gl_FragDepth 的條件。 即使該敘述位於某個動態期間可能不會進入的條件分支也算,例如:
if (use_custom_depth) {
gl_FragDepth = 0.4;
}這段程式就符合靜態寫入 gl_FragDepth 的條件
由於這段程式碼已經構成了靜態寫入,因此每次正常結束的 invocation 都必須在實際走過的分支中為 gl_FragDepth 賦值,透過 return 結束的分支也包含在內
以上例來說,當 use_custom_depth 為 true 時,候選深度為 false 時,由於該次 invocation 會沿著未賦值的分支正常結束,因此這次 invocation 的候選深度是未定義值,也不會改用 gl_FragCoord.z
執行 discard 的分支會直接終止該次 invocation 並丟棄目前片段。 這次 invocation 不會交出候選深度,因此該分支不需要為 gl_FragDepth 賦值
以 UV 讀取紋理顏色
已內插的 UV 會指出紋理中的相對位置。 為了將這個位置轉成 fragment 的候選顏色,fragment shader 還需要取得該位置所保存的資料,這會需要指定紋理影像與取樣設定
紋理影像的實際資料會保存在 texture object 中。 為了讓 shader 在 draw call 執行時選擇要使用的 texture object,OpenGL 需要一組具有整數編號的綁定位置。 這些位置稱為紋理影像單元(texture image unit),每個單元都能記錄目前綁定的二維 texture object,取樣時我們可以藉由單元編號來找到這個 object 與目前有效的取樣設定
UV 提供的是紋理中的連續座標。 為了將這個座標轉成一筆確定的取樣結果,取樣流程需要知道座標超出紋理範圍時的處理方式,以及從多個相鄰資料格選取資料的方法。 前者會由紋理環繞模式來處理超出範圍的座標,後者則由過濾方式與 LOD 等設定來決定該如何選取或混合鄰近資料
Texture object 會保存紋理影像資料,也會保存一套過濾方式、紋理環繞模式與 LOD 等取樣設定。 為了讓同一張紋理影像可以搭配另一套取樣方式,OpenGL 也提供了專門保存取樣設定的 sampler object。 一個紋理影像單元可以同時綁定以下兩種物件:
- texture object:提供紋理影像資料,以及自身保存的取樣設定
- sampler object:選用的物件,用來提供一套獨立的取樣設定
當紋理影像單元有綁定 sampler object 時,雖然紋理影像仍由 texture object 提供,但取樣流程使用的取樣方式會由 sampler object 決定。 本文的共用範例會直接採用 texture object 自身保存的取樣設定,因此紋理影像單元只需要綁定 texture object
以本文使用的二維紋理為例,每個紋理影像單元都會記錄一個目前綁定的二維 texture object。 OpenGL 可以同時使用多個單元,例如讓單元 0 綁定 texture object A、單元 1 綁定 texture object B
GLSL 提供了內建函式 texture() 來執行紋理取樣。 這個函式需要取得兩項資訊:
- 一組 UV 取樣座標
- 要使用的紋理影像單元
GLSL 會用 sampler uniform 保存紋理影像單元的編號。 對二維紋理而言,shader 會以 sampler2D 宣告這項 uniform,再將它與 UV 一起傳入 texture()。 texture() 會從指定單元取得二維 texture object 與取樣設定,並傳回取樣得到的 RGBA。 下方的共用範例會用一段完整的 fragment shader 展開這項流程
對 sampler2D 而言,texture() 會將連續的正規化 UV 解讀為紋理中的相對位置。
為了將連續的 UV 轉成一筆確定的取樣結果,取樣流程需要處理紋理邊界,並決定要從哪些 texels 取得資料。 前者會由紋理環繞模式(texture wrap mode)負責,後者則由過濾模式(filtering mode)負責
OpenGL 提供了多種紋理環繞模式,分別會以不同方式處理紋理邊界。 本文的共用範例將 clamp-to-edge 模式,讓超出範圍的座標停留在紋理邊緣。 為了比較需要重複鋪排同一張紋理的情況,下面也會介紹 repeat:
clamp-to-edge:將超出範圍的座標限制在紋理邊界,使邊界外的取樣繼續使用最接近的邊緣 texelsrepeat:OpenGL 的預設環繞模式,讓座標週期性地繞回紋理另一側,例如 與 會落在相同的水平位置。 常用於地板、牆面等需要重複鋪排的紋理
OpenGL 也提供了多種過濾模式,用來決定如何從鄰近的 texels 取得顏色。 這邊我們簡單介紹兩種常見的模式:
nearest:選擇最接近目前取樣位置的一個 texel,因此邊界清楚,但放大時容易看見方格linear:在二維紋理中讀取取樣位置周圍的四個 texels,先沿一個方向內插兩組結果,再沿另一個方向內插,得到雙線性過濾結果
以寬度為 nearest 再選擇中心最接近這個位置的 texel。 在本文採用的 nearest 與 clamp-to-edge 情境中,可以用下式取得 texel 索引:
這裡的
紋理縮小時,一個 screen-space 像素所對應的紋理範圍可能會包含許多來源 texels,直接從原始尺寸的影像選值容易造成閃爍與鋸齒。 為了提供更接近目前縮小比例的資料,mipmap 會替同一張紋理準備一系列逐層縮小的影像。 縮小過濾模式可以指定使用 mipmap。 此時紋理取樣流程會依目前的縮小程度選擇一個層級,或混合兩個相鄰層級的結果
共用範例:以內插 UV 取得候選顏色
上一個共用範例完成了 rasterization 對像素
因此在進入 Fragment Processing 時,我們手上會有這筆 fragment 及其包含的資料:
- 片段來源:三角形
在像素 產生的 fragment - 取樣位置與內插位置:兩者都是像素中心
- coverage mask:
。 唯一的位元為 ,表示中心 sample 被 涵蓋 - 內插完成的 shader inputs:
- RGBA 為
- UV 為
- RGBA 為
- 候選 screen-space 深度:
現在我們要執行 fragment shader,使用內插後的 UV 從紋理取得 RGBA,並將這筆 RGBA 交給管線作為候選顏色。 候選深度則沿用 rasterizer 算出的
為了執行這項工作,Application 需要在提交 draw call 前選用包含 fragment shader 的 OpenGL program object,並準備好 shader 需要的紋理資源與取樣狀態。 本例使用的 fragment shader 如下:
#version 330 core
in vec2 vertex_uv;
uniform sampler2D color_texture;
layout(location = 0) out vec4 fragment_color;
void main()
{
fragment_color = texture(color_texture, vertex_uv);
}這段程式會從 vertex_uv 取得目前 fragment 的 UV。 color_texture 保存 Application 指定的紋理影像單元編號,texture() 會使用這兩項資料執行紋理取樣。 取樣得到的 RGBA 會寫入 fragment_color,成為 fragment color output location 0 的候選顏色
本例由 Application 準備的紋理資源與取樣狀態如下:
- 紋理影像單元:單元 0 綁定一個二維 texture object,sampler uniform
color_texture的值也設為 - 紋理影像:texture object 內含一張
的影像,而且只有一個 mipmap 層級- Internal format 為
GL_RGBA8 - 每個紋理元素會用 4 個介於
到 的 8-bit 無號整數,分別保存 R、G、B 與 A sampler2D取樣時會將每個整數除以 ,轉成 內的浮點數
- Internal format 為
- 紋理內容:整數索引
所指向的紋理元素保存位元組 - 紋理取樣狀態:放大與縮小過濾都使用
nearest, 與 方向的紋理環繞模式都使用clamp-to-edge
下例的 Application 程式碼會建立上述的紋理資源與取樣狀態。 這裡的 program 是已經連結好前述 fragment shader 的 OpenGL program object:
GLubyte texture_data[4][4][4] = {};
for (int y = 0; y < 4; ++y) {
for (int x = 0; x < 4; ++x) {
texture_data[y][x][0] = static_cast<GLubyte>(64 * x);
texture_data[y][x][1] = static_cast<GLubyte>(64 * y);
texture_data[y][x][2] = 32;
texture_data[y][x][3] = 255;
}
}
GLuint color_texture_object = 0;
glGenTextures(1, &color_texture_object);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, color_texture_object);
glTexImage2D(
GL_TEXTURE_2D,
0,
GL_RGBA8,
4,
4,
0,
GL_RGBA,
GL_UNSIGNED_BYTE,
texture_data
);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glUseProgram(program);
GLint color_texture_location =
glGetUniformLocation(program, "color_texture");
glUniform1i(color_texture_location, 0);此範例的兩層迴圈會走訪
這組數字是共用範例刻意設定的紋理內容,由 Application 寫入。 設計目的如下:
- R 使用
:每往右一格,紅色增加 64 - G 使用
:每往上一格,綠色增加 64 - B 固定為 32
- A 固定為 255,代表完全不透明
例如:
因此 x 越大則紅色分量越高,y 越大則綠色分量越高。 藍色固定為 32,透明度固定為 255。 這樣每一列與每一行就都會有容易辨認的顏色數值,方便我們後面確認 UV 最後選中了哪一個紋理元素
接著 glTexImage2D() 會為目前綁定的 texture object 建立一張 texture_data 中的 16 個 texels 複製進去,相關屬性與對應的意義為:
- mipmap 層級 0:紋理原本的完整尺寸,也就是
GL_RGBA8:每個 texel 使用 R、G、B、A 四個通道,每個通道占 8 bits
取得 UV 後,OpenGL 還需要知道如何選擇 texel。 例子中的四次 glTexParameteri() 呼叫分別設定了:
- 縮小紋理時使用
nearest - 放大紋理時使用
nearest - u 方向超出紋理範圍時使用
clamp-to-edge - v 方向超出紋理範圍時使用
clamp-to-edge
接著後面幾行程式碼的作用是:
glActiveTexture(GL_TEXTURE0)- 將紋理影像單元 0 設為目前操作的單元
glBindTexture(GL_TEXTURE_2D, color_texture_object)- 將
color_texture_object綁定到目前的單元 0
- 將
glUniform1i(color_texture_location, 0)- 將數值
0寫入 sampler uniformcolor_texture,告訴 shader 使用紋理影像單元 0
- 將數值
這段是在建立 fragment shader 與 texture object 之間的連接。 OpenGL 會使用「紋理影像單元」作為兩者的中間位置:

因此 shader 程式在執行:
texture(color_texture, vertex_uv)時,color_texture 便有辦法引導 texture() 找到單元 0 綁定的二維 texture object,vertex_uv 則指定要在這張紋理的哪個位置取樣。 本例的取樣設定保存在了 texture object 中,所以取樣時也會套用先前設定的 nearest 與 clamp-to-edge
在執行 fragment shader 之前,會先做幾項檢查:
pixel ownership test:確認 fragment 對應的像素可以由目前的 OpenGL drawable 更新scissor test:確認像素是否位於 Application 指定的裁切矩形內- 本例沒有開啟此項測試
multisample fragment operations:更新 coverage mask,決定其中哪些 samples 繼續處理
由於共用範例使用 single-sample,因此像素
- coverage mask [1]
- pixel ownership test 通過
- scissor test 關閉,因此保留
- multisample operations 沒有清除這個 sample
- 交給 fragment shader 的遮罩仍為 [1]
- 執行一次 fragment shader
開始執行這次的 fragment shader 時,rasterizer 會將內插 UV vertex_uv,並把取樣位置、候選深度與內插後的 gl_FragCoord。 為了展開 gl_FragCoord.w 的數值計算,我們還會沿用上一階段得到的下列中間值:
- screen-space 重心權重:
- 三筆頂點的倒數齊次座標:
接下來我們會先算出 gl_FragCoord 的具體內容,再讓 fragment shader 使用 vertex_uv 找出紋理中的確切紋理元素
由於本文使用 single-sample framebuffer,因此這次 invocation 的 screen-space 位置就是像素
接著將上面列出的重心權重與三筆頂點的 gl_FragCoord.w 的內插公式:
加上 rasterizer 已算出的 screen-space 深度
另外我們將前面輸入的正規化 UV
下圖在 nearest 最後會選中 texel

Texel texture() 依 GL_RGBA8 的正規化規則將每個通道除以
因此這次 fragment shader invocation 會交出候選顏色
Framebuffer Operations:測試片段並更新輸出影像
Framebuffer Operations 的目標是決定目標 sample 的候選顏色與候選深度能否用來更新 framebuffer。 新的 fragment 到達時,Framebuffer Operations 會把它帶來的資料,與 framebuffer 在同一個像素、同一個 sample 已保存的資料進行比較,再決定是否要更新
具體來說:
sample mask會指出這筆 fragment 中有哪些 samples 需要處理- 對每個有效的 sample,fragment 會提供候選顏色與候選深度
- Framebuffer 內的 color、depth 與 stencil attachments 則保存著該位置原有的資料
- 管線會執行 stencil test 與 depth test,並依兩項測試的結果更新 stencil 值。 同時通過兩項測試的 sample 會再執行色彩操作,並在相應的附件操作中套用寫入遮罩。 這些操作都是逐 sample 進行的
為了讓這些操作讀取既有資料,framebuffer 會透過 attachments 連接影像儲存空間,其中各 attachment 的職責為:
- Color attachment 為每個 sample 保存色彩
- depth attachment 保存深度
- Stencil attachment 會為每個 sample 保存一個整數。 Application 可以自行定義這些數值的意義,再透過 stencil test 控制後續繪製要處理哪些 samples
- 例如:
- 先把鏡面涵蓋的 samples 寫成 1
- 繪製反射畫面時,只讓 stencil 值等於 1 的 samples 通過測試
- 反射畫面便只會出現在鏡面區域內
- 例如:
測試、色彩合成與寫入都需要由目前 draw call 的設定控制。 本文將這些比較函式、運算方式與寫入權限統稱為了繪製狀態(draw state)。 Framebuffer Operations 會依這些設定,依序完成下列工作:
- 更新 sample mask,找出仍需處理的 samples
- 對這些 samples 執行 stencil test 與 depth test,並依測試結果選擇對應的 stencil operation
- 對通過測試的 samples 執行 blending 或 color logical operation,產生色彩結果
- 在相應的附件操作中套用 color、depth 與 stencil write masks,決定允許更新的通道或位元
最後,framebuffer write 會沿著 attachment 的連接關係,保存獲准寫入的資料。 接下來每項操作都會先說明使用的資料與判定方式,再使用一組獨立的具體數值示範。 各小節可以依操作內容選擇不同組態,直接呈現該項設定如何影響結果
per-fragment operations:逐 sample 篩選候選資料
Per-fragment operations 會逐一處理這些 samples,決定各 sample 的 stencil、depth 與 color 如何更新
同一筆 fragment 可以涵蓋一個像素內的多個 samples。 每個 sample 都會取得候選顏色與候選深度,並分別讀取 framebuffer 中對應 sample 已保存的 stencil、depth 與 color 值
- 當同一次的 fragment shader invocation 需要負責多個 samples 時,這些 samples 會共用該次執行交出的候選顏色
- 當啟用了逐 sample 的 shading 時,各 sample 可以分別取得自己的 shader output
更新 sample mask:決定哪些 samples 繼續處理
進入後續測試以前,管線需要先找出這筆 fragment 中仍需處理的 samples。 Fragment Processing 交出的 sample mask 會提供第一組結果,其中每個 bit 都會對應到目前像素內的其中一個 sample,數值
Fragment shader 可以透過內建輸出 gl_SampleMask 來再提供一組遮罩。 gl_SampleMask[0] 內保存著前 32 個 samples 的位元,bit gl_SampleMask 任一元素的寫入,每次正常結束的 invocation 就需要替所有相關元素賦值,這組輸出才具有明確內容。 該次執行沒有賦值的相關元素會得到未定義值
啟用逐 sample shading 時,每次 invocation 只會以自己負責的 samples 所對應的位元更新 sample mask。 其餘位元不會影響由其他 invocations 負責的 samples。 Multisample color buffer 還能使用 alpha-to-coverage,讓一筆 fragment shader color output 的 alpha 控制保留下來的 samples
令 gl_SampleMask 提供的遮罩,
逐位元 AND 會保留三組遮罩都等於
提示
以
gl_SampleMask:由 fragment shader 直接決定要保留哪些 samples- 一次 shader invocation 負責四個 samples 時,它可以同時控制四個 bits
- 逐 sample shading 會讓每次 fragment shader invocation 只負責一個 sample。 例如負責
的 invocation,只有 bit 2 會影響 ,它寫入的其他 bits 不會控制 、 或假設這次 invocation 負責
,則它的有效範圍就相當於:所以即使 shader 寫出了完整的遮罩:
管線也只會檢查 bit 2:
- bit 2 為
:保留 - bit 2 為
:排除
、 與 由其他 shader invocations 負責,所以這次 invocation 寫出的 bit 0、bit 1 與 bit 3 對它們沒有作用換句話說,shader 形式上會寫出完整的
gl_SampleMask整數,但管線只採用其中對應於這次 invocation 所負責 samples 的 bits- bit 2 為
- Alpha-to-coverage:由固定功能管線把 shader 輸出顏色的 alpha(
fragment_color.a)轉成另一組 sample mask,以藉由保留不同數量的 samples 來表現部分涵蓋效果- alpha 為
時排除全部 samples - alpha 為
時保留全部 samples - alpha 為
時, multisample 通常會保留其中約兩個 samples,實際選擇由 OpenGL implementation 決定
- alpha 為
gl_SampleMask 與 alpha-to-coverage 是兩種不同的篩選來源,兩者都只能繼續清除 sample mask 中的位元。 例如原本的 sample mask 是:
Shader 輸出的 gl_SampleMask 是:
Alpha-to-coverage 又產生:
管線會對它們執行逐位元 AND:
所以最後只會繼續處理
GLSL 會以 output location 將色彩輸出送往對應的 draw buffer。 同一個 location 若提供了兩筆來源顏色,則會再以 output index 0 與 1 區分。 本文的 fragment_color 位於 output location 0,並使用預設的 output index 0,因此 alpha-to-coverage 會採用這筆輸出的 alpha
Draw framebuffer 的 SAMPLE_BUFFERS 會指出它是否具有 multisample 儲存空間,數值
SAMPLE_BUFFERS=1:具有 multisample bufferSAMPLES=4:每個像素有四個 samples
當啟用了 GL_MULTISAMPLE 與 GL_SAMPLE_ALPHA_TO_COVERAGE、且 SAMPLE_BUFFERS 為 GL_NONE)或使用 normalized 或 floating-point 色彩格式時,OpenGL 會由該筆 alpha 產生一組遮罩。 Alpha-to-coverage 會將
提示
假設 shader 寫成:
layout(location = 0) out vec4 fragment_color;
void main()
{
fragment_color = vec4(1.0, 0.0, 0.0, 0.5);
}Alpha-to-coverage 會使用 fragment_color.a 來設定 alpha,也就是這裡的
啟用 alpha-to-coverage 後,OpenGL 會把 fragment_color.a 轉成一組 sample mask。 以四個 samples 為例:
:保留 0 個 samples :通常保留約 2 個 samples :保留全部 4 個 samples
例如
實際選中哪兩個 samples 由 OpenGL implementation 決定。 這組遮罩還會與原有的 sample mask 做 AND,只有最後仍為
另外,即使 draw buffer 0 沒有連接色彩影像,shader 輸出的 alpha 仍可用來篩選 depth 或 stencil samples
Stencil test:比較標記並選擇 stencil operation
Application 可以自己設定遮罩來去調整 draw call 能操作「sample 內部保存的 stencil 整數」中的哪些 bits,為此 Application 需要設定好對應的遮罩:
- Sample mask:每個 bit 對應像素內的一個 sample,數值為
的 sample 才會進入後續測試 - Stencil attachment:為 framebuffer 中每個 sample 保存一筆 stencil 整數
- Stencil value mask:指定 stencil test 要比較整數中的哪些 bits
- Stencil write mask:指定 stencil operation 可以修改整數中的哪些 bits
其中:
Stencil value mask 由
glStencilFunc()的第三個參數設定:glStencilFunc(GL_EQUAL, 0x05, 0x0F);三個參數分別表示:
- 比較函式
func=GL_EQUAL:要求遮罩處理後的 stencil reference 與既有 stencil value 相等,sample 才能通過測試 - Stencil reference
reference=0x05:Application 為這次 draw call 提供的比較基準。 本例會用它尋找低四個 bits 等於0x5的 stencil values - Stencil value mask
value_mask=0x0F:在比較前分別與 reference 和既有 stencil value 執行逐位元 AND。0x0F的低四個 bits 是 ,因此只有兩個 stencil 整數的低四個 bits 會參與比較
- 比較函式
Stencil operation 由
glStencilOp()設定:glStencilOp(GL_REPLACE, GL_INCR, GL_KEEP);三個參數會依測試結果選擇要套用的操作:
- Stencil test 失敗時使用第一個參數
GL_REPLACE,以 stencil reference 作為 operation result - Stencil test 通過、depth test 失敗時使用第二個參數
GL_INCR,將既有 stencil 值增加 ,並在附件格式可表示的最大值停止 - Stencil test 與 depth test 都通過時使用第三個參數
GL_KEEP,保留既有 stencil 值
- Stencil test 失敗時使用第一個參數
Stencil write mask 由
glStencilMask()設定:glStencilMask(0x0F);
令 func 比較兩個結果:
例如,GL_EQUAL 要求兩側相等,GL_LESS 要求左側小於右側,GL_ALWAYS 則會直接讓 sample 通過。 Stencil test 通過的 sample 會繼續進行 depth test,測試失敗的 sample 則會停止處理候選顏色與候選深度,並套用 glStencilOp() 第一個參數指定的操作
stencil test 會逐 sample 比較 stencil attachment 中的既有值與目前 draw call 提供的基準。 假設 4× multisample 像素具有以下資訊:
sample mask: [1, 1, 0, 0]
q0 stencil value:0xA2
q1 stencil value:0xB1
q2 stencil value:0xC2
q3 stencil value:0xD2如果 Application 設定了:
glStencilFunc(GL_EQUAL, 0x05, 0x0F); // reference 與 value mask
glStencilOp(GL_REPLACE, GL_INCR, GL_KEEP);
glStencilMask(0x0F); // write mask則管線會先透過 Sample mask 選出要處理的
- 從 stencil attachment 讀取每個 sample 的既有值
- 將 reference 與既有 stencil value 分別和 value mask
0x0F執行逐位元 AND,再比較兩個結果 - 根據 stencil test 與 depth test 的結果選擇一項 stencil operation
- 使用 write mask
0x0F,只允許 operation 修改低四個 bits - 將結果寫回 stencil attachment
以
而我們的 stencil value mask 是 0x0F:
這代表 stencil test 只讀取並比較低四個 bits,因此:
GL_EQUAL 會比較這兩個結果。 由於 0x05 與 0x02 不相等,glStencilOp() 第一個參數指定的 GL_REPLACE。 這項操作使用 stencil reference 0x05 作為 operation result
接著,stencil write mask 0x0F 只允許 operation result 的低四個 bits 寫回附件,因此:
所以高四個 bits 會保留為原本的 0xA,低四個 bits 則會採用 operation result 的 0x5。 將兩部分組合後,新值為:
同樣位於 sample mask 中的 0x1,因此比較結果同樣失敗,GL_REPLACE 會配合 write mask 將既有值 0xB1 更新成 0xB5。
Depth test:比較候選深度與既有深度
當目標 sample 通過 stencil test 後,depth test 會接著比較這筆 fragment 在該 sample 的候選深度,以及 depth attachment 保存的既有深度,判斷目前表面是否比先前寫入的表面更接近相機。 為了做出判斷,depth test 會取得 fragment 的候選 screen-space 深度,以及 depth attachment 在同一個 sample 保存的既有深度,再依 Application 指定的比較方式檢查兩者
- 候選深度
:由 rasterizer 算出,也可以由 fragment shader 寫入gl_FragDepth取代 - 既有深度
:depth attachment 在目前 sample 保存的數值 - Depth test 的啟用狀態:Application 以
glEnable(GL_DEPTH_TEST)啟用深度比較。 停用時,sample 會直接進入色彩操作,depth attachment 也不會更新 - 深度比較函式:由 Application 呼叫
glDepthFunc()設定
若將 glDepthFunc() 指定的比較關係記為
本文採用的 projection matrix 會以較小的 screen-space 深度表示較接近近平面的表面。 因此,若要讓較靠近相機的表面通過 depth test,可以使用 GL_LESS,要求候選深度小於既有深度。 Application 會透過 glDepthFunc() 指定實際使用的比較規則,常見設定包括:
GL_LESS:候選深度小於既有深度時通過GL_LEQUAL:候選深度小於或等於既有深度時通過GL_GREATER:候選深度大於既有深度時通過GL_ALWAYS:每一筆候選深度都通過
例如設定:
glDepthFunc(GL_LESS);時,公式中的
若設定 GL_LEQUAL,則會變成:
當 draw framebuffer 具有 stencil attachment,而且有啟用 stencil test 時,depth test 的結果也會決定 glStencilOp() 選用哪一項操作:
- Depth test 失敗:執行
depth_fail指定的 stencil operation,並停止處理該 sample 的候選顏色與候選深度 - Depth test 通過:執行
depth_pass指定的 stencil operation,並讓該 sample 繼續進入色彩操作
停用 stencil test 的 draw call 會直接依 depth test 的結果,決定 sample 是否要進入色彩操作,以及候選深度是否取得寫入資格
以下使用一個
- 最終 sample mask 為
- Depth test:
- 啟用狀態:已啟用
- 比較函式:
GL_LESS - 候選深度:
- Depth write mask:允許寫入
- Stencil test 與 stencil operation 使用下列設定:
- Stencil reference:
0x02 - Stencil value mask:
0x0F - Stencil write mask:
0x0F glStencilOp():stencil_fail:GL_REPLACEdepth_fail:GL_INCRdepth_pass:GL_KEEP
- Stencil reference:
:- 既有 stencil:
0xA2 - 既有 depth:
- Stencil test:通過
- 既有 stencil:
:- Stencil test:失敗
- Stencil 更新:由
0xB1變成0xB2
:- 既有 stencil:
0xC2 - 既有 depth:
- Stencil test:通過
- 既有 stencil:
:- Sample-mask bit:
- 處理狀態:已在 sample-mask 階段停止
- Sample-mask bit:
Depth test 會對仍有效的
由於 depth_pass 對應的 GL_KEEP,stencil 值會維持為 0xA2。 由於本例的 depth write mask 允許寫入,所以這個 sample 的深度更新結果為
至於 depth_fail 對應到的 GL_INCR。 Operation 會先將 0xC2 增加成 0xC3,stencil write mask 0x0F 再允許寫回低四個 bits:
所以
當大量 samples 注定無法通過測試時,Application 可以要求管線提前執行測試,讓失敗的 samples 省下 fragment shader 的運算。 GLSL 提供了下例的介面限定詞來進行這項設定:
layout(early_fragment_tests) in;啟用這項設定後,執行順序與資料來源如下:
- Stencil test 與 depth test 會在 fragment shader invocation 之前執行
- Depth test 會使用 rasterizer 產生的候選深度
- Shader 對
gl_FragDepth的寫入不會改變這次測試與附件更新所使用的深度 - 提前完成的 depth 或 stencil 更新會保留,後續 shader 執行
discard也不會撤銷這些更新
例如候選深度為 GL_LESS 會在 shader 執行前就判定 gl_FragDepth 寫成了
補充:透視投影如何分配深度精度
Depth test 會直接比較 screen-space 深度,但這些數值其實並未均勻地對應到相機前方的實際距離。 為了理解深度附件在近處與遠處能分辨多細的距離差異,我們需要觀察透視投影與透視除法會如何分配
:沿著相機視線軸量出的距離 :相機到近平面的距離 :相機到遠平面的距離
對本文使用的 OpenGL projection matrix 與預設 depth range 而言,轉換後的深度為:
以
可以看見:
- 從
移動到 ,實際距離只增加 ,深度值卻從 增加到 - 從
移動到 ,實際距離增加了 ,但深度值只從 增加到了 ,整段只能使用長度為 的數值範圍
因此這項非線性分配會讓近平面附近保有較細的深度精度,遠處的不同表面則較容易得到相同或非常接近的儲存值
下圖是一般的色彩結果與同一個場景的深度附件視覺化出來的結果。 右側以灰階呈現了深度附件中的數值,較近處顯示得較暗,較遠處顯示得較亮:

Blending、color logical operation 與 dithering:產生色彩結果
通過 stencil test 與 depth test 之後,目標 sample 就取得了更新 color attachment 所連接影像的資格。 為了產生準備寫入該 sample 的色彩結果,管線會取得 fragment shader 交出的候選顏色,以及該影像在同一個 sample 保存的既有顏色:
- 來源顏色
:fragment shader 交出的候選顏色 - 目的顏色
:color attachment 所連接的影像在同一個 sample 保存的既有顏色
接著管線需要決定要用哪一種方式處理這兩筆顏色。 OpenGL 提供了 blending 與 color logical operation 這兩條互斥的色彩合成路徑:
- Blending:把 RGBA 視為數值,透過算術公式決定來源顏色與目的顏色各占多少比例。 這項操作常用於透明、淡入淡出與光照累加
- Color logical operation:把顏色視為一串 bits,再對來源顏色與目的顏色執行
AND、OR或XOR等位元運算。 這項操作常用於位元遮罩與需要直接控制色彩 bits 的繪製效果
Application 會使用 GL_COLOR_LOGIC_OP 來選擇要走哪一條路徑:
- 關閉這項狀態時,管線會依 blending state 執行 blending,或直接讓來源顏色繼續傳遞
- 啟用這項狀態時,管線會略過 blending,再依目的 attachment 的 internal format 與
GL_FRAMEBUFFER_SRGB狀態處理 color logical operation
這兩條路徑的輸出最後都要寫入 color attachment 中,但每個對應的 color attachment 內使用的儲存格式可能會不一樣,因此計算的結果可能會遇到「一個連續色彩值無法由 attachment 精確表示」的情況。 為了解決這個問題,Application 可以啟用 Dithering 來處理這兩個路徑的輸入與輸出資料,讓其最終要寫入 color attachment 的資料能夠被正確表示
Color attachment 的 internal format:決定顏色如何儲存
Color attachment 是 framebuffer 中用來連接色彩影像的 attachment point。 為了讓管線知道一筆 RGBA 要占用多少 bits,以及各個 bits 應該如何解讀,Application 會在建立這張影像時指定一個 internal format。 這項格式會定義每個色彩通道的資料型態、位元數與編碼方式
本文後面會先介紹下列三種常見的格式:
GL_RGBA8:R、G、B 與 A 都採用 8-bit unsigned normalized fixed-point 格式- 儲存方式:每個通道以
到 的整數保存,共有 個可儲存值 - 解讀方式:管線以
解讀 attachment 中保存的整數 。 因此 對應 , 約對應 , 對應
- 儲存方式:每個通道以
GL_RGBA16F:R、G、B 與 A 都採用 16-bit floating-point 格式- 儲存方式:每個通道保存一個 16-bit 浮點值
- 解讀方式:通道中的 bits 依 floating-point 格式表示數值,因此可以保存負數與大於
的值
GL_RGBA8UI:R、G、B 與 A 都採用 8-bit unsigned integer 格式- 儲存方式:每個通道以
到 的整數保存 - 解讀方式:附件中的整數就是該通道的數值,例如整數
代表數值
- 儲存方式:每個通道以
另外由於人眼對暗部的亮度差異較敏感,而 8-bit 通道一共只有
GL_SRGB8_ALPHA8:RGB 使用 sRGB encoding,A 使用 8-bit unsigned normalized fixed-point 格式- 儲存方式:RGB 每個通道保存一組 8-bit unsigned normalized 數值,這些數值會被視為 sRGB 編碼值。 A 則保存線性的 8-bit unsigned normalized 數值
- 解讀方式:RGB 的整數代表非線性的 sRGB 編碼值,經過 sRGB decoding 後可以還原成對應的線性亮度。 A 則直接以
解讀
這四種 internal format 會決定每個色彩分量有哪些可表示值。 下一小節我們會說明色彩落在兩個相鄰可表示值之間時,管線該如何藉由 Dithering 來選出後續要使用的值
Dithering:依 internal format 選擇可表示值
Color attachment 只能保存 internal format 所定義的離散數值。 以 GL_RGBA8 為例,每個通道會以
當準備寫入的色彩分量
- 可表示下界:小於或等於
的最大可表示值 - 可表示上界:大於或等於
的最小可表示值
Dithering 會逐一處理各個色彩分量,從這兩個候選值中選出實際要寫入的結果。 GL_DITHER 的狀態會決定選擇時要使用哪些資訊,OpenGL context 的初始狀態會啟用 GL_DITHER,Application 可以透過 glEnable(GL_DITHER) 與 glDisable(GL_DITHER) 調整這項狀態:
- 啟用
GL_DITHER:OpenGL implementation 可以同時依據 與 pixel 的 screen-space 位置選擇下界或上界,使相鄰位置採用不同的可表示值 - 停用
GL_DITHER:OpenGL implementation 只依據 選擇下界或上界,同一套選擇規則會套用到所有 screen-space 位置
例如,假設十個相鄰 pixels 的某個通道都算出了 GL_RGBA8,則在它兩側的可表示值是
停用
GL_DITHER後,假設 implementation 採用下界,那十個位置就會全都選擇 ,這片區域的平均值會是啟用 dithering 後,實作可以讓其中六個位置選擇
,另外四個位置選擇 ,此時平均值為:因此每個位置取得的仍是 internal format 可以表示的數值,但一片區域的平均亮度可以更接近原始色彩
實際採用的圖樣與選擇方式由 OpenGL implementation 決定。 Dithering 選出的數值會交給這筆 fragment 後續的逐 sample 操作,真正修改 color attachment 的動作則由後面的 framebuffer write 完成

Blending:以 RGBA 算術合成兩筆顏色
下圖整理了 blending 路徑的完整資料流。 管線會先根據 color attachment 的 internal format 準備來源顏色與目的顏色,再執行 blending。 完成 blending 後,管線會根據目的格式找出與計算結果相鄰的可表示值,再由 dithering 選出後續要使用的結果:

為了用算術公式來合成「來源顏色」與「目的顏色」,Application 需要指定是否要啟用 blending、要用哪一種公式產生結果,以及需要加權時每筆顏色在公式中所占的比例。 OpenGL 會使用下列 blending state 來保存這些設定:
- Blending 的啟用狀態:Application 以
glEnable(GL_BLEND)啟用 blending - Blending equation:指定來源顏色與目的顏色要按照哪一種公式產生結果,由
glBlendEquation()設定GL_FUNC_ADD:來源的加權結果加上目的的加權結果GL_FUNC_SUBTRACT:來源的加權結果減去目的的加權結果GL_FUNC_REVERSE_SUBTRACT:目的的加權結果減去來源的加權結果GL_MIN:逐分量選擇來源顏色與目的顏色中的較小值,計算時不使用 blending factorsGL_MAX:逐分量選擇來源顏色與目的顏色中的較大值,計算時不使用 blending factors
- Blending factors:指定來源 RGB 與目的 RGB 各自要逐分量乘上的三個數值,以及來源 alpha 與目的 alpha 各自要乘上的單一數值。
glBlendFuncSeparate()的四個參數會分別選擇這四組 factors 的計算規則。 本文後面的範例會用到下列選項GL_ZERO:RGB factor 為 ,alpha factor 為GL_ONE:RGB factor 為 ,alpha factor 為GL_SRC_ALPHA:將來源顏色的 alpha 複製成 RGB factor 的三個分量,alpha factor 也使用來源 alphaGL_ONE_MINUS_SRC_ALPHA:將 來源顏色的 alpha 複製成 RGB factor 的三個分量,alpha factor 也使用 來源 alpha
Application 會透過這些 blending state 選定 equation 與四個 factor 的計算規則。 以 GL_FUNC_ADD 為例,RGB 與 alpha 的計算可以分別寫成:
管線會根據目的 color attachment 的 internal format,決定是否執行 blending,並將來源顏色與目的顏色準備成該格式所需的數值。 完成 blending 後,管線也會依同一個格式找出計算結果周圍的可表示值,再由 dithering 選出一個結果
常見的幾種 internal format 如下:
GL_RGBA8:- 來源顏色:fragment shader 使用浮點
vec4交出顏色。 管線會將各分量限制在 內,並以限制後的浮點值作為 blending 的來源顏色 - 目的顏色:管線會以
將 attachment 中的 8-bit 整數解讀成 內的浮點值 - Blending:來源顏色、目的顏色與 factors 都會限制在
內,再代入 blending equation - Blending 後的格式處理:管線會將計算結果限制在
內,再依 8-bit normalized 尺度找出相鄰的 可表示值 - Dithering:逐分量選出其中一個
,並將這組結果交給後續的逐 sample 操作
- 來源顏色:fragment shader 使用浮點
GL_RGBA16F:- 來源顏色:fragment shader 使用浮點
vec4交出顏色 - 目的顏色:管線會將 attachment 中的 16-bit floating-point 數值提供給 blending
- Blending:來源顏色、目的顏色與 factors 會以浮點值參與計算,負數與大於
的數值也能進入 blending equation - Blending 後的格式處理:管線會依 16-bit floating-point 格式找出每個計算結果周圍的可表示值
- Dithering:逐分量選出其中一個 16-bit floating-point 可表示值,並將結果交給後續的逐 sample 操作
- 來源顏色:fragment shader 使用浮點
GL_RGBA8UI:- 來源顏色:fragment shader 使用
uvec4這類 unsigned integer output 交出顏色 - 目的顏色:attachment 以 8-bit unsigned integer 保存各通道
- Blending:integer color attachment 會略過 blending equation,來源顏色接著進入儲存格式處理
- Blending 後的格式處理:每個來源分量本身就是整數格式使用的數值。 位於格式範圍內時,它就已經可以由 attachment 精確表示了
- Dithering:由於可表示下界與上界是同一個整數,因此來源值會直接交給後續的逐 sample 操作
- 來源顏色:fragment shader 使用
GL_SRGB8_ALPHA8:- 來源顏色:fragment shader 使用浮點
vec4交出顏色。 管線會將各分量限制在 內,並以限制後的浮點值執行 blending - 目的顏色:
- 啟用
GL_FRAMEBUFFER_SRGB:將 attachment 中的 sRGB 編碼值解碼成 linear RGB。 A 維持線性的 normalized 數值 - 停用
GL_FRAMEBUFFER_SRGB:直接以 解讀 attachment 中保存的 RGB 與 A
- 啟用
- Blending:
- 啟用
GL_FRAMEBUFFER_SRGB:將來源 RGB 視為 linear RGB,並與解碼後的目的 RGB 進行計算 - 停用
GL_FRAMEBUFFER_SRGB:直接以來源與目的的 normalized RGB 進行計算
- 啟用
- Blending 後的格式處理:
- 啟用
GL_FRAMEBUFFER_SRGB:將 blending 產生的 linear RGB 編碼成 sRGB,再依 8-bit normalized 尺度找出相鄰的可表示值 - 停用
GL_FRAMEBUFFER_SRGB:直接依 8-bit normalized 尺度找出 blending 所產生之 RGB 周圍的可表示值 - A 維持線性數值,並依 8-bit normalized 尺度找出相鄰的可表示值
- 啟用
- Dithering:逐分量選出 RGB 與 A 後續要使用的 8-bit normalized 可表示值
- 補充:sRGB attachment 保存的是非線性編碼後的 RGB,而 blending 需要在線性空間中進行算術運算
- Application 啟用
GL_FRAMEBUFFER_SRGB後,管線會先將 attachment 讀出的目的 RGB 解碼成 linear RGB,再與 fragment shader 交出的來源 RGB 進行 blending。 計算完成後,管線會將結果重新編碼成 sRGB,供 attachment 儲存
- Application 啟用
- 來源顏色:fragment shader 使用浮點
底下以 GL_RGBA8 color attachment 為例,完整追蹤 blending 前的輸入準備、blending 計算,以及 blending 後的格式處理與 dithering。 本例使用加法 blending equation,並分別設定了 RGB 與 alpha 的 factor 計算規則:
glDisable(GL_COLOR_LOGIC_OP);
glEnable(GL_BLEND);
glBlendEquation(GL_FUNC_ADD);
glEnable(GL_DITHER);
glBlendFuncSeparate(
GL_SRC_ALPHA,
GL_ONE,
GL_ONE,
GL_ONE_MINUS_SRC_ALPHA
);假設 fragment shader 交出的來源顏色是:
由於目標 attachment 使用 GL_RGBA8,因此管線會先將來源顏色的每個分量限制在
管線會以這組限制後的浮點值作為 blending 的來源顏色:
假設 color attachment 在同一個 sample 內保存的四個 8-bit 整數為 GL_RGBA8 會將每個整數除以
glBlendFuncSeparate() 的四個參數會選定 factors 的計算規則。 將來源 alpha
- 來源 RGB factor:
GL_SRC_ALPHA,使用來源 alpha,也就是 - 目的 RGB factor:
GL_ONE,使用 - 來源 alpha factor:
GL_ONE,使用 - 目的 alpha factor:
GL_ONE_MINUS_SRC_ALPHA,使用 來源 alpha,也就是
將這些 factors 代入 GL_FUNC_ADD 的計算公式後,可以得到:
因此 blending 算出的完整顏色是:
這筆 GL_RGBA8 的 normalized 範圍,因此管線會先將四個分量限制在
接著,管線會將每個分量乘上 GL_RGBA8 的 8-bit 尺度:
由於 R 與 B 已經是整數了,因此兩者的可表示下界與上界相同。 G 與 A 則由於位於兩個相鄰整數之間,因此各自具有兩個候選值:
| 分量 | 8-bit 尺度上的數值 | 可表示下界 | 可表示上界 |
|---|---|---|---|
| R | |||
| G | |||
| B | |||
| A |
假設 OpenGL implementation 根據目前 pixel 的 screen-space 位置,為 G 與 A 都選擇了上界,則 Dithering 會逐一得到下列結果:
- R:下界與上界都是
,因此選出 - G:從
與 中選出上界 - B:下界與上界都是
,因此選出 - A:從
與 中選出上界
所以這個 sample 經過 dithering 後的四個 8-bit 值為:
由於資料格式為 GL_RGBA8,所以後續在解讀時會再將四個整數會分別除以
Color logical operation:以位元運算組合兩筆顏色
下圖以 GL_RGBA8 color attachment 為例,整理 color logical operation 路徑的完整資料流。 管線會先依 8-bit normalized 的格式來找出來源顏色周圍的可表示值,再由 dithering 選出實際的來源 bits。 這些來源 bits 接著會與 attachment 中既有的目的 bits 一起進入 logical operation:

當 Application 要直接控制色彩表示中的 bits 時,可以啟用 GL_COLOR_LOGIC_OP,再用 glLogicOp() 指定來源顏色與目的顏色之間的位元運算。 常用的操作包括:
GL_AND:只有兩側都為 的 bits 會留下GL_OR:任一側為 的 bits 都會留下GL_XOR:兩側不同的 bits 會留下
Internal format 會決定來源顏色要如何轉成 bits,以及 color logical operation 是否會套用到該 attachment:
GL_RGBA8:- 來源顏色:fragment shader 使用浮點
vec4交出顏色。 管線會將各分量限制在 內,再依 8-bit normalized 尺度找出相鄰的可表示值 - Dithering:逐分量選出其中一個可表示值,這些值的 8-bit 表示就是 logical operation 使用的來源 bits
- 目的顏色:attachment 會為每個通道提供一組既有的 8-bit bits
- Color logical operation:管線會對來源與目的的四組 8-bit bits 分別執行
glLogicOp()指定的運算 - Operation result:位元運算會產生四組新的 8-bit bits,可以直接進入 color write mask 與後續寫入
- 來源顏色:fragment shader 使用浮點
GL_RGBA16F:- 來源顏色:fragment shader 使用浮點
vec4交出顏色 - Dithering:逐分量選出 16-bit floating-point 格式可以表示的來源值
- 目的顏色:attachment 以 16-bit floating-point 格式保存各通道
- Color logical operation:logical operation 在 floating-point color attachment 上不產生作用。 來源顏色會直接進入後續的寫入處理
- Operation result:完成格式處理的來源顏色會直接進入 color write mask 與後續寫入
- 來源顏色:fragment shader 使用浮點
GL_RGBA8UI:- 來源顏色:fragment shader 使用
uvec4這類 unsigned integer output 交出顏色 - Dithering:位於格式範圍內的來源整數已經可以精確表示,因此可表示下界與上界是同一個值
- 目的顏色:attachment 會為每個通道提供一組既有的 8-bit bits
- Color logical operation:管線會對來源與目的的整數 bits 分別執行
glLogicOp()指定的運算 - Operation result:位元運算結果可以直接進入 color write mask 與後續寫入
- 來源顏色:fragment shader 使用
GL_SRGB8_ALPHA8:- 來源顏色:
- 啟用
GL_FRAMEBUFFER_SRGB:將 fragment shader 交出的來源 RGB 視為 linear RGB,編碼成 sRGB,再依 8-bit normalized 尺度找出相鄰的可表示值 - 停用
GL_FRAMEBUFFER_SRGB:直接依 8-bit normalized 尺度找出來源 RGB 周圍的可表示值 - A 維持線性數值,並依 8-bit normalized 尺度找出相鄰的可表示值
- 啟用
- Dithering:逐分量選出 RGB 與 A 的 8-bit normalized 來源 bits
- 目的顏色:
- 啟用
GL_FRAMEBUFFER_SRGB:目的 bits 不會進入位元運算 - 停用
GL_FRAMEBUFFER_SRGB:attachment 中既有的四組 8-bit bits 會作為 logical operation 的目的值
- 啟用
- Color logical operation:
- 啟用
GL_FRAMEBUFFER_SRGB:logical operation 在這個 attachment 上不產生作用,完成格式轉換的來源 bits 會直接進入 color write mask - 停用
GL_FRAMEBUFFER_SRGB:對來源與目的的 fixed-point bits 執行glLogicOp()指定的運算
- 啟用
- Operation result:
- 啟用
GL_FRAMEBUFFER_SRGB:完成格式處理的來源 bits 會直接進入 color write mask 與後續寫入 - 停用
GL_FRAMEBUFFER_SRGB:logical operation 產生的四組 8-bit bits 會進入 color write mask 與後續寫入
- 啟用
- 來源顏色:
以下使用 GL_RGBA8 color attachment 示範完整的 GL_XOR 運算。 GL_RGBA8 會用 8-bit unsigned normalized fixed-point 格式保存 R、G、B 與 A 的每個通道
Application 會先啟用 color logical operation,並指定 XOR:
glDisable(GL_BLEND);
glEnable(GL_COLOR_LOGIC_OP);
glLogicOp(GL_XOR);
glEnable(GL_DITHER);假設 fragment shader 交出的浮點來源顏色為:
由於目標 attachment 使用的是 GL_RGBA8,所以管線會先將各分量限制在
接著,管線會將四個分量乘上 GL_RGBA8 的 8-bit 尺度:
其中 G 與 B 已經是格式可以精確表示的整數了,但由於 R 與 A 位於兩個相鄰整數之間,因此 dithering 需要為它們分別選出一個 8-bit 值:
| 分量 | 8-bit 尺度上的數值 | 可表示下界 | 可表示上界 |
|---|---|---|---|
| R | |||
| G | |||
| B | |||
| A |
假設 OpenGL implementation 根據目前 pixel 的 screen-space 位置,為 R 選擇了下界,並為 A 選擇了上界,則 Dithering 會逐分量得到:
- R:從
與 中選出下界 - G:下界與上界都是
,因此選出 - B:下界與上界都是
,因此選出 - A:從
與 中選出上界
因此,這個 sample 交給 logical operation 的 normalized 來源顏色為:
用
假設 color attachment 在同一個 sample 中已經保存了下列四個 8-bit 整數,那這組數值就是 logical operation 的目的 bits:
管線會分別對四個通道中相同位置的 bits 執行 XOR。 以 R 通道為例,兩個輸入值可以先展開成二進位:
XOR 會讓兩個輸入 bit 不同的位置得到
其餘三個通道會執行相同的逐 bit 運算:
將四個通道的輸出合在一起後,color logical operation 交出的結果為:
由於 color logical operation 會以兩組 8-bit 資料作為輸入,並維持每個通道的 8-bit 寬度,所以運算結果一定會落在 0x00 到 0xFF 之間,也就是 GL_RGBA8 可以表示的範圍內,因此其輸出可以直接成為下一節 color write mask 的輸入,不需要再做一次轉換
由於資料格式為 GL_RGBA8,所以後續在解讀時會再將四個整數會分別除以
Write masks:控制各 attachments 的寫入範圍
前面我們藉由 stencil operation 算出了新的 stencil 值,接著用 depth test 決定了候選深度是否具有寫入資格,並且還用色彩操作算出了新的顏色。 在 per-fragment operations 將這些結果交給後面的 framebuffer write 之前,管線會再套用上對應的 write mask,決定結果中的哪些部分可以用來更新 attachment
Write masks 是 Application 在提交 draw call 前設定的繪製狀態:
- Color write mask 用來控制 color attachment 的 RGBA 通道
glColorMask(red, green, blue, alpha):red:控制 color attachment 的 R 通道green:控制 color attachment 的 G 通道blue:控制 color attachment 的 B 通道alpha:控制 color attachment 的 A 通道
- depth write mask 用來控制候選深度是否能寫入 depth attachment
glDepthMask(flag):控制通過 depth test 的候選深度能否寫入 depth attachment
- stencil write mask 用來控制新 stencil 值中的哪些 bits 可以寫入 stencil attachment
glStencilMask(mask):以每個 bit 控制 stencil operation 的結果能否寫入 stencil attachment
這讓同一筆 draw call 能夠只更新 Application 指定的部分,其餘資料會保留原值。 例如,假設某個 sample 經過 blending 後得到了下列色彩結果,而 color attachment 在同一個 sample 內還保存了另一筆既有顏色:
如果 Application 使用了下列 color write mask,只允許更新 R 與 B 通道:
glColorMask(GL_TRUE, GL_FALSE, GL_TRUE, GL_FALSE);則管線會從 blending 的結果中取出 R 與 B,G 與 A 則沿用 attachment 的既有值:
Depth write mask:決定候選深度是否寫入
由於一個 sample 在 depth attachment 中只會保存一筆深度,因此 depth write mask 只需要決定「允許寫入」或「保留既有值」即可。 假設目前具有下列資料與設定:
- 候選深度:
- Depth attachment 的既有深度:
- Depth comparison function:
GL_LESS - Application 設定的 depth write mask:
glDepthMask(GL_FALSE)
Depth test 會先比較候選深度與既有深度:
比較成立,所以候選深度
glDepthMask(GL_TRUE):允許候選深度寫入,depth attachment 可以由 更新成glDepthMask(GL_FALSE):禁止候選深度寫入,depth attachment 保留既有的
由於本例使用 GL_FALSE,所以 framebuffer write 不會修改這個 sample 的深度,最後保存的值仍是
Stencil write mask:決定新 stencil 值中的哪些 bits 可以寫入
Stencil write mask 會逐 bit 選擇 stencil attachment 是要保留既有值,還是採用 stencil operation 算出的新值。 假設目前具有下列資料與設定:
- 既有 stencil 值:
- Stencil operation result:
- Application 設定的 stencil write mask:
glStencilMask(0x0F),所以
將三筆 8-bit 資料展開後,可以得到:
Write mask 中的每個 bit 都會決定結果中同一個 bit 的來源:
- Mask bit 為
:沿用 中對應的 bit - Mask bit 為
:採用 中對應的 bit
由於 0x0F 的高四個 bits 都是
在 8-bit 範圍內,將 0x0F 的每個 bit 逐一取反,就會從 0xF0。 管線會先使用 0xF0 取出既有值中需要保留的高四個 bits:
接著使用 write mask 0x0F 取出 operation result 中允許寫入的低四個 bits:
最後再對兩部分執行逐位元 OR,組成準備交給 framebuffer write 的 stencil 值:
因此,新值的高四個 bits 來自既有 stencil 值 0xA2,低四個 bits 則來自 operation result 0x35。 Stencil write mask 最後交出的結果是 0xA5,framebuffer write 會使用這筆結果來更新 stencil attachment
framebuffer write:保存最後的色彩與深度
前面的 Per-fragment operations 已經逐 sample 完成了測試、色彩操作、格式轉換與 write-mask 的選擇,因此目前我們手上有各個 attachment 最後準備寫入的值。 為了將這些值保存到實際的影像中,管線還需要知道本次 draw call 使用的是哪一個 framebuffer、每筆結果應前往哪一個 attachment,以及該 attachment 連接到了哪一張影像。 Framebuffer write 會沿著這些連接關係找到目標影像,再更新其中對應 sample 的內容
OpenGL context 會分別保存目前選用的 draw framebuffer 與 read framebuffer。 Draw framebuffer 會接收 rendering pipeline 產生的色彩、深度與 stencil 結果,本節的 framebuffer write 使用的就是這個目標。 Read framebuffer 則提供了 glReadPixels() 或 framebuffer blit 等讀取操作的來源。 Application 可以讓兩個角色指向同一個 framebuffer,也可以分別指定不同的 framebuffer
當 Application 將名稱 GL_DRAW_FRAMEBUFFER 時,draw call 會直接使用視窗系統提供的 default framebuffer。 而將 Application 建立的非零名稱綁定到 GL_DRAW_FRAMEBUFFER 時,draw call 則會使用 framebuffer object(FBO)。 FBO 適合保存離屏繪製與 render-to-texture 所需的連接狀態,像是「先將畫面繪製到一張紋理影像,再於後續 draw call 中讀取該影像」這種操作
Framebuffer attachment point 是影像與繪製管線之間的連接位置。 FBO 會保存每個 attachment point 目前連接哪張影像,像素與 samples 的實際內容則保存連接到的 texture image 或 renderbuffer image 中。 因此,FBO 可以視為一組「attachment point 到影像」的連接狀態
一個 FBO 具有下列 attachment points:
- Color attachment points:以
GL_COLOR_ATTACHMENT0、GL_COLOR_ATTACHMENT1等值指定。 OpenGL 標頭預先定義了從GL_COLOR_ATTACHMENT0到GL_COLOR_ATTACHMENT31的名稱,Application 可以使用的數量 則由 implementation 決定- OpenGL 4.6 保證
至少為 - Application 可以透過
GL_MAX_COLOR_ATTACHMENTS查詢 ,可用編號為 到 - 每個 attachment point 可以維持空白,或連接一張 color image
- OpenGL 4.6 保證
- Depth attachment point:以
GL_DEPTH_ATTACHMENT指定,用來連接一張保存深度的影像 - Stencil attachment point:以
GL_STENCIL_ATTACHMENT指定,用來連接一張保存 stencil 整數的影像 - Depth-stencil 連接方式:
GL_DEPTH_STENCIL_ATTACHMENT能一次設定兩個 attachment points,會讓同一張 packed depth-stencil image 同時連接到 depth 與 stencil attachment points
例如,延遲著色使用的 FBO 可以將最終色彩、法線與材質參數分別連接到 GL_COLOR_ATTACHMENT0、GL_COLOR_ATTACHMENT1 與 GL_COLOR_ATTACHMENT2,並另外連接一張 depth image。 一般的離屏 FBO 也可以只連接 GL_COLOR_ATTACHMENT0 與一張 depth image,將其餘 attachment points 維持空白
GL_COLOR_ATTACHMENT0 在 OpenGL API 中是一個預先定義的值,用來選取第 color[0]:
framebuffer object
├─ GL_COLOR_ATTACHMENT0 → color[0] → image 或空白
├─ GL_COLOR_ATTACHMENT1 → color[1] → image 或空白
├─ ...
├─ GL_DEPTH_ATTACHMENT → depth → image 或空白
└─ GL_STENCIL_ATTACHMENT → stencil → image 或空白FBO 的 attachment point 可以連接以下兩種 Application 建立的影像:
- Texture image:texture object 中的某個 mipmap 層級。 Application 可以依 texture target 與連接 API,選擇該層級中的單一 cube-map face、單一 array layer,或將整個層級連成 layered attachment。 Framebuffer write 可以更新這張影像,後續 draw call 也能讓 shader 對它進行紋理取樣
- Renderbuffer image:renderbuffer object 提供的一張可繪製影像。 Application 會指定它的 internal format、尺寸與 sample 數量,並將它用作 color、depth 或 stencil 儲存空間
Default framebuffer 的影像則由視窗系統建立並交給 OpenGL context。 Application 會在建立 drawable 時要求所需的 color、depth 與 stencil buffer 組態,實際可用的 buffers 由視窗系統選出的 framebuffer configuration 決定。 繪製期間的 draw-buffer 與 read-buffer state 會再選擇要使用的 color buffers,視窗系統則負責這些影像與畫面呈現之間的銜接
以目前 workspace 中的 Mesa 原始程式碼為例,Mesa 使用 C 的 struct 保存上述關係。 src/mesa/main/mtypes.h 中與 attachment 連接最相關的欄位如下。 程式碼中的省略號代表此處暫時略過的其他 framebuffer 狀態:
struct gl_renderbuffer_attachment
{
GLenum16 Type;
GLboolean Complete;
struct gl_renderbuffer *Renderbuffer;
struct gl_texture_object *Texture;
GLuint TextureLevel;
GLsizei NumSamples;
GLuint CubeMapFace;
GLuint Zoffset;
GLboolean Layered;
GLsizei NumViews;
};
struct gl_framebuffer
{
/* ... */
GLuint Name;
/* ... */
struct gl_renderbuffer_attachment Attachment[BUFFER_COUNT];
GLenum16 ColorDrawBuffer[MAX_DRAW_BUFFERS];
GLenum16 ColorReadBuffer;
/* ... */
};Attachment[] 中的每個元素都是一筆描述 attachment point 連接狀態的資料。 其中幾個主要欄位的用途如下:
Type:指出該 attachment point 目前連接 texture、renderbuffer 或空白Texture、TextureLevel等欄位:指定 texture object 中的確切 imageRenderbuffer:提供 Mesa 繪製時使用的 renderbuffer reference。 Texture image 作為 attachment 時,Mesa 也會在這裡建立統一的 renderable view
對 Application 建立的 FBO 而言,ColorDrawBuffer[] 會保存 fragment color outputs 到 color attachment points 的路由。 Mesa 也使用同一個欄位保存 default framebuffer 的 color-buffer selection,例如 GL_FRONT_LEFT 或 GL_BACK_LEFT。 這項路由狀態與 Attachment[] 保存的影像連接屬於不同層次
這裡的 GL_MAX_COLOR_ATTACHMENTS 與 GL_MAX_DRAW_BUFFERS 會限制不同的數量。 GL_MAX_COLOR_ATTACHMENTS 表示一個 FBO 可以提供多少個 color attachment points。 GL_MAX_DRAW_BUFFERS 則表示一次 draw call 最多能將多少筆 fragment color outputs 同時路由到 color buffers。 FBO 可以預先連接多張 color images,再由每次 draw call 使用的 draw-buffer state 選出這次要更新的部分
Mesa 的 get_attachment() 會將 OpenGL API 收到的列舉值轉成 Attachment[] 的索引。 第 Attachment[BUFFER_COLOR0 + i],depth 與 stencil 則分別對應到 Attachment[BUFFER_DEPTH] 與 Attachment[BUFFER_STENCIL]。 這項映射顯示了 GL_COLOR_ATTACHMENT0 等列舉值如何在 implementation 內選到一筆 attachment descriptor
目前綁定的 draw 與 read framebuffer 則保存在 OpenGL context 中。 Mesa 的 gl_context 會分別持有兩個指標:
struct gl_framebuffer *DrawBuffer; /* buffer for writing */
struct gl_framebuffer *ReadBuffer; /* buffer for reading */Application 建立 FBO 後,會把已配置儲存空間的影像連接到需要使用的 attachment points,再設定 fragment color outputs 的路由。 例如下列程式會建立一個 FBO,將一張 GL_RGBA8 texture image 連接到 color attachment 0,並將一張 GL_DEPTH32F_STENCIL8 renderbuffer image 同時連接到 depth 與 stencil attachment points:
GLuint framebuffer;
glCreateFramebuffers(1, &framebuffer);
glNamedFramebufferTexture(
framebuffer,
GL_COLOR_ATTACHMENT0,
color_texture,
0
);
glNamedFramebufferRenderbuffer(
framebuffer,
GL_DEPTH_STENCIL_ATTACHMENT,
GL_RENDERBUFFER,
depth_stencil_renderbuffer
);
const GLenum draw_buffers[] = {
GL_COLOR_ATTACHMENT0
};
glNamedFramebufferDrawBuffers(framebuffer, 1, draw_buffers);glNamedFramebufferTexture() 的最後一個參數 color_texture 的 mipmap level 0。 glNamedFramebufferRenderbuffer() 則會記錄 depth_stencil_renderbuffer 的連接。 glNamedFramebufferDrawBuffers() 中陣列的第 GL_COLOR_ATTACHMENT0
FBO 的 attachment 組態還需要符合 framebuffer completeness rules,例如被連接的影像必須使用可供該 attachment 繪製的格式,multisample 影像的 sample 數量也必須相容。 Application 可以在開始繪製前檢查 FBO:
GLenum status = glCheckNamedFramebufferStatus(
framebuffer,
GL_DRAW_FRAMEBUFFER
);
if (status == GL_FRAMEBUFFER_COMPLETE) {
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, framebuffer);
}當這個 FBO 成為 draw framebuffer 後,framebuffer write 會沿著下列路徑找到每筆結果的儲存位置:
- Fragment shader 的 output location 0 產生候選顏色
- Draw-buffer state 將 output location 0 對應到
GL_COLOR_ATTACHMENT0 - FBO 的
GL_COLOR_ATTACHMENT0找到color_texture的 mipmap level 0 - Depth test 接受的深度與 stencil operation 產生的整數,分別經由 depth 與 stencil attachment points 找到
depth_stencil_renderbuffer - Framebuffer write 更新各張影像中目標像素與 sample 的內容
假設這是一個 single-sample FBO,現在要更新像素
- Color attachment 0:
- 目標影像:
color_texture的 mipmap level 0 - 既有 RGBA 位元組:
- 獲准寫入的 RGBA 位元組:
- 目標影像:
- Depth attachment:
- 目標影像:
depth_stencil_renderbuffer的 depth component - 既有深度:
- 獲准寫入的深度:
- 目標影像:
- Stencil attachment:
- 目標影像:
depth_stencil_renderbuffer的 stencil component - 既有 stencil 值:
0xA2 - 獲准寫入的 stencil 值:
0xA5
- 目標影像:
Framebuffer write 會按照 FBO 中保存的連接狀態,更新兩張被連接影像在像素
Multisample color attachment 的 resolve
Multisample color attachment 會在同一個像素保存多筆 sample 色彩,但顯示或後續使用的 single-sample 影像每個像素只能保存一筆色彩。 為了讓兩種儲存方式銜接,OpenGL 會將同一個像素的多筆 sample 色彩組合成一筆像素色彩。 這項轉換稱為 resolve
- Multisample default framebuffer:OpenGL 會在輸出到 single-sample 的 default color buffer 時完成 resolve
- Multisample FBO:Application 可以呼叫
glBlitFramebuffer(),把 multisample color attachment resolve 到 single-sample 目標影像 - Normalized 或 floating-point color attachments:各 sample 的具體組合方式由 OpenGL implementation 決定
例如一個
Resolve 會在 single-sample 目標影像的同一個像素只寫入一筆
完成 framebuffer write,以及需要時執行的 multisample resolve 後,draw call 產生的色彩、深度與 stencil 結果便已保存到對應的目標影像,rendering pipeline 對這批片段的處理也到此完成
