Cg 程式設計/Unity/傳送門

本教程涵蓋了傳送門和魔法透鏡的渲染。
它需要相當多的關於著色器程式設計的知識,特別是“可程式設計圖形管道”部分和“頂點變換”部分.
術語“傳送門”在計算機圖形學中有多種含義。通常,它指的是透過“傳送門渲染”加速表面可見性計算的物件。然而,在這裡,我們使用“傳送門”一詞來描述 3D 場景中一個 3D 物件(通常是一個平面物件)的概念,它允許我們檢視另一個 3D 場景;也就是說,一個不同的 3D 場景,而不是傳送門周圍的場景出現在傳送門的位置。“魔法透鏡”在技術上非常相似,但這種情況下,另一個場景通常與魔法透鏡周圍的場景非常密切相關。
傳送門的用例:渲染包含(虛擬)顯示器的 3D 場景,該顯示器顯示另一個 3D 場景;渲染到另一個時間和/或位置的傳送門;等等。魔法透鏡的一些用例:渲染模擬的增強現實顯示;渲染顯示相同場景但以不同風格的透鏡/濾鏡;等等。
本節介紹了一種使用單個攝像頭的方案。它遵循以下步驟
- 渲染傳送門所在的場景(但不包括傳送門)。
- 將傳送門渲染到場景中,並在模板緩衝區中標記傳送門可見的所有畫素。
- 清除傳送門可見處的深度緩衝區(如模板緩衝區中指定)。
- 渲染傳送門可見處的另一個場景(如模板緩衝區中指定),但僅渲染傳送門後面的場景部分。
讓我們逐一看看這些步驟。
只要深度緩衝區設定正確,就可以使用任何渲染不透明物體的技術,這樣就可以將傳送門插入並具有正確的遮擋效果。(如果透明物體位於傳送門後面,則此方法會導致偽影。)
此步驟必須在渲染其餘不透明場景之後執行,以便僅將傳送門光柵化到實際可見的畫素中。我們透過在傳送門的著色器中使用以下程式碼來確保傳送門在不透明場景之後渲染
Tags { "RenderType"="Opaque" "Queue"="Geometry+200"}
Geometry+200指定此物件應在不透明物件之後渲染。(除了 200,任何其他正數也都可以。)
此外,此通道必須透過將模板緩衝區中畫素的值設定為特定值(例如 1)來標記模板緩衝區中傳送門可見的畫素。(所有畫素的預設模板值為 0。)將所有光柵化畫素的模板值設定為 1 的程式碼如下所示
Stencil {
Ref 1
Comp Always // always pass stencil test
Fail Keep // do not change stencil value if stencil test fails
ZFail Keep // do not change stencil value if stencil test passes but depth test fails
Pass Replace // set stencil value to 1 if stencil test and depth test pass
}
此程式碼指定模板測試,但由於我們不想測試任何內容,因此我們將比較 (Comp) 設定為 Always,也就是說,“測試”始終透過。因此,我們的模板測試不應該失敗,但為了安全起見,我們將失敗的模板測試操作 (Fail) 設定為 Keep,也就是說,在這種情況下,我們不會更改模板緩衝區的值。如果深度測試失敗 (ZFail),也就是說,傳送門被某些東西遮擋,我們不想標記畫素(因為傳送門在這個畫素中不可見)。因此,我們將失敗的深度測試(和透過的模板測試)操作設定為 Keep,也就是說,不會更改此畫素的模板值。最後,對於所有深度測試未失敗且始終透過的模板測試透過的畫素 (Pass),我們將操作設定為 Replace,這意味著將引用值(Ref之後的數字)寫入畫素的模板值。
傳送門的完整著色器程式碼可能如下所示
Shader "Custom/PortalShader" {
SubShader
{
Tags { "RenderType"="Opaque" "Queue"="Geometry+200"}
Pass
{
Stencil {
Ref 1
Comp Always // always pass stencil test
Fail Keep // do not change stencil value if stencil test fails
ZFail Keep // do not change stencil value if stencil test passes but depth test fails
Pass Replace // set stencil value to 1 if stencil test and depth test pass
}
Cull Off // turn off backface culling
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
float4 vert (float4 vertex: POSITION) : SV_POSITION
{
return UnityObjectToClipPos(vertex);
}
fixed4 frag () : SV_Target
{
return float4(0.0, 1.0, 0.0, 1.0);
}
ENDCG
}
}
}
我們將顏色設定為任意顏色(在本例中為綠色),這對於除錯很有用,但無關緊要,因為它將被後面的物件覆蓋。
在我們能夠將另一個場景渲染到傳送門可見的畫素中之前,我們必須清除深度緩衝區。有多種方法可以僅清除模板緩衝區中標記的畫素中的深度緩衝區。最簡單的方法是簡單地渲染一個足夠大的球體以包含另一個場景(透過傳送門看到的場景)。你可以將這個球體看作另一個場景的天空盒。如果球體足夠大,則產生的深度值將大於場景中的任何深度值。這與清除深度緩衝區並不完全相同,但足夠好。
為了確保此球體在傳送門之後渲染,我們使用 Geometry+210(而不是 210,任何大於我們用於傳送門的數值的值也可以工作)
Tags { "RenderType"="Opaque" "Queue"="Geometry+210"}
為了確保即使從內部看我們也能渲染球體,我們使用 Cull Off。
為了確保即使球體被其他物件幾何遮擋,我們也能渲染球體,我們使用 ZTest Always。
由於我們只想清除模板緩衝區設定為 1 的那些畫素的深度緩衝區,因此我們使用以下模板測試
Stencil {
Ref 1
Comp Equal // only pass stencil test if stencil value equals 1
Fail Keep // do not change stencil value if stencil test fails
ZFail Keep // do not change stencil value if stencil test passes but depth test fails
Pass Keep // keep stencil value if stencil test passes
}
比較 (Comp) 設定為 Equal,以便只有模板值為引用值 1 (Ref 1) 的畫素透過模板測試,而所有其他畫素都被丟棄。所有操作都設定為 Keep,因為我們不想在任何情況下更改模板值。
完整的著色器可能如下所示
Shader "Custom/ClearDepthShader" {
SubShader
{
Tags { "RenderType"="Opaque" "Queue"="Geometry+210"}
Pass
{
ZTest Always // always pass depth test (nothing occludes this material)
Cull Off // turn off backface culling
Stencil {
Ref 1
Comp Equal // only pass stencil test if stencil value equals 1
Fail Keep // do not change stencil value if stencil test fails
ZFail Keep // do not change stencil value if stencil test passes but depth test fails
Pass Keep // keep stencil value if stencil test passes
}
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
float4 vert (float4 vertex: POSITION) : SV_POSITION
{
return UnityObjectToClipPos(vertex);
}
fixed4 frag () : SV_Target
{
return float4(0.0, 0.0, 0.0, 0.0);
}
ENDCG
}
}
}
我們將片段的顏色設定為黑色,這將是另一個場景的背景。請參閱“天空盒”部分以獲取渲染天空盒的著色器程式碼,可以擴充套件模板測試。
一旦深度緩衝區被清除(並渲染了背景),我們就可以渲染另一個場景的不透明幾何體。為了確保我們僅在清除深度緩衝區之後渲染它,我們使用 Geometry+220(而不是 220,任何大於我們用於清除的數值的值也可以工作):
Tags { "RenderType"="Opaque" "Queue"="Geometry+220"}
我們使用與清除深度緩衝區相同的模板測試(參見上一節),因為我們想要在完全相同的畫素中光柵化另一個世界。
還有三個問題。一個是可能需要避免從傳送門周圍的場景投射到另一個場景的陰影,也就是說,另一個場景的著色器不應該接收任何陰影。如果物體投射出在另一個場景中不可見的陰影,這一點尤為重要。在表面著色器中,我們可以在此行使用 noshadow 關鍵字來避免任何陰影計算
#pragma surface surf Standard noshadow
第二個問題是另一個場景的物體不應該向傳送門周圍的場景投射陰影。同樣,問題是潛在的不可見物體不應該投射陰影。當我們使用表面著色器時,我們可以透過使用 Fallback Off 來避免投射陰影,以便不 (!) 指定回退著色器,因為回退著色器通常包含一個帶有 "LightMode" = "ShadowCaster" 的陰影投射通道。
第三個問題是,通常應該裁剪位於傳送門前面的其他場景中的物體。這可以透過將世界座標中的片段位置(3D 向量IN.worldPos)轉換為傳送門的區域性座標系來實現。在下面的著色器程式碼中,4x4 變換矩陣是_WorldToPortal。我們假設傳送門是標準的 Unity "quad",其中表面法向量沿區域性 z 軸方向。然後區域性 z 座標的符號告訴我們片段是在傳送門前面還是後面。我們可以對攝像機的世界位置(3D 向量_WorldSpaceCameraPos)進行相同的變換;攝像機位置的區域性 z 座標的符號告訴我們攝像機是在傳送門前面還是後面。如果片段的區域性 z 座標與其區域性 z 座標的符號相同,我們希望裁剪(即丟棄)片段。在程式碼中
if (mul(_WorldToPortal, float4(_WorldSpaceCameraPos, 1.0)).z > 0.0) {
if (mul(_WorldToPortal, float4(IN.worldPos, 1.0)).z + _ClipDistanceOffset > 0.0) {
// position on same side of portal as camera?
discard; // discard fragment
}
}
else {
if (-mul(_WorldToPortal, float4(IN.worldPos, 1.0)).z + _ClipDistanceOffset > 0.0) {
// position on same side of portal as camera?
discard; // discard fragment
}
}
變數_ClipDistanceOffset預設情況下為 0。正值將裁剪平面移入其他場景,負值將裁剪平面移出。這有助於避免渲染偽影。
其他場景中漫射材質的完整著色器可能如下所示
Shader "Custom/OtherworldShader" {
Properties {
_Color ("Color", Color) = (1,1,1,1)
_ClipDistanceOffset ("Clip Offset", Float) = 0.0
}
SubShader {
Tags { "RenderType"="Opaque" "Queue" = "Geometry+220" }
Stencil {
Ref 1
Comp Equal // only pass stencil test if stencil value equals 1
Fail Keep // do not change stencil value if stencil test fails
ZFail Keep // do not change stencil value if stencil test passes but depth test fails
Pass Keep // do not change stencil value if stencil test passes
}
CGPROGRAM
#pragma surface surf Standard noshadow // don't receive shadows
fixed4 _Color;
float _ClipDistanceOffset;
float4x4 _WorldToPortal;
struct Input {
float3 worldPos;
};
void surf (Input IN, inout SurfaceOutputStandard o) {
o.Albedo = _Color;
if (mul(_WorldToPortal, float4(_WorldSpaceCameraPos, 1.0)).z > 0.0) {
if (mul(_WorldToPortal, float4(IN.worldPos, 1.0)).z + _ClipDistanceOffset > 0.0) {
// position on same side of portal as camera?
discard; // discard fragment
}
}
else {
if (-mul(_WorldToPortal, float4(IN.worldPos, 1.0)).z + _ClipDistanceOffset > 0.0) {
// position on same side of portal as camera?
discard; // discard fragment
}
}
}
ENDCG
}
Fallback Off
// no fallback shader, thus, no pass with tag "LightMode" = "ShadowCaster"
// and therefore not casting shadows
}
要設定著色器變數_WorldToPortal,以下 C# 指令碼在特定物件的材質中設定該變數,因此,對於使用該著色器的每個材質,該指令碼應附加到使用該材質的其中一個物件
using UnityEngine;
[ExecuteInEditMode]
public class SetMatrixProperty : MonoBehaviour {
public GameObject portal;
public Material otherWorldMaterial;
void Update () {
Renderer portalRenderer = portal.GetComponent<Renderer>();
otherWorldMaterial = GetComponent<Renderer>().sharedMaterial;
otherWorldMaterial.SetMatrix("_WorldToPortal", portalRenderer.worldToLocalMatrix);
}
}
此程式碼也在 Unity 編輯器中執行。如果指令碼不需要在編輯器中執行,可以透過在Start函式中分配portalRenderer和otherWorldMaterial來對其進行最佳化。
限制
[edit | edit source]這種方法有三個主要的限制
- 無法穿過傳送門。
- 透明物體可能導致遮擋不正確。
- 可能需要停用其他場景中的陰影投射和接收。
穿過傳送門需要切換場景與其他場景的角色。一種解決方案是為所有物體建立兩個副本:一個用於物體位於傳送門場景中的情況,另一個用於物體位於其他場景中的情況。透過啟用每個物體的相應副本,可以使用正確的材質。
透明物體的一些問題可以透過再次渲染傳送門的深度來解決,即不光柵化顏色(使用ColorMask 0)。這應該遵循其他場景(傳送門後面)中透明物體的渲染,但要先於傳送門周圍場景的透明物體的渲染。這留給讀者作為練習。
解決透明度和陰影問題的另一種方法是使用第二個攝像機。這可以透過設定第二個攝像機並將其位置和旋轉與主攝像機同步(但在傳送門後面的場景中)來實現。該第二個攝像機將傳送門後面的場景渲染到Render Texture中,然後使用該紋理在主場景中紋理化傳送門。這將與“鏡子”部分中的平面鏡子非常相似。缺點主要是使用渲染紋理的效能成本以及使用必須與主攝像機同步的額外攝像機可能帶來的問題。
總結
[edit | edit source]本教程討論了一種使用單個攝像機渲染傳送門或魔法透鏡的方法。如果您想避免使用多個攝像機,這將特別有用。但是,它也會導致透明物體和陰影方面的問題,這在許多應用程式中可能無法接受。
進一步閱讀
[edit | edit source]如果您還想了解更多
- 關於魔法透鏡的資訊,您可以閱讀 Eric A. Bier 等人撰寫的論文"Toolglass and Magic Lenses: The See-Through Interface"。
- 關於模板緩衝區的資訊,您應該閱讀Unity 手冊中的模板測試。