OpenGL學習腳印:幾何著色器(geometry shader)
寫在前面
一直以來我們使用了頂點著色器(vertex shader)和片元著色器(fragment shader),實際上OpenGL還提供了一個可選的幾何著色器(geometry shader)。幾何著色器位於頂點和片元著色器之間,如果沒有使用時,則頂點著色器輸出到片元著色器,在使用幾何著色器後,頂點著色器輸出組成一個基礎圖元的頂點資訊到幾何著色器,經過幾何著色器處理後,再輸出到片元著色器。幾何著色器能夠產生0個以上的基礎圖元(primitive),它能起到一定的裁剪作用、同時也能產生比頂點著色器輸入更多的基礎圖元。本節將學習幾何著色器的基本用法,示例程式碼均可以從我的github下載
幾何著色器的基本概念
幾何著色器在啟用後,它將獲得頂點著色器以組成一個基礎圖元為一組的頂點輸入,通過對輸入的頂點進行處理,幾何著色器將決定輸出的圖元型別和個數。當輸出的圖元減少或者不輸出時,實際上起到了裁剪圖形的作用,當輸出的圖元型別改變或者輸出更多圖元時起到了產生和改變圖元的作用。
要啟用幾何著色器,我們需要在之前的頂點和片元著色器基礎上,將幾何著色器GL_GEOMETRY_SHADER連結到著色器程式上,在程式碼上沒有太大改動,你可以從我的github檢視這個標頭檔案。在程式中,我們建立一個包含上述3中著色器的程式:
// 準備著色器程式
Shader shader("scene.vertex" , "scene.frag", "scene.gs");
一個直通的幾何著色器
首先從一個基本的直通幾何著色器來了解(以下簡稱gs)。這裡我們繪製4個點,在gs中將這4個點的位置、大小資訊原樣輸出到片元著色器。
頂點著色器如下:
#version 330 core
layout(location = 0) in vec2 position;
void main()
{
gl_Position = vec4(position, 0.5, 1.0);
gl_PointSize = 2.8; // 指定點大小 需要在主程式中開啟 glEnable(GL_PROGRAM_POINT_SIZE);
}
幾何著色器:
#version 330 core
layout(points) in ;
layout(points, max_vertices = 1) out;
// 直通的幾何著色器 原樣輸出
void main()
{
gl_Position = gl_in[0].gl_Position;
gl_PointSize = gl_in[0].gl_PointSize;
EmitVertex();
EndPrimitive();
}
片元著色器:
#version 330 core
out vec4 color;
void main()
{
color = vec4(0.0, 1.0, 0.0, 1.0);
}
觀察發現,在幾何著色器中in和out分別指示了輸入的圖元,和輸出的圖元等引數。這裡填寫的是型別points表示輸出點。從頂點著色器輸入的圖元型別,對映到幾何著色器的輸入模式如下表所示(參考自OpenGL SuperBible: Comprehensive Tutorial and Reference, 6th Edition):
幾何著色器輸入模式 | 頂點著色器輸入 | 頂點最少個數 |
---|---|---|
points | GL_POINTS | 1 |
lines | GL_LINES, GL_LINE_LOOP, GL_LINE_STRIP | 2 |
triangles | GL_TRIANGLES, GL_TRIANGLE_FAN, GL_TRIANGLE_STRIP | 3 |
lines_adjacency | GL_LINES_ADJACENCY,GL_LINE_STRIP_ADJACENCY | 4 |
triangles_adjacency | GL_TRIANGLES_ADJACENCY,GL_TRIANGLE_STRIP_ADJACENCY | 6 |
同時從幾何著色器輸出模式,則有3種:
- points
- line_strip
- triangle_strip
這3種模式基本包含了所有繪圖型別,例如triangle_strip就包含了triangle這種特例。max_vertices表示從幾何著色器最多輸出頂點數目,如果超過設定的這個數目,OpenGL不會輸出多餘的頂點。
在上述幾何著色器中EmitVertex表示輸出一個頂點,而EndPrimitive表示結束一個圖元的輸出,這是一對命令。gl_in是內建輸入變數,定義為:
in gl_PerVertex
{
vec4 gl_Position;
float gl_PointSize;
float gl_ClipDistance[];
} gl_in[];
這是一個interface block,對這一概念不熟悉的可以回過頭去檢視uniform block這一節的內容。定義輸入block為一個數組,因為輸入的頂點要組成一個圖元,因此通常不止一個。上面的例子中,使用一個頂點,因此我們使用gl_in[0]來獲取這個頂點的資訊。幾何著色器中內建了一個輸出變數,定義如下:
out gl_PerVertex
{
vec4 gl_Position;
float gl_PointSize;
float gl_ClipDistance[];
};
這是一個沒有使用名字的interface block,因此在著色器中可以直接引用變數名字。
上面的輸入:
layout(points) in ;
表示從頂點著色器輸入GL_POINTS圖元。
輸出語句:
layout(points, max_vertices = 1) out;
表示從幾何著色器輸出points,因為是一個點,因此max_vertices選項填寫1。
在主程式中,我們指定頂點資料如下:
// 指定頂點屬性資料 頂點位置
GLfloat points[] = {
-0.5f, 0.5f, // 左上
0.5f, 0.5f, // 右上
0.5f, -0.5f, // 右下
-0.5f, -0.5f // 左下
};
使用命令:
glDrawArrays(GL_POINTS, 0, 4);
繪圖後得到4個點的輸出,效果如下圖所示:
從點到直線
下面我們在著色器中通過將輸入的一個點,產生兩個發生了少許偏移的頂點,而繪製直線,著色器改為:
#version 330 core
layout(points) in ;
layout(line_strip, max_vertices = 2) out; // 注意輸出型別
// 通過點產生直線輸出
void main()
{
gl_Position = gl_in[0].gl_Position
+ vec4(-0.1, 0.0, 0.0, 0.0);
gl_PointSize = gl_in[0].gl_PointSize;
EmitVertex();
gl_Position = gl_in[0].gl_Position + vec4(0.1, 0.0, 0.0, 0.0);
EmitVertex();
EndPrimitive();
}
得到的效果如下圖所示:
點變為房子圖案
上面產生了4條直線,我們繼續產生一個triangle_strip輸出,計算一個簡單的房子圖案的輸出如下:
#version 330 core
layout(points) in ;
layout(triangle_strip, max_vertices = 5) out; // 注意輸出型別
void makeHouse(vec4 position)
{
gl_Position = position + vec4(-0.2f, -0.2f, 0.0f, 0.0f); // 左下角
EmitVertex();
gl_Position = position + vec4(0.2f, -0.2f, 0.0f, 0.0f); // 右下角
EmitVertex();
gl_Position = position + vec4(-0.2f, 0.2f, 0.0f, 0.0f); // 左上角
EmitVertex();
gl_Position = position + vec4(0.2f, 0.2f, 0.0f, 0.0f); // 右上角
EmitVertex();
gl_Position = position + vec4(0.0f, 0.4f, 0.0f, 0.0f); // 頂部
EmitVertex();
EndPrimitive();
}
// 輸出房子樣式三角形帶
void main()
{
gl_PointSize = gl_in[0].gl_PointSize;
makeHouse(gl_in[0].gl_Position);
}
採用線框模式繪製得到如下圖所示效果:
在集合著色器中,我們仍然可以輸出其他變數,例如顏色。我們調整下頂點屬性資料,包含顏色屬性,資料如下:
// 指定頂點屬性資料 頂點位置 顏色
GLfloat points[] = {
-0.5f, 0.5f, 1.0f, 0.0f, 0.0f, // 左上
0.5f, 0.5f, 0.0f, 1.0f, 0.0f, // 右上
0.5f, -0.5f, 0.0f, 0.0f, 1.0f, // 右下
-0.5f, -0.5f, 1.0f, 1.0f, 0.0f // 左下
};
在頂點著色器中向幾何著色器輸入顏色,更改為:
#version 330 core
layout(location = 0) in vec2 position;
layout(location = 1) in vec3 color;
// 定義輸出interface block
out VS_OUT
{
vec3 vertColor;
}vs_out;
void main()
{
gl_Position = vec4(position, 0.5, 1.0);
gl_PointSize = 2.8;
vs_out.vertColor = color;
}
在幾何著色器中接受顏色輸入,並調整後輸出到片元著色器:
#version 330 core
layout(points) in ;
layout(triangle_strip, max_vertices = 5) out; // 注意輸出型別
// 定義輸入interface block
in VS_OUT
{
vec3 vertColor;
}gs_in[];
out vec3 fcolor;
void makeHouse(vec4 position)
{
fcolor = gs_in[0].vertColor;
gl_PointSize = gl_in[0].gl_PointSize;
gl_Position = position + vec4(-0.2f, -0.2f, 0.0f, 0.0f); // 左下角
EmitVertex();
gl_Position = position + vec4(0.2f, -0.2f, 0.0f, 0.0f); // 右下角
EmitVertex();
gl_Position = position + vec4(-0.2f, 0.2f, 0.0f, 0.0f); // 左上角
EmitVertex();
gl_Position = position + vec4(0.2f, 0.2f, 0.0f, 0.0f); // 右上角
EmitVertex();
gl_Position = position + vec4(0.0f, 0.4f, 0.0f, 0.0f); // 頂部
fcolor = vec3(1.0f, 1.0f, 1.0f); // 這裡改變頂部顏色
EmitVertex();
EndPrimitive();
}
// 輸出房子樣式三角形帶
void main()
{
makeHouse(gl_in[0].gl_Position);
}
繪製得到的房子圖案如下所示:
這裡我們可以發現,從4個點的輸入,通過幾何著色器我們構造了4個房子圖案,比原始輸入產生了更多的圖元,在某些場景中,這種方式能夠節省CPU發往GPU的資料,從而節省頻寬。
構造爆炸效果
幾何著色器還能夠產生很多有趣的效果,這裡動手實踐一個爆炸的效果。實現的基本思路是: 將模型的每個三角形,沿著這個三角形的法向量,隨著時間變動,偏移一定的量offset,這個
在結合著色器中,首先我們需要計算法向量如下:
// 從輸入的3個頂點 計算法向量
vec3 getNormal(vec4 pos0, vec4 pos1, vec4 pos2)
{
vec3 a = vec3(pos0) - vec3(pos1);
vec3 b = vec3(pos2) - vec3(pos1);
return normalize(cross(a, b));
}
然後需要對輸入的頂點,沿著法向量方向,偏移一定的量:
// 計算偏移後的三角形頂點
void explode()
{
vec3 normal = getNormal(gl_in[0].gl_Position, gl_in[1].gl_Position, gl_in[2].gl_Position);
float magnitude = ((sin(time) + 1) / 2.0f) * 2.0f; // 使位移偏量保持在[0, 2.0f]範圍內
vec4 offset = vec4(normal * magnitude, 0.0f);
gl_Position = gl_in[0].gl_Position + offset;
TextCoord = gs_in[0].TextCoord; // 頂點和紋理座標每個頂點都不相同
EmitVertex();
gl_Position = gl_in[1].gl_Position + offset;
TextCoord = gs_in[1].TextCoord;
EmitVertex();
gl_Position = gl_in[2].gl_Position + offset;
TextCoord = gs_in[2].TextCoord;
EmitVertex();
EndPrimitive();
}
在主程式中,設定time的uniform變數:
glUniform1f(glGetUniformLocation(shader.programId, "time"), glfwGetTime());
這樣隨著時間變動,我們的模型的三角形頂點將發生位移,而且這個位移是向外的,因此模擬出了爆炸效果,如下圖所示:
繪製法向量
另外一個有用的技巧是,通過幾何著色器將模型的法向量渲染出來,這樣能夠觀察法向量是否正確,從而排查一些由於法向量指定、計算錯誤而導致的難以除錯的錯誤,例如在光照計算中的法向量。
繪製法向量基本思路是: 繪製兩遍,第一遍,用正常著色器渲染模型;第二遍,用包含了產生代表法向量方向直線的著色器再次繪製模型,這次只輸出這些表示法向量的直線。在繪製代表法向量的直線時, 首先通過頂點著色器輸入法向量,這個法向量需要同gl_Position一樣在裁剪座標系下。同時在幾何著色器中,利用輸入的法向量,為每個三角形的頂點,繪製一個直線表示這個法向量。
計算模型的法向量到裁剪座標系,需要一些技巧,在頂點著色器中實現為:
// 定義輸出interface block
out VS_OUT
{
vec3 normal;
}vs_out;
void main()
{
gl_Position = projection * view * model * vec4(position, 1.0);
// 注意這裡需要向幾何著色器 輸出裁剪座標系下(clip space)法向量
// 不是世界座標系或者相機座標系下的法向量
mat3 normalMatrix = mat3(transpose(inverse(view * model)));
vs_out.normal = normalize( vec3( projection * vec4(normalMatrix * normal, 1.0) ) ); // 注意再次使用normalize
}
注意上面程式碼中,最後一行的normalize需要再次呼叫的,否則計算出錯誤的法向量。 如果對於計算法向量不熟悉的話,可以回過頭去檢視光照計算裡面的法向量的轉換。
在幾何著色器中,根據輸入的法向量,繪製代表法向量的直線:
#version 330 core
layout(triangles) in ; // 輸入三角形
layout(line_strip, max_vertices = 6) out; // 輸出3個代表法向量的直線
// 定義輸入interface block
in VS_OUT
{
vec3 normal;
}gs_in[];
float magnitude = 0.1f;
// 為指定索引的頂點產生代表法向量的直線
void generateNormalLine(int index)
{
gl_Position = gl_in[index].gl_Position;
EmitVertex();
vec4 offset = vec4(gs_in[index].normal * magnitude, 0.0f);
gl_Position = gl_in[index].gl_Position + offset;
EmitVertex();
EndPrimitive();
}
// 輸出代表法向量的直線
void main()
{
generateNormalLine(0);
generateNormalLine(1);
generateNormalLine(2);
}
經過兩次渲染,最終我們得到的效果如下圖所示:
這個效果可以用來實現模型的毛髮等效果,看起來就像是身上長了毛髮的效果。
值得注意的是,在頂點著色器中計算裁剪座標系中的法向量時,最後一定要再次使用normalize函式,否則計算出的法向量不正確,而導致錯誤的效果,如下圖所示:
最後的說明
本節介紹了幾何著色器的使用,以及基於此實現的一些特效。實際上還有其他的特效和應用,感興趣地可以自行參考GLSL Geometry Shaders這個非常經典的文件。