高级篇 屏幕后处理效果
12.1 建立一个基本的屏幕后处理脚本系统
屏幕后处理,顾名思义,通常指的是在渲染完整个场景得到屏幕图像后,再对这个图像进行一系列操作,实现各种屏幕特效
使用这种技术,可以为游戏画面添加更多的艺术效果,例如景深(Depth ofField)、运动模糊(Motion Blur)等
因此,想要实现屏幕后处理的基础在于得到渲染后的屏幕图像,即抓取屏幕,而Unity为我们提供了这样一个方便的接口– OnRenderImage函数
当我们在脚本中声明此函数后,Unity会把当前渲染得到的图像存储在第一个参数对应的源渲染纹理中,通过函数中的一系列操作后,再把目标渲染纹理,即第二个参数对应的渲染纹理显示到屏幕上
在OnRenderImage函数中,我们通常是利用Graphics.Blit函数来完成对渲染纹理的处理
1
2
3public static void Blit(Texture src, RenderTexture dest);
public static void Blit(Texture src, RenderTexture dest, Material mat, int pass = -1);
public static void Blit(Texture src, Material mat, int pass = -1);- 它有3种函数声明,其中src就是当前屏幕的渲染纹理,或是上一步处理后得到的渲染纹理
- 参数dest是目标渲染纹理,如果值为null,则直接将结果显示在屏幕上面
- 参数mat是使用的材质,这个材质所用的UnityShader将会进行各种屏幕后处理操作,而src纹理将会被传递给Shader中名为_MainTex的纹理属性
- 参数pass的默认值为-1,表示将会依次调用Shader内的所有Pass,否则只会调用给定的所有Pass
- 在默认情况下,OnRenderImage函数会在所有不透明和透明的Pass执行完毕后被调用,以便于对场景中所有的游戏对象都产生影响。
- 但有时如果希望在不透明的Pass(渲染队列小于等于2500)执行完毕后立即调用OnRenderImage函数,从而不对透明物体产生任何影响,此时可以在OnRenderImage函数前添加Image Effect Opacity属性来实现这样的目的
总结而言,要想在Unity中实现屏幕后处理效果,过程通常如下:
- 首先在摄像机中添加一个用于屏幕后处理的脚本
- 在这个脚本中实现OnRenderImage函数来获取当前屏幕的渲染纹理
12.2 调整屏幕的亮度、饱和度和对比度
- 脚本:
- 在这个基类里,会检查一系列条件是否满足,比如当前平台是否支持渲染纹理和屏幕特效
- 继承这个基类的脚本需要绑定在Camera上面
- 在CheckShaderAndCreateMaterial()里可以指定一个Shader来创建一个用于后处理渲染的材质
- 首先检查Shader的可用性,通过后返回一个使用了该Shader的材质
- 亮度饱和度对比度脚本:
- 让材质使用Shader,这个材质是动态生成的
- 在OnRenderImage()里面给材质设置三个属性,再用Graphics.Blit()进行绘制
- 而这个材质会被传递给Shader中名为_MainTex的属性纹理
- src传递给_MainTex;material所设定的会被传递给下面三个属性;Pass默认值是-1,它会调用Shader中所有的Pass;dest会被渲染到屏幕上面,也就是目标渲染纹理,值为null则会直接将结果显示在屏幕上面
- 我们需要在SubShader中定义好Pass,关闭深度写入是为了防止它挡住在其后面被渲染的物体,比如当前OnImageRender()函数在所有不透明的Pass执行完毕后立即被调用,不关闭深度写入就会影响后面透明Pass的渲染。这些状态设置可以认是用于屏幕后处理的系列的标配,其实就是关闭深度写入、关闭深度测试以及不做剔除
12.3 边缘检测
- 边缘检测是使用一系列边缘检测算子对图像进行卷积
12.3.0
- 边缘检测是描边效果的一种实现方法
12.3.1 什么是卷积
- 在图像处理中,卷积操作指的就是使用一个卷积核(kernel)对一张图像中的每个像素进行一系列操作
- 卷积核通常是一个四方形网格结构(例如2×2、3×3的方形区域),该区域内每个方格都有一个权重值
- 当对图像中的某个像素进行卷积时,我们会把卷积核的中心放置于该像素上,翻转核之后再依次计算核中每个元素和其覆盖的图像像素值的乘积并求和,得到的结果就是该位置的新像素值
- 通俗来说,原图是个w×h的矩形,算子也是个矩形,把算子矩形框覆盖在上面,对应元素做一个乘积再求和
- 在实际的使用中,卷积需要进行一下翻转,先上下翻转再左右翻转
12.3.2 常见的边缘检测算子
- Roberts
- Prewitt
- Sobel
12.3.3 实现
- 使用算子:
- Gx会在横向上对其梯度做一个计算,Gy会在竖向上对y轴进行计算
- 最后会得到Gx和Gy各自的值
- 标准的梯度计算是用G = sqrt(Gx^2 + Gy^2)
- 但平方开根号比较影响性能,可以用G = |Gx| + |Gy|做代替
- 这个梯度G就可以来判断哪些像素点对应的是边缘,G越大,像素变化越剧烈,越可能是边缘
- Sobel算子:
- 脚本:
- 依然继承PostEffectsBase,与之前类似,只改变了几个属性
- 分别用于调整边缘线强度、描边颜色、背景颜色
- 同时需要指明一个Shader:
- 接收刚才的三个参数,以及接收src的值
- Camera组件上的Target Texture为空则直接输出到屏幕,不为空就输出到目标纹理上
- 对uv使用一个手动定义的9维数组,它对应了使用Sobel算子采样时需要的9个邻域纹理坐标
- 通过把采样纹理坐标的代码从片元着色器中转移到顶点着色器,可以减少运算提高性能,同时由于从顶点着色器到片元着色器的插值是线性的,因此这样的转移并不会影响纹理坐标的计算结果
- 相当于以每一个像素为原点(0,0),组成9个点 16.23
- 利用边缘检测结果去分别计算背景为原图和纯色下的颜色值
- 定义水平方向和竖直方向使用的卷积和Gx和Gy,一直对9个像素进行采样
- edge值越小表明它越可能是边缘点
12.4 高斯模糊
- 还可以使用均值模糊和中值模糊,只需要对刚才的卷积做一下更改,全部改成1/9就得到了均值模糊
- 高斯模糊同样是使用卷积核做运算
- σ^2为标准方差,取值一般为1;xy分别对应了当前位置到卷积中心的整数距离
- 要构建一个高斯核,只需要计算高斯核中各个位置对应的高斯值
- 为了保证滤波后的图像不会变暗,需要对高斯核中的权重进行归一化处理,即让每个权重除以所有权重的和,这样可以保证所有权重和为1,因此高斯核中e前面的系数实际上不会对结果有任何影响
- 在脚本中提供了调整高斯模糊的迭代次数、模糊范围和缩放系数的参数
- 先使用缩放系数进行缩放
- 然后将缩放后的原图渲染到buffer0中,缩放的方式是Bilinear线性缩放
- 之后根据轮数进行迭代
- 下采样downSample越大,需要处理的像素越少。它能进一步提高模糊程度,但过大的下采样会导致图像的像素化
- 模糊范围blurSpread越大,模糊程度越高,但不会影响到采样数downSample
- 之后循环迭代,使用高斯模糊对图像进行处理
- 不同在于Blit中一个0一个1,它分别会使两个Pass做这样的处理
- 需要使用一个中间缓存来存储第一个Pass执行完毕的结果,也就是buffer1
- 其实际上先用竖直方向的一维来做滤波,再使用水平方向上的一维进行滤波,这是对高斯的一个优化,它是一个矩阵二维的和,但是当n不断增加时,采样次数就变得非常巨大,所以可以将其拆成两个一维的来减少计算复杂度
- Shader:
- 有2个Pass
- 在接受的过程中,会将原图也就是_MainTex传递过来,然后是模糊范围_BlurSize
- 使用_MainTex_TexelSize以计算相邻像素的纹理坐标的偏移量
- 在顶点着色器中,分别使用垂直方向和水平方向上两组来计算
- 数组的第一个坐标储存了当前的采样值,剩余的4个坐标,是高斯模糊中对邻域采样时使用的纹理坐标
- 和属性_BlurSize相乘来控制采样距离,在高斯和维数不变的情况下,_BlurSize越大,模糊程度就越高,而采样数不会受到影响,但过大的_BlurSize会造成虚影
- 通过把计算采样纹理的坐标从片元着色器转移到顶点着色器中,可以有效地减少运算,提高性能
- 在片元着色器中,之前一个5x5的二维高斯核可以拆分成两个大小为5的一维高斯核,并且由于它们的对称性,需要记录3个高斯权重,也就是代码中weight变量
- 首先声明了各个邻域像素对应的权重weight,将其结果sum初始化为当前像素乘以它的权重值,根据对称性进行2次迭代,每次迭代包含了2次纹理采样,并把像素值和权重相乘后的结果迭代到sum中,最后函数返回滤波的结果sum
- 之后定义了高斯模糊所使用的两个Pass,首先设置了渲染状态,为两个Pass使用了NAME语义来定义了它们的名字,因为高斯模糊是很常见的操作,很多屏幕特效都建立在它的基础上。为Pass定义名字后可以在其他系统中直接通过名字来使用该Pass,而不需要重复写代码
- 在最后关闭该Shader的FallBack
12.5 Bloom效果
- Bloom特效是游戏中常见的一种屏幕效果。这种特效可以模拟真实摄像机的一种图像效果,它让画面中较亮的区域”扩散”到周围的区城中,造成一种朦胧的效果
- 脚本:
- Bloom是建立在高斯模糊基础上的,加了一个阈值来控制较亮区域阈值的大小,尽管大多数情况下图像的亮度值不会超过1,但在开启HDR后,硬件会允许颜色存在一个更高精度的范围缓冲中,此时的像素亮度值就可能会超过1,因此这里将阈值限定在0-4之间
- Shader:
- 在这里有4个Pass,分别是第0、1、2、3个Pass
- 定义了较亮区域使用了顶点着色器,以及提取较亮元素所使用的片元着色器
- 在片元着色器中,将采样得到的亮度值减去阈值,并把结果约束到0-1之间,把该值和原像素值相乘,就得到了提取得到的亮部区域,也就是第0个Pass
- 在第1、2个Pass中使用前面的垂直和水平的高斯计算
- 接下来去混合亮部区和原图像
- 定义混合的过程v2fBloom vertBloom,这里使用的顶点着色器和之前的有所不同,定义了2个纹理坐标,xy订阅了原图_MainTex的纹理坐标,zw就是Bloom模糊后较亮区域的纹理坐标
- 在片元着色器中,直接对两个纹理做混合
12.6 运动模糊
- 运动模糊是真实世界中摄像机的一种效果
- 如果在摄像机曝光时,拍摄场景发生了变化,就会产生模糊的画面
- 运动模糊的实现有多种方法
- 一种是利用一块累积缓存(accumulation buffer)来混合多张连续的图像,当物体快速移动产生多张图像后,取它们之间的平均值作为最后的运动模糊图像
- 然而,这种暴力的方法对性能的消耗很大,因为想要湖区多张帧图像往往意味着需要在同一帧里渲染多次场景
- 另一种应用广泛的方法是创建和使用速度换成(velocity buffer),这个缓存中存储了各个像素当前运动速度,然后利用该值来决定模糊的方向和大小
- 一种是利用一块累积缓存(accumulation buffer)来混合多张连续的图像,当物体快速移动产生多张图像后,取它们之间的平均值作为最后的运动模糊图像
- 使用第一种方法实现:
- 我们不需要在一帧中把场景渲染多次,它需要保存之前的渲染结果,不断地把当前的渲染图像叠加到之前的渲染图像中,从而产生一种运动轨迹的视觉效果,这种方法对比原始的利用缓存累积的方法相比,性能更好,但模糊的效果会略有影响
- 代码:
- 定义了模糊参数blurAmount,值越大,运动的拖尾效果越明显。同时为了防止拖尾效果完全替代当前帧的渲染结果,把它的值截取在0~0.9范围内
- 定义了RenderTexture类型的变量,去保存叠加的结果
- 在脚本不运行时,也就是OnDisable()中会立即销毁这个累计的渲染结果,这样在下次开始运用运动模糊时,它会重新叠加图像
- 首先判断用于混合图像的RT是否可用,不可用则重新创建一个,创建完对它做一个调整,将它的hideFlags设置为HideAndDontSave,也就是这个变量不会显示到Hierarchy面板中也不会保存到场景中
- 然后使用当前帧初始化accumulationTexture,也就是在Graphics.Blit(src,accumulationTexture)这里,将当前帧渲染到accumulationTexture
- 当得到有效的accumulationTexture变量后,调用它的MarkRestoreExpected()函数来表明需要进行一个渲染纹理的恢复操作,恢复操作发生在渲染到纹理而该纹理没有被提前清空或销毁的情况下
- 在本例中,每次调用OnRenderImage()时,都需要把当前帧图像和accumulationTexture中的图像混合,而accumulationTexture不需要提前清空,因为它保存了之前的混合效果
- 然后将参数传递给材质,并根据材质对纹理做一个混合,最后把它直接输出到屏幕上面
- Shader部分:
- Shader部分比较简单,经过了两个Pass,第一个Pass用于更新渲染纹理的RGB通道,另一个用于更新渲染纹理的A通道
- RGB通道会对RGB的图像做一个采样,A通道直接返回采样结果就行了
- 实际上只是为了维护渲染纹理的透明通道值,不让其受到模糊混合时所使用的透明度值的影响
- 最后将结果输出,就完成了模糊的过程
- 这是一种运动模糊的简单实现,混合了连续帧之间的图像,就得到了一张具有模糊拖尾的图像
- 当然,当物体移动速度过快时,这种方法可能会造成单独的帧图像变得可见