走进 Stencil Buffer 系列 3:镜面反射

作者:阿创
2020-05-18
6 2 0

零、前言

镜面反射是游戏里十分常见又比较麻烦的需求,大多情况都需要额外创建一个摄像机,根据镜面镜像反转位置来渲染镜子中的内容。

不过我们如果基于 Stencil 原理来操作,就可以不需要额外创建摄像机就可以实现镜面效果了噢!

相信大家都看了前几章后(应该)(文章链接),对于模板 Stencil 作用会有个感性的理解:遮罩作用。那这篇文章将使用模板 Stencil 进行镜面区域限定,配合模型顶点镜面反转,来实现镜面反射的效果。

一、实现思路

我们先来想一想真实世界中镜子成像的原理 :太阳或者灯的光照射到人或物体的身上,随后人或物体又反射这些光(大部分是漫反射)射向到镜面上。平面镜又将光镜面反射到人的眼睛里,因此我们看到了自己或物体在平面镜中的虚像。

我们分析得出一下三点特征:

  1. 假设镜子光滑的是完美镜面反射(即光只改变方向不改变光的颜色),在镜子里可以看到的物体(虚像)和实际的物体(实体)外观细节(纹理颜色)是一模一样的,因为都是漫反射光的结果。
  2. 因为是镜面反射成像,虚像和实体之间会关于镜子平面互相对称。
  3. 镜子成的像只能在镜子里面看到(看起来是废话哈哈,不过这正是 Stencil 模板发挥作用的地方噢)。


第一点,对于我们来说是个好消息。既然纹理颜色是一样的,我们可以使用相同内容的两个 Pass 将物体渲染两遍就好了。

第二点,是一个比较难搞又重要的问题。我们需要让它们关于镜子平面互相对称才行。这怎么做呢?

(这里要十分感谢群里 Colin 和其他大佬们提供的思路)

贴张图来展示一下:关于镜子平面互相对称,只需要构建一个”Wrold“ To ”MirrorWorld“ Matrix(世界转换到镜子世界的矩阵)将物体关于镜子 Y 轴对称反转就可以了。


“Wrold” To “MirrorWorld” Matrix(世界转换到镜子世界的矩阵)具体构建思路如下:

  1. 在镜子表面的中心放一个新的空 GameObject,让其 Local 坐标系下的 Y 轴指向镜子外面。
  2. 用其 Transform worldToLocalMatrix 矩阵将物体从世界坐标系转换至以镜子为中心的本地坐标系;
  3. 然后构建一个 Y 轴反转矩阵(即 Y 变成 -Y)左乘上面得到的 worldToLocalMatrix 矩阵;
  4. 最后再用其 Transform localToWorldMatrix 矩阵左乘以上的矩阵。

第三点,相信大家都看过前面几篇文章后,可能会有个体会:模板 Stencil 的效果可以大致理解为一个遮罩效果,使用遮罩来限制某些区域(像素)的显示。

那我们的镜子模型就是这个遮罩,限制虚像也就是我们反转后的模型显示的区域。

二、具体实现

由上面所说的思路,我们来搭个框架,讲讲核心代码。


1、被镜面反射的物体 Shader

创建 Shader 和材质给到要被镜面反射的物体身上

然后就像上面所说的,在 Shader 代码中虚像和实体先是一样的 Pass。(其实顶点着色器有点不一样,后面有提到)

  Shader "Custom/StencilBufferTwoPassReflection"
  {
    Properties
    {
      _MainTex ("Texture", 2D) = "white" {}
    }
    SubShader
    {
      Tags { "RenderType"="Opaque" }
      LOD 100

      Pass
      {
        //这里渲染虚像的 Pass,正常的渲染
      }

      Pass
      {
        //这里渲染实像的 Pass,正常的渲染
      }
    }
  }


2、虚像模型关于镜面对称反转

我们先再镜子表面中心创建一个空物体命名 WtoMW_Object,并使其 Local 坐标系下 Y 轴朝向镜面外部。

