GLSL程式設計/Unity/鏡子
本教程涵蓋了平面鏡中物體虛擬影像的渲染。
它基於“透明度”部分中描述的混合,並且需要對“頂點變換”部分有一定的瞭解。
我們在平面鏡中看到的影像稱為“虛擬影像”,因為它與真實場景的影像相同,只是所有位置都在鏡面平面處進行了映象;因此,我們看到的不是真實場景,而是它的“虛擬”影像。
可以透過將每個位置從世界空間變換到鏡子的區域性座標系來計算真實物體到虛擬物體的這種變換;對座標取反(假設鏡面平面由和軸跨越);並將得到的位置轉換回世界空間。這表明了一種非常簡單的渲染遊戲物件虛擬影像的方法,方法是使用另一個著色器通道,該通道具有一個頂點著色器,用於映象每個頂點和法線向量,以及一個片段著色器,用於在計算著色之前映象光源的位置。(實際上,原始位置的光源也可能被考慮在內,因為它們代表了在到達真實物體之前被鏡子反射的光。)這種方法沒有什麼問題,除了它非常有限:鏡面平面後面不允許有其他物體(即使是部分物體),並且鏡面平面後面的空間只能透過鏡子看到。如果包含整個場景的盒子牆壁上的鏡子,並且可以移除盒子外的所有幾何體,那麼這對盒子來說是可以的。但是,它不適用於帶有其後物體的鏡子(例如委拉斯開茲的繪畫)也不適用於半透明鏡子,例如玻璃窗。
事實證明,在Unity的免費版本中實現更通用的解決方案並不簡單,因為Unity的免費版本既不提供渲染到紋理(這將允許我們從鏡子後面的虛擬攝像機位置渲染場景),也不提供模板緩衝區(這將允許我們限制渲染到鏡子的區域)。
我想出了以下解決方案:首先,每個可能出現在鏡子中的遊戲物件都必須有一個虛擬的“替身”,即一個跟隨真實遊戲物件所有動作的副本,但位置在鏡面平面處進行了映象。每個虛擬物件都需要一個指令碼,根據相應的真實物件和鏡面平面設定其位置和方向,這些由公共變數指定
@script ExecuteInEditMode()
var objectBeforeMirror : GameObject;
var mirrorPlane : GameObject;
function Update ()
{
if (null != mirrorPlane)
{
renderer.sharedMaterial.SetMatrix("_WorldToMirror",
mirrorPlane.renderer.worldToLocalMatrix);
if (null != objectBeforeMirror)
{
transform.position = objectBeforeMirror.transform.position;
transform.rotation = objectBeforeMirror.transform.rotation;
transform.localScale =
-objectBeforeMirror.transform.localScale;
transform.RotateAround(objectBeforeMirror.transform.position,
mirrorPlane.transform.TransformDirection(
Vector3(0.0, 1.0, 0.0)), 180.0);
var positionInMirrorSpace : Vector3 =
mirrorPlane.transform.InverseTransformPoint(
objectBeforeMirror.transform.position);
positionInMirrorSpace.y = -positionInMirrorSpace.y;
transform.position = mirrorPlane.transform.TransformPoint(
positionInMirrorSpace);
}
}
}
區域性座標系的原點(objectBeforeMirror.transform.position)如上所述進行變換;即,將其變換到鏡子的區域性座標系中,使用mirrorPlane.transform.InverseTransformPoint(),然後反射座標,然後使用mirrorPlane.transform.TransformPoint()將其轉換回世界空間。但是,在JavaScript中很難指定方向:我們必須反射所有座標(transform.localScale = -objectBeforeMirror.transform.localScale)並在鏡面的表面法線向量(變換到世界座標的Vector3(0.0, 1.0, 0.0))周圍將虛擬物體旋轉180°。這樣做有效,因為繞180°旋轉對應於兩個正交於旋轉軸的軸的反射。因此,此旋轉撤消了前兩個軸的先前反射,我們剩下的是旋轉軸方向的一個反射,該旋轉軸被選擇為鏡面的法線。
當然,虛擬物件應該始終跟隨真實物件,即它們不應該與其他物件碰撞,也不應該以任何其他方式受到物理的影響。在所有虛擬物件上使用此指令碼對於上面提到的情況已經足夠了:鏡面平面後面沒有真實物件,除了透過鏡子之外沒有其他方法可以檢視鏡面平面後面的空間。在其他情況下,我們必須渲染鏡子以遮擋其後面的真實物件。
現在事情變得有點棘手了。讓我們列出我們想要實現的目標
- 鏡子後面的真實物體應該被鏡子遮擋。
- 鏡子應該被虛擬物體遮擋(實際上它們在鏡子後面)。
- 鏡子前面的真實物體應該遮擋鏡子和任何虛擬物體。
- 虛擬物體只能在鏡子裡看到,不能在鏡子外面看到。
如果我們可以將渲染限制到螢幕的任意部分(例如使用模板緩衝區),這將很容易:渲染所有幾何體,包括不透明的鏡子;然後將渲染限制到鏡子的可見部分(即不被其前面的其他真實物體遮擋的部分);清除鏡子這些可見部分的深度緩衝區;並渲染所有虛擬物體。如果我們有模板緩衝區,這將非常簡單。
由於我們沒有模板緩衝區,因此我們使用幀緩衝區的alpha分量(也稱為不透明度或A分量)作為替代(類似於“半透明物體”部分中使用的技術)。在鏡子的著色器第一遍中,鏡子的可見部分(即不被其前面的真實物體遮擋的部分)中的所有畫素都將用0的alpha分量標記,而螢幕其餘部分的畫素應該具有1的alpha分量。第一個問題是,我們必須確保螢幕的其餘部分具有1的alpha分量,即所有背景著色器和物件著色器都應將alpha設定為1。例如,Unity的天空盒沒有將alpha設定為1;因此,我們必須修改和替換所有未將alpha設定為1的著色器。讓我們假設我們可以做到這一點。然後,鏡子的著色器第一遍是
// 1st pass: mark mirror with alpha = 0
Pass {
GLSLPROGRAM
#ifdef VERTEX
void main()
{
gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex;
}
#endif
#ifdef FRAGMENT
void main()
{
gl_FragColor = vec4(1.0, 0.0, 0.0, 0.0);
// this color should never be visible,
// only alpha is important
}
#endif
ENDGLSL
}
這如何幫助我們將渲染限制在alpha等於0的畫素上?它做不到。但是,它確實透過使用巧妙的混合方程(參見“透明度”部分)幫助我們限制幀緩衝區中顏色的任何變化。
混合 OneMinusDstAlpha DstAlpha
我們可以將混合方程視為
vec4 result = vec4(1.0 - pixel_color.a) * gl_FragColor + vec4(pixel_color.a) * pixel_color;
其中pixel_color是幀緩衝區中畫素的顏色。讓我們看看當pixel_color.a等於1(即鏡子可見部分之外)時表示式是什麼
vec4(1.0 - 1.0) * gl_FragColor + vec4(1.0) * pixel_color == pixel_color
因此,如果pixel_color.a等於1,則混合方程確保我們不會更改幀緩衝區中的畫素顏色。如果pixel_color.a等於0(即鏡子可見部分內部)會發生什麼?
vec4(1.0 - 0.0) * gl_FragColor + vec4(0.0) * pixel_color == gl_FragColor
在這種情況下,幀緩衝區的畫素顏色將設定為片段著色器中設定的片段顏色。因此,使用此混合方程,我們的片段著色器只會更改 alpha 分量為 0 的畫素的顏色。請注意,gl_FragColor 中的 alpha 分量也應為 0,以便畫素仍然被標記為鏡面可見區域的一部分。
這是第一遍。第二遍必須在開始渲染虛擬物件之前清除深度緩衝區,以便我們可以使用正常的深度測試來計算遮擋(參見“逐片段操作”部分)。實際上,我們是否只對鏡面可見部分的畫素或螢幕的所有畫素清除深度緩衝區並不重要,因為無論如何我們都不會更改 alpha 值等於 1 的任何畫素的顏色。事實上,這是非常幸運的,因為(沒有使用模板測試)我們無法將深度緩衝區的清除限制在鏡面的可見部分。相反,我們透過將頂點變換到遠裁剪平面(即最大深度)來清除整個鏡面的深度緩衝區。
如“頂點變換”部分所述,頂點著色器在 gl_Position 中的輸出將自動除以第四個座標 gl_Position.w 以計算 -1 到 +1 之間的歸一化裝置座標。事實上,一個座標為 +1 表示最大深度;因此,這是我們的目標。但是,由於自動(透視)除以 gl_Position.w,我們必須將 gl_Position.z 設定為 gl_Position.w 以獲得 +1 的歸一化裝置座標。以下是鏡面著色器的第二遍
// 2nd pass: set depth to far plane such that
// we can use the normal depth test for the reflected geometry
Pass {
ZTest Always
Blend OneMinusDstAlpha DstAlpha
GLSLPROGRAM
uniform vec4 _Color;
// user-specified background color in the mirror
#ifdef VERTEX
void main()
{
gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex;
gl_Position.z = gl_Position.w;
// the perspective division will divide gl_Position.z
// by gl_Position.w; thus, the depth is 1.0,
// which represents the far clipping plane
}
#endif
#ifdef FRAGMENT
void main()
{
gl_FragColor = vec4(_Color.rgb, 0.0);
// set alpha to 0.0 and
// the color to the user-specified background color
}
#endif
ENDGLSL
}
ZTest 設定為 Always 以停用它。這是必要的,因為我們的頂點實際上位於鏡面後面(為了重置深度緩衝區);因此,片段將無法透過正常的深度測試。我們使用上面討論過的混合方程來設定鏡面的使用者指定背景顏色。(如果場景中存在天空盒,則必須計算映象的視角方向並在此處查詢環境貼圖;參見“天空盒”部分。)
這是鏡面的著色器。以下是完整的著色器程式碼,它使用 "Transparent+10" 來確保在所有真實物件(包括透明物件)渲染完成後再渲染它
Shader "GLSL shader for mirrors" {
Properties {
_Color ("Mirrors's Color", Color) = (1, 1, 1, 1)
}
SubShader {
Tags { "Queue" = "Transparent+10" }
// draw after all other geometry has been drawn
// because we mess with the depth buffer
// 1st pass: mark mirror with alpha = 0
Pass {
GLSLPROGRAM
#ifdef VERTEX
void main()
{
gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex;
}
#endif
#ifdef FRAGMENT
void main()
{
gl_FragColor = vec4(1.0, 0.0, 0.0, 0.0);
// this color should never be visible,
// only alpha is important
}
#endif
ENDGLSL
}
// 2nd pass: set depth to far plane such that
// we can use the normal depth test for the reflected geometry
Pass {
ZTest Always
Blend OneMinusDstAlpha DstAlpha
GLSLPROGRAM
uniform vec4 _Color;
// user-specified background color in the mirror
#ifdef VERTEX
void main()
{
gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex;
gl_Position.z = gl_Position.w;
// the perspective division will divide gl_Position.z
// by gl_Position.w; thus, the depth is 1.0,
// which represents the far clipping plane
}
#endif
#ifdef FRAGMENT
void main()
{
gl_FragColor = vec4(_Color.rgb, 0.0);
// set alpha to 0.0 and
// the color to the user-specified background color
}
#endif
ENDGLSL
}
}
}

