游戏后期特效#1::镜头炫光与光晕(Flare)

作者:音速键盘猫
2017-03-16
8 13 2

编者按

本文已向作者 @音速键盘猫 授权转载,原载于知乎,如需转载请务必联系原作者。

引言

哈罗大家好, 我是大萌喵, 一只对渲染与着色器技术十分着迷的学生党. 我之所以突然间想办一个专门讲解着色器技术的专栏, 其一是因为国内的相关资料少之又少, 其二是作为一个学生, 我的不足之处还有很多, 通过写文章可以排除很多模棱两可的知识点, 其三是同时还能和知乎上的更多开发者交流, 相互促进 ~

镜头炫光和光晕是个啥?

我们都知道, 光在传播的过程中经过两种折射率不同的介质交界面时, 会发生折射和反射现象. 当折射和反射达成某种角度, 尤其是光比比较大时, 反射光和折射光可以交汇形成炫光。 在多数情况下, 这并不是一个好的现象, 因为这导致被观察物体的表现形式失真. 现在大多数镜头的镜片上都会有各种各样的镀膜, 为的就是减少炫光的出现。

1

如上图, 这就是一个标准的镜头炫光的结果. 这种结果当然不会是我们想要的。

2

当然了, 如果使用得当, 镜头光晕也可以像这样 ...

那么, 我们为什么要做一个镜头炫光的特效呢?

第一, 虽然镜头炫光是人造效果, 但是它能够增加一张图片的动态范围, 使其更加直观和清晰地阐述明亮度

第二, 镜头炫光给人一种意境化的感觉, 使用得当可以让人更加身临其境

第三, 镜头炫光看起来真的很 ...

所以说我们要干啥?

根据一个RenderTexture(一般简称为RT)的颜色信息, 通过后期图像特效(Image Effect)计算产生一张带有其镜头光晕的RT, 然后将后产生的RT合并到原本的RT中通过屏幕输出。

文章中介绍的做法不是基于物理的, 但是性能开销非常廉价, 可以应用到移动端上. 效果也还可以。

想看懂这篇文章, 我得知道啥?

为了能够完全读懂大萌喵写的东东, 您只需要知道Image Effect的基本原理就足够了。

看完了这篇文章, 我能得到啥?

你将知道我是怎么利用屏幕后期特效实现镜头光晕效果的。 在我讲解的过程中可能会有些名词或方法你之前并不了解, 我也会提供相应的辅助手段帮助你了解。

你也会获得完整源代码. 但是如果之前您没实现过类似的屏幕特效, 那么我个人强烈建议您在看完整源代码前自己动手实现一遍。

最后, 如果您有任何话题想要和大萌喵交流, 大萌喵都是欢迎哒欢迎哒欢迎哒!

大萌喵.不正常模式.SetStatus(false);

大萌喵.SetFace (Face.严肃脸);

着色器特效处理流程

第一步, 根据一个阈值提取图像中的所有明亮度高的像素, 并将结果适当降采样(DownSample)以提升时间效率。不了解降采样的童鞋可以通过搜索引擎了解一下. Unity的Standard Assets中的后期特效中有降采样的源代码。

第二步, 基于第一步得到的RT(如果你不知道RT是什么, ctrl+F一下就行), 计算出对应的鬼影位置。

第三步, 对第二步得到的RT进行高斯模糊, 而后进行星射线采样处理, 得到真正的镜头炫光。

第四步, 将第三步的RT和原有ColorBuffer进行混合, 投射到屏幕上。

第一步: 降采样和像素提取

降采样是为了以牺牲图像质量为代价来降低后续操作的性能开销。 在后面的操作过程中我们涉及到了高斯模糊, 而顾名思义反正早晚要模糊, 那干脆输入一个本来就有点糊(注意, 我说是"有点"), 但是尺寸小很多的RT该多么划算。 因此可以先用将原图采样到一个尺寸更小的图片中的方式来压缩原图。 思路是使用一个长宽等比例缩小k倍的RT, 在着色器实现过程中每隔原图的k个像素点采样一次, 放到新的降采样RT中。

具体到程序实现的话可以如此理解: 将降采样RT"强行"拉伸到原图的尺寸, 那么相邻两个片元的UV坐标差距也就增加到了原来的K倍。 因此我们可以利用降采样RT片元的UV坐标来采样原图. 为了保证降采样后的效果, 我们将原图对应部分的上下左右四个像素点求平均值进行输出. (在Unity官方Shader中是采用对应部分像素点和其左边, 右边, 左下角共四个像素点求平均值, 在最终的视觉效果上感觉也没什么差异, 但是取上下左右似乎更符合"对称"的强迫症思想)。

在降采样后, 我们需要从中提取一些明亮度较高的像素。因为我们只希望将图片中特别明亮的部分进行镜头光晕化的处理. 具体做法就很简单了, 降采样后的颜色像素RGB超过阈值的部分提取出来。

struct v2f_DownSample {
    float4 pos : SV_POSITION;
    half2 uv20 : TEXCOORD0;    
    half2 uv21 : TEXCOORD1;    
    half2 uv22 : TEXCOORD2;    
    half2 uv23 : TEXCOORD3;
};
v2f_DownSample vert_DownSample ( appdata_img v ) {
    v2f_DownSample o;
    o.pos = mul ( UNITY_MATRIX_MVP, v.vertex );
    o.uv20 = UnityStereoScreenSpaceUVAdjust ( v.texcoord + _MainTex_TexelSize * half2 ( 0.5h, 0.5h ), _MainTex_ST );
    o.uv21 = UnityStereoScreenSpaceUVAdjust ( v.texcoord + _MainTex_TexelSize * half2 ( -0.5h, 0.5h ), _MainTex_ST );
    o.uv22 = UnityStereoScreenSpaceUVAdjust ( v.texcoord + _MainTex_TexelSize * half2 ( -0.5h, -0.5h ), _MainTex_ST );
    o.uv23 = UnityStereoScreenSpaceUVAdjust ( v.texcoord + _MainTex_TexelSize * half2 ( 0.5h, -0.5h ), _MainTex_ST );
    return o;
}
fixed4 frag_DownSample ( v2f_DownSample i ) : COLOR {
    fixed4 color = tex2D ( _MainTex, i.uv20 ) + tex2D ( _MainTex, i.uv21 ) + tex2D ( _MainTex, i.uv22 ) + tex2D ( _MainTex, i.uv23 );
    return max ( 0.0, color / 4 - _Threshold ) * _Intensity;
}

降采样部分的C#源码:

int rtWidth = source.width >>
downSampleNum;int rtHright = source.height >> downSampleNum;
RenderTexture downSampleBuffer = RenderTexture.GetTemporary(rtWidth, rtHright, 0, source.format);
downSampleBuffer.filterMode = FilterMode.Bilinear;
//Shader的第0个Pass对应的点元着色函数是vert_DownSample, 片元着色函数是frag_DownSample
Graphics.Blit(source, downSampleBuffer, material, 0);

成果如下:
3

原图

4

提取高亮颜色

5

降采样后

第二步: 计算鬼影

鬼影是啥? 听起来好恐怖的样子, 不会让Unity崩溃吧?

简单来说, 我们要实现的鬼影就是将第一步中得到的RT的明亮部分错位重复渲染几次, 达到模拟多层镜片反射的效果。 在这里我们默认对称中心就是我们的原图图像中心(当然因此也就同时是降采样后的RT中心)。

OK, 我们先来个简单的版本, 只渲染一个鬼影, 和原本的像素位置相对于图片中心呈中心对称。 实现上很简单, 重新采样一次然后和混合相加就好:
6

请和第一步的图二做对比

这似乎有点太无聊了, 我们应该再加几个鬼影才能实现模拟多次反射的效果. 但是我们都知道, 在真正的镜头中, 这种鬼影越趋近于反射中心越小, 越靠近镜头边缘则越大, 相当于在中心对称的过程中"又多了一小段距离"。 因此我们应该引入一个鬼影发散率dis, 用以表示第i层鬼影相对于i-1层的位置和大小的偏离情况。

具体到实现上, 我们可以从当前像素点向画面中心连一个向量v, 很容易发现v * dis * i就是当前鬼影的偏离向量.。采样后相加, 我们就得到了这样的结果:

7

程序如下:

v2f_Simple vert_Simple ( appdata_img v ) {
    v2f_Simple o;
    o.pos = mul ( UNITY_MATRIX_MVP, v.vertex );
    o.uv = v.texcoord.xy;    
    return o;
}
fixed4 frag_Ghost ( v2f_Simple i ) : COLOR    {
    half2 newUV = half2 ( 1.0h, 1.0h ) - i.uv;
    half2 ghostVector = ( half2 ( 0.5h, 0.5h ) - newUV ) * _GhostDispersal;
    fixed4 finalColor = fixed4 ( 0, 0, 0, 0 );
    for (int ii = 0;ii < _GhostNum;ii++) {
        half2 offset = frac ( newUV + ghostVector * float ( ii ) );
        float weight = length ( half2 ( 0.5h, 0.5h ) - offset ) / length ( half2 ( 0.5h, 0.5h ) );  
        weight = pow ( 1.0 - weight, 1.0 );
         finalColor += tex2D ( _MainTex, offset ) * weight;    
    }
    return finalColor * tex2D ( _Gradient, length ( half2 ( 0.5h, 0.5h ) - newUV ) / length ( half2 ( 0.5h, 0.5h ) ) );
}

第三步: 模糊

8
哇卡卡卡卡卡鬼影都计算完了! 让我们赶快糊上去看看是什么效果吧!

在办专栏之前, 我认真看了专栏守则, 文章中不允许出现污言秽语的。

好吧, 那我就无法评论这个结果了, 我们继续吧。

问题出在哪?

太写实了! 我们要的是光斑, 光晕, 和炫光, 但是这个结果叫镜面反射!

那么我们接下来要做的事情就很明确了: 输入一个棱角分明的RT, 输出一个模模糊糊带着光斑的RT。

很好, 我猜你也想到了高斯模糊(Gaussian Blur)

关于高斯模糊我不打算详细介绍, 网上的资料已经足够多了。 在这里我贴上浅墨大大的CSDN博客, 大家想要详细了解高斯模糊的话可以移步查阅, 浅墨大大讲得可比我好多啦 ~
8-2
代码我也就不贴了, 毕竟一大坨, 而且长得也和浅墨大大的代码差不多。成果如下:

第四步: 将第三步得到的RT和sourceRT混合, 输出

我们将第三步得到的RT设置为着色器的最终鬼影纹理, 并单独在一个pass中将原图和鬼影进行混合.

fixed4 frag_Flare ( v2f_Simple i ) : COLOR
{
    return tex2D ( _MainTex, i.uv ) + tex2D ( _Flare, i.uv );
}

然后我们就得到了下面的结果:
10
但是, 我们不希望它看起来奇怪
8
干嘛又是这张图? 因为这张图虽然丑, 但是它的光晕看起来很正常. 所以能给我们一点提示.

首先, 这个光晕受一种"星射线"的影响, 给人以一种朦胧, 颤抖的感觉. 因此, 我们可以考虑为模糊后的鬼影加上一层StarBurst采样.
12
再者, 通过观察我们发现, 光晕的蓝色和黄色(其实是绿色)的部分集中在中央, 而红色则扩散到了整个屏幕的各个角落. 这是因为镜头对不同波长的光有不同的折射效果. 为了实际地表现出这种效果, 我们需要做两件事情:

第一, 在计算鬼影的过程中, 应该允许RGB各自以不同的缩放率进行采样.

第二, 在鬼影计算完成后, 加入一个镜头径向采样纹理, 与鬼影相混合.

13

径向采样纹理, 这张纹理意味着靠近屏幕中央的鬼影更加偏向红色, 越向外越开始靠近绿色, 蓝色, 依次往复类推

为了做到第一步, 我们修改下第二步中鬼影的采样即可.

finalColor += GetDistortedColor ( offset, normalize ( ghostVector ) ) * weight;
fixed4 GetDistortedColor ( half2 uv, half2 dir) 
{
    return fixed4 (tex2D ( _MainTex, uv + dir * _ColorDistortion.r ).r, tex2D ( _MainTex, uv + dir * _ColorDistortion.g ).g, tex2D ( _MainTex, uv + dir * _ColorDistortion.b ).b, 1);
}

最终成果如下:
14

后记

大萌喵会尽快将全部源代码上传到GitHub上滴. 我后续也会对光晕特效做出优化和改进 ~ 最慢一天哦!

FIN

大萌喵是个学生党, 非常热切地希望能和诸位前辈们交流!