【Unity Shaders】游戏性和画面特效——创建一个老电影式的画面特效

时间:2021-03-18 07:16:12

本系列主要参考《Unity Shaders and Effects Cookbook》一书(感谢原书作者),同时会加上一点个人理解或拓展。

这里是本书所有的插图。这里是本书所需的代码和资源(当然你也可以从官网下载)。

========================================== 分割线 ==========================================

写在前面

终于到了本书的最后一章了,好激动有木有!作为压轴章,虽然只有两篇,但每篇的内容是比之前的任何一篇都要复杂。写完这章要去总结一下啦~那么,开始学习吧!

学习这本书的人绝大部分在业余时间玩过一两个游戏。那么,你肯定有感触,实时游戏的一个很重要的特性就是要让玩家有种身临其境的感觉。越是现代的游戏,越是使用了更多的画面特效来达到这种沉浸感。

使用画面特效,只需通过改变游戏画面,我们就可以把某个环境的氛围烘托成冷静到恐怖各个层次。想象我们在某个关卡中走入一个房间,然后游戏接管,开始播放过场动画。许多现代游戏都是用了不同的画面特效来改变当前时刻的气氛。而理解怎样创建这些在游戏性中被触发的画面特效将是我们下面要完成的工作。

在本章中,我们将会学习一些常见的游戏画面特效。这些包括,如何把一个正常的画面改变成一个老电影式的画面效果,在许多第一人称射击游戏中如何在屏幕上应用夜视效果(night vision effects)。

首先,我们来学习如何创建一个老电影式的画面特效。

游戏往往会建立在不同的背景时间上。一些发生在想象的世界中,一些发生在未来世界中,还有一些甚至发生在古老的西方,而那时候电影摄像机才刚刚发展起来,人们看到的电影都是黑白的,有时还会呈现出棕褐色调(a sepia effect,Unity Pro中有自带的脚本和Shader)的着色效果。这种效果看起来非常独特,我们将在Unity中使用画面特效来重现这种效果。

实现这个效果需要一些步骤。我们先来分析一下下面的图像,然后分解制作这样老电影视觉的步骤:

【Unity Shaders】游戏性和画面特效——创建一个老电影式的画面特效

上面的图像实际是有一系列从网上找到的图片组合起来实现的。我们可以利用Photoshop来创建这样风格的图片,来帮助你完成画面特效的草图。进行这样的过程(在Photoshop里制作原型)不仅可以告诉我们需要哪些元素,还可以快速让我们知道应该使用哪些混合模式,以及如何构建屏幕特效的图层(layers)。不过作者说的Photoshop源文件我没有找到。。。

本文最后实现的效果大概就是下面这样啦:

【Unity Shaders】游戏性和画面特效——创建一个老电影式的画面特效

而原始的画面是:

【Unity Shaders】游戏性和画面特效——创建一个老电影式的画面特效

准备工作

现在让我们来看一下每一个图层是如何被组合在一起从而创建出最后的效果的,以便我们为Shader和脚本准备下所需的资源。

  • 棕褐色调(Sepia Tone):这种效果是比较容易是新建的,我们只需要从原始的render texture中把所有像素颜色转换到一个单一的颜色范围即可。这可以通过使用原始图像的光度(luminance)加上一个常量颜色值来实现。我们第一个图层看起来像下面这样:
    【Unity Shaders】游戏性和画面特效——创建一个老电影式的画面特效
  • 晕影效果(Vignette effect):我们总是可以看到,当使用老的电影投影机把老电影投影到屏幕上时,总有些模糊的边框。这是因为,老式投影仪使用的灯泡在*的亮度高于四周的亮度。这种效果通常被称为晕影效果(Vignette Effect),而这正是我们屏幕特效的第二个图层。我们可以使用一张叠加的纹理覆盖在整个屏幕上来达到这种效果。下面的图像展示了这个图层单独看起来的样子:
    【Unity Shaders】游戏性和画面特效——创建一个老电影式的画面特效
  • 灰尘(Dust)和划痕(Scratches):最后一层图层就是灰尘(Dust)和划痕(Scratches)了。这个图层利用了两张不同的平铺(tiled)纹理,一个用于灰尘,一个用于划痕。使用它们的原因是因为我们想要使用不同的平铺速率,按时间来移动这两张纹理。由于老电影的每一帧通常都会出现一些小的划痕和灰尘,这使得整个画面看起来像电影正在放映。下面的图片展示了这个图层单独看起来的效果:
    【Unity Shaders】游戏性和画面特效——创建一个老电影式的画面特效