一旦我們清除了深度緩衝區並透過將 alpha 分量設定為 0 來標記鏡面的可見部分,我們就可以使用混合方程
混合 OneMinusDstAlpha DstAlpha
來渲染虛擬物件。不是嗎?還有一種情況我們不應該渲染虛擬物件,那就是當它們從鏡面中出來時!當真實物件移動到反射表面時,實際上會發生這種情況。睡蓮和游泳的物體就是例子。我們可以透過使用 discard 指令(參見“切除”部分)丟棄在鏡面外部的虛擬物件的片段的光柵化,如果它們在鏡面的區域性座標系中的座標為正。為此,頂點著色器必須計算鏡面區域性座標系中的頂點位置,因此著色器需要相應的變換矩陣,我們已經在上面的指令碼中設定了它。虛擬物件的完整著色器程式碼如下
Shader "GLSL shader for virtual objects in mirrors" {
Properties {
_Color ("Virtual Object's Color", Color) = (1, 1, 1, 1)
}
SubShader {
Tags { "Queue" = "Transparent+20" }
// render after mirror has been rendered
Pass {
Blend OneMinusDstAlpha DstAlpha
// when the framebuffer has alpha = 1, keep its color
// only write color where the framebuffer has alpha = 0
GLSLPROGRAM
// User-specified uniforms
uniform vec4 _Color;
uniform mat4 _WorldToMirror; // set by a script
// The following built-in uniforms
// are also defined in "UnityCG.glslinc",
// i.e. one could #include "UnityCG.glslinc"
uniform mat4 _Object2World; // model matrix
// Varying
varying vec4 positionInMirror;
#ifdef VERTEX
void main()
{
positionInMirror =
_WorldToMirror * (_Object2World * gl_Vertex);
gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex;;
}
#endif
#ifdef FRAGMENT
void main()
{
if (positionInMirror.y > 0.0)
// reflection comes out of mirror?
{
discard; // don't rasterize it
}
gl_FragColor = vec4(_Color.rgb, 0.0); // set alpha to 0.0
}
#endif
ENDGLSL
}
}
}
請注意,這一行
Tags { "Queue" = "Transparent+20" }
確保虛擬物件在鏡面之後渲染,鏡面使用 "Transparent+10"。在此著色器中,虛擬物件使用統一的使用者指定顏色進行光柵化,以使著色器儘可能簡短。在完整的解決方案中,著色器將使用映象法線向量和光源的映象位置來計算照明和紋理。但是,這很簡單,並且很大程度上取決於用於真實物件的特定著色器。
此方法有一些我們尚未解決的限制。例如
- 多個鏡面平面(一個鏡面的虛擬物件可能出現在另一個鏡面中)
- 鏡面中的多次反射
- 半透明虛擬物件
- 半透明鏡面
- 鏡面中的光反射
- 不規則的鏡面(例如,帶有法線貼圖)
- Unity 免費版本中的不規則鏡面
- 等等。
恭喜!做得好。我們看過的兩件事
- 如何使用模板緩衝區渲染鏡面。
- 如何不使用模板緩衝區渲染鏡面。
如果你還想了解更多
- 關於使用模板緩衝區渲染鏡面的資訊,你可以閱讀 Tom McReynolds 組織的 SIGGRAPH '98 課程“使用 OpenGL 的高階圖形程式設計技術”的第 9.3.1 節,該課程可在網上獲取。