本文是OpenGL 4。0 Shading Language Cookbook的學習筆記。
通常我們透過在模型邊緣和褶皺處繪製輪廓線來實現卡通或手繪效果。在本文,我們將討論如何使用幾何著色器來產生額外的輪廓線。這裡,我們使用四邊形來近似輪廓線。
下圖顯示了使用幾何著色器生成的黑色輪廓線。這些輪廓線由較小的四邊形產生。
本文介紹的這一技術來自Philip Rideout的部落格(prideout。net/blog/?p=54)。他的實現使用了兩遍處理,並且包含了很多最佳化,比如反走樣和自定義深度測試(透過g-buffers)。我們的目的是為了演示幾何著色器的特性,為了保持簡單易懂,我們的實現只進行了一遍處理,且沒有進行反走樣和自定義深度測試。如果你對這些附加處理很感興趣,可以閱讀Philip的部落格。
幾何著色器可以為輸入的圖元附加其它資訊,同時還可以訪問圖元的鄰接頂點資訊。下面是訪問鄰接頂點的一些常量定義:
GL_LINES_ADJACENCY:線段以及相鄰頂點(4個頂點)。
GL_LINE_STRIP_ADJACENCY:連線以及相鄰頂點(對於n條線,有n+3個頂點)。
GL_TRIANGLES_ADJACENCY:三角形以及相鄰三角形(每個圖元有6個頂點)。
GL_TRIANGLES_STRIP_ADJACENCY:三角形帶以及相鄰三角形(對於n個三角形,有2(n+2)個頂點)。
對於每個常量的詳細說明,可以參考OpenGL官方文件。在本文,我們GL_TRIANGLES_ADJACENCY模式提供網格中的鄰接三角形的資訊。在這個模式下,每個圖元包含6個頂點。下圖顯示了這些頂點的位置:
圖中實線表示三角形本身,虛線表示鄰接三角形。第1個,第3個和第5個頂點組成了三角形本身。第2個,第4個和第6個組成了鄰接三角形。
網格資料通常不是以這種形式組織,所以,我們需要對我們的網格進行預處理使它變成上面的形式。通常,這意味著我們需要把索引陣列擴充套件為原來的兩倍。位置,法線和紋理座標陣列保持不變。
我們透過鄰接三角形來判定一條邊是否使褶皺的一部分。我們假定一條邊是褶皺,如果這個三角形是正面的,但對應的鄰接三角形不是正面。
我們透過計算三角形的法線(使用叉積運算)判斷一個三角形是正面還是反面。在剪下空間下,法線的z座標如果為正那麼三角形為正面。所以,我們只需要計算法線的z座標就可以。對於一個頂點為A,B,C的三角形,法線的z座標可以用下面的式子計算:
確定一條邊為褶皺後,幾何著色器會產生一個四邊形來覆蓋這條褶皺邊。這些四邊形放在一起,就組成上圖中的褶皺部分。生成完所有的四邊形後,幾何著色器會輸出原始的三角形。
為了在一遍處理中繪製網格和輪廓線,我們添加了一個輸出變數,用於在片段著色器中判斷圖元是網格還是我們生成的四邊形,然後使用對應的著色方法進行著色。
實現
之前提到,我們需要擴充套件索引陣列來存放附加的鄰接三角形資訊。我們可以透過查詢網格中的公共邊來完成擴充套件。由於篇幅限制,這裡就不詳細討論具體實現,前面提到的部落格有如何實現它的具體說明。本文的程式碼中包含了一個簡單的方法來實現它(但不夠高效)。
本例還使用了下面這些Uniform變數:
EdgeWidth:剪下空間(規範空間)下的輪廓線寬度。
PctExtend:覆蓋褶皺邊的四邊形擴充套件的百分比。
LineColor:輪廓線顏色。
還有一些用於模型著色和矩陣變換的Uniform變數需要我們設定。
我們採取以下步驟來渲染模型輪廓線:
1。 使用下面的程式碼作為頂點著色器:
#version 400
layout
(
location
=
0
)
in
vec3
VertexPosition
;
layout
(
location
=
1
)
in
vec3
VertexNormal
;
out
vec3
VNormal
;
out
vec3
VPosition
;
uniform
mat4
ModelViewMatrix
;
uniform
mat3
NormalMatrix
;
uniform
mat4
ProjectionMatrix
;
uniform
mat4
MVP
;
void
main
()
{
VNormal
=
normalize
(
NormalMatrix
*
VertexNormal
);
VPosition
=
vec3
(
ModelViewMatrix
*
vec4
(
VertexPosition
,
1。0
));
gl_Position
=
MVP
*
vec4
(
VertexPosition
,
1。0
);
}
2。 使用下面的程式碼作為幾何著色器:
#version 400
layout
(
triangles_adjacency
)
in
;
layout
(
triangle_strip
,
max_vertices
=
15
)
out
;
out
vec3
GNormal
;
out
vec3
GPosition
;
// Which output primitives are silhouette edges
flat
out
bool
GIsEdge
;
in
vec3
VNormal
[];
// Normal in camera coords。
in
vec3
VPosition
[];
// Position in camera coords。
uniform
float
EdgeWidth
;
// Width of sil。 edge in clip cds。
uniform
float
PctExtend
;
// Percentage to extend quad
bool
isFrontFacing
(
vec3
a
,
vec3
b
,
vec3
c
)
{
return
((
a
。
x
*
b
。
y
-
b
。
x
*
a
。
y
)
+
(
b
。
x
*
c
。
y
-
c
。
x
*
b
。
y
)
+
(
c
。
x
*
a
。
y
-
a
。
x
*
c
。
y
))
>
0
;
}
void
emitEdgeQuad
(
vec3
e0
,
vec3
e1
)
{
vec2
ext
=
PctExtend
*
(
e1
。
xy
-
e0
。
xy
);
vec2
v
=
normalize
(
e1
。
xy
–
e0
。
xy
);
vec2
n
=
vec2
(
-
v
。
y
,
v
。
x
)
*
EdgeWidth
;
// Emit the quad
GIsEdge
=
true
;
//This is part of the sil。 edge
gl_Position
=
vec4
(
e0
。
xy
-
ext
,
e0
。
z
,
1。0
);
EmitVertex
();
gl_Position
=
vec4
(
e0
。
xy
-
n
-
ext
,
e0
。
z
,
1。0
);
EmitVertex
();
gl_Position
=
vec4
(
e1
。
xy
+
ext
,
e1
。
z
,
1。0
);
EmitVertex
();
gl_Position
=
vec4
(
e1
。
xy
-
n
+
ext
,
e1
。
z
,
1。0
);
EmitVertex
();
EndPrimitive
();
}
void
main
()
{
vec3
p0
=
gl_in
[
0
]。
gl_Position
。
xyz
/
gl_in
[
0
]。
gl_Position
。
w
;
vec3
p1
=
gl_in
[
1
]。
gl_Position
。
xyz
/
gl_in
[
1
]。
gl_Position
。
w
;
vec3
p2
=
gl_in
[
2
]。
gl_Position
。
xyz
/
gl_in
[
2
]。
gl_Position
。
w
;
vec3
p3
=
gl_in
[
3
]。
gl_Position
。
xyz
/
gl_in
[
3
]。
gl_Position
。
w
;
vec3
p4
=
gl_in
[
4
]。
gl_Position
。
xyz
/
gl_in
[
4
]。
gl_Position
。
w
;
vec3
p5
=
gl_in
[
5
]。
gl_Position
。
xyz
/
gl_in
[
5
]。
gl_Position
。
w
;
if
(
isFrontFacing
(
p0
,
p2
,
p4
)
)
{
if
(
!
isFrontFacing
(
p0
,
p1
,
p2
)
)
emitEdgeQuad
(
p0
,
p2
);
if
(
!
isFrontFacing
(
p2
,
p3
,
p4
)
)
emitEdgeQuad
(
p2
,
p4
);
if
(
!
isFrontFacing
(
p4
,
p5
,
p0
)
)
emitEdgeQuad
(
p4
,
p0
);
}
// Output the original triangle
GIsEdge
=
false
;
// This triangle is not part of an edge。
GNormal
=
VNormal
[
0
];
GPosition
=
VPosition
[
0
];
gl_Position
=
gl_in
[
0
]。
gl_Position
;
EmitVertex
();
GNormal
=
VNormal
[
2
];
GPosition
=
VPosition
[
2
];
gl_Position
=
gl_in
[
2
]。
gl_Position
;
EmitVertex
();
GNormal
=
VNormal
[
4
];
GPosition
=
VPosition
[
4
];
gl_Position
=
gl_in
[
4
]。
gl_Position
;
EmitVertex
();
EndPrimitive
();
}
3。 使用下面的程式碼作為片段著色器:
#version 400
//*** Light and material uniforms go here ****
uniform
vec4
LineColor
;
// The sil。 edge color
in
vec3
GPosition
;
// Position in camera coords
in
vec3
GNormal
;
// Normal in camera coords。
flat
in
bool
GIsEdge
;
// Whether or not we‘re drawing an edge
layout
(
location
=
0
)
out
vec4
FragColor
;
vec3
toonShade
(
)
{
// *** 之前專欄文章中的卡通著色程式碼 ***
}
void
main
()
{
//If we’re drawing an edge,use constant color,
//otherwise,shade the poly。
if
(
GIsEdge
)
{
FragColor
=
LineColor
;
}
else
{
FragColor
=
vec4
(
toonShade
(),
1。0
);
}
}
原理
在頂點著色器中,我們將頂點位置,法線轉換到相機座標系,並透過變數VPosition和VNormal將它們傳遞到下一階段。同時變數gl_Position被設定為經過模型檢視投影變換後的座標。
我們使用下面的程式碼定義幾何著色器的輸入和輸出。
layout
(
triangles_adjacency
)
in
;
layout
(
triangle_strip
,
max_vertices
=
15
)
out
;
上面程式碼表示輸入圖元型別是帶有鄰接資訊的三角形,輸出圖元型別為三角形帶。幾何著色器會產生一個三角形(和輸入的三角形一模一樣),以及最多一個四邊形用來覆蓋褶皺,最多輸出15個頂點。
輸出變數GIsEdge用於在片段著色器表明片段是否是褶皺四邊形的一部分,從而選擇使用對應的著色方法。它只是一個布林量,我們不需要對它進行插值,所以對它使用flat限定符。
在main函式中,我們對所有6個頂點的座標除以它們的w成分來將座標轉換到笛卡爾座標系。這一步對於透視投影是必須的,但對於平行投影是不必的。
接著,我們需要確定主三角形(由頂點0,2,4構成的三角形)是否是正面的。函式isFrontFacing使用我們前面提到的式子計算三角形是否為正面。如果三角形是正面的,對於它的邊,如果對應的三角形不是正面的,我們就生成一條四邊形覆蓋褶皺。
函式emitEdgeQuad用於產生和邊對齊的四邊形。這條邊由它的引數e0和e1定義。從e0出發到e1構成向量ext,然後我們將向量ext乘以PctExtend進行縮放。對其進行縮放是為了透過延長四邊形來遮蓋兩個四邊形之間的空隙(我們將在
最佳化
小節對這一問題進行討論)。
我們在這裡忽略了z座標。我們的點都定義在剪下空間,並且我們生成的四邊形都和x-y平面對齊(面對相機)。我們直接使用了每個頂點最後的位置座標,不做任何變換。
接著,變數v被賦值為從e0到e1的單位向量。變數n賦值為與v正交的向量(在二維空間,我們可以透過交換x,y座標,並將交換後的x座標取相反數得到一個向量的正交向量),也就v向量逆時針旋轉90度。我們將n向量以EdgeWidth為係數進行縮放,來使向量n的長度和四邊形寬度一樣。向量ext和向量n結合得到四邊形,如下圖所示:
四邊形的四個角分別是:e0-ext,e0-n-ext,e1+ext和e1-n+ext。較低的兩個頂點的z座標和e0的z座標相同,較高的兩個頂點和z座標和e1的z座標相同。
在emitEdgeQuad函式,我們設定變數GIsEdge來使片段著色器對褶皺採用不同的著色方法。我們還輸出了四邊形的四個頂點,最後我們呼叫EndPrimitive函式結束圖元定義。
回到main函式,生成褶皺邊後,我們輸出不做任何改變輸出原始的三角形。頂點0,2和4的VNormal,VPosition和gl_Position不做修改傳遞給片段著色器。
在片段著色器,我們根據變數GIsEdge的值選擇著色方法。
最佳化
前面提到,這一技術存在一個問題:兩個褶皺四邊形之間可能出現縫隙。
我們可以看到上圖的褶皺之間存在縫隙。我們可以用三角形來填充這個縫隙,這裡我們透過延長四邊形來簡化處理,但這樣做,可能會造成一定的人工痕跡,但在實踐中,這種處理看起來還不錯。
另一個問題和深度測試相關。如果一條褶皺擴充套件長度到網格的另一區域,褶皺就可能因為深度測試而被剪下掉。下圖就是一個例子:
褶皺垂直穿過了影象的中間部分,但由於褶皺處於網格之後,部分被剪下掉。這個問題可以透過在渲染褶皺時使用自定義深度測試來解決。具體細節參考之前提到的部落格。也可以在繪製褶皺時關閉深度測試,還需要注意不要再模型背面繪製褶皺。