上面是分析了Photoshop里面各图层的样子和实现。现在,我们来使用上述纹理在Unity里正式实现我们的画面特效!

  1. 准备好一张晕影(Vignette)纹理,一张灰层纹理,一张划痕纹理,你可以在本书资源(见文章最上方)里找到。
  2. 创建一个新的脚本,命名为OldFilmEffect.cs。创建一个新的Shader,命名为OldFilmEffectShader.shader。
  3. 使用前一章第一篇里的代码填充上述新的脚本和Shader。
  4. 把OldFilmEffect脚本添加到Camera上,并使用OldFilmEffectShader给OldFilmEffect脚本中的Cur Shader赋值。

实现

我们的老电影式的画面特效中的每一个独立图层实际都很简单,但是,当我们把它们整合在一起我们就可以得到非常震撼的效果。现在你的画面特效脚本系统应该已经建立好了,现在我们来实现具体的脚本和Shader。
首先,我们来填写脚本的主要代码。
  1. 第一步我们要定义一些需要在面板中显示的变量,以便让用户进行调整。我们可以利用之前制作原型所用的Photoshop作为参考,来决定我们需要显示哪些变量。在脚本中添加如下代码:
    	#region Variables
    public Shader oldFilmShader; public float oldFilmEffectAmount = 1.0f; public Color sepiaColor = Color.white;
    public Texture2D vignetteTexture;
    public float vignetteAmount = 1.0f; public Texture2D scratchesTexture;
    public float scratchesXSpeed;
    public float scratchesYSpeed; public Texture2D dustTexture;
    public float dustXSpeed;
    public float dustYSpeed; private Material curMaterial;
    private float randomValue;
    #endregion
  2. 然后,我们需要填充OnRenderImage函数。在这个函数里,我们将要把上述变量传递给Shader,使得Shader可以使用这些数据来处理render texture:
    	void OnRenderImage (RenderTexture sourceTexture, RenderTexture destTexture){
    if (oldFilmShader != null) {
    material.SetColor("_SepiaColor", sepiaColor);
    material.SetFloat("_VignetteAmount", vignetteAmount);
    material.SetFloat("_EffectAmount", oldFilmEffectAmount); if (vignetteTexture) {
    material.SetTexture("_VignetteTex", vignetteTexture);
    } if (scratchesTexture) {
    material.SetTexture("_ScratchesTex", scratchesTexture);
    material.SetFloat("_ScratchesXSpeed", scratchesXSpeed);
    material.SetFloat("_ScratchesYSpeed", scratchesYSpeed);
    } if (dustTexture) {
    material.SetTexture("_DustTex", dustTexture);
    material.SetFloat("_DustXSpeed", dustXSpeed);
    material.SetFloat("_DustYSpeed", dustYSpeed);
    material.SetFloat("_RandomValue", randomValue);
    } Graphics.Blit(sourceTexture, destTexture, material);
    } else {
    Graphics.Blit(sourceTexture, destTexture);
    }
    }
  3. 最后,我们需要在Update函数中保证一些变量的范围:
    	void Update () {
    vignetteAmount = Mathf.Clamp(vignetteAmount, 0.0f, 1.0f);
    oldFilmEffectAmount = Mathf.Clamp(oldFilmEffectAmount, 0.0f, 1.0f);
    randomValue = Random.Range(-1.0f, 1.0f);
    }

