前言
十一放假很开心,正好赶上观望了了许久的《尼尔·机械纪元》打折啦。窝在家里搞了三天三夜,终于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的光照叠加部分:
half3 color = diffColor * (gi.diffuse + light.color * diffuseTerm) + specularTerm * light.color * FresnelTerm (specColor, lh) + surfaceReduction * gi.specular * FresnelLerp (specColor, grazingTerm, nv);
不考虑菲尼尔项的话,就是direct diffuse + direct specular + gi diffuse + gi specular,前两者通过BRDF计算,而后两者就是所谓的环境光,我们看一下Unity官方的GI Shader源代码:
- inline UnityGI UnityGI_Base(UnityGIInput data, half occlusion, half3 normalWorld)
- {
- UnityGI o_gi;
- ResetUnityGI(o_gi);
-
- // Base pass with Lightmap support is responsible for handling ShadowMask / blending here for performance reason
- #if defined(HANDLE_SHADOWS_BLENDING_IN_GI)
- half bakedAtten = UnitySampleBakedOcclusion(data.lightmapUV.xy, data.worldPos);
- float zDist = dot(_WorldSpaceCameraPos - data.worldPos, UNITY_MATRIX_V[2].xyz);
- float fadeDist = UnityComputeShadowFadeDistance(data.worldPos, zDist);
- data.atten = UnityMixRealtimeAndBakedShadows(data.atten, bakedAtten, UnityComputeShadowFade(fadeDist));
- #endif
-
- o_gi.light = data.light;
- o_gi.light.color *= data.atten;
-
- #if UNITY_SHOULD_SAMPLE_SH
- o_gi.indirect.diffuse = ShadeSHPerPixel(normalWorld, data.ambient, data.worldPos);
- #endif
-
- #if defined(LIGHTMAP_ON)
- // Baked lightmaps
- half4 bakedColorTex = UNITY_SAMPLE_TEX2D(unity_Lightmap, data.lightmapUV.xy);
- half3 bakedColor = DecodeLightmap(bakedColorTex);
-
- #ifdef DIRLIGHTMAP_COMBINED
- fixed4 bakedDirTex = UNITY_SAMPLE_TEX2D_SAMPLER (unity_LightmapInd, unity_Lightmap, data.lightmapUV.xy);
- o_gi.indirect.diffuse += DecodeDirectionalLightmap (bakedColor, bakedDirTex, normalWorld);
-
- #if defined(LIGHTMAP_SHADOW_MIXING) && !defined(SHADOWS_SHADOWMASK) && defined(SHADOWS_SCREEN)
- ResetUnityLight(o_gi.light);
- o_gi.indirect.diffuse = SubtractMainLightWithRealtimeAttenuationFromLightmap (o_gi.indirect.diffuse, data.atten, bakedColorTex, normalWorld);
- #endif
-
- #else // not directional lightmap
- o_gi.indirect.diffuse += bakedColor;
-
- #if defined(LIGHTMAP_SHADOW_MIXING) && !defined(SHADOWS_SHADOWMASK) && defined(SHADOWS_SCREEN)
- ResetUnityLight(o_gi.light);
- o_gi.indirect.diffuse = SubtractMainLightWithRealtimeAttenuationFromLightmap(o_gi.indirect.diffuse, data.atten, bakedColorTex, normalWorld);
- #endif
-
- #endif
- #endif
-
- #ifdef DYNAMICLIGHTMAP_ON
- // Dynamic lightmaps
- fixed4 realtimeColorTex = UNITY_SAMPLE_TEX2D(unity_DynamicLightmap, data.lightmapUV.zw);
- half3 realtimeColor = DecodeRealtimeLightmap (realtimeColorTex);
-
- #ifdef DIRLIGHTMAP_COMBINED
- half4 realtimeDirTex = UNITY_SAMPLE_TEX2D_SAMPLER(unity_DynamicDirectionality, unity_DynamicLightmap, data.lightmapUV.zw);
- o_gi.indirect.diffuse += DecodeDirectionalLightmap (realtimeColor, realtimeDirTex, normalWorld);
- #else
- o_gi.indirect.diffuse += realtimeColor;
- #endif
- #endif
-
- o_gi.indirect.diffuse *= occlusion;
- return o_gi;
- }
虽然代码看起来比较长,不过绝大部分都不是我们关心的重点,我们重点在于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关键代码:
- v2f vert (appdata v)
- {
- v2f o;
- float2 uv = v.uv;
- uv.y = 1 - v.uv.y;
- o.vertex = float4(uv * 2 - 1, 0, 1);
- return o;
- }
还是上面的小狮子模型,这下被拍扁到屏幕上了,好惨:
不过接下来,我们就可以比较容易地实现AO的烘焙了。首先,我们围绕半球空间构建一系列的随机采样点,然后将这些点通过模型的tbn基坐标转化到模型空间,然后对于每个采样点的方向,计算该方向与其他所有三角形是否相交且小于遮挡半径。最终平均多个采样点的结果,得到最终的AO贴图。
关键部分代码如下,此处将三角形的顶点信息直接传递到了uniform中,但是目前有顶点数限制,1300顶点以上烘焙显卡就会崩溃,不过实验的话,已经足够啦。
- float aovalue = 0;
- for (int s = 0; s < (int)_SampleDirCount; s++)
- {
- float3 sampleDir = _SampleDir[s];
- sampleDir = normalize(sampleDir);
- float3 objDir = i.objTangent * sampleDir.x + i.objBiNormal * sampleDir.y + i.objNormal * sampleDir.z;
- float currentLength = _AOTracingRadius;
- for (int j = 0; j < (int)_TriangleCount; j++)
- {
- float3 p0 = _TriangleX[j].xyz;
- float3 p1 = _TriangleY[j].xyz;
- float3 p2 = _TriangleZ[j].xyz;
- float raylength;
- bool result = RayTriangleTest(objDir, i.objPos, p0, p1, p2, raylength);
- if (result && raylength < currentLength)
- {
- currentLength = raylength;
- }
- }
- float ao = clamp(currentLength, 0, _AOTracingRadius) / _AOTracingRadius;
- aovalue += ao;
- }
- aovalue /= _SampleDirCount;
- 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的实现,首先,我们生成一系列的随机采样点,为了让遮蔽效果更好,我们尽量保证在靠近采样点的位置分布更多的随机点:
- private void GenerateAOSampleKernel()
- {
- if (SampleKernelCount == sampleKernelList.Count)
- return;
- sampleKernelList.Clear();
- for(int i = 0; i < SampleKernelCount; i++)
- {
- var vec = new Vector4(Random.Range(-1.0f, 1.0f), Random.Range(-1.0f, 1.0f), Random.Range(0, 1.0f), 1.0f);
- vec.Normalize();
- var scale = (float)i / SampleKernelCount;
- //使分布符合二次方程的曲线
- scale = Mathf.Lerp(0.01f, 1.0f, scale * scale);
- vec *= scale;
- sampleKernelList.Add(vec);
- }
- }
关键Shader部分代码如下:
- fixed4 frag_ao (v2f i) : SV_Target
- {
- fixed4 col = tex2D(_MainTex, i.uv);
-
- float linear01Depth;
- float3 viewNormal;
-
- float4 cdn = tex2D(_CameraDepthNormalsTexture, i.uv);
- DecodeDepthNormal(cdn, linear01Depth, viewNormal);
-
- float3 viewPos = linear01Depth * i.viewRay;
- float3 viewDir = normalize(viewPos);
- viewNormal = normalize(viewNormal);
-
- int sampleCount = _SampleKernelCount;
-
- float oc = 0.0;
- for(int i = 0; i < sampleCount; i++)
- {
- float3 randomVec = _SampleKernelArray[i].xyz;
- //如果随机点的位置与法线反向,那么将随机方向取反,使之保证在法线半球
- randomVec = dot(randomVec, viewNormal) < 0 ? -randomVec : randomVec;
-
- float3 randomPos = viewPos + randomVec * _SampleKeneralRadius;
- float3 rclipPos = mul((float3x3)unity_CameraProjection, randomPos);
- float2 rscreenPos = (rclipPos.xy / rclipPos.z) * 0.5 + 0.5;
-
- float randomDepth;
- float3 randomNormal;
- float4 rcdn = tex2D(_CameraDepthNormalsTexture, rscreenPos);
- DecodeDepthNormal(rcdn, randomDepth, randomNormal);
- float range = abs(randomDepth - linear01Depth) * _ProjectionParams.z < _SampleKeneralRadius ? 1.0 : 0.0;
- float ao = randomDepth + _DepthBiasValue < linear01Depth ? 1.0 : 0.0;
- oc += ao * range;
- }
- oc /= sampleCount;
- oc = max(0.0, 1 - oc * _AOStrength);
-
- col.rgb = oc;
- return col;
- }
我们没有使用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#部分代码如下:
- /********************************************************************
- FileName: ScreenSpaceAOEffect.cs
- Description: SSAO屏幕空间环境光遮蔽效果
- history: 6:10:2018 by puppet_master
- https://blog.csdn.net/puppet_master
- *********************************************************************/
- using System.Collections;
- using System.Collections.Generic;
- using UnityEngine;
-
- [ExecuteInEditMode]
- public class ScreenSpaceAOEffect : MonoBehaviour
- {
- private Material ssaoMaterial = null;
- private Camera currentCamera = null;
- private List<Vector4> sampleKernelList = new List<Vector4>();
-
- [Range(0, 0.002f)]
- public float DepthBiasValue = 0.002f;
- [Range(0.010f, 1.0f)]
- public float SampleKernelRadius = 1.0f;
- [Range(4, 32)]
- public int SampleKernelCount = 16;
- [Range(0.0f, 5.0f)]
- public float AOStrength = 1.0f;
- [Range(0, 2)]
- public int DownSample = 0;
-
- [Range(1, 4)]
- public int BlurRadius = 1;
- [Range(0, 0.2f)]
- public float BilaterFilterStrength = 0.2f;
-
- public bool OnlyShowAO = false;
-
- public enum SSAOPassName
- {
- GenerateAO = 0,
- BilateralFilter = 1,
- Composite = 2,
- }
-
- private void Awake()
- {
- var shader = Shader.Find("AO/ScreenSpaceAOEffect");
- ssaoMaterial = new Material(shader);
- currentCamera = GetComponent<Camera>();
- }
-
- private void OnEnable()
- {
- currentCamera.depthTextureMode |= DepthTextureMode.DepthNormals;
- }
-
- private void OnDisable()
- {
- currentCamera.depthTextureMode &= ~DepthTextureMode.DepthNormals;
- }
-
- private void OnRenderImage(RenderTexture source, RenderTexture destination)
- {
- GenerateAOSampleKernel();
-
- var aoRT = RenderTexture.GetTemporary(source.width >> DownSample, source.height >> DownSample, 0);
-
- ssaoMaterial.SetMatrix("_InverseProjectionMatrix", currentCamera.projectionMatrix.inverse);
- ssaoMaterial.SetFloat("_DepthBiasValue", DepthBiasValue);
- ssaoMaterial.SetVectorArray("_SampleKernelArray", sampleKernelList.ToArray());
- ssaoMaterial.SetFloat("_SampleKernelCount", sampleKernelList.Count);
- ssaoMaterial.SetFloat("_AOStrength", AOStrength);
- ssaoMaterial.SetFloat("_SampleKeneralRadius", SampleKernelRadius);
- Graphics.Blit(source, aoRT, ssaoMaterial, (int)SSAOPassName.GenerateAO);
-
- var blurRT = RenderTexture.GetTemporary(source.width >> DownSample, source.height >> DownSample, 0);
- ssaoMaterial.SetFloat("_BilaterFilterFactor", 1.0f - BilaterFilterStrength);
-
- ssaoMaterial.SetVector("_BlurRadius", new Vector4(BlurRadius, 0, 0, 0));
- Graphics.Blit(aoRT, blurRT, ssaoMaterial, (int)SSAOPassName.BilateralFilter);
-
- ssaoMaterial.SetVector("_BlurRadius", new Vector4(0, BlurRadius, 0, 0));
- if (OnlyShowAO)
- {
- Graphics.Blit(blurRT, destination, ssaoMaterial, (int)SSAOPassName.BilateralFilter);
- }
- else
- {
- Graphics.Blit(blurRT, aoRT, ssaoMaterial, (int)SSAOPassName.BilateralFilter);
- ssaoMaterial.SetTexture("_AOTex", aoRT);
- Graphics.Blit(source, destination, ssaoMaterial, (int)SSAOPassName.Composite);
- }
-
- RenderTexture.ReleaseTemporary(aoRT);
- RenderTexture.ReleaseTemporary(blurRT);
- }
-
- private void GenerateAOSampleKernel()
- {
- if (SampleKernelCount == sampleKernelList.Count)
- return;
- sampleKernelList.Clear();
- for(int i = 0; i < SampleKernelCount; i++)
- {
- var vec = new Vector4(Random.Range(-1.0f, 1.0f), Random.Range(-1.0f, 1.0f), Random.Range(0, 1.0f), 1.0f);
- vec.Normalize();
- var scale = (float)i / SampleKernelCount;
- //使分布符合二次方程的曲线
- scale = Mathf.Lerp(0.01f, 1.0f, scale * scale);
- vec *= scale;
- sampleKernelList.Add(vec);
- }
- }
-
- }
Shader部分代码如下:
- /********************************************************************
- FileName: ScreenSpaceAOEffect.cs
- Description: SSAO屏幕空间环境光遮蔽效果
- history: 6:10:2018 by puppet_master
- https://blog.csdn.net/puppet_master
- *********************************************************************/
- Shader "AO/ScreenSpaceAOEffect"
- {
- Properties
- {
- _MainTex ("Texture", 2D) = "black" {}
- }
- CGINCLUDE
- #include "UnityCG.cginc"
-
- struct appdata
- {
- float4 vertex : POSITION;
- float2 uv : TEXCOORD0;
- };
-
- struct v2f
- {
- float2 uv : TEXCOORD0;
- float4 vertex : SV_POSITION;
- float3 viewRay : TEXCOORD1;
- };
-
- #define MAX_SAMPLE_KERNEL_COUNT 32
- sampler2D _MainTex;
- sampler2D _CameraDepthNormalsTexture;
- float4x4 _InverseProjectionMatrix;
- float _DepthBiasValue;
- float4 _SampleKernelArray[MAX_SAMPLE_KERNEL_COUNT];
- float _SampleKernelCount;
- float _AOStrength;
- float _SampleKeneralRadius;
-
- float4 _MainTex_TexelSize;
- float4 _BlurRadius;
- float _BilaterFilterFactor;
-
- sampler2D _AOTex;
-
- float3 GetNormal(float2 uv)
- {
- float4 cdn = tex2D(_CameraDepthNormalsTexture, uv);
- return DecodeViewNormalStereo(cdn);
- }
-
- half CompareNormal(float3 normal1, float3 normal2)
- {
- return smoothstep(_BilaterFilterFactor, 1.0, dot(normal1, normal2));
- }
-
- v2f vert_ao (appdata v)
- {
- v2f o;
- o.vertex = UnityObjectToClipPos(v.vertex);
- o.uv = v.uv;
- float4 clipPos = float4(v.uv * 2 - 1.0, 1.0, 1.0);
- float4 viewRay = mul(_InverseProjectionMatrix, clipPos);
- o.viewRay = viewRay.xyz / viewRay.w;
- return o;
- }
-
- fixed4 frag_ao (v2f i) : SV_Target
- {
- fixed4 col = tex2D(_MainTex, i.uv);
-
- float linear01Depth;
- float3 viewNormal;
-
- float4 cdn = tex2D(_CameraDepthNormalsTexture, i.uv);
- DecodeDepthNormal(cdn, linear01Depth, viewNormal);
- float3 viewPos = linear01Depth * i.viewRay;
- viewNormal = normalize(viewNormal) * float3(1, 1, -1);
-
- int sampleCount = _SampleKernelCount;
-
- float oc = 0.0;
- for(int i = 0; i < sampleCount; i++)
- {
- float3 randomVec = _SampleKernelArray[i].xyz;
- //如果随机点的位置与法线反向,那么将随机方向取反,使之保证在法线半球
- randomVec = dot(randomVec, viewNormal) < 0 ? -randomVec : randomVec;
-
- float3 randomPos = viewPos + randomVec * _SampleKeneralRadius;
- float3 rclipPos = mul((float3x3)unity_CameraProjection, randomPos);
- float2 rscreenPos = (rclipPos.xy / rclipPos.z) * 0.5 + 0.5;
-
- float randomDepth;
- float3 randomNormal;
- float4 rcdn = tex2D(_CameraDepthNormalsTexture, rscreenPos);
- DecodeDepthNormal(rcdn, randomDepth, randomNormal);
- float range = abs(randomDepth - linear01Depth) * _ProjectionParams.z < _SampleKeneralRadius ? 1.0 : 0.0;
- float ao = randomDepth + _DepthBiasValue < linear01Depth ? 1.0 : 0.0;
- oc += ao * range;
- }
- oc /= sampleCount;
- oc = max(0.0, 1 - oc * _AOStrength);
-
- col.rgb = oc;
- return col;
- }
-
- fixed4 frag_blur (v2f i) : SV_Target
- {
- float2 delta = _MainTex_TexelSize.xy * _BlurRadius.xy;
-
- float2 uv = i.uv;
- float2 uv0a = i.uv - delta;
- float2 uv0b = i.uv + delta;
- float2 uv1a = i.uv - 2.0 * delta;
- float2 uv1b = i.uv + 2.0 * delta;
- float2 uv2a = i.uv - 3.0 * delta;
- float2 uv2b = i.uv + 3.0 * delta;
-
- float3 normal = GetNormal(uv);
- float3 normal0a = GetNormal(uv0a);
- float3 normal0b = GetNormal(uv0b);
- float3 normal1a = GetNormal(uv1a);
- float3 normal1b = GetNormal(uv1b);
- float3 normal2a = GetNormal(uv2a);
- float3 normal2b = GetNormal(uv2b);
-
- fixed4 col = tex2D(_MainTex, uv);
- fixed4 col0a = tex2D(_MainTex, uv0a);
- fixed4 col0b = tex2D(_MainTex, uv0b);
- fixed4 col1a = tex2D(_MainTex, uv1a);
- fixed4 col1b = tex2D(_MainTex, uv1b);
- fixed4 col2a = tex2D(_MainTex, uv2a);
- fixed4 col2b = tex2D(_MainTex, uv2b);
-
- half w = 0.37004405286;
- half w0a = CompareNormal(normal, normal0a) * 0.31718061674;
- half w0b = CompareNormal(normal, normal0b) * 0.31718061674;
- half w1a = CompareNormal(normal, normal1a) * 0.19823788546;
- half w1b = CompareNormal(normal, normal1b) * 0.19823788546;
- half w2a = CompareNormal(normal, normal2a) * 0.11453744493;
- half w2b = CompareNormal(normal, normal2b) * 0.11453744493;
-
- half3 result;
- result = w * col.rgb;
- result += w0a * col0a.rgb;
- result += w0b * col0b.rgb;
- result += w1a * col1a.rgb;
- result += w1b * col1b.rgb;
- result += w2a * col2a.rgb;
- result += w2b * col2b.rgb;
-
- result /= w + w0a + w0b + w1a + w1b + w2a + w2b;
- return fixed4(result, 1.0);
- }
-
- fixed4 frag_composite(v2f i) : SV_Target
- {
- fixed4 ori = tex2D(_MainTex, i.uv);
- fixed4 ao = tex2D(_AOTex, i.uv);
- ori.rgb *= ao.r;
- return ori;
- }
-
- ENDCG
-
- SubShader
- {
-
- Cull Off ZWrite Off ZTest Always
-
- //Pass 0 : Generate AO
- Pass
- {
- CGPROGRAM
- #pragma vertex vert_ao
- #pragma fragment frag_ao
- ENDCG
- }
-
- //Pass 1 : Bilateral Filter Blur
- Pass
- {
- CGPROGRAM
- #pragma vertex vert_ao
- #pragma fragment frag_blur
- ENDCG
- }
-
- //Pass 2 : Composite AO
- Pass
- {
- CGPROGRAM
- #pragma vertex vert_ao
- #pragma fragment frag_composite
- ENDCG
- }
- }
- }
关于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代码如下:
- inline float2 RotateDirections(float2 dir, float2 rot) {
- return float2(dir.x * rot.x - dir.y * rot.y,
- dir.x * rot.y + dir.y * rot.x);
- }
-
- inline float Falloff2(float distance, float radius)
- {
- float a = distance / radius;
- return clamp(1.0 - a * a, 0.0, 1.0);
- }
-
- float3 GetViewPos(v2f i, float2 uv)
- {
- float linear01Depth;
- float3 viewNormal;
- float4 cdn = tex2D(_CameraDepthNormalsTexture, uv);
- DecodeDepthNormal(cdn, linear01Depth, viewNormal);
- float3 viewPos = linear01Depth * i.viewRay;
- return viewPos;
- }
-
- // Reconstruct view-space position from UV and depth.
- // p11_22 = (unity_CameraProjection._11, unity_CameraProjection._22)
- // p13_31 = (unity_CameraProjection._13, unity_CameraProjection._23)
- float3 ReconstructViewPos(float2 uv)
- {
- float3x3 proj = (float3x3)unity_CameraProjection;
- float2 p11_22 = float2(unity_CameraProjection._11, unity_CameraProjection._22);
- float2 p13_31 = float2(unity_CameraProjection._13, unity_CameraProjection._23);
- float depth;
- float3 viewNormal;
- float4 cdn = tex2D(_CameraDepthNormalsTexture, uv);
- DecodeDepthNormal(cdn, depth, viewNormal);
- depth *= _ProjectionParams.z;
- return float3((uv * 2.0 - 1.0 - p13_31) / p11_22 * (depth), depth);
- }
-
- inline float2 GetRayMarchingDir(float angle)
- {
- float sinValue, cosValue;
- sincos(angle, sinValue, cosValue);
- return RotateDirections(float2(cosValue, sinValue), float2(1.0, 0));
- }
-
- fixed4 frag_ao (v2f i) : SV_Target
- {
- float2 InvScreenParams = _ScreenParams.zw - 1.0;
- fixed4 col = tex2D(_MainTex, i.uv);
- float3 viewPos = ReconstructViewPos(i.uv);
-
- float4 cdn = tex2D(_CameraDepthNormalsTexture, i.uv);
- float3 viewNormal = DecodeViewNormalStereo(cdn) * float3(1.0, 1.0, -1.0);
-
- float rayMarchingRadius = min(_SampleRadius / viewPos.z, _MaxPixelRadius);
- float rayMarchingStepSize = rayMarchingRadius / _RayMarchingStep;
- float rayAngleSize = 2.0 * UNITY_PI / _RayAngleStep;
-
- float oc = 0.0;
- for(int j = 0; j < _RayAngleStep; j++)
- {
- float2 rayMarchingDir = GetRayMarchingDir(j * rayAngleSize);
- float oldangle = _AngleBiasValue;
- float2 deltauv = round(1 + rayMarchingDir * rayMarchingStepSize) * InvScreenParams;
-
- for(int k = 1; k < _RayMarchingStep; k++)
- {
- float2 uv = k * deltauv + i.uv;
- float3 sviewPos = ReconstructViewPos(uv);
-
- float3 svdir = sviewPos - viewPos;
- float l = length(svdir);
- float angle = UNITY_PI * 0.5 - acos(dot(viewNormal, normalize(svdir)));
- if (angle > oldangle)
- {
- float value = sin(angle) - sin(oldangle);
- float atten = Falloff2(l, _AORadius);
- oc += value * atten;
- oldangle = angle;
- }
- }
- }
- oc *= 1.0 / (_RayAngleStep) * _AOStrength;
- oc = 1.0 - oc;
-
- col.rgb = oc;
- return col;
- }
直接使用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的开头又有东西写啦!