并在 WtoMW_Object 上挂一个脚本,来构建并向 Shader 传递“Wrold” To “MirrorWorld” Matrix(世界转换到镜子世界的矩阵)。

具体脚本代码如下:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

//Set World to Mirror World Matrix
public class SetWtoMWMatrix : MonoBehaviour
{
  //WtoMW_Object 的 transform;
  Transform refTransform;
  //”Wrold“ To ”MirrorWorld“ Matrix(世界转换到镜子世界的矩阵)
  Matrix4x4 WtoMW;
  Material material;
  //Y 轴对称反转矩阵
  Matrix4x4 YtoNegativeY = new Matrix4x4(
    new Vector4(1, 0, 0, 0),
    new Vector4(0, -1, 0, 0),
    new Vector4(0, 0, 1, 0),
    new Vector4(0, 0, 0, 1));

  private void Start()
  {
    material = GetComponent<MeshRenderer>().sharedMaterial;
    refTransform = GameObject.Find("WtoMW_Object").transform;

  }

  void Update()
  {
    WtoMW = refTransform.localToWorldMatrix * YtoNegativeY * refTransform.worldToLocalMatrix;
    material.SetMatrix("_WtoMW", WtoMW);
  }
}


3、应用镜面对称反转矩阵

这时我们被镜面反射的物体 ShaderShader 代码也要更新一下,来接收与使用脚本传递来的矩阵。

我们声明了 float4x4 类型的 _WtoMW 矩阵,来接受脚本传递来的矩阵。

并在渲染虚像 Pass 里的顶点着色器使用此矩阵,将顶点从世界空间转换至镜子空间。

具体看代码注释:

Shader "Custom/StencilBufferTwoPassReflection"
{
  Properties
  {
    _MainTex ("Texture", 2D) = "white" {}
  }
  SubShader
  {
    Tags { "RenderType"="Opaque" "LightMode"="ForwardBase" "Queue"="Geometry" }


    //这里是其他变量的声明..

    //声明 float4x4 类型的 _WtoMW 矩阵,来接受脚本传递来的矩阵
    float4x4 _WtoMW;

    //这里渲染虚像的 Pass
    Pass
    {
      //这里是一些设置..

      //顶点函数  
      v2f vert (appdata v)
      {
        v2f o;

        //首先将模型顶点转换至世界空间坐标系
        float4 worldPos = mul(unity_ObjectToWorld,v.vertex);
        //再把顶点从世界空间转换至镜子空间
        float4 mirrorWorldPos = mul(_WtoMW,worldPos);
        //最后就后例行把顶点从世界空间转换至裁剪空间
        o.vertex = mul(UNITY_MATRIX_VP,mirrorWorldPos);

        o.uv = TRANSFORM_TEX(v.uv, _MainTex);
        // Transform the normal from object space to world space
        o.worldNormal = mul(v.normal, (float3x3)unity_WorldToObject);
        return o;
      }

      //frag 函数和实体的是一样的..

    }

    Pass
    {
      //这里渲染实体的 Pass
    }
  }
}


4、为虚像的 Pass 添加指令

我们更新一下 StencilBufferTwoPassReflection 被镜面反射的物体 Shader 代码:

为虚像的 Pass 添加 StencilZTest AlwaysCull Front 指令。

Stencil 里边的指令老生常谈了,原理和上一章的非欧世界内的物体一模一样,虚像在其余地方时,因为 Ref 参考值和缓冲值不相等,物体渲染出颜色将会被抛弃(即不能显示出来)。注释里也有详细解释。


需要注意的是经过镜像反转,位置发生了变换,位置上陷入了镜子世界中。所以默认情况下深度测试会失败。

虚像模型正反面也发生了变换,原来模型的正面现在变成虚像的背面,模型的背面现在变成虚像的正面,而恰恰 Unity 默认会剔除掉模型的背面,只显示模型的正面。也就是说,虚像的正面将会被剔除掉,只显示背面,这显然是不正确的。

