第九章 游戏和屏幕效果

当我们要创建可信和沉浸的游戏的时候,我们要考虑的不仅仅只有材料。屏幕效果也会改变游戏的整体感觉。这在电影里面非常常见,比如后期制作阶段中的调色。使用 第八章 通过Unity渲染纹理实现屏幕效果 中学到的知识,你也可以在游戏中实现这些技术。在这一章将会呈现两个有趣的效果;当然,你可以适当修改它们以适用于你的需求,也可以创建完全属于你自己屏幕效果。

在这一章,你将会学到下面的这些知识点:

  • 创建一个老电影屏幕效果
  • 创建一个夜视屏幕效果

  • 介绍
    如果你正在阅读这本书,你很可能玩过一两个游戏。即时游戏一方面会使玩家进入一个沉浸世界,让人觉得他们好像在现实世界玩游戏一样。现代的游戏利用的屏幕效果越多获得的沉浸感也越多。

    通过屏幕效果,我们可以将在某个确切环境中的心境从平静转为惊恐,仅仅只要改变屏幕看起来的样子。想象一下走进了某个关卡中的房间,然后游戏突然接管并且将你带进一个电影时刻。很多现代游戏都会使用不同的屏幕效果来改变不同时刻的一个心境。理解如何创建在游戏中使用的效果是我们学习编写着色器的下一个旅程。

    在这一章,我们将了解一些更加常用的游戏中的屏幕效果。你将会学习如何改变游戏的样子,把它从正常的样子改成一个老电影效果的样子,并且我们还会去了解大多数 FPS(first-person shooter第一人称射击) 游戏是如何使他们的夜视效果呈现在屏幕中的。通过这些知识点,我们将了解如何将这些效果跟游戏中的事件关联起来,好让游戏根据当前演出的需要去打开或者关闭这些特效。

创建一个老电影屏幕效果

很多游戏背景设定在不同的时期。有些发生在幻想世界或者科幻世界,更有甚者发生在旧西部,那个时候电影摄像机才刚刚发明并且人们看到的都是一些黑白电影或者棕褐色效果色调的电影。它们看起来格外不同,我们将在Unity中用屏幕效果来复制这种看起来的样子。

获得这种效果需要一些步骤,如果要将整个屏幕变成黑或白或灰,我们需要将这个效果分解成不同的组成部分。如果我们分析一些相关的老电影的镜头,我们就可以开始做这个了。让我们来看看下面这张图片并且分解其中的元素,看看是那些构成了这个老电影的样子:
diagram

我们用一些在网上找到的图片构建了这个图片。像这样尝试利用Photoshop来构建图片总是一个很好的主意,它能为你的新的屏幕特效打好一个计划。在这个过程中它不仅能将我们需要用代码编写的元素告诉我们,还提供了一个快捷的方式让我们了解我们的屏幕效果需要使用哪一种混合模式和我们将要构建那些层级。本书这个知识点中我们为Photoshop创建的这些文件的支持网站在http://www.packtpub.com/support[已经失效(译者注)]。它是一个名为 OldFilmEffect_Research_Layout.psd 的文件。


  • 始前准备
    我们现在目的明确,让我们看看最终效果的每一层都包含了什么然后为我们的着色器和C#脚本收集一些资源。

    • 复古色调(Sepia tone): 这是一个相对容易获得的效果,我们只需把原始渲染纹理的所有像素颜色变为一个单一的颜色范围。使用原始图像的亮度然后加上一个常量颜色就可以很容易获得。我们的第一层将会看起来跟下面的图片一样:
      diagram
    • 渐晕效果(Vignette effect): 当一些古老的电影放映机放映老电影的时候,我们经常看到某种软边界[ 我个人觉得翻译的不够准确 ]围绕在老电影的四周。这是因为这种电影放映机使用的球形灯泡发出的光中间部位比周围要亮造成的。这种效果通常叫做渐晕效果并且是我们屏幕效果的第二层。我们可以通过在整个屏幕上覆盖一张纹理来获得这个效果。下面的图片演示了这一层看起来的样子,就是一张纹理:
      diagram
    • 灰尘和划痕(Dust and scratches): 第三层也是最后一层就是我们的老电影屏幕效果中的灰尘和划痕。这一层将使用两种不同的平铺纹理,一种作为划痕然后另一种作为灰尘。原因就是我们将根据时间以不同的速度对这两种纹理做动画。这将会产生一种效果,就是当电影在播放的时候同时在老电影的每一帧中都会有一些细小的划痕和灰尘。下图演示了它们的纹理看起来效果:
      diagram

  • 操作步骤
    老电影屏幕效果的那些独特层级都很简单,但是将它们组合之后我们就能获得令人震惊的视觉效果。让我们缕一缕该怎么构建我们的脚本和着色器,之后我们就能逐行解析并且学习为什么可以那样写。此时,你应该有个设置好的屏幕效果系统而且能顺利运行,因为我们这个知识点不会涵盖如何设置这个系统。
    • 1.我们将添加脚本代码。我们要输入的第一个代码块将定义我们的变量,这些变量会在 检查器 (Inspector) 上显示,好让这个效果的使用者可以可以用想填的数据修改它们。如我们还想在检查器上显示我们效果用到的那些我们处理好的Photoshop文件,也可以在此添加它们的引用。在脚本中添加下面的代码:
    #region Variables
    public Shader oldFilmShader;
    
    public float OldFilmEffectAmount = 1.0f;
    public float contrast = 3.0f;
    public float distortion = 0.2f;
    public float cubicDistortion = 0.6f;
    public float scale = 0.8f;
    
    public Color sepiaColor = Color.white;
    public Texture2D vignetteTexture;
    public float vignetteAmount = 1.0f;
    
    public Texture2D scratchesTexture;
    public float scratchesYSpeed = 10.0f;
    public float scratchesXSpeed = 10.0f;
    
    public  Texture2D dustTexture;
    public float dustYSpeed = 10.0f;
    public float dustXSpeed = 10.0f;
    
    private Material curMaterial;
    private float randomValue;
    #endregion
    
    • 2.接下来,我们需要修改 OnRenderImage() 方法中的内容了。在这里,我们将把脚本中变量的数据传给着着色器好让着色器在处理渲染纹理的时候可以使用这些数据:
    private void OnRenderImage(RenderTexture src, RenderTexture dest)
    {
        if(oldFilmShader != null)
        {	
            material.SetColor("_SepiaColor", sepiaColor);
            material.SetFloat("_VignetteAmount", vignetteAmount);
            material.SetFloat("_EffectAmount", OldFilmEffectAmount);
            material.SetFloat("_Contrast", contrast);
            material.SetFloat("_cubicDistortion", cubicDistortion);
            material.SetFloat("_distortion", distortion);
            material.SetFloat("_scale",scale);
    
            if(vignetteTexture)
            {
                material.SetTexture("_VignetteTex", vignetteTexture);
            }
    
            if(scratchesTexture)
            {
                material.SetTexture("_ScratchesTex", scratchesTexture);
                material.SetFloat("_ScratchesYSpeed", scratchesYSpeed);
                material.SetFloat("_ScratchesXSpeed", scratchesXSpeed);
            }
    
            if(dustTexture)
            {
                material.SetTexture("_DustTex", dustTexture);
                material.SetFloat("_dustYSpeed", dustYSpeed);
                material.SetFloat("_dustXSpeed", dustXSpeed);
                material.SetFloat("_RandomValue", randomValue);
            }
    
            Graphics.Blit(src, dest, material);
        }
        else
        {
            Graphics.Blit(src, dest);
        }
    }
    
    • 3.为了完成特效的脚本部分,接下来只要确保把变量值控制在合理的范围而不是任意值就可以了。
    private void Update()
    {
        vignetteAmount = Mathf.Clamp01(vignetteAmount);
        OldFilmEffectAmount = Mathf.Clamp(OldFilmEffectAmount, 0f, 1.5f);
        randomValue = Random.Range(-1f,1f);
        contrast = Mathf.Clamp(contrast, 0f, 4f);
        distortion = Mathf.Clamp(distortion, -1f,1f);
        cubicDistortion = Mathf.Clamp(cubicDistortion, -1f, 1f);
        scale = Mathf.Clamp(scale, 0f, 1f);
    }
    
    注意 作者这里只贴了关键代码,下面是完整的脚本代码[译者注]:
    using UnityEngine;
    [ExecuteInEditMode]
    public class OldFilmEffect : MonoBehaviour
    {
        #region Variables
        public Shader oldFilmShader;
    
        public float OldFilmEffectAmount = 1.0f;
        public float contrast = 3.0f;
        public float distortion = 0.2f;
        public float cubicDistortion = 0.6f;
        public float scale = 0.8f;
    
        public Color sepiaColor = Color.white;
        public Texture2D vignetteTexture;
        public float vignetteAmount = 1.0f;
    
        public Texture2D scratchesTexture;
        public float scratchesYSpeed = 10.0f;
        public float scratchesXSpeed = 10.0f;
    
        public  Texture2D dustTexture;
        public float dustYSpeed = 10.0f;
        public float dustXSpeed = 10.0f;
    
        private Material curMaterial;
        private float randomValue;
        #endregion
    
        #region Properties
        Material material
        {
            get
            {
                if(curMaterial == null)
                {
                    curMaterial = new Material(oldFilmShader);
                    curMaterial.hideFlags = HideFlags.HideAndDontSave;
                }
                return curMaterial;
            }
        }
        #endregion
    
        private void Start()
        {
            if (!SystemInfo.supportsImageEffects)
            {
                enabled = false;
                return;
            }
            if (oldFilmShader && !oldFilmShader.isSupported)
            {
                enabled = false;
            }
        }
    
        private void OnRenderImage(RenderTexture src, RenderTexture dest)
        {
            if(oldFilmShader != null)
            {	
                material.SetColor("_SepiaColor", sepiaColor);
                material.SetFloat("_VignetteAmount", vignetteAmount);
                material.SetFloat("_EffectAmount", OldFilmEffectAmount);
                material.SetFloat("_Contrast", contrast);
                material.SetFloat("_cubicDistortion", cubicDistortion);
                material.SetFloat("_distortion", distortion);
                material.SetFloat("_scale",scale);
    
                if(vignetteTexture)
                {
                    material.SetTexture("_VignetteTex", vignetteTexture);
                }
    
                if(scratchesTexture)
                {
                    material.SetTexture("_ScratchesTex", scratchesTexture);
                    material.SetFloat("_ScratchesYSpeed", scratchesYSpeed);
                    material.SetFloat("_ScratchesXSpeed", scratchesXSpeed);
                }
    
                if(dustTexture)
                {
                    material.SetTexture("_DustTex", dustTexture);
                    material.SetFloat("_dustYSpeed", dustYSpeed);
                    material.SetFloat("_dustXSpeed", dustXSpeed);
                    material.SetFloat("_RandomValue", randomValue);
                }
    
                Graphics.Blit(src, dest, material);
            }
            else
            {
                Graphics.Blit(src, dest);
            }
        }
    
        private void Update()
        {
            vignetteAmount = Mathf.Clamp01(vignetteAmount);
            OldFilmEffectAmount = Mathf.Clamp(OldFilmEffectAmount, 0f, 1.5f);
            randomValue = Random.Range(-1f,1f);
            contrast = Mathf.Clamp(contrast, 0f, 4f);
            distortion = Mathf.Clamp(distortion, -1f,1f);
            cubicDistortion = Mathf.Clamp(cubicDistortion, -1f, 1f);
            scale = Mathf.Clamp(scale, 0f, 1f);
        }
    
        private void OnDisable()
        {
            if (curMaterial)
            {
                DestroyImmediate(curMaterial);
            }
        }
    }
    
    • 4.当我们的脚本完成之后,注意力转移到着色器上来。我们要在着色器上也创建跟脚本变量对应的着色器变量。这样的话可以让脚本跟着色器相互通信。在着色器的 属性块(Properties block) 中添加下面的代码:
    Properties
    {
        _MainTex ("Base (RGB)", 2D) = "white" {}
        _VignetteTex ("Vignette Texture", 2D) = "white"{}
        _ScratchesTex ("Scartches Texture", 2D) = "white"{}
        _DustTex ("Dust Texture", 2D) = "white"{}
        _SepiaColor ("Sepia Color", Color) = (1,1,1,1)
        _EffectAmount ("Old Film Effect Amount", Range(0,1)) = 1.0
        _VignetteAmount ("Vignette Opacity", Range(0,1)) = 1.0
        _ScratchesYSpeed ("Scratches Y Speed", Float) = 10.0
        _ScratchesXSpeed ("Scratches X Speed", Float) = 10.0
        _dustXSpeed ("Dust X Speed", Float) = 10.0
        _dustYSpeed ("Dust Y Speed", Float) = 10.0
        _RandomValue ("Random Value", Float) = 1.0
        _Contrast ("Contrast", Float) = 3.0
    
        _distortion ("Distortion", Float) = 0.2
        _cubicDistortion ("Cubic Distortion", Float) = 0.6
        _scale ("Scale (Zoom)", Float) = 0.8
    }
    
    • 5.接下来,跟往常一样,接下来在 CGPROGRAM 块中添加与属性块对应的变量好让属性块可以跟 CGPROGRAM 块通信:
    uniform sampler2D _MainTex;
    uniform sampler2D _VignetteTex;
    uniform sampler2D _ScratchesTex;
    uniform sampler2D _DustTex;
    fixed4 _SepiaColor;
    fixed _VignetteAmount;
    fixed _ScratchesYSpeed;
    fixed _ScratchesXSpeed;
    fixed _dustXSpeed;
    fixed _dustYSpeed;
    fixed _EffectAmount;
    fixed _RandomValue;
    fixed _Contrast;
    float _distortion;
    float _cubicDistortion;
    float _scale;
    
    • 6.现在,我们简单的将 frag() 函数内容修改一下好让我们能处理屏幕效果上的像素。让我们获取来自脚本的 渲染纹理(render texture)渐晕纹理(vignette texture)
    fixed4 frag(v2f_img i) : COLOR
    {
        //获得渲染纹理的颜色并且获取 v2f_img的uv
        half2 distortedUV = barrelDistortion(i.uv);
        distortedUV = half2(i.uv.x, i.uv.y + (_RandomValue * _SinTime.z * 0.005));
        fixed4 renderTex = tex2D(_MainTex, i.uv);
    
        //获取渐晕纹理的像素
        fixed4 vignetteTex = tex2D(_VignetteTex, i.uv);
    
    • 7.我们接下来添加处理划痕和灰尘的代码:
    //处理划痕的UV和像素
    half2 scratchesUV = half2(i.uv.x + (_RandomValue * _SinTime.z * _ScratchesXSpeed), i.uv.y + (_Time.x * _ScratchesYSpeed));
    fixed4 scratchesTex = tex2D(_ScratchesTex, scratchesUV);
    //处理灰尘的UV和像素
    half2 dustUV = half2(i.uv.x + (_RandomValue * (_SinTime.z * _dustXSpeed)), i.uv.y + (_RandomValue * (_SinTime.z * _dustYSpeed)));
    fixed4 dustTex = tex2D(_DustTex, dustUV);
    
    • 8.接下来处理深褐色色调:
    //使用YIQ值从渲染纹理中获取亮度值。
    fixed lum = dot(fixed3(0.299, 0.587, 0.114), renderTex.rgb);
    //给亮度值加上一个常量颜色
    fixed4 finalColor = lum + lerp(_SepiaColor, _SepiaColor + fixed4(0.1f, 0.1f, 0.1f, 1.0f), _RandomValue);
    finalColor = pow(finalColor, _Contrast);
    
    • 9.最后,我们将所有的层和颜色都叠加到一块并将最终的屏幕效果纹理返回:
    //创建一个白色的常量颜色,这样我们可以调节效果的不透明度。
        fixed3 constantWhite = fixed3(1,1,1);
        //将不同的层混合到一起来创建最终的屏幕效果
        finalColor = lerp(finalColor, finalColor * vignetteTex, _VignetteAmount);
        finalColor.rgb *= lerp(scratchesTex, constantWhite, (_RandomValue));
        finalColor.rgb *= lerp(dustTex.rgb, constantWhite, (_RandomValue * _SinTime.z));
        finalColor = lerp(renderTex, finalColor, _EffectAmount);
        return finalColor;
    }
    
    • 10.当我们把所有代码都完成并且没有遇到错误,你应该有个跟下图很相似的结果。在编辑器中点击运行按钮,就可以看到灰尘效果和划痕效果,而且可以看到屏幕效果上的轻微的图片位移:
      diagram
      注意 作者少说了一个函数,将下面的 barrelDistortion 函数添加到 frag() 函数上面[译者注]:
    float2 barrelDistortion(float2 coord) 
      {
      	// Inspired by SynthEyes lens distortion algorithm
      	// See http://www.ssontech.com/content/lensalg.htm
      	float2 h = coord.xy - float2(0.5, 0.5);
      	float r2 = h.x * h.x + h.y * h.y;
      	float f = 1.0 + r2 * (_distortion + _cubicDistortion * sqrt(r2));
      	return f * _scale * h + 0.5;
      }
    

  • 原理介绍
    现在让我们来逐步分析这个屏幕效果的每一层,剖析每一行代码的工作原理,以及对于这个屏幕效果我们该如何丰富它这方面获得更多的简洁。

    现在我们的老电影屏幕效果正常工作了,让我们逐行看看 frag() 函数中的代码,其实本书中的其他代码在此刻都比较不解自明。

    就像我们的Photoshop层一样,着色器会处理每一层并且把它们合到一起,所以当我们查看每一层的时候,试着想想Photoshop中的层的工作方式。请留意这个概念,对于我们开发新的屏幕效果很有帮助。

    这里,是我们 frag() 函数第一部分设置的代码:
    fixed4 frag(v2f_img i) : COLOR
    {
        //获得渲染纹理的颜色并且获取 v2f_img的uv
        half2 distortedUV = barrelDistortion(i.uv);
        distortedUV = half2(i.uv.x, i.uv.y + (_RandomValue * _SinTime.z * 0.005));
        fixed4 renderTex = tex2D(_MainTex, i.uv);
    
    frag() 函数声明后的第一行代码,定义了对于我们的主渲染纹理来说,UV应该如何工作,亦或者说游戏实际的渲染帧。因为我们期望去模仿一种老电影的风格,我们想在每一帧中去修改渲染纹理,来模拟那种闪烁。这种闪烁刚好可以模拟老式电影放映机卷胶卷。这个告诉我们我们需要对UV做动画然后这也是第一行代码做的事情。

    我们使用了Unity提供的内建的 _SinTime 变量,来获取 -11 之间的值。然后将这个值乘以了一个非常小的数,这里的话是0.005,这么做是为了减少效果的灵敏度。最后获得结果再乘以 _RandomValue 变量,这个变量是在C#中生成的。这个值也会在 -11 之间随意变化,这种正负的变化会让动画播放方向发生前后变化。

    当我们的UV构建好并且保存到 distortedUV 变量中后,我们就可以使用 tex2D() 函数来对渲染纹理进行采样。这个操作会给我们最终的渲染纹理,这个纹理之后会在着色器的剩余部分用来进行各种处理。

    我们看看上面代码的最后一行,我们仅是简单的用 tex2D() 函数对渐晕纹理进行采样。我们不需要使用我们创建的有动画的UV,因为渐晕纹理会自己平铺到摄像机动画中并且也不会影响摄像机影像的闪烁。

    下面的代码片段阐明了 frag() 函数中的第二部分的代码行:
    //处理划痕的UV和像素
    half2 scratchesUV = half2(i.uv.x + (_RandomValue * _SinTime.z * _ScratchesXSpeed), i.uv.y + (_Time.x * _ScratchesYSpeed));
    fixed4 scratchesTex = tex2D(_ScratchesTex, scratchesUV);
    
    //处理灰尘的UV和像素
    half2 dustUV = half2(i.uv.x + (_RandomValue * (_SinTime.z * _dustXSpeed)), i.uv.y + (_RandomValue * (_SinTime.z * _dustYSpeed)));
    fixed4 dustTex = tex2D(_DustTex, dustUV);
    
    这部分代码跟前面代码很相似,我们需要生成独有的有动画的UV值来修改 屏幕效果层(screen effect layers) 的位置。我们使用了Unity内建的 _SinTime 变量来获取 -11 之间的值,然后把它乘以一个随机值,之后再乘以一个值来调整动画的整体播放速度。一旦这些UV值生成后,我们就可以使用这些有动画的数值来对灰尘纹理和划痕纹理进行采样。

    我们接下来的部分代码主要是负责为我们的老电影屏幕效果生成颜色相关的效果。由下面的这些代码片段来展示:
    //使用YIQ值从渲染纹理中获取亮度值。
    fixed lum = dot(fixed3(0.299, 0.587, 0.114), renderTex.rgb);
    
    //给亮度值加上一个常量颜色
    fixed4 finalColor = lum + lerp(_SepiaColor, _SepiaColor + fixed4(0.1f, 0.1f, 0.1f, 1.0f), _RandomValue);
    finalColor = pow(finalColor, _Contrast);
    
    来看看这部分的代码设置,我们为整个渲染纹理创建了一个实际的颜色着色效果。为了完成这些工作,我们需要将渲染纹理转换成一个灰度版的渲染纹理。为了得到这个灰度版的渲染纹理,我们使用了 YIQ 值提供的亮度值。YIQ 值是 NTSC颜色TV系统使用的颜色空间。YIQ中的每一个字母都保存了一个TV使用的颜色常量,这样可以让颜色的调整过程更具可读性。

    当然没有必要去确切的知道这些色度的意义,这里只需要知道 YIQ 中的 Y 是表示任何图像的亮度值常量。所以我们可以通过获取渲染纹理中的每一个像素来生成我们渲染纹理的灰度图像,之后再把它与我们的亮度值进行点积操作。这正是这部分代码的第一行所作的事情。

    当我们拥有这些亮度值之后,我们就可以简单的加上我们想让图像着色的目标颜色。这个颜色是从我们的脚本中传过来给着色器的,这个颜色保存在我们的 CGPROGRAM 代码块中的变量中,我们在这部分代码这里将它加给了我们的灰度渲染纹理。当这些完成之后,我们将有一张完美的着色了的图像了。

    最终,我们在屏幕效果的每一层之间创建了混合的代码。下面的代码片段展示的正是我们想看到的这部分代码:
    //创建一个白色的常量颜色,这样我们可以调节效果的不透明度。
    fixed3 constantWhite = fixed3(1,1,1);
    
    //将不同的层混合到一起来创建最终的屏幕效果
    finalColor = lerp(finalColor, finalColor * vignetteTex, _VignetteAmount);
    finalColor.rgb *= lerp(scratchesTex, constantWhite, (_RandomValue));
    finalColor.rgb *= lerp(dustTex.rgb, constantWhite, (_RandomValue * _SinTime.z));
    finalColor = lerp(renderTex, finalColor, _EffectAmount);
    return finalColor;
    
    我们最后一部分的代码设置相对来说比较简单,不需要做太多的解释。简单来说就是通过 multiply 操作将所有的层合到一起从而获得我们想要的最终结果。就跟我们在Photoshop中把所有层合到一起一样,只是我们在这里是通过着色器将它们合到一起。每一层都通过一个 lerp() 函数来处理,这样的话我们就可以调整每一层的不透明度,从而让我们对最终效果有在艺术性上有更好的控制。对最终呈现的屏幕效果来说,操控选项越多越好。