接下来,我们来实现关键的Shader部分。

  1. 首先,我们需要创建对应的Properties。这使得脚本和Shader之间可以进行通信。在Properties块中输入如下代码:
    	Properties {
    _MainTex ("Base (RGB)", 2D) = "white" {}
    _VignetteTex ("Vignette Texture", 2D) = "white" {}
    _VignetteAmount ("Vignette Opacity", Range(0, 1)) = 1
    _ScratchesTex ("Scraches Texture", 2D) = "white" {}
    _ScratchesXSpeed ("Scraches X Speed", Float) = 10.0
    _ScratchesYSpeed ("Scraches Y Speed", Float) = 10.0
    _DustTex ("Dust Texture", 2D) = "white" {}
    _DustXSpeed ("Dust X Speed", Float) = 10.0
    _DustYSpeed ("Dust Y Speed", Float) = 10.0
    _SepiaColor ("Sepia Color", Color) = (1, 1, 1, 1)
    _EffectAmount ("Old Film Effect Amount", Range(0, 1)) = 1
    _RandomValue ("Random Value", Float) = 1.0
    }
  2. 和往常一样,我们需要在CGPROGRAM块中添加对应的变量,以便Properties块可以和CGPROGRAM块通信:
    	SubShader {
    Pass {
    CGPROGRAM #pragma vertex vert_img
    #pragma fragment frag #include "UnityCG.cginc" uniform sampler2D _MainTex;
    uniform sampler2D _VignetteTex;
    uniform sampler2D _ScratchesTex;
    uniform sampler2D _DustTex;
    fixed4 _SepiaColor;
    fixed _VignetteAmount;
    fixed _ScratchesXSpeed;
    fixed _ScratchesYSpeed;
    fixed _DustXSpeed;
    fixed _DustYSpeed;
    fixed _EffectAmount;
    fixed _RandomValue;
  3. 现在,我们来填充最关键的frag函数,在这里我们将真正处理画面特效中的每一个像素。首先,我们来获取render texture和晕影纹理(Vignette texture):
    			fixed4 frag (v2f_img i) : COLOR {
    half2 renderTexUV = half2(i.uv.x, i.uv.y + (_RandomValue * _SinTime.z * 0.005));
    fixed4 renderTex = tex2D(_MainTex, renderTexUV); // Get teh pixed from the Vignette Texture
    fixed4 vignetteTex = tex2D(_VignetteTex, i.uv);

    解释:frag函数是整个特效的关键所在。和Photoshop中的图层类似,我们的Shader也是处理的每一个图层,然后把它们再结合在一起。因此,我们的分析过程也是按每个图层,你可以想象Photoshop中图层是如何工作的。这样的思维可以帮助我们将来创建新的画面特效。

    这里的几行代码定义了UV坐标是如何为render texture工作的。由于我们想要模仿一个老电影的风格,我们可以在每一帧调整render texture的UV坐标,来模拟一个闪烁的效果。

    第一、二行对render texture的Y方向添加了一些偏移来达到上述的闪烁效果。它使用了Unity内置的_SinTime变量,来得到一个范围在-1到1的正弦值。然后再乘以了一个很小的值0.005,来得到一个小范围的偏移(-0.005, +0.005)。最后的值又乘以了_RandomValue变量,这是我们在脚本中定义的变量,它在Update函数中被随机生成为-1到1中的某一个值,来实现上下随机弹动的效果。在得到UV坐标后,我们在第二行使用了tex2D()函数在render texture上进行采样。

    最后一行很简单,直接使用tex2D()函数对晕影纹理进行采样,不需要再移动UV坐标了。

    通过上述代码我们现在得到了底色(renderTex)和第一层图层(vignetteTex)。

  4. 然后,我们需要添加对灰尘(dust)和划痕(scratches)的处理。添加如下代码:
    				// Process the Scratches UV and pixels
    half2 scratchesUV = half2(i.uv.x + (_RandomValue * _SinTime.z * _ScratchesXSpeed),
    i.uv.y + (_Time.x * _ScratchesYSpeed));
    fixed4 scratchesTex = tex2D(_ScratchesTex, scratchesUV); // Process the Dust UV and pixels
    half2 dustUV = half2(i.uv.x + (_RandomValue * _SinTime.z * _DustXSpeed),
    i.uv.y + (_Time.x * _DustYSpeed));
    fixed4 dustTex = tex2D(_DustTex, dustUV);

    解释:这些代码和上一步中的很类似,也就是我们需要生成移动后的UV坐标来修改当前图层在画面特效中的位置。我们还是使用了内置的_SinTime变量来得到一个-1到1范围内的值,再乘以我们的随机值_RandomValue,最后再乘以一个系数来调整移动的整体速度。一旦生成UV坐标后,我们就可以使用tex2D函数对灰尘纹理和划痕纹理进行采样。

    通过上述代码,我们得到了第二层图层中的scratchesTex和dustTex。

  5. 然后,处理棕褐色调(Sepia Tone):
    				// Get the luminosity values from the render texture using the YIQ values
    fixed lum = dot(fixed3(0.299, 0.587, 0.114), renderTex.rgb); // Add the constant calor to the lum values
    fixed4 finalColor = lum + lerp(_SepiaColor, _SepiaColor + fixed4(0.1f, 0.1f, 0.1f, 0.1f), _RandomValue);

    解释:这一步是处理老电影效果的颜色。通过上述代码,我们给整个画面染上了一种发黄的颜色。首先,我们把render texture转换到它的灰度版本(第一行)。我们使用了YIQ值中的光度值(luminosity,即Y表示的意思)来完成这个目的。YIQ值是NTSC电视系统标准使用的颜色空间。更多的关于YIQ颜色的内容可以参考文章最后的链接。这里我们只要知道,YIQ中的Y值就是任意图像的光度常量值,也就是说对任意图像我们都可以通过乘以这个常量值来得到这个图像的每个像素的光度值(luminosity)。因此,我们可以通过把render texture中的每一个像素点乘光度常量系数,来生成一个灰度图。这也就是第一行所做的事情。

    一旦我们得到光度值后,我们可以简单地添加一个颜色,来得到我们想要图像锁呈现的色调。这个颜色(_SepiaColor)是脚本传递给Shader的。我们还使用了一个lerp函数,其右边界值是_SepiaColor加上一个常量后所得到的一个更亮的颜色,并且以_RandomValue作为第三个参数,来模拟一个光度上的闪烁效果。

    通过上述代码,我们得到了第三层图层中的棕褐色调(暂时存储在finalColor)。

  6. 最后,我们把上述图层和颜色结合在一起,返回最终的画面颜色:
    				// Create a constant white color we can use to adjust opacity of effects
    fixed3 constantWhite = fixed3(1, 1, 1); // Composite together the different layers to create final Screen Effect
    finalColor = lerp(finalColor, finalColor * vignetteTex, _VignetteAmount);
    finalColor.rgb *= lerp(scratchesTex, constantWhite, _RandomValue);
    finalColor.rgb *= lerp(dustTex, constantWhite, (_RandomValue * _SinTime.z));
    finalColor = lerp(renderTex, finalColor, _EffectAmount); return finalColor;

    解释:最后,我们把每一个图层混合在一起完成最终的画面特效。这里,我们把所有图层乘起来,就像我们在Photoshop中将所有图层乘起来一样(当然那里是使用了混合模式)。每一个图层还使用了一个lerp函数以便我们可以调整透明度。

    其实这里没有非常清晰的解释,可以看出来上述lerp函数的参数很多同样使用了随机数来模拟一个闪烁弹动的效果。第一个lerp(对应vignetteTex)比较简单,我们可以通过在面板中调整Vignette Amount来调整晕影纹理的透明度。第二个lerp(对应scrachesTex)的右边界值是(1, 1, 1, 1),来模拟画面中划痕时隐时现的效果。第三个lerp(对应dustTex)的右边界同样使用了(1, 1, 1, 1),而且第三个参数还乘以了_SinTime,好吧,这里我也不知道为什么。。。最后一个lerp(对应renderTex)很好理解,此时的finalColor是所有图层相乘后得到的最终老电影效果,通过调整面板的Effect Amount可以控制画面特效的透明度。

完整的脚本和Shader如下:
OldFilmEffect脚本:
using UnityEngine;
using System.Collections; [ExecuteInEditMode]
public class OldFilmEffect : MonoBehaviour { #region Variables
public Shader oldFilmShader; public float oldFilmEffectAmount = 1.0f; public Color sepiaColor = Color.white;
public Texture2D vignetteTexture;
public float vignetteAmount = 1.0f; public Texture2D scratchesTexture;
public float scratchesXSpeed;
public float scratchesYSpeed; public Texture2D dustTexture;
public float dustXSpeed;
public float dustYSpeed; private Material curMaterial;
private float randomValue;
#endregion #region Properties
public Material material {
get {
if (curMaterial == null) {
curMaterial = new Material(oldFilmShader);
curMaterial.hideFlags = HideFlags.HideAndDontSave;
}
return curMaterial;
}
}
#endregion // Use this for initialization
void Start () {
if (SystemInfo.supportsImageEffects == false) {
enabled = false;
return;
} if (oldFilmShader != null && oldFilmShader.isSupported == false) {
enabled = false;
}
} void OnRenderImage (RenderTexture sourceTexture, RenderTexture destTexture){
if (oldFilmShader != null) {
material.SetColor("_SepiaColor", sepiaColor);
material.SetFloat("_VignetteAmount", vignetteAmount);
material.SetFloat("_EffectAmount", oldFilmEffectAmount); if (vignetteTexture) {
material.SetTexture("_VignetteTex", vignetteTexture);
} if (scratchesTexture) {
material.SetTexture("_ScratchesTex", scratchesTexture);
material.SetFloat("_ScratchesXSpeed", scratchesXSpeed);
material.SetFloat("_ScratchesYSpeed", scratchesYSpeed);
} if (dustTexture) {
material.SetTexture("_DustTex", dustTexture);
material.SetFloat("_DustXSpeed", dustXSpeed);
material.SetFloat("_DustYSpeed", dustYSpeed);
material.SetFloat("_RandomValue", randomValue);
} Graphics.Blit(sourceTexture, destTexture, material);
} else {
Graphics.Blit(sourceTexture, destTexture);
}
} // Update is called once per frame
void Update () {
vignetteAmount = Mathf.Clamp(vignetteAmount, 0.0f, 1.0f);
oldFilmEffectAmount = Mathf.Clamp(oldFilmEffectAmount, 0.0f, 1.0f);
randomValue = Random.Range(-1.0f, 1.0f);
} void OnDisable () {
if (curMaterial != null) {
DestroyImmediate(curMaterial);
}
}
}

OldFilmEffectShader如下:

Shader "Custom/OldFilmEffectShader" {
Properties {
_MainTex ("Base (RGB)", 2D) = "white" {}
_VignetteTex ("Vignette Texture", 2D) = "white" {}
_VignetteAmount ("Vignette Opacity", Range(0, 1)) = 1
_ScratchesTex ("Scraches Texture", 2D) = "white" {}
_ScratchesXSpeed ("Scraches X Speed", Float) = 10.0
_ScratchesYSpeed ("Scraches Y Speed", Float) = 10.0
_DustTex ("Dust Texture", 2D) = "white" {}
_DustXSpeed ("Dust X Speed", Float) = 10.0
_DustYSpeed ("Dust Y Speed", Float) = 10.0
_SepiaColor ("Sepia Color", Color) = (1, 1, 1, 1)
_EffectAmount ("Old Film Effect Amount", Range(0, 1)) = 1
_RandomValue ("Random Value", Float) = 1.0
}
SubShader {
Pass {
CGPROGRAM #pragma vertex vert_img
#pragma fragment frag #include "UnityCG.cginc" uniform sampler2D _MainTex;
uniform sampler2D _VignetteTex;
uniform sampler2D _ScratchesTex;
uniform sampler2D _DustTex;
fixed4 _SepiaColor;
fixed _VignetteAmount;
fixed _ScratchesXSpeed;
fixed _ScratchesYSpeed;
fixed _DustXSpeed;
fixed _DustYSpeed;
fixed _EffectAmount;
fixed _RandomValue; fixed4 frag (v2f_img i) : COLOR {
half2 renderTexUV = half2(i.uv.x, i.uv.y + (_RandomValue * _SinTime.z * 0.005));
fixed4 renderTex = tex2D(_MainTex, renderTexUV); // Get teh pixed from the Vignette Texture
fixed4 vignetteTex = tex2D(_VignetteTex, i.uv); // Process the Scratches UV and pixels
half2 scratchesUV = half2(i.uv.x + (_RandomValue * _SinTime.z * _ScratchesXSpeed),
i.uv.y + (_Time.x * _ScratchesYSpeed));
fixed4 scratchesTex = tex2D(_ScratchesTex, scratchesUV); // Process the Dust UV and pixels
half2 dustUV = half2(i.uv.x + (_RandomValue * _SinTime.z * _DustXSpeed),
i.uv.y + (_Time.x * _DustYSpeed));
fixed4 dustTex = tex2D(_DustTex, dustUV); // Get the luminosity values from the render texture using the YIQ values
fixed lum = dot(fixed3(0.299, 0.587, 0.114), renderTex.rgb); // Add the constant calor to the lum values
fixed4 finalColor = lum + lerp(_SepiaColor, _SepiaColor + fixed4(0.1f, 0.1f, 0.1f, 0.1f), _RandomValue); // Create a constant white color we can use to adjust opacity of effects
fixed3 constantWhite = fixed3(1, 1, 1); // Composite together the different layers to create final Screen Effect
finalColor = lerp(finalColor, finalColor * vignetteTex, _VignetteAmount);
finalColor.rgb *= lerp(scratchesTex, constantWhite, _RandomValue);
finalColor.rgb *= lerp(dustTex, constantWhite, (_RandomValue * _SinTime.z));
finalColor = lerp(renderTex, finalColor, _EffectAmount); return finalColor;
} ENDCG
}
}
FallBack "Diffuse"
}
保存后返回Unity。我们需要在面板中设置对应的图片和属性,像下面这样:
【Unity Shaders】游戏性和画面特效——创建一个老电影式的画面特效

扩展链接

关于YIQ: