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 手册中的模板测试。