跳转到内容

GLSL编程/Unity/镜子

来自Wikibooks,开放世界中的开放书籍

本教程涵盖了平面镜中物体虚拟图像的渲染。

它基于“透明度”部分中描述的混合,并且需要对“顶点变换”部分有一定的了解。

平面镜中的虚拟图像

[编辑 | 编辑源代码]

我们在平面镜中看到的图像称为“虚拟图像”,因为它与真实场景的图像相同,只是所有位置都在镜面平面处进行了镜像;因此,我们看到的不是真实场景,而是它的“虚拟”图像。

可以通过将每个位置从世界空间变换到镜子的局部坐标系来计算真实物体到虚拟物体的这种变换;对坐标取反(假设镜面平面由轴跨越);并将得到的位置转换回世界空间。这表明了一种非常简单的渲染游戏对象虚拟图像的方法,方法是使用另一个着色器通道,该通道具有一个顶点着色器,用于镜像每个顶点和法线向量,以及一个片段着色器,用于在计算着色之前镜像光源的位置。(实际上,原始位置的光源也可能被考虑在内,因为它们代表了在到达真实物体之前被镜子反射的光。)这种方法没有什么问题,除了它非常有限:镜面平面后面不允许有其他物体(即使是部分物体),并且镜面平面后面的空间只能通过镜子看到。如果包含整个场景的盒子墙壁上的镜子,并且可以移除盒子外的所有几何体,那么这对盒子来说是可以的。但是,它不适用于带有其后物体的镜子(例如委拉斯开兹的绘画)也不适用于半透明镜子,例如玻璃窗。

放置虚拟物体

[编辑 | 编辑源代码]

事实证明,在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 节,该课程可在网上获取。


< GLSL 编程/Unity

除非另有说明,否则此页面上的所有示例源代码均授予公共领域。
华夏公益教科书