所以我们通过以下两个指令修复这些错误:

ZTest Always 指令作用是:无论深度测试是什么结果都算通过深度测试。这样就避免了因为深度测试失败而不能显示。

Cull Front 指令的作用是 :剔除掉模型的正面(即虚像的背面),显示模型的反面(即虚像的正面)。


Shader "Unlit/StencilBufferTwoPassReflection"
{
  Properties
  {
    _MainTex("Main Tex",2D)= "white"{}
    _Color("Color Diffuse",Color) = (1,1,1,1)
    _RefValue("Ref Value",Int) = 0
  }
  SubShader
  {
    Tags { "RenderType"="Opaque" "LightMode"="ForwardBase" "Queue"="Geometry" }

    //这里是虚像的渲染
    Pass
    {

      //[_RefValue] 就是我们自己设置的参考值
      //Equal 表示了只有和缓冲值相等才通过测试,物体才能被显示出来
      //Keep 表示通过模板测试或深度测试失败后,都保留原有缓冲值.
      Stencil{
        Ref [_RefValue]
        Comp Equal
        Pass keep
        ZFail keep
      }

      //因为虚像经过镜像反转,位置也发生了变换,陷入了镜子世界中。所以势必会深度测试失败。
      //作用无论深度测试是什么结果都算通过深度测试。
      ZTest Always

      //剔除掉模型的正面(即虚像的背面),显示模型的反面(即虚像的正面)。
      Cull Front

      //这里是其他变量的声明和设置....

      //声明 float4x4 类型的 _WtoMW 矩阵,来接受脚本传递来的矩阵
      float4x4 _WtoMW;

      //顶点函数  
      v2f vert (appdata v)
      {
        v2f o;

        //首先将模型顶点转换至世界空间坐标系
        float4 worldPos = mul(unity_ObjectToWorld,v.vertex);
        //再把顶点从世界空间转换至镜子空间
        float4 mirrorWorldPos = mul(_WtoMW,worldPos);
        //最后就后例行把顶点从世界空间转换至裁剪空间
        o.vertex = mul(UNITY_MATRIX_VP,mirrorWorldPos);

        o.uv = TRANSFORM_TEX(v.uv, _MainTex);
        // Transform the normal from object space to world space
        o.worldNormal = mul(v.normal, (float3x3)unity_WorldToObject);
        return o;
      }

      //frag 函数和实体的是一样的..

    }

    Pass
    {
      //这里渲染实体的 Pass
    }
  }
}


5、镜子的 Shader :限制虚像只在镜面中显示

在创建一个 Shader 和材质给到镜子物体身上

并在镜子的 Shader 中写入 Stencil 指令:(和上一章的非欧世界面片 Quad 原理一模一样,就是起到遮罩作用,限定虚像显示区域。

细节看注释:

Shader "Unlit/StencilBufferMirror"
{
  Properties
  {
    _MainTex ("Texture", 2D) = "white" {}
    _RefValue("Ref Value",Int) = 0
    _Color("Color Tint",Color) = (0,0,0,1)
  }
  SubShader
  {

      //Queue 渲染队列设置到 Geometry-1 是因为想在被反射的物体渲染之前就进行渲染,写入 stencil 值
    Tags { "RenderType"="Opaque" "Queue"="Geometry-1" } 

    //[_RefValue]就是我们自己设置的参考值
    //Always 表示了无论如何都通过模板测试
    //Replace 表示通过模板测试后用参考值替换掉 Stencil Buffer 中此像素原有的 stencil 值(缓冲值)
    Stencil{
      Ref [_RefValue]
      Comp Always
      Pass Replace
    }

    Pass{
      //这里镜子的正常渲染(默认我使用 Unlit 的代码
    }
  }
}

三、效果展示


参考资料:

(再次感谢群里 Colin 和其他大佬们提供的思路)

四、下一章预告

Stencil 原理的屏幕后处理,局部描边: