Unity Shader-Ambient Occlusion环境光遮蔽(AO贴图,GPU AO贴图烘焙,SSAO,HBAO)

前言

十一放假很开心,正好赶上观望了了许久的《尼尔·机械纪元》打折啦。窝在家里搞了三天三夜,终于E结局通关啦!!!真的好久没玩过这么好玩的游戏了,于是乎我的废话应该会多不少,毕竟,写blog的另一个目的就是记录玩过的好玩的游戏,2333。

最开始听说这个游戏的时候,只是被2B小姐姐的人设吸引了,毕竟小姐姐还是很漂亮的。而且游戏类型也是我喜欢的类型,看起来打击感还不错,加上最近打《地铁》和《消失的光芒》玩出3D眩晕症了,就差嗑两片晕车药再打了,正好搞一个动作型的游戏换换口味。

玩起来我才发现游戏的音乐太好听啦,最近简直每天在循环停不下来,场景也是体积光,SSAO之类的高级效果大量使用。

但是游戏通关之后,我才发现游戏的故事本身就很让人深思,加上丰富的支线剧情。我不敢说玩的游戏很多,但是的确实本人最近玩过的画面,音乐,战斗,剧情都很给力的一款游戏了。

好在三周目打完之后,加上读档几次,终于打出了E结局,还算比较完美。然而游戏竟然有26个结局,等到重温时争取都打出来。

唉,一不小心就忍不住想贴贴贴,毕竟游戏太好玩了,但是到此为止了。我不想剧透,下面才是本文的正题。

简介

《尼尔·机械纪元》中有一个关卡--复制之街。刚一进这个场景,我不由得发出一声惊叹,“我靠,这场景贴图是不是丢了?”

不过这个场景风格也是蛮不错的,同时也让我想起了一个略微进阶一些的图形技术,环境光遮蔽(AO)。整个场景看起来没有表示颜色的albedo,但是场景的阴影效果和AO效果还是存在的,这让场景的层次细节在即使没有颜色的情况下也可以展现出来,形成了一种特殊的风格。

环境光遮蔽对效果的提升有多重要,看一下顽皮狗在《Uncharted 2: HDR Lighting》的一个对比图,可以看到,左侧的车底遮挡了大部分光线,形成了阴影看起来很自然,而右侧的车感觉就像飘在上面一样,看起来比较假:

环境光遮蔽(Ambient Occlusion),最经常听到的应该是它的缩写AO。既然名字本身就带Ambient,说明其本身是对于环境光强度的一种控制,所以有必要来先了解一下环境光的计算。

光照是可以线性叠加的,一般来说最终的光照结果 = 直接光照 +  间接光照。我们计算物体的直接光照效果时,可以直接通过BRDF计算,而环境光属于间接光照,要想计算真正的环境光,需要在该点法线方向所对应的半球积分计算,在离线渲染的情况下也只能通过蒙特卡洛积分等方式近似计算,对于光线追踪的方式渲染的情况,自然可以得到比较好的效果,但是即使现在的RTX似乎也不能真正地实时跑光线追踪,所以在实时渲染领域,环境光一般使用的就是环境贴图(SkyBox,Reflection Probe),球谐光照(Spherical Harmonic Lighting),光照贴图(Light Map,需要用离线烘焙),甚至直接加一个固定的环境光值(简单粗暴,比如Unity中的UNITY_LIGHTMODEL_AMBIENT宏)。普通光源的遮挡效果也就是阴影,我们可以通过Shadow Map,模板阴影等来实现,但是对于环境光的遮挡效果,半球上的光线自然没有方法用普通的Shadow Map方式来计算了。所以研究怎样遮挡环境光的强度的就叫环境光遮蔽。

环境光遮蔽主要用来控制物体和物体相交,夹角,褶皱等位置遮挡漫反射光线的效果,简单来说就是某一点对于环境的暴露比例,如果是平面,那么没有遮蔽;如果是夹角,褶皱等那么周围的面就会遮蔽一部分环境光,就导致该点的环境光相对较弱。如果环境光没有遮蔽效果,那么不管褶皱还是平面,环境光照结果是一致的。而环境光遮蔽可以使褶皱,夹角等位置的光照效果变弱(比如一根管子,在管口的位置应该比较亮,而越向内,应该越暗),提高暗部阴影效果达到一种近似自阴影的效果,提升画面的层次感,增加细节。

在了解了环境光遮蔽的基本概念之后,本文主要实现几种主流的环境光遮蔽效果,AO贴图(使用预烘焙的贴图,实现离线的基于GPU的烘焙AO贴图的工具),SSAO(屏幕空间环境光遮蔽),HBAO(水平基准环境光遮蔽)。

AO Map-环境光遮蔽贴图

首先看一下最简单的AO贴图的使用,这也是性能最好的方式,但是这并不代表这种方法整体性能好,只是在运行时使用了预计算的结果。而AO贴图的生成是使用光线追踪的方式,反而是这几种AO方式种耗时最长但是效果相对更好的一种,毕竟只要一离线,时间什么的都是次要的。

使用美术工具烘焙AO贴图

AO贴图技术已经比较古老了,现有的各种3D软件基本都支持AO的烘焙,如3dsMax,Maya等。我今天使用的是Substance Painter,这个功能很强大的软件,而且相比于前两者,烘焙比较方便,但是据说效果没有前两者好。不过这些都不重要,毕竟怎么烘焙,那是美术同学的事情。

使用Substance Painter的烘焙选项,支持直接烘焙Mesh,烘焙面板如下:

我们用一个小狮子的模型导入Substance Painter中,然后使用低模烘焙一发AO贴图,同时工具也支持带有法线贴图的低模烘焙AO贴图,可以把法线细节的AO效果也烘焙出来。烘焙的贴图如下,左侧为直接烘焙,右侧为带有法线贴图之后烘焙的AO效果:

如果抓帧哪个游戏看到某个类似的通道,在褶皱处偏黑的,可能就是AO贴图啦。得到AO贴图之后,下面就需要看一下AO贴图的使用了。

AO贴图的使用

上面说过,光照是可以线性叠加的,全局光照 = 直接光照 + 间接光照。Unity也不例外,下面是Unity官方Shader的光照叠加部分:

  1. half3 color = diffColor * (gi.diffuse + light.color * diffuseTerm)
  2. + specularTerm * light.color * FresnelTerm (specColor, lh)
  3. + surfaceReduction * gi.specular * FresnelLerp (specColor, grazingTerm, nv);

不考虑菲尼尔项的话,就是direct diffuse + direct specular + gi diffuse + gi specular,前两者通过BRDF计算,而后两者就是所谓的环境光,我们看一下Unity官方的GI Shader源代码:

  1. inline UnityGI UnityGI_Base(UnityGIInput data, half occlusion, half3 normalWorld)
  2. {
  3. UnityGI o_gi;
  4. ResetUnityGI(o_gi);
  5. // Base pass with Lightmap support is responsible for handling ShadowMask / blending here for performance reason
  6. #if defined(HANDLE_SHADOWS_BLENDING_IN_GI)
  7. half bakedAtten = UnitySampleBakedOcclusion(data.lightmapUV.xy, data.worldPos);
  8. float zDist = dot(_WorldSpaceCameraPos - data.worldPos, UNITY_MATRIX_V[2].xyz);
  9. float fadeDist = UnityComputeShadowFadeDistance(data.worldPos, zDist);
  10. data.atten = UnityMixRealtimeAndBakedShadows(data.atten, bakedAtten, UnityComputeShadowFade(fadeDist));
  11. #endif
  12. o_gi.light = data.light;
  13. o_gi.light.color *= data.atten;
  14. #if UNITY_SHOULD_SAMPLE_SH
  15. o_gi.indirect.diffuse = ShadeSHPerPixel(normalWorld, data.ambient, data.worldPos);
  16. #endif
  17. #if defined(LIGHTMAP_ON)
  18. // Baked lightmaps
  19. half4 bakedColorTex = UNITY_SAMPLE_TEX2D(unity_Lightmap, data.lightmapUV.xy);
  20. half3 bakedColor = DecodeLightmap(bakedColorTex);
  21. #ifdef DIRLIGHTMAP_COMBINED
  22. fixed4 bakedDirTex = UNITY_SAMPLE_TEX2D_SAMPLER (unity_LightmapInd, unity_Lightmap, data.lightmapUV.xy);
  23. o_gi.indirect.diffuse += DecodeDirectionalLightmap (bakedColor, bakedDirTex, normalWorld);
  24. #if defined(LIGHTMAP_SHADOW_MIXING) && !defined(SHADOWS_SHADOWMASK) && defined(SHADOWS_SCREEN)
  25. ResetUnityLight(o_gi.light);
  26. o_gi.indirect.diffuse = SubtractMainLightWithRealtimeAttenuationFromLightmap (o_gi.indirect.diffuse, data.atten, bakedColorTex, normalWorld);
  27. #endif
  28. #else // not directional lightmap
  29. o_gi.indirect.diffuse += bakedColor;
  30. #if defined(LIGHTMAP_SHADOW_MIXING) && !defined(SHADOWS_SHADOWMASK) && defined(SHADOWS_SCREEN)
  31. ResetUnityLight(o_gi.light);
  32. o_gi.indirect.diffuse = SubtractMainLightWithRealtimeAttenuationFromLightmap(o_gi.indirect.diffuse, data.atten, bakedColorTex, normalWorld);
  33. #endif
  34. #endif
  35. #endif
  36. #ifdef DYNAMICLIGHTMAP_ON
  37. // Dynamic lightmaps
  38. fixed4 realtimeColorTex = UNITY_SAMPLE_TEX2D(unity_DynamicLightmap, data.lightmapUV.zw);
  39. half3 realtimeColor = DecodeRealtimeLightmap (realtimeColorTex);
  40. #ifdef DIRLIGHTMAP_COMBINED
  41. half4 realtimeDirTex = UNITY_SAMPLE_TEX2D_SAMPLER(unity_DynamicDirectionality, unity_DynamicLightmap, data.lightmapUV.zw);
  42. o_gi.indirect.diffuse += DecodeDirectionalLightmap (realtimeColor, realtimeDirTex, normalWorld);
  43. #else
  44. o_gi.indirect.diffuse += realtimeColor;
  45. #endif
  46. #endif
  47. o_gi.indirect.diffuse *= occlusion;
  48. return o_gi;
  49. }

虽然代码看起来比较长,不过绝大部分都不是我们关心的重点,我们重点在于UnityGI_Base和UnityGI_IndirectSpecular两个函数的倒数第二行代码,通过一个occlusion值调制了一下最终gi diffuse和gi specular的结果,而这个occlusion就是我们从AO贴图中采样得到的结果。实际上AO贴图的原理就是简单粗暴地将AO贴图采样出来的值乘以到GI的输出上,这样AO贴图中黑色的部分就会抑制环境光的强度达到环境光遮蔽的效果。

最后,我们对比一下使用AO贴图后的效果。使用上面两张烘焙贴图左的贴图,不带法线贴图烘焙后的结果。左侧为仅显示环境光效果,中间为带有AO贴图的效果,右侧为无AO贴图的效果:

使用的右侧的AO贴图,烘焙时使用了法线贴图。左侧为仅显示环境光效果,中间为带有AO贴图的效果,右侧为无AO贴图的效果:

可见,使用了AO贴图后,在小狮子的眼窝,嘴巴,爪子下,接缝,球底等部分环境光都降低了,使狮子的细节表现更加丰富,而最右侧的无AO贴图的效果则整体光照效果偏平,没有过渡。

Bake AO-GPU AO贴图烘焙

上面我们看到了AO贴图的使用,也使用了美术工具烘焙出了AO贴图,下面,为了更进一步理解AO贴图,我们来研究一下离线的AO贴图是怎样生成的,并且使用Shader实现一个非常简易的AO烘焙工具(娱乐而已,并不实用)。

AO烘焙的原理

离线烘焙,我们无需考虑过多近似的方法苟,直接使用光线追踪即可。关于光线追踪,之前看到好多知乎的大佬们都在玩,其实我之前也小玩了一下,实现过一版简易的方法。实际上主要就是参考了《RayTracing In one Weekend》这篇文章。不过,那时使用的是C++,纯单核CPU实现,速度慢到令人发指。所以,这次我决定改变一下思路,实现一个基于GPU的烘焙。

上面说过,AO所描述的就是一点对于周围环境暴露的比例。那么很简单,我们对于模型上的每一个点(对应展开uv到贴图上的每个像素点),向其法线所在的半球空间发射无数的光线,如果碰撞到了其他的三角形,就认为被遮挡了,当然,还需要判断是否超出了遮挡的范围,也就是我们控制AO半球的直径,为了效果更好,我们也可以再乘以一个距离的权重。

理想很美好,但是现实很残酷,我们没有办法发射无数的光线,所以我们只能用近似的方法模拟,也就是所谓的蒙特卡洛方法,当样本数达到一定量级的时候,概率也可以作为结果。

AO烘焙的实现

下面看一下实现,首先,我们要烘焙一张贴图,那么最重要的就是怎样把贴图直接展开到uv上并且显示出来,其实也比较简单,我们可以在vertex shader中得到uv,但是我们不进行正常的mvp变换,而是直接把uv座标的位置作为输出的位置,就可以把模型展开的uv再渲染到RT上,vertex关键代码:

  1. v2f vert (appdata v)
  2. {
  3. v2f o;
  4. float2 uv = v.uv;
  5. uv.y = 1 - v.uv.y;
  6. o.vertex = float4(uv * 2 - 1, 0, 1);
  7. return o;
  8. }

还是上面的小狮子模型,这下被拍扁到屏幕上了,好惨:

不过接下来,我们就可以比较容易地实现AO的烘焙了。首先,我们围绕半球空间构建一系列的随机采样点,然后将这些点通过模型的tbn基座标转化到模型空间,然后对于每个采样点的方向,计算该方向与其他所有三角形是否相交且小于遮挡半径。最终平均多个采样点的结果,得到最终的AO贴图。

关键部分代码如下,此处将三角形的顶点信息直接传递到了uniform中,但是目前有顶点数限制,1300顶点以上烘焙显卡就会崩溃,不过实验的话,已经足够啦。

  1. float aovalue = 0;
  2. for (int s = 0; s < (int)_SampleDirCount; s++)
  3. {
  4. float3 sampleDir = _SampleDir[s];
  5. sampleDir = normalize(sampleDir);
  6. float3 objDir = i.objTangent * sampleDir.x + i.objBiNormal * sampleDir.y + i.objNormal * sampleDir.z;
  7. float currentLength = _AOTracingRadius;
  8. for (int j = 0; j < (int)_TriangleCount; j++)
  9. {
  10. float3 p0 = _TriangleX[j].xyz;
  11. float3 p1 = _TriangleY[j].xyz;
  12. float3 p2 = _TriangleZ[j].xyz;
  13. float raylength;
  14. bool result = RayTriangleTest(objDir, i.objPos, p0, p1, p2, raylength);
  15. if (result && raylength < currentLength)
  16. {
  17. currentLength = raylength;
  18. }
  19. }
  20. float ao = clamp(currentLength, 0, _AOTracingRadius) / _AOTracingRadius;
  21. aovalue += ao;
  22. }
  23. aovalue /= _SampleDirCount;
  24. aovalue = pow(aovalue, _AOStrength);

烘焙后的效果,左侧为带有AO贴图效果,右侧为无AO的效果:

关于三角形与射线相交的代码,可以参考《射线和三角形的相交检测(ray triangle intersection test) 》这篇blog,这位大佬写得非常清楚啦,膜拜一波。

关于AO烘焙,不想花太多的时间去做优化了,毕竟目前美术工具烘焙AO已经很成熟了,下面才是本文的重点,屏幕空间的AO算法,SSAO和HBAO。

SSAO-屏幕空间环境光遮蔽

屏幕空间环境光遮蔽(Screen Space Ambient Occlusion),简称SSAO。最早是07年CryTek开始在《孤岛危机2》中提出的一项技术。由于环境光遮蔽的计算比较复杂,即使是使用蒙特卡洛积分的方式,仅仅是进行随机采样进行计算,如果逐物体计算也是不太可能的,而屏幕空间计算可以保证计算复杂度与场景复杂度解耦,只计算屏幕对应像素的环境光遮蔽,再配合jitter以及降低分辨率等计算,使实时近似的环境遮蔽成为可能,并且真正在游戏中运用。从此这项技术便一发不可收拾,成为了各大游戏必备的选项,并且后续衍生出了各种进化版本如HBAO,SSDO,TSSAO等基于屏幕空间计算的环境光遮蔽效果。(当年的CryEngine真的是引领了一大波渲染技术的热潮啊)。

SSAO的原理

关于SSAO相关的一些原理,可以参考《Comparative Study of SSAO Methods》这篇论文,文中比对了各种SSAO的计算。下文中几张SSAO的原理图引用自该论文。

前面说过,环境光本身是基于当前点法线半球上的积分计算,想真正求积分是不可能滴。近似求积分的话,最容易的就是蒙特卡洛积分,说的通俗易懂一点的话,就是概率,当样本数达到一定程度之后,我们就不求计算精确的值,而是直接使用一些样本进行采样。那么,我们在法线的半球上计算环境光遮蔽的因子时,我们就可以采用概率的方式。在法线的半球上设置一系列随机的采样点,然后遍历每一个采样点,判断采样点的深度值是否小于该点对应的深度,如果小于说明这个采样点没有被平面挡住,遍历完成后除以总采样点数,就可以得到当前半球上环境光显示的百分比,1-环境光百分比得到最终的环境光遮蔽值。

不过SSAO技术在最早在CryTek使用的时候并非是在半球上进行的计算,而是在一个球形内进行的概率计算,如下图,P为当前像素点,在该点一定范围的球形分布着随机采样点,绿色为未被遮蔽的,红色为被遮蔽的:

CryTek使用球形进行计算的话有一个好处就是无需屏幕空间法线,对于前向渲染的话仅有深度就可以实现SSAO的计算,无需额外考虑全屏Normal,但是也有一个不好的地方,在于整个球进行概率计算的话,不管怎么样,都会有50%的点被遮蔽。这也是导致CryTek最早版本的SSAO效果很奇怪的原因,对于褶皱处AO效果很明显,但是平面上也会被计算出遮蔽值(图片来自《Finding Next Gen – CryEngine 2》):

所以后续的版本就采用了更加精确的方式,即只在法线对应方向的半球上进行遮蔽概率计算,使环境光遮蔽效果大大提升。如下图所示,P点为当前像素点,n为对应法线方向,所在半球上分布着随机采样点,其中绿色的采样点为未被遮蔽的,红色的为为被遮蔽的:

SSAO的优缺点

SSAO还是有很多优点的:

1.速度较快,相对于离线的AO烘焙,至少使实时计算AO成为了可能。

2.无需预处理,不需要预先烘焙AO贴图,降低美术工作成本,降低贴图数量(少一个通道也是省啊,干点啥不好)。

3.支持动态AO遮挡,动态物体没有办法烘焙AO,只能使用SSAO。

4.SS系列的共性优点,与场景复杂度无关,仅与屏幕分辨率有关。

5.无CPU消耗,纯GPU逻辑,易集成,有Depth Normal即可,与当今的延迟渲染非常契合。甚至NVIDIA显卡自身都可以开。

SSAO的缺点:

1.GPU瓶颈,虽然不耗CPU,但是这个真的是超级费GPU,手机上本人曾经测试小米8开启后帧率直接下降15帧左右!

2.SS系列的共性缺点,屏幕外面的东西如果遮挡了,是没有效果的。比如车底,本身不在DepthBuffer中,取不到信息,自然也就算不出遮挡以及AO了。不过好在这个问题不像SSR那样明显。

3.前向渲染不划算,和SSR一样,这个SSAO需要全屏幕的Depth以及Normal,全场景先来一遍你懂得。

SSAO的实现

要使用SSAO,需要有全屏幕的深度来反算视空间位置;而为了保证采样点集中在法线所在半球,需要有全屏幕的法线图。如果是延迟渲染,那么这两个都可以免费得到,通过DepthTexture和GBuffer得到。但是前向渲染下我们就只能通过CameraDepthNormalTexture来得到深度+法线纹理。

下面看一下SSAO的实现,首先,我们生成一系列的随机采样点,为了让遮蔽效果更好,我们尽量保证在靠近采样点的位置分布更多的随机点:

  1. private void GenerateAOSampleKernel()
  2. {
  3. if (SampleKernelCount == sampleKernelList.Count)
  4. return;
  5. sampleKernelList.Clear();
  6. for(int i = 0; i < SampleKernelCount; i++)
  7. {
  8. var vec = new Vector4(Random.Range(-1.0f, 1.0f), Random.Range(-1.0f, 1.0f), Random.Range(0, 1.0f), 1.0f);
  9. vec.Normalize();
  10. var scale = (float)i / SampleKernelCount;
  11. //使分布符合二次方程的曲线
  12. scale = Mathf.Lerp(0.01f, 1.0f, scale * scale);
  13. vec *= scale;
  14. sampleKernelList.Add(vec);
  15. }
  16. }

关键Shader部分代码如下:

  1. fixed4 frag_ao (v2f i) : SV_Target
  2. {
  3. fixed4 col = tex2D(_MainTex, i.uv);
  4. float linear01Depth;
  5. float3 viewNormal;
  6. float4 cdn = tex2D(_CameraDepthNormalsTexture, i.uv);
  7. DecodeDepthNormal(cdn, linear01Depth, viewNormal);
  8. float3 viewPos = linear01Depth * i.viewRay;
  9. float3 viewDir = normalize(viewPos);
  10. viewNormal = normalize(viewNormal);
  11. int sampleCount = _SampleKernelCount;
  12. float oc = 0.0;
  13. for(int i = 0; i < sampleCount; i++)
  14. {
  15. float3 randomVec = _SampleKernelArray[i].xyz;
  16. //如果随机点的位置与法线反向,那么将随机方向取反,使之保证在法线半球
  17. randomVec = dot(randomVec, viewNormal) < 0 ? -randomVec : randomVec;
  18. float3 randomPos = viewPos + randomVec * _SampleKeneralRadius;
  19. float3 rclipPos = mul((float3x3)unity_CameraProjection, randomPos);
  20. float2 rscreenPos = (rclipPos.xy / rclipPos.z) * 0.5 + 0.5;
  21. float randomDepth;
  22. float3 randomNormal;
  23. float4 rcdn = tex2D(_CameraDepthNormalsTexture, rscreenPos);
  24. DecodeDepthNormal(rcdn, randomDepth, randomNormal);
  25. float range = abs(randomDepth - linear01Depth) * _ProjectionParams.z < _SampleKeneralRadius ? 1.0 : 0.0;
  26. float ao = randomDepth + _DepthBiasValue < linear01Depth ? 1.0 : 0.0;
  27. oc += ao * range;
  28. }
  29. oc /= sampleCount;
  30. oc = max(0.0, 1 - oc * _AOStrength);
  31. col.rgb = oc;
  32. return col;
  33. }

我们没有使用Tangent进行旋转来保证采样点在法线半球,而是直接使用屏幕空间的法线与随机采样方向点乘,如果二者方向相反,说明没在同一半球,再将其取反。在最终计算深度进行比较时,我们增加了一个BiasValue,为了防止在平面上产生自阴影的问题,同时也需要增加一个深度差的比较,我们需要保证深度差小于采样半径,否则可能会出现相距很远的物体也产生遮蔽的情况,如下图,左侧为没有添加距离判断的情况,人物距离后面的墙面已经很远了,但是仍然产生了不正常的遮蔽效果:

最后,在计算遮蔽权重时,我们并非直接计算非0即1的遮蔽值,而是乘以了一个距离的权重,使AO有一个更好的渐变效果,AO的效果如下,在台阶的拐角处,夹缝等地方都有比较明显的环境光遮蔽的效果:

但是仔细观察上图,我们会发现AO贴图中有一些噪点,并不平滑,有些许颗粒感。这和我们之前使用Dither RayMarching的体积光,屏幕空间反射的道理一样,都是因为我们引入了随机噪声。所以下一步就是需要使用一个滤波的Pass对AO的结果进行去噪。最简单的方式肯定就是高斯模糊了,但是如果们使用高斯模糊的话,整个图片就都会被模糊掉了,如下图:

显然,这不是我们想要的效果,所以我们需要引入一种能够保持明显边界,而又可以去噪的滤波。也就是所谓的双边滤波(Bilateral Filter)。双边滤波可以在模糊的同时保持图像中的边缘信息。除了考虑正常高斯滤波的空域信息(domain)外,还要考虑另外的一个图像本身携带的值域信息(range)。这个值域信息的选择并非唯一的,可以是采样点间像素颜色的差异,可以是采样点像素对应的法线信息,可以是采样点像素对应的深度信息。使用双边滤波可以实现一些好玩的效果,比如用于美颜的磨皮滤镜。关于详细的双边滤波,实现,这里不再赘述,可以参考本人之前的blog-《UnityShader-BilateralFilter(双边滤波,磨皮滤镜)》。还是上面的AO效果,使用双边滤波进行去噪后的结果如下:

可见,使用双边滤波后的AO效果已经达到了可以接受的程度。最后,我们要做的就是将AO贴图与原始图像进行混合,用AO值来调制原始图像的颜色。如果是延迟渲染,自然我们可以在GBuffer渲染之后进行SSAO计算,然后在最终光照时将SSAO运用到环境光遮蔽上,但是对于前向渲染来说,我们没有办法这么做(当然,如果每个Shader里面都ComputeScreenPos的话也不是不可以,但是我想基本没有人想这么干吧),直接将SSAO运用到整个图像上也可以达到很好的效果了。

下面看一下使用AO前后的效果对比,原始的场景如下,仅有一盏主平行光源,开启ShadowMap的效果,画面整体偏平,没有细节过渡:

开启SSAO之后,在楼梯折角,缝隙,草根,墙角等地方光照强度都降低了,使画面细节大大增加(AO强度开得大了点,不过,我喜欢^_^):

下面附上SSAO代码,C#部分代码如下:

  1. /********************************************************************
  2. FileName: ScreenSpaceAOEffect.cs
  3. Description: SSAO屏幕空间环境光遮蔽效果
  4. history: 6:10:2018 by puppet_master
  5. https://blog.csdn.net/puppet_master
  6. *********************************************************************/
  7. using System.Collections;
  8. using System.Collections.Generic;
  9. using UnityEngine;
  10. [ExecuteInEditMode]
  11. public class ScreenSpaceAOEffect : MonoBehaviour
  12. {
  13. private Material ssaoMaterial = null;
  14. private Camera currentCamera = null;
  15. private List<Vector4> sampleKernelList = new List<Vector4>();
  16. [Range(0, 0.002f)]
  17. public float DepthBiasValue = 0.002f;
  18. [Range(0.010f, 1.0f)]
  19. public float SampleKernelRadius = 1.0f;
  20. [Range(4, 32)]
  21. public int SampleKernelCount = 16;
  22. [Range(0.0f, 5.0f)]
  23. public float AOStrength = 1.0f;
  24. [Range(0, 2)]
  25. public int DownSample = 0;
  26. [Range(1, 4)]
  27. public int BlurRadius = 1;
  28. [Range(0, 0.2f)]
  29. public float BilaterFilterStrength = 0.2f;
  30. public bool OnlyShowAO = false;
  31. public enum SSAOPassName
  32. {
  33. GenerateAO = 0,
  34. BilateralFilter = 1,
  35. Composite = 2,
  36. }
  37. private void Awake()
  38. {
  39. var shader = Shader.Find("AO/ScreenSpaceAOEffect");
  40. ssaoMaterial = new Material(shader);
  41. currentCamera = GetComponent<Camera>();
  42. }
  43. private void OnEnable()
  44. {
  45. currentCamera.depthTextureMode |= DepthTextureMode.DepthNormals;
  46. }
  47. private void OnDisable()
  48. {
  49. currentCamera.depthTextureMode &= ~DepthTextureMode.DepthNormals;
  50. }
  51. private void OnRenderImage(RenderTexture source, RenderTexture destination)
  52. {
  53. GenerateAOSampleKernel();
  54. var aoRT = RenderTexture.GetTemporary(source.width >> DownSample, source.height >> DownSample, 0);
  55. ssaoMaterial.SetMatrix("_InverseProjectionMatrix", currentCamera.projectionMatrix.inverse);
  56. ssaoMaterial.SetFloat("_DepthBiasValue", DepthBiasValue);
  57. ssaoMaterial.SetVectorArray("_SampleKernelArray", sampleKernelList.ToArray());
  58. ssaoMaterial.SetFloat("_SampleKernelCount", sampleKernelList.Count);
  59. ssaoMaterial.SetFloat("_AOStrength", AOStrength);
  60. ssaoMaterial.SetFloat("_SampleKeneralRadius", SampleKernelRadius);
  61. Graphics.Blit(source, aoRT, ssaoMaterial, (int)SSAOPassName.GenerateAO);
  62. var blurRT = RenderTexture.GetTemporary(source.width >> DownSample, source.height >> DownSample, 0);
  63. ssaoMaterial.SetFloat("_BilaterFilterFactor", 1.0f - BilaterFilterStrength);
  64. ssaoMaterial.SetVector("_BlurRadius", new Vector4(BlurRadius, 0, 0, 0));
  65. Graphics.Blit(aoRT, blurRT, ssaoMaterial, (int)SSAOPassName.BilateralFilter);
  66. ssaoMaterial.SetVector("_BlurRadius", new Vector4(0, BlurRadius, 0, 0));
  67. if (OnlyShowAO)
  68. {
  69. Graphics.Blit(blurRT, destination, ssaoMaterial, (int)SSAOPassName.BilateralFilter);
  70. }
  71. else
  72. {
  73. Graphics.Blit(blurRT, aoRT, ssaoMaterial, (int)SSAOPassName.BilateralFilter);
  74. ssaoMaterial.SetTexture("_AOTex", aoRT);
  75. Graphics.Blit(source, destination, ssaoMaterial, (int)SSAOPassName.Composite);
  76. }
  77. RenderTexture.ReleaseTemporary(aoRT);
  78. RenderTexture.ReleaseTemporary(blurRT);
  79. }
  80. private void GenerateAOSampleKernel()
  81. {
  82. if (SampleKernelCount == sampleKernelList.Count)
  83. return;
  84. sampleKernelList.Clear();
  85. for(int i = 0; i < SampleKernelCount; i++)
  86. {
  87. var vec = new Vector4(Random.Range(-1.0f, 1.0f), Random.Range(-1.0f, 1.0f), Random.Range(0, 1.0f), 1.0f);
  88. vec.Normalize();
  89. var scale = (float)i / SampleKernelCount;
  90. //使分布符合二次方程的曲线
  91. scale = Mathf.Lerp(0.01f, 1.0f, scale * scale);
  92. vec *= scale;
  93. sampleKernelList.Add(vec);
  94. }
  95. }
  96. }

Shader部分代码如下:

  1. /********************************************************************
  2. FileName: ScreenSpaceAOEffect.cs
  3. Description: SSAO屏幕空间环境光遮蔽效果
  4. history: 6:10:2018 by puppet_master
  5. https://blog.csdn.net/puppet_master
  6. *********************************************************************/
  7. Shader "AO/ScreenSpaceAOEffect"
  8. {
  9. Properties
  10. {
  11. _MainTex ("Texture", 2D) = "black" {}
  12. }
  13. CGINCLUDE
  14. #include "UnityCG.cginc"
  15. struct appdata
  16. {
  17. float4 vertex : POSITION;
  18. float2 uv : TEXCOORD0;
  19. };
  20. struct v2f
  21. {
  22. float2 uv : TEXCOORD0;
  23. float4 vertex : SV_POSITION;
  24. float3 viewRay : TEXCOORD1;
  25. };
  26. #define MAX_SAMPLE_KERNEL_COUNT 32
  27. sampler2D _MainTex;
  28. sampler2D _CameraDepthNormalsTexture;
  29. float4x4 _InverseProjectionMatrix;
  30. float _DepthBiasValue;
  31. float4 _SampleKernelArray[MAX_SAMPLE_KERNEL_COUNT];
  32. float _SampleKernelCount;
  33. float _AOStrength;
  34. float _SampleKeneralRadius;
  35. float4 _MainTex_TexelSize;
  36. float4 _BlurRadius;
  37. float _BilaterFilterFactor;
  38. sampler2D _AOTex;
  39. float3 GetNormal(float2 uv)
  40. {
  41. float4 cdn = tex2D(_CameraDepthNormalsTexture, uv);
  42. return DecodeViewNormalStereo(cdn);
  43. }
  44. half CompareNormal(float3 normal1, float3 normal2)
  45. {
  46. return smoothstep(_BilaterFilterFactor, 1.0, dot(normal1, normal2));
  47. }
  48. v2f vert_ao (appdata v)
  49. {
  50. v2f o;
  51. o.vertex = UnityObjectToClipPos(v.vertex);
  52. o.uv = v.uv;
  53. float4 clipPos = float4(v.uv * 2 - 1.0, 1.0, 1.0);
  54. float4 viewRay = mul(_InverseProjectionMatrix, clipPos);
  55. o.viewRay = viewRay.xyz / viewRay.w;
  56. return o;
  57. }
  58. fixed4 frag_ao (v2f i) : SV_Target
  59. {
  60. fixed4 col = tex2D(_MainTex, i.uv);
  61. float linear01Depth;
  62. float3 viewNormal;
  63. float4 cdn = tex2D(_CameraDepthNormalsTexture, i.uv);
  64. DecodeDepthNormal(cdn, linear01Depth, viewNormal);
  65. float3 viewPos = linear01Depth * i.viewRay;
  66. viewNormal = normalize(viewNormal) * float3(1, 1, -1);
  67. int sampleCount = _SampleKernelCount;
  68. float oc = 0.0;
  69. for(int i = 0; i < sampleCount; i++)
  70. {
  71. float3 randomVec = _SampleKernelArray[i].xyz;
  72. //如果随机点的位置与法线反向,那么将随机方向取反,使之保证在法线半球
  73. randomVec = dot(randomVec, viewNormal) < 0 ? -randomVec : randomVec;
  74. float3 randomPos = viewPos + randomVec * _SampleKeneralRadius;
  75. float3 rclipPos = mul((float3x3)unity_CameraProjection, randomPos);
  76. float2 rscreenPos = (rclipPos.xy / rclipPos.z) * 0.5 + 0.5;
  77. float randomDepth;
  78. float3 randomNormal;
  79. float4 rcdn = tex2D(_CameraDepthNormalsTexture, rscreenPos);
  80. DecodeDepthNormal(rcdn, randomDepth, randomNormal);
  81. float range = abs(randomDepth - linear01Depth) * _ProjectionParams.z < _SampleKeneralRadius ? 1.0 : 0.0;
  82. float ao = randomDepth + _DepthBiasValue < linear01Depth ? 1.0 : 0.0;
  83. oc += ao * range;
  84. }
  85. oc /= sampleCount;
  86. oc = max(0.0, 1 - oc * _AOStrength);
  87. col.rgb = oc;
  88. return col;
  89. }
  90. fixed4 frag_blur (v2f i) : SV_Target
  91. {
  92. float2 delta = _MainTex_TexelSize.xy * _BlurRadius.xy;
  93. float2 uv = i.uv;
  94. float2 uv0a = i.uv - delta;
  95. float2 uv0b = i.uv + delta;
  96. float2 uv1a = i.uv - 2.0 * delta;
  97. float2 uv1b = i.uv + 2.0 * delta;
  98. float2 uv2a = i.uv - 3.0 * delta;
  99. float2 uv2b = i.uv + 3.0 * delta;
  100. float3 normal = GetNormal(uv);
  101. float3 normal0a = GetNormal(uv0a);
  102. float3 normal0b = GetNormal(uv0b);
  103. float3 normal1a = GetNormal(uv1a);
  104. float3 normal1b = GetNormal(uv1b);
  105. float3 normal2a = GetNormal(uv2a);
  106. float3 normal2b = GetNormal(uv2b);
  107. fixed4 col = tex2D(_MainTex, uv);
  108. fixed4 col0a = tex2D(_MainTex, uv0a);
  109. fixed4 col0b = tex2D(_MainTex, uv0b);
  110. fixed4 col1a = tex2D(_MainTex, uv1a);
  111. fixed4 col1b = tex2D(_MainTex, uv1b);
  112. fixed4 col2a = tex2D(_MainTex, uv2a);
  113. fixed4 col2b = tex2D(_MainTex, uv2b);
  114. half w = 0.37004405286;
  115. half w0a = CompareNormal(normal, normal0a) * 0.31718061674;
  116. half w0b = CompareNormal(normal, normal0b) * 0.31718061674;
  117. half w1a = CompareNormal(normal, normal1a) * 0.19823788546;
  118. half w1b = CompareNormal(normal, normal1b) * 0.19823788546;
  119. half w2a = CompareNormal(normal, normal2a) * 0.11453744493;
  120. half w2b = CompareNormal(normal, normal2b) * 0.11453744493;
  121. half3 result;
  122. result = w * col.rgb;
  123. result += w0a * col0a.rgb;
  124. result += w0b * col0b.rgb;
  125. result += w1a * col1a.rgb;
  126. result += w1b * col1b.rgb;
  127. result += w2a * col2a.rgb;
  128. result += w2b * col2b.rgb;
  129. result /= w + w0a + w0b + w1a + w1b + w2a + w2b;
  130. return fixed4(result, 1.0);
  131. }
  132. fixed4 frag_composite(v2f i) : SV_Target
  133. {
  134. fixed4 ori = tex2D(_MainTex, i.uv);
  135. fixed4 ao = tex2D(_AOTex, i.uv);
  136. ori.rgb *= ao.r;
  137. return ori;
  138. }
  139. ENDCG
  140. SubShader
  141. {
  142. Cull Off ZWrite Off ZTest Always
  143. //Pass 0 : Generate AO
  144. Pass
  145. {
  146. CGPROGRAM
  147. #pragma vertex vert_ao
  148. #pragma fragment frag_ao
  149. ENDCG
  150. }
  151. //Pass 1 : Bilateral Filter Blur
  152. Pass
  153. {
  154. CGPROGRAM
  155. #pragma vertex vert_ao
  156. #pragma fragment frag_blur
  157. ENDCG
  158. }
  159. //Pass 2 : Composite AO
  160. Pass
  161. {
  162. CGPROGRAM
  163. #pragma vertex vert_ao
  164. #pragma fragment frag_composite
  165. ENDCG
  166. }
  167. }
  168. }

关于TSSAO其实是SSAO的一种优化,主体的思想是没有变的,主要是使用了Reverse Reprojection技术加速计算,这个等以后玩Temporal的时候再说啦。下面看一种与SSAO本身实现差异较大的一种AO实现。

HBAO-水平基准环境光遮蔽

HBAO,是NVIDIA提出的另一种实现SSAO的方式,全称Horizon-Based Ambient Occlusion为水平基准环境光遮蔽。这个技术最早是在08年时提出的,在CryTek之后。而后在最近几年吸取了Scalable Ambient Obscurance等方法的优点,在14年左右进化成了HBAO+,一度成为了当时效果最好的环境光遮蔽。不过当时基于距离场的方法还没火哈。15年的时候被游戏评测爆吹了一顿,《SSAO进化之巅峰—水平基准环境光遮蔽HBAO+》,当年我看到这个文章的时候就是,“哇塞,看不懂,收藏,告辞“,其实现在HBAO+我也没懂,今天要玩的就是最普通的HBAO,甚至是简化版本的HBAO,但是个人感觉效果已经比32随机采样点的SSAO效果要好。

HBAO实现原理

HBAO的实现原理首先可以参考08年Siggraph上NVIDIA分享的PPT《Image Space Horizon-Based Ambient Occlusion》以及《ShaderX7》,书中有一整章讲Ambient Occlusion的章节,当然还有SSAO中提到的那篇对比各种SSAO实现方式的论文。注意:本文实现的并非正统HBAO,感兴趣的可以去看原论文。我只是玩了个简化的版本,可能原理上是错误的,但是简单,粗暴,效果差不太远。(对我来说,苟出一个省一些的效果,要远比基于“物理”更重要。)

SSAO中判断是否遮挡是通过深度来判断的,而HBAO做得更加彻底,直接将屏幕空间的一个方向对应的深度信息作为高度信息,沿着这个方向进行Ray Marching判断是否遮挡。关于Ray Marching,之前我们在体积光,屏幕空间反射都使用过Ray Marching,不过都是通过屏幕空间深度反算视空间位置,在视空间进行的Ray Marching,而HBAO的Ray Marching方向略有不同,是直接在屏幕空间进行Ray Marching,如下图所示(来自上文NVIDIA的PPT):

首先,在屏幕空间任意一点,将其周围360的角度进行均分,每个方向分别做RayMarching,入图中左半部分,分为四个方向进行Ray Marching。而对于每个方向来说,则如右图所示,沿着Ray Marching的方向为Image Plane所示的方向,每步进一次,采样一次深度信息判断角度:

如图,P点为当前像素点,+X方向为Ray Marching的方向,S0为第一个采样点,该点的角度值大于Bias值(预先设定的阈值)即认为遮挡,而第二个采样点S1,角度小于PS0,不遮挡不计,而S2的角度大于了S0的角度,计入遮挡,S3同理。这样的好处在于可以处理类似S1这样的假遮挡点,使最终的AO结果更加精确。最终每个遮挡点根据距离权重计入,每个方向进行遮挡计算的和,除以方向数,就得到最终的遮挡结果了。

原论文的HBAO,所指的夹角是Z轴(或XY平面)和PS之间的夹角,还需要考虑进来真正的顶点所对应的法线方向以及tangent平面,即最终是AO值 = sin h(Horizon Angle,atan(H.z /  H.xy)) - sin t(Tangent Angle,atan(T.z / T.xy)),为了简化,本人直接使用P点对应平面与PS之间的夹角进行计算,再一步转化就可以用P点对应Normal与PS之间夹角进行计算,测试也可以得到不错的效果。

HBAO效果实现

HBAO关键部分Shader代码如下:

  1. inline float2 RotateDirections(float2 dir, float2 rot) {
  2. return float2(dir.x * rot.x - dir.y * rot.y,
  3. dir.x * rot.y + dir.y * rot.x);
  4. }
  5. inline float Falloff2(float distance, float radius)
  6. {
  7. float a = distance / radius;
  8. return clamp(1.0 - a * a, 0.0, 1.0);
  9. }
  10. float3 GetViewPos(v2f i, float2 uv)
  11. {
  12. float linear01Depth;
  13. float3 viewNormal;
  14. float4 cdn = tex2D(_CameraDepthNormalsTexture, uv);
  15. DecodeDepthNormal(cdn, linear01Depth, viewNormal);
  16. float3 viewPos = linear01Depth * i.viewRay;
  17. return viewPos;
  18. }
  19. // Reconstruct view-space position from UV and depth.
  20. // p11_22 = (unity_CameraProjection._11, unity_CameraProjection._22)
  21. // p13_31 = (unity_CameraProjection._13, unity_CameraProjection._23)
  22. float3 ReconstructViewPos(float2 uv)
  23. {
  24. float3x3 proj = (float3x3)unity_CameraProjection;
  25. float2 p11_22 = float2(unity_CameraProjection._11, unity_CameraProjection._22);
  26. float2 p13_31 = float2(unity_CameraProjection._13, unity_CameraProjection._23);
  27. float depth;
  28. float3 viewNormal;
  29. float4 cdn = tex2D(_CameraDepthNormalsTexture, uv);
  30. DecodeDepthNormal(cdn, depth, viewNormal);
  31. depth *= _ProjectionParams.z;
  32. return float3((uv * 2.0 - 1.0 - p13_31) / p11_22 * (depth), depth);
  33. }
  34. inline float2 GetRayMarchingDir(float angle)
  35. {
  36. float sinValue, cosValue;
  37. sincos(angle, sinValue, cosValue);
  38. return RotateDirections(float2(cosValue, sinValue), float2(1.0, 0));
  39. }
  40. fixed4 frag_ao (v2f i) : SV_Target
  41. {
  42. float2 InvScreenParams = _ScreenParams.zw - 1.0;
  43. fixed4 col = tex2D(_MainTex, i.uv);
  44. float3 viewPos = ReconstructViewPos(i.uv);
  45. float4 cdn = tex2D(_CameraDepthNormalsTexture, i.uv);
  46. float3 viewNormal = DecodeViewNormalStereo(cdn) * float3(1.0, 1.0, -1.0);
  47. float rayMarchingRadius = min(_SampleRadius / viewPos.z, _MaxPixelRadius);
  48. float rayMarchingStepSize = rayMarchingRadius / _RayMarchingStep;
  49. float rayAngleSize = 2.0 * UNITY_PI / _RayAngleStep;
  50. float oc = 0.0;
  51. for(int j = 0; j < _RayAngleStep; j++)
  52. {
  53. float2 rayMarchingDir = GetRayMarchingDir(j * rayAngleSize);
  54. float oldangle = _AngleBiasValue;
  55. float2 deltauv = round(1 + rayMarchingDir * rayMarchingStepSize) * InvScreenParams;
  56. for(int k = 1; k < _RayMarchingStep; k++)
  57. {
  58. float2 uv = k * deltauv + i.uv;
  59. float3 sviewPos = ReconstructViewPos(uv);
  60. float3 svdir = sviewPos - viewPos;
  61. float l = length(svdir);
  62. float angle = UNITY_PI * 0.5 - acos(dot(viewNormal, normalize(svdir)));
  63. if (angle > oldangle)
  64. {
  65. float value = sin(angle) - sin(oldangle);
  66. float atten = Falloff2(l, _AORadius);
  67. oc += value * atten;
  68. oldangle = angle;
  69. }
  70. }
  71. }
  72. oc *= 1.0 / (_RayAngleStep) * _AOStrength;
  73. oc = 1.0 - oc;
  74. col.rgb = oc;
  75. return col;
  76. }

直接使用HBAO,在步进次数和方向数足够大(8-16左右)时,个人感觉不适用滤波操作,效果也可以接受:

无AO的效果:

开启HBAO效果:

仅显示AO效果:

可以控制_RayMarchingStep和_RayAngleStep两个值控制步进次数和步进方向分割。另外,既然是RayMarching,我们在体积光和屏幕空间反射的老套路就又可以使用了,通过Dither + 模糊实现Jitter Ray Marching来大大降低光线追踪的消耗。此处的模糊我们仍然使用双边滤波,在去噪的同时保持边缘。上图中的步进次数为8x8,计算相当的费。而如果我们把次数改为3x3效果就很差了:

已经无法很明确地区分出AO部分,下面我们把采样方向和每次步进的起始位置加上Dither值后,仍然是3x3的采样:

效果很奇怪,但是这只是中间结果,下面我们加入双边滤波去噪,3x3采样后的效果:

与上面8x8采样效果虽然还是差了一些,但是已经不会出现错误的情况,但是计算量极大地降低了。

总结

本文主要实现了目前游戏中主要的几种环境光遮蔽的方法。基于AO贴图的遮蔽,通过GPU烘焙AO贴图,屏幕空间环境光遮蔽SSAO,水平基准环境光遮蔽(HBAO)。几种技术各有优缺点,技术本身不分好坏,只有适合自己项目的。实际上,这些AO技术通常会同时使用,最常见的就是AO贴图和各种屏幕空间的AO算法同时使用。《Making it Large, Beautiful, Fast and Consistent: Lessons Learned Developing Just Cause 2》所介绍的,正当防卫这款游戏中,就包含了三种AO,除上述两种外,还有一种称之为AO Volumes的技术,简单来说就是为了弥补Bake AO无法实现动态物体遮挡的问题,使用一个圆柱或者立方体实现一个假的AO遮挡效果,这种技术用于人物脚底,或者车底,可以增加不少细节效果。关于AO,实际上还有很多很多进阶的技术,如Bent Normal,AAO,TSSAO,VXAO,UE4的Distance Filed Ambient Occlusion等等,有机会再玩啦。。

这篇blog拖了很久才写完,主要最近游戏买的有点多,加上周末要看英雄联盟的比赛,感觉有点颓废。不过好在刚好又通关了一个很不错的游戏《心灵杀手(Alan Wake)》,下篇blog的开头又有东西写啦!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章