前言

十一放假很开心,正好赶上观望了了许久的《尼尔·机械纪元》打折啦。窝在家里搞了三天三夜,终于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));#endifo_gi.light = data.light;o_gi.light.color *= data.atten;#if UNITY_SHOULD_SAMPLE_SHo_gi.indirect.diffuse = ShadeSHPerPixel(normalWorld, data.ambient, data.worldPos);#endif#if defined(LIGHTMAP_ON)// Baked lightmapshalf4 bakedColorTex = UNITY_SAMPLE_TEX2D(unity_Lightmap, data.lightmapUV.xy);half3 bakedColor = DecodeLightmap(bakedColorTex);#ifdef DIRLIGHTMAP_COMBINEDfixed4 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 lightmapo_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 lightmapsfixed4 realtimeColorTex = UNITY_SAMPLE_TEX2D(unity_DynamicLightmap, data.lightmapUV.zw);half3 realtimeColor = DecodeRealtimeLightmap (realtimeColorTex);#ifdef DIRLIGHTMAP_COMBINEDhalf4 realtimeDirTex = UNITY_SAMPLE_TEX2D_SAMPLER(unity_DynamicDirectionality, unity_DynamicLightmap, data.lightmapUV.zw);o_gi.indirect.diffuse += DecodeDirectionalLightmap (realtimeColor, realtimeDirTex, normalWorld);#elseo_gi.indirect.diffuse += realtimeColor;#endif#endifo_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.csDescription: SSAO屏幕空间环境光遮蔽效果history: 6:10:2018 by puppet_masterhttps://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.csDescription: SSAO屏幕空间环境光遮蔽效果history: 6:10:2018 by puppet_masterhttps://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 32sampler2D _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;}ENDCGSubShader{Cull Off ZWrite Off ZTest Always//Pass 0 : Generate AO Pass{CGPROGRAM#pragma vertex vert_ao#pragma fragment frag_aoENDCG}//Pass 1 : Bilateral Filter BlurPass{CGPROGRAM#pragma vertex vert_ao#pragma fragment frag_blurENDCG}//Pass 2 : Composite AOPass{CGPROGRAM#pragma vertex vert_ao#pragma fragment frag_compositeENDCG}}
}

关于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的开头又有东西写啦!

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

  1. Ambient Occlusion 环境光遮蔽 后期处理系列4

    Ambient Occlusion 环境光遮蔽 本文档主要是对Unity官方手册的个人理解与总结(其实以翻译记录为主:>) 仅作为个人学习使用,不得作为商业用途,欢迎转载,并请注明出处. 文章中 ...

  2. 关于游戏中的Ambient Occlusion 环境光遮罩(AO)

    Ambient Occlusion环境光遮罩简称AO 讲述AO的时候需要了解下关于游戏中光照的知识 原来的游戏当中都是直接光照来绘制游戏当中的场景   直接光照指光源直接打在物体上,然后由物体反射到人 ...

  3. [MetalKit]33-Ambient-Occlusion-in-Metal环境光遮蔽

    本系列文章是对 metalkit.org 上面MetalKit内容的全面翻译和学习. MetalKit系统文章目录 今天我们将学习ambient occlusion环境光遮蔽.我们将使用Shadows ...

  4. Learn OpenGL 笔记6.10 SSAO(Screen Space Ambient Occlusion屏幕空间环境光遮蔽)

    我们在基本照明一章中简要介绍了该主题:ambient lighting环境光. Ambient lighting环境光是一个固定的光常数,我们添加到场景的整体照明中以模拟光的scattering散射. ...

  5. 光影的魔法!Cocos Creator 实现屏幕空间的环境光遮蔽(SSAO)

    引言: 本文作者 alpha 从事游戏前端开发已经5年,毕业后他先是入职了腾讯无线大连研发中心,而后开启了北漂生涯,在北京的这3年一直都在使用 Cocos Creator,对前端业务,包体.内存优化有 ...

  6. 图形 4.2 SSAO算法 屏幕空间环境光遮蔽

    链接: SSAO算法 屏幕空间环境光遮蔽思维导图. SSAO算法 屏幕空间环境光遮蔽 SSAO介绍 什么是AO SSAO原理详解 SSAO介绍 SSAO原理 计算近似AO SSAO算法实现 比较与分析 ...

  7. Unity Shader ASE——输出面板详情

    目录 一.基本配置 1.General 通用设置 2.Blend Mode 混合模式 3.Stencil Buffer 模板缓冲 4.Tessellation 镶嵌 5.Outline 轮廓 6.Bi ...

  8. 读《环境光遮蔽技术在图形图像中若干关键技术的研究》总结-其一

    末尾附文章引用 文章架构: 开篇先写Abstract,对本文章的研究内容进行了总结性概述: Abstract怎么写? 1.点名研究内容,研究意义,提出当下需要解决的问题. 2.讲本文章解决这些问题的思 ...

  9. Games101结合Unity Shader入门精要学习笔记(个人向)

    第四章 3D旋转 绕X轴旋转: 绕Y轴旋转: 绕Z轴旋转: 旋转变换(一)旋转矩阵_csxiaoshui的博客-CSDN博客_旋转矩阵 重点:MVP变换!!! model transformation ...

最新文章

  1. python中修饰器的优点和作用_Python装饰器(你想知道的这里都有)
  2. substring java_Java String.substring()用法
  3. ImageMagick常用指令详解
  4. 13个大奖任你拿,网易MINI开发挑战赛强势来袭!
  5. linux的11186端口,linux – CentOS – semanage – 删除端口范围
  6. iOS开发--UITableView
  7. java并发编程入门_探讨一下!Java并发编程基础篇一
  8. C# 中XML序列化与反序列化学习笔记
  9. Trick(十六)—— 随机数的生成
  10. red hat linux 6.4 DNS配置(怎么不让发表?)
  11. java修改文件后缀_java批量修改文件后缀的方法介绍
  12. M5311连接HTTPS服务器下载bin文件(干货)
  13. 完全数(Perfect Number)
  14. 2021年安全生产模拟考试(全国特种作业操作证电工作业-继电保护模拟考试题库一)安考星
  15. 独孤木专栏Delayed Project(中)
  16. 初学SpringMVC注册前端控制器DispatcherServlet:org.springframework.web.servlet.DispatcherServlet报红
  17. mysql concat 长度限制_mysql中group_concat()长度限制
  18. NLP 利器 Gensim 来训练 word2vec 词向量模型的参数设置
  19. oracle 主键 唯一性,oracle 唯一索引,唯一约束,主键之间的联系
  20. 杂谈:使用SteamCMD搭建七日杀(7 days to die, 7DTD)Linux版本专用服务器

热门文章

  1. 【paper 2】Learning from Simulated and Unsupervised Images through Adversarial Training
  2. 嵌入式技术及应用基础实验
  3. 如何使用虚拟机运行“小HomeKit”智汀家庭云
  4. 平面解析几何----焦点弦上焦半径长度符合的条件1/AF+1/BF=2/ep
  5. IPO并不遥远,飞哥IPERi模型助你打开互联网创业创新成功密码
  6. 项目——3——lnmp-gitlab-jenkins-ansible
  7. LeetCode--441--排列硬币
  8. 外贸老鸟帮新人点评、修改的5个开发信案例
  9. LOL全英雄皮肤爬虫
  10. 忘了neo4j密码怎么办