第八章 通过Unity渲染纹理实现屏幕效果

我们在这一章会学习下面的这些知识点:

  • 设置屏幕效果脚本系统
  • 在屏幕效果中使用亮度, 饱和度和对比度
  • 在屏幕效果中使用基础的类Photoshop混合模式
  • 屏幕效果中的覆盖混合模式

  • 介绍
    学习编写着色器最让人印象深刻的是创建你自己的屏幕效果的过程,也就是常说的后处理。有了这些屏幕效果,我们就可以用 BloomMotion BlurHDR 效果等技术创建出一些惊奇的实时图像。当今游戏市场推出的大部分游戏在 景深(depth of field) 效果,辉光(bloom) 效果甚至 颜色修正(color correction) 效果上都大量使用了屏幕效果。
    通过这个章节,你将会学习如何构建一个如何去控制这些屏幕效果的脚本系统。我们将会涵盖 渲染纹理(Render Texture)深度缓冲(depth buffer),以及如何创建一个类Photoshop感的效果,从而去控制游戏的最终渲染图片的效果。通过为你的游戏使用屏幕效果,你不经可以完成你的着色器编写知识,同时你还将会拥有在Unity中创自己那些不可思议的渲染器的能力。

  • 设置屏幕效果脚本系统
    创建屏幕效果是这么一个过程,我们先抓取一张全屏的图像(或者纹理),然后用一个着色器在GPU中去处理它的像素,之后再把它发回给Unity的渲染器并且应用到游戏的整个渲染好的图像上去。这样的话就允许我们实时的在游戏的渲染图片上进行逐像素操作了,从而使我们对艺术效果有更广的控制。
    试想一下如果你不得不仔细检查并且调节你游戏中每一个游戏对象上的每个材质,而这么做仅仅是想调节游戏最终视效的对比度。尽管可行,但还是有点费事。通过利用屏幕效果,我们就可以整个的调整屏幕的最终效果,因此对于有些最终呈现我们能获得更强的 类Photoshop(Photoshop-like) 的控制。
    为了建立起一个屏幕效果系统并且使它成功运行,我们需要准备一个脚本来跟游戏当前的渲染图片进行通信,这个 渲染图片(rendered image) 就是Unity中的 渲染纹理(Render Texture) 。利用这个脚本将渲染纹理传给着色器,我们能创建一个灵活的系统来创建屏幕效果。对于我们的第一个屏幕效果,我们将创建一个非常简单的灰度效果,这个效果可以让我们的游戏看起来是黑白的效果。让我们拭目以待。

  • 始前准备
    为了构建好我们的屏幕效果系统并且顺利运行,我们需要为当前的Unity工程创建一些资产。为了获得这些,跟着下面的步骤走就对了:

    • 1.在当前的项目中,我们创建一个名为 TestRenderImage.cs 的脚本。
    • 2.创建一个新的着色器,命名为 ImageEffect.shader
    • 3.在场景中新建一个球体并且为其添加一个新的材质球,新材质可以任意,比如我们创建了一个简单的红色的,带有高光的材质球。
    • 4.最后,创建一个新的方向光,然后保存场景。

    当我们把所有的资源准备好后,就等于是简单的设置好了场景,看起来就跟下图一样:
    diagram


  • 操作步骤
    为了让我们灰度屏幕效果能运行,我们需要一个脚本和着色器。我们将会在这里完成这新的两项并且给它们添加合适的代码从而生成我们的第一个屏幕效果。我们的首个任务就是完成C#脚本。这样可以让我们的整个系统跑起。在这之后,我们将会完成着色器的编写并且看到我们的屏幕效果。让我们通过下面的步骤完成我们的脚本和着色器:
    • 1.打开 TestRenderImage.cs 这个脚本并且添加一些变量好让我们能保存导入的游戏对象和数据。在 TestRenderImage 这个类的最上面添加下面的代码:
    public class TestRenderImage : MonoBehaviour
    {
        #region Variables
        public Shader curShader;
        public float grayScaleAmount = 1.0f;
        public Material curMaterial;
        #endregion
        #region Properties
    }
    
    • 2.当Unity编辑器没有运行的时候,为了让我们能够实时的编辑屏幕效果,我们需要在 TestRenderImage 类的声明上面添加下面这行代码:
      diagram
    • 3.因为我们的屏幕效果是使用一个着色器在一个屏幕图像上进行逐像素操作,所以我们必须要创建一个材质来运行这个着色器。没有这个材质,我们就没有办法访问着色器的属性。因此,我们将创建一个C#的属性来检测材质,如果没有找到这个材质就创建一个。在第一个步骤的变量声明的下面输入下面的代码:
    #region Properties
    Material material
    {
        get
        {
            if (curMaterial == null)
            {
                curMaterial = new Material(CurShader);
                curMaterial.hideFlags = HideFlags.HideAndDontSave;
            }
            return curMaterial;
        }
    }
    #endregion
    
    • 4.现在我们想在脚本中设置一些检测来看看当前我们build的Unity游戏在平台上是否支持图像效果。如果在脚本开始运行的时候发现不支持,这个脚本将会被禁用掉:
    private void Start()
    {
        if (!SystemInfo.supportsImageEffects)
        {
            enabled = false;
            return;
        }
        if (curShader && !curShader.isSupported)
        {
            enabled = false;
        }
    }
    
    事实上 SystemInfo.supportsImageEffects 在较新的Unity版本中,是一直返回 true 的,这个属性已经被废弃掉了【译者述】
    • 5.为了能够从Unity的渲染器中抓取 渲染图像(Rendered Image) ,我们需要利用下面这个Unity内建的 OnRenderImage() 方法。请输入下面的代码以便于我们能访问当前的 渲染纹理(Render Texture)
    private void OnRenderImage(RenderTexture src, RenderTexture dest)
    {
        if (curShader != null)
        {
            material.SetFloat("_LuminosityAmount", grayScaleAmount);
            Graphics.Blit(src, dest, material);
        }
        else
        {
            Graphics.Blit(src, dest);
        }
    }
    
    • 6.我们的屏幕效果有一个叫 grayScaleAmount 的变量,它可以控制我们想要的灰度屏幕效果的程度。所以,在这里我们需要控制它的取值范围是[ 0 - 1 ],0表示没有灰度效果,1表示满程度的灰度效果。我们将会在 Update() 方法中进行这个操作,这意味着当脚本运行的时候会在游戏的每一帧去设置它们:
    private void Update()
    {
        grayScaleAmount = Mathf.Clamp(grayScaleAmount, 0.0f, 1.0f);
    }
    
    • 7.最后,当脚本运行的时候,对于我们创建的这些对象我们需要对它们进行一些清理,这样这个脚本就完成了:
    private void OnDisable()
    {
        if (curMaterial)
        {
            DestroyImmediate(curMaterial);
        }
    }
    
    这个时候,如果编译通过后,我们可以将脚本挂在到我们的摄像机中去了。让我们把 TestRenderImage.cs 这个脚本挂载到我们场景中的主摄像机上。你可以在编辑器上看到 grayScaleAmount 这个值和一个着色器的域,但这个脚本会在控制台窗口抛出一个错误。说它丢失了一个对象实例并且有可能会运行不正常。如果你回顾第四个步骤的话,可以看到我们做了一些检测来确定我们是否有着色器和当前的平台是否支持该着色器。我们还没有给这个屏幕效果脚本一个着色器让它能正常的工作,所以 curShader 变量是空的,所以抛出了这个错误。所以让我们继续完成着色器来完善我们的屏幕效果系统吧。
    • 8.开始着手编写我们的着色器了,我们将会修改着色器的属性块,添加一些属性,好让我们能给这个着色器发送一些数据:
    Properties
    {
        _MainTex ("Albedo (RGB)", 2D) = "white" {}
        _LuminosityAmount ("GrayScale Amount", Range(0.0, 1)) = 1.0
    }
    
    • 9.现在我们的着色器将使用纯CG着色器代码编写,而不是使用Unity内建的 表面着色器(Surface Shader) 代码。因为这样我们的屏幕效果可以得到更好的优化,因为这里我们仅仅是需要处理 渲染纹理(Render Texture) 的像素而已。所以我们将在着色器中创建一个新的 通道块(Pass block) 并且添加一些我们之前没有看过的 #pragma 声明:
    Pass
    {
        CGPROGRAM
        #pragma vertex vert_img
        #pragma fragment frag
        #pragma fragmentoption ARB_precision_hint_fastest
        #include "UnityCG.cginc"
    
    • 10.为了能够访问从Unity编辑器发送到着色器的数据,我们需要在 CGPROGRAM 中创建对应的变量:
    uniform sampler2D _MainTex;
    fixed _LuminosityAmount;
    
    • 11.最后,就剩下去设置我们的 像素函数(pixel function) 了,在这个例子中就是 frag() 函数。这也是这个屏幕效果的关键代码。这个函数将会处理 渲染纹理(Render Texture) 的每一个像素并且给我们的 TestRenderImage.cs 脚本中返回一张新的图像:
    fixed4 frag(v2f_img i) : COLOR
    {
        fixed4 renderTex = tex2D(_MainTex, i.uv);
        float luminosity = 0.299 * renderTex.r + 0.587 * renderTex.g + 0.114 * renderTex.b;
        fixed4 finalColor = lerp(renderTex, luminosity, _LuminosityAmount);
        return finalColor;
    }
    
    【为了防止大家跟我一样,看的一头雾水,我在这里贴一下完整的着色器代码】:
    Shader "Custom/ImageEffect"
    {
      Properties
      {
          _MainTex ("Albedo (RGB)", 2D) = "white" {}
          _LuminosityAmount ("GrayScale Amount", Range(0.0, 1)) = 1.0
      }
      SubShader
      {
          Tags { "RenderType"="Opaque" }
          LOD 200
          Pass
          {
              CGPROGRAM
              #pragma vertex vert_img
              #pragma fragment frag
              #pragma fragmentoption ARB_precision_hint_fastest
              #include "UnityCG.cginc"
              uniform sampler2D _MainTex;
              fixed _LuminosityAmount;
    
              fixed4 frag(v2f_img i) : COLOR
              {
                  fixed4 renderTex = tex2D(_MainTex, i.uv);
                  float luminosity = 0.299 * renderTex.r + 0.587 * renderTex.g + 0.114 * renderTex.b;
                  fixed4 finalColor = lerp(renderTex, luminosity, _LuminosityAmount);
                  return finalColor;
              }
              ENDCG
          }
      }
      FallBack "Diffuse"
    }
    

    当完成我们的着色器之后,返回到Unity编辑器让它编译着色器并且看看有没有遇到任何的错误。如果没有,就将这个新的着色器拖拽并且赋值给 TestRenderImage.cs 脚本,并且修改脚本上面的灰度变量的值。你应该可以在游戏窗口中看到游戏从有颜色变为灰色。下面的图片演示了这个屏幕效果:
    diagram
    当完成了这些之后,我们就有了一个非常方便的途径去测试新的屏幕效果着色器,这样就不用反复的去编写 屏幕效果系统(Screen Effect system) 的代码了。接下来让我们更深入的去了解一下在 渲染纹理(Render Texture) 中都发生了什么,并且在整个过程中是如何处理它的。

  • 原理介绍
    为了完成我们的屏幕效果并且在Unity中能运行起来,我们需要创建一个脚本和一个着色器。这个脚本在Unity的编辑器上会实时刷新,它也负责从从主摄像机中捕获 渲染纹理(Render Texture) 并且把它传到着色器中。一旦这个渲染纹理到达着色器,我们就可以使用这个着色器进行逐像素操作。


    在脚本的开始运行的时候,我们进行了一些检测,这样是为了确保我们当前选择build的平台是否支持屏幕效果,是否支持我们的着色器。它们是当前平台是否支持屏幕效果和是否支持我们使用的着色器的实例。所以检查一下我们在 Start() 方法中所做的事情,确保如果当前平台不支持屏幕效果的时候不会有任何的报错。


    当我们的游戏脚本通过了检测之后,我们就通过调用Unity内建的 OnRenderImage() 方法来初始化屏幕效果系统。这个方法负责抓取渲染纹理,并且通过调用 Graphics.Blit() 方法将它传给着色器,并且将处理好的图像返回给Unity的渲染器。你可以通过下面的两个链接找到这两个方法更详细的信息:


    一旦当前的渲染纹理到达着色器后,着色器就通过 frag() 函数处理获取到的纹理,并且返回每一个像素最终颜色。


    可以看到这很强大,对于游戏的最终渲染图像,它能给我们类似于Photoshop这种工具一样控制。这些屏幕效果就像Photoshop中的层级概念一样在摄像机中按次序的工作。当你把这些屏幕效果依次前后叠好,它们就会按照排列顺序处理。这些仅仅是屏幕效果工作过程的一个大致步骤,但也是屏幕效果系统如何工作的核心。


  • 额外内容
    现在我们完成了一个简单的屏幕效果系统并且能够运行,让我们来了解一下我们能从Unity的渲染器中获得的一些其他的有用的信息:
    diagram
    我们可以通过打开Unity内建的深度模式来让当前游戏中的所有物体都有深度效果。一旦深度效果打开,我们就可以为很多不同的效果使用深度信息。让我们来了解一下这是怎么实现的:
    • 1.创建一个名为 SceneDepth_Effect 的着色器。然后双击打开编辑。
    • 2.我们将会创建两个属性,主纹理属性和深度控制属性。深度控制属性用来控制场景深度的程度。在你的着色器中输入下面的代码:
    Properties
      {
          _MainTex ("Albedo (RGB)", 2D) = "white" {}
          _DepthPower ("Depth Power", Range(1, 5)) = 1
      }
    
    • 3.接下来我们要在 CGPROGRAM 块中添加相应的变量。我们将添加一个叫 _CameraDepthTexture 的额外变量。这是Unity通过UnityCG cginclude 文件提供给我们的一个内建的变量。它提供了来自摄像机的深度信息:
    Pass
    {
        CGPROGRAM
        #pragma vertex vert_img
        #pragma fragment frag
        #pragma fragmentoption ARB_precision_hint_fastest
        #include "UnityCG.cginc"
    
        uniform sampler2D _MainTex;
        fixed _DepthPower;
        sampler2D _CameraDepthTexture;
    
    • 4.我们将使用Unity提供的几个内建的函数来完成我们的 深度着色器(depth shader) ,一个是 UNITY_SAMPLE_DEPTH() ,另一个是 linear01Depth() 。第一个函数从变量 _CameraDepthTexture 中获得深度信息并且为每一个像素生成一个单精度浮点值。Linear01Depth() 函数确保这个单精度浮点值能控制在 [ 0 - 1 ]范围内,因为这个值要作为指数,因为在场景内[ 0 - 1 ]之间的值能确保它的位置在摄像机内【翻译不太准确】:
    fixed4 frag(v2f_img i) : COLOR
    {
        float d = UNITY_SAMPLE_DEPTH(tex2D(_CameraDepthTexture, i.uv.xy));
        d = pow(Linear01Depth(d), _DepthPower);
        return d;
    }
    
    • 5.完成我们的着色器之后,让我们把注意力转移到我们的屏幕效果脚本上来。我们需要给脚本添加一个 depthPower 变量,这样我们就可以让用户在编辑器上去修改这个值:
    #region Variables
    public Shader curShader;
    public Material curMaterial;
    public float depthPower = 1.0f;
    #endregion
    
    • 6.我们的 OnRenderImage() 方法需要更新一下好让我们能够传递正确的值给着色器:
    private void OnRenderImage(RenderTexture src, RenderTexture dest)
      {
          if (curShader != null)
          {
              material.SetFloat("_DepthPower", depthPower);
              Graphics.Blit(src, dest, material);
          }
          else
          {
              Graphics.Blit(src, dest);
          }
      }
    
    • 7.要完成我们的屏幕深度效果,我们需要告诉Unity让它在当前的摄像机上打开深度渲染。我们可以通过设置摄像机的 depthTextureMode 属性来达到目的:
    private void Update()
      {
          Camera.main.depthTextureMode = DepthTextureMode.Depth;
          depthPower = Mathf.Clamp(depthPower, 0, 5);
      }
    

    当代码写好后,保存你的脚本和着色器然后返回到Unity编辑器让它们完成编译。如果没有遇到什么错误,你将会看到如下图类似的结果:
    diagram