前言:SSAO算法目前只在最高档的机型才可以使用,例如iPhone10之后、骁龙3845之后等等,这是因为它的开销很大。但随着设备性能不断提示,可以进行尝试。
1. SSAO介绍
1.1. AO-环境光遮蔽
在具体介绍SSAO之前, 先介绍更加广义的AO(Ambient Occlusion 环境光遮蔽)。简单来说, AO是一种基于全局照明中的环境光(Ambient Light)参数和环境几何信息来计算场景中任何一点的光照强度系数的算法。AO描述了表面上的任何一点所接受到的环境光被周围几何体所遮蔽的百分比,因此使得渲染的结果更加富有层次感, 对比度更高。它是模拟光线到达物体能力的粗略的全局方法。
计算 AO 可通过在半球面上对可见性函数的积分来得到,公式为:
其中:
- Ω是 p 点朝向法线方向的半球面上的方向集合
- d(ω) 是 p 点到其沿ω方向与场景的第一个交点的距离
- V(ω)是距离衰减函数衰减函数从 单位距离1 开始衰减并在某个固定距离下衰减到 0
1.2. SSAO-屏幕空间环境光遮蔽
屏幕空间环境光遮蔽,全程 Screen Space Ambient Occlusion,一种用于计算机图形中实时实现近似环境光遮蔽效果的渲染技术。
一般光照模型中,环境光用于模拟光线的二次散射(向外散射,反射),从而使得不受直接光照的地方也能有一定的亮度。但很明显,暗处不应该有太多的光被散射出来,所以暗处和直接被环境光照射的地方亮度不应该相同。而在光照模型中,环境光是一个定值。所以ssao出现了,它解决的就是如何定量环境光照射不到的地方应该吸收多少环境光的问题(二次散射不出去的光自然大部分都被物体吸收)。下图左边的暗处就太亮了,而右边暗处更加真实。
通过获取像素的深度缓冲
、法线缓冲
以及像素坐标
来计算实现,来近似的表现物体在间接光下产生的阴影。基于顶点的AO计算需要进行光线与场景的求交运算,所以是十分耗时,所以实际应用中主要使用SSAO算法,SSAO 算法将深度缓存当成场景的一个粗略的近似并用深度比较代替光线求交来简化 AO计算。
SSAO历史:
AO这项技术最早是在Siggraph 2002年会上由ILM(工业光魔)的技术主管Hayden Landis所展示,当时就被叫做Ambient Occlusion。
2007年,Crytek公司发布了一款叫做屏幕空间环境光遮蔽(Screen-Space Ambient Occlusion, SSAO)的技术,并用在了他们的看家作孤岛危机上。
2. SSAO原理
总览:
- 计算深度、法线缓冲
- 深度->像素坐标
- 法线->法向半球随机向量
- 计算像素随机后的坐标(多次采样)
- 获取随机后深度并比较
- 判断加权AO
- 后期(模糊等)
2.1. 样本缓冲
- 深度缓冲:深度缓冲中的depth值用于当前视点下的场景的每一个像素距离相机的一个粗略表达,用于重构像素相机空间中的坐标(Z),来近似重构该视点下的三维场景。如下图为深度缓冲的图,越黑代表离摄像机越近,深度值范围为0-1,越小越近。
- 法线缓冲:相机空间中的法线信息,用于重构每个像素的“法线-切线-副切线”构成的坐标轴(切线空间),用于法线半球中的采样随机向量(随机向量用于判断、描述该像素的AO强度)。
2.2. 法向半球
前面说过,SSAO是为了定量环境光照射不到的地方应该吸收多少环境光的问题,也就是光线的二次散射。因此需要为每一个片元模拟一个随机的光线散射模型。
对屏幕空间内每一个像素计算其在三维空间里的位置 p, 执行下列步骤 :
- 在以 p 点为中心、 R 为半径的法线半球体空间内随机地产生若干三维采样点。
- 对于铺屏四边形(Screen-filled Quad)上的每一个片元,我们都会根据周边深度值计算一个遮蔽因子(Occlusion Factor)。这个遮蔽因子之后会被用来减少或者抵消片段的环境光照分量。遮蔽因子是通过采集片段周围法向半球(Kernel)的多个深度样本,并和当前片段深度值对比而得到的。高于片段深度值样本的个数就是我们想要的遮蔽因子。上图中在几何体内灰色的深度样本采样点都是高于片段深度值的,他们会增加遮蔽因子;几何体内样本个数越多,片段获得的环境光照也就越少。
- 红点表示我们需要计算的样本,红色向量表示样本的法向量;白色灰色点为采样点(很明显,采样点的多少影响最后的渲染效果),其中灰色点表示被遮挡采样点(深度大于周围),据此判断最终AO的强度。
- 很明显,渲染效果的质量和精度与我们采样的样本数量有直接关系。
-
- 如果样本数量太低,渲染的精度会急剧减少,我们会得到一种叫做波纹(Banding)的效果,左图;
- 如果它太高了,反而会影响性能。
- 我们可以通过引入随机性到采样核心(Sample Kernel)的采样中从而减少样本的数目。通过随机旋转采样核心,我们能在有限样本数量中得到高质量的结果。然而这仍然会有一定的麻烦,因为随机性引入了一个很明显的噪声图案,中间图。我们将需要通过模糊结果来修复这一问题,右图。
- 这里之所以使用法线半球体,是因为如果使用球体会整体画面会变得灰蒙蒙。
- 因为针对大部分像素,采样时总有一半的采样点在物体后面,也就是说大部分像素的遮蔽率最高就是0.5,所以整体画面会显得灰蒙蒙。以下两张图可以形象地说明这个过程:
- 估算每个采样点产生的 AO 情况,具体来说:
- 对每个采样点做深度测试,判断采样点是否存在遮蔽。再统计所有采样点通过深度测试的比例,该比例就是屏幕空间中该像素粗略的遮蔽率。当大部分采样点通过深度测试说明p点附近采样点大部分都没有被遮挡,p点大概率不会被遮挡。
- 网上流行的另一种计算AO的方法是:直接计算采样点在屏幕上的投影点跟 p 点的深度差异,来计算遮蔽情况。如下图所示,下图大部分投影的的深度值都小于P点深度,说明P点被遮挡的概率较大。该方法往往会带来自身遮蔽等走样问题,因此使用情况较少。
3. SSAO算法实现
3.1. Buffer-获取深度&法线缓冲数据
获取深度&法线缓冲数据
- C#部分(获取相机的深度和法线纹理)
-
- Shader部分(采样tex2D,调用UnityCG.cginc解码Decode)
-
-
- UnityCG.cginc
-
如果是延迟渲染这一步可以省略,G-Buffer中可以直接拿到深度&法线缓冲数据
3.2. 重建相机空间坐标
重建方法参考链接:Unity从深度缓冲重建世界空间位置 - 知乎 (zhihu.com)
在Unity中如何从深度缓冲中重建世界空间位置。
3.2.1. 计算深度
Unity如何计算深度:在UnityCG.cginc中计算观察线性深度与01深度。
//Unity中的观察线性深度(Eye depth)就是顶点在观察空间中的z分量
//符号取反的原因是在Unity的观察空间中z轴翻转了,摄像机的前向量就是z轴的正方向。这是和OpenGL中不一样的一点。
#define COMPUTE_EYEDEPTH(o) o = -mul( UNITY_MATRIX_MV, v.vertex ).z
//01线性深度就是观察线性深度通过除以摄像机远平面重新映射到[0,1]区间所得到的值。
//_ProjectionParams.w 是 1 / FarPlane,FarPlane是摄像机远平面
#define COMPUTE_DEPTH_01 -(mul( UNITY_MATRIX_MV, v.vertex ).z * _ProjectionParams.w)
我们可以从深度缓冲中采样得到深度值,并使用Unity中内置的功能函数将原始数据转换成线性深度。
// Z buffer to linear 0..1 depth (0 at eye, 1 at far plane)
inline float Linear01Depth( float z )
{
return 1.0 / (_ZBufferParams.x * z + _ZBufferParams.y);
}
// Z buffer to linear depth
inline float LinearEyeDepth( float z )
{
return 1.0 / (_ZBufferParams.z * z + _ZBufferParams.w);
}
3.2.2. 反向空间变换
而此重建过程为反向的:
屏幕空间--(反齐次除法)--NDC--(逆投影矩阵)--裁剪空间--观察空间--世界空间
第一种方法是通过像素的屏幕坐标位置来计算。
- 首先将屏幕空间坐标转换到NDC空间中。o.screenPos / o.screenPos.w映射到01,再映射到-1~1。
float4 ndcPos = (o.screenPos / o.screenPos.w) * 2 - 1;
- 然后将屏幕像素对应在摄像机远平面(Far plane)的点转换到剪裁空间(Clip space)。因为在NDC空间中远平面上的点的z分量为1,所以可以直接乘以摄像机的Far值来将其转换到剪裁空间(实际就是反向齐次除法)。
float far = _ProjectionParams.z;//摄像机远平面距离
float3 clipVec = float3(ndcPos.x, ndcPos.y, 1.0) * far;
- 接着通过逆投影矩阵(Inverse Projection Matrix)将点转换到观察空间(View space)。
float3 o.viewVec = mul(unity_CameraInvProjection, clipVec.xyzz).xyz;
- 已知在观察空间中摄像机的位置一定为(0,0,0),所以从摄像机指向远平面上的点的向量就是其在观察空间中的位置。将向量乘以线性深度值,得到在深度缓冲中储存的值的观察空间位置。
float depth = UNITY_SAMPLE_DEPTH(tex2Dproj(_CameraDepthTexture, i.screenPos));
float3 viewPos = i.viewVec * Linear01Depth(depth);
- 最后将观察空间中的位置变换到世界空间中。
float3 worldPos = mul(UNITY_MATRIX_I_V, float4(viewPos, 1.0)).xyz;
附上在Shader Graph中的实现。这里Unity有bug导致如果使用Transformation Matrix节点的Inverse Projection会报错,所以这里使用了一个Custom Function节点输出一个4x4矩阵unity_CameraInvProjection。理论上效果是一样的。
代码:
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct v2f
{
float4 vertex : SV_POSITION;
float4 screenPos : TEXCOORD0;
float3 viewVec : TEXCOORD1;
};
v2f vert(appdata_base v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
// 屏幕坐标
o.screenPos = ComputeScreenPos(o.vertex);
// NDC坐标
float4 ndcPos = (o.screenPos / o.screenPos.w) * 2 - 1;
// 相机远平面距离
float far = _ProjectionParams.z;
// 将屏幕像素对应在摄像机远平面的点转换到剪裁空间
float3 clipVec = float3(ndcPos.x, ndcPos.y, 1.0) * far;
// 通过逆投影矩阵将点转换到观察空间
o.viewVec = mul(unity_CameraInvProjection, clipVec.xyzz).xyz;
return o;
}
sampler2D _CameraDepthTexture;
half4 frag(v2f i) : SV_Target
{
// Sample the depth texture to get the linear 01 depth
float depth = UNITY_SAMPLE_DEPTH(tex2Dproj(_CameraDepthTexture, i.screenPos));
depth = Linear01Depth(depth);
// View space position
float3 viewPos = i.viewVec * depth;
// Pixel world position
float3 worldPos = mul(UNITY_MATRIX_I_V, float4(viewPos, 1)).xyz;
return float4(worldPos, 1.0);
}
3.3. 构建法向量正交基
设置法向量
- viewNormal为观察空间法向量,从摄像机出发,为z轴负方向,而法线为z轴正方向,要乘以-1.
-
生成随机向量(用于构建的正交基随机,而非所有样本计算的到的正交基一致),先归一化
求出切向量,再利用函数cross叉积求副切线向量
-
- 切向量tangent求法图解:dot(randvec.viewNormal)为随机向量在法向量投影的长度,再乘以viewNormal就是投影向量,随机向量减去投影向量就是切线向量。
-
3.4. 核心-法线半球
传入给定的随机采样向量,并通过法向量正交基转化至法线半球中的向量。
获取半球上随机坐标点。_SampleKeneralRadius为法线半球半径
转换至屏幕空间坐标(反推:观察-裁剪-屏幕)
计算随机向量转化至屏幕空间后对应的深度值
并判断累加AO,判断这个随机向量的深度值是否小于原像素深度,如果小于,则说明随机向量更加靠近相机,会遮挡原像素的一部分光照,那么对AO则有贡献,取1。
4. SSAO效果改进
4.1. 随机正交基(增加随机性)
在前面,为了不使求得的法向半球的正交基一致,我们引入随机向量,已求得不用想象的切向量。
利用uv采样一张Noise贴图(如下图4x4像素的Noise贴图,可选择其他尺寸),或者随机向量。
- noiseScale是除以4的结果是因为Noise贴图是4x4的,这个可以更具贴图而变。
-
并在C#中传入噪声贴图。
4.2. AO累加平滑优化
4.2.1. 范围判定(模型边界)
样本采样,可能会采集到的深度差非常大的随机点,会导致边界出现AO,如下图中蓝色是天空,但是白色管道周围也出现阴影,这是不应该的。
原因是:在对天空盒的像素进行采样法线半球时,有物体会遮挡天空盒,那么天空盒在屏幕上离物体近的点就会影响它的AO,造成阴影。
除了天空盒,其他被遮挡的物体边缘也会有这种错误阴影,产生一种毛边的效果。
为了解决这个物体,我们加入样本深度和随机点的深度值判定,当深度值相差太大的话,那么就截取掉,不进行AO。
(效果如下图)
4.2.2. 自身判定
如果随机点深度值和自身一样或者非常接近(可能会导致虽然在同一平面,也会出现AO),如下图
判断深度值的大小时,加上一个变量_DepthBiasValue,来改善问题
4.2.3. AO权重平滑
AO深度判断,非0即1,比较生硬,为其增加一权重,如下图
本例中的权重为:发现半球中随机采样后的点x、y(切线平面)距离样本的距离为参考,平滑权重
4.2.4. 模糊
采用基于法线的双边滤波原理(Bilateral Filtering)
5. 模型烘焙AO对比
5.1. 不同软件烘焙方式
- 三维建模软件烘焙AO方式
-
- 通过三维建模软件(如3DMax),设定好渲染参数,对模型(单一选择模型实体),烘焙AO到纹理。
- 游戏引擎烘焙AO方式(Unity3D Lighting)
-
- 通过Unity的后处理
-
5.2. 建模软件烘焙优缺点
- 优点
-
- 单一物体可控性强(通过单一物体的材质球上的AO纹理贴图),可以控制单一物体的AO的强弱;
- 弥补场景烘焙的细节,整体场景的烘焙(包含AO信息),并不能完全包含单一物体细节上的AO,而通过三维建模软件烘焙到纹理的方式,增加物体的AO细节;
- 不影响其(Unity场景中)静态或者动态;
- 缺点
-
- 操作较其他方式繁琐,需要对模型进行UV处理,再进行烘焙到纹理;
- 不利于整体场景的整合(如3DMax烘焙到纹理,只能选择单一物体,针对整体场景的处理工作量巨大);
- 增加AO纹理贴图,不利于资源优化(后期可通过其他纹理通道利用整合资源);
- 只有单个物体本身具有AO信息,获取物体之间的AO信息工作量巨大(不是不可能)。
5.3. Unity烘焙优缺点
- 优点
-
- 操作简易,整体场景的烘焙,包含AO的选择;
- 不受物体本身的UW影响,Unity通过Generate Lightmap UVs生成模型第二个纹理坐标数据;
- 可生成场景中物体与物体之间的AO信息;
- 缺点
-
- 缺少单一物体的细节(可调整参数提高烘焙细节,但换之将增加烘焙纹理数量和尺寸,以及烘焙时间);
- 受物体是否静态影响,动态物体无法进行烘焙,获得AO信息。
5.4. SSAO优缺点
- 优点
-
- 不依赖场景的复杂度,其效果质量依赖于最终图片像素大小;
- 实时计算,可用于动态场景;
- 可控性强,灵活性强,操作简单;
- 缺点
-
- 性能消耗较之上述2种方式更多,计算非常昂贵;
- 理论上,AO质量上要比较离线式烘焙(上述2种)差。
6. SSAO性能消耗
6.1. AO核心采样消耗说明
AO法向半球的随机采样
双边滤波的多重采样
本例SSAO算法中,主要核心为计算AO随机法向半球的采样点,并加以半段计算AO权值。
- 利用For结构代码进行半球堆积向量的采样,If、For等对于GPU计算性能上不友好
- 采样数的数量(上图的_SmpleKernelCount,针对For循环的次数),过低的采样数得不到好的结果。以64为例,1334*750的分辨率,每个像素计算循环64次,合计1334*750*64次AO核心计算。
- 循环体重采样,同样以64为例,每个像素计算需要采样64次来求得屏幕深度值法线值。
6.2. 滤波采样消耗说明
本例采用的是双边滤波(Bilateral Filter),为保证不被模糊,采样基于法线的双边滤波。
- C#后期脚本中,Blit两次(横向和纵向),合计调用两次滤波渲染Pass;
- 单一滤波渲染Pass中,多重采样,包括7次主纹理的采样和7次屏幕像素的法线信息的采用,屏幕中每个像素合计14次纹理采样。