DirectX11 With Windows SDK--32 SSAO(屏幕空间环境光遮蔽)

前言

由于性能的限制,实时光照模型往往会忽略间接光因素(即场景中其他物体所反弹的光线)。但在现实生活中,大部分光照其实是间接光。在第7章里面的光照方程里面引入了环境光项:

\[C_a = \mathbf{A_L}\otimes\mathbf{m_d} \]

其中颜色\(\mathbf{A_L}\)表示的是从某光源发出,经过环境光反射而照射到物体表面的间接光总量。漫反射\(\mathbf{m_d}\)则是物体表面根据漫反射率将入射光反射回的总量。这种方式的计算只是一种简化,并非真正的物理计算,它直接假定物体表面任意一点接收到的光照都是相同的,并且都能以相同的反射系数最终反射到我们眼睛。下图展示了如果仅采用环境光项来绘制模型的情况,物体将会被同一种单色所渲染:

当然,这种环境光项是不真实的,我们对其还有一些改良的余地。

学习目标:

  1. 了解环境光遮蔽技术背后的基本原理,并知道如何通过投射光线来实现环境光遮蔽(见龙书d3d11CodeSet3/AmbientOcclusion项目)
  2. 熟悉屏幕空间环境光遮蔽这种近似于实时的环境光遮蔽技术(本章项目)。

DirectX11 With Windows SDK完整目录

Github项目源码

欢迎加入QQ群: 727623616 可以一起探讨DX11,以及有什么问题也可以在这里汇报。

投射光线实现环境光遮蔽

环境光遮蔽技术的主体思路如下图所示,表面上一点p所接收到的间接光总量,与照射到p为中心的半球的入射光量成正比。

一种估算点p受遮蔽程度的方法是采用光线投射法。我们随机投射出一些光线,使得它们传过以点p为中心的半球,并检测这些光线与网格相交的情况。或者说我们以点p作为射线的起点,随机地在半球范围选择一个方向进行投射。

如果投射了N条光线,有h条与网格相交,那么点p的遮蔽率大致为:

\[occlusion=\frac{h}{N} \in [0, 1] \]

并且仅当光线与网格的交点q与点p之间的距离小于某个阈值d时才会认为该光线产生遮蔽。这是因为若交点p与点p距离过远时就说明这个方向上照射到点p的光不会受到物体的遮挡。

遮蔽因子用来测量该点受到遮蔽的程度(有多少光线不能到达该点)。计算该值出来,是为了知道该点能够接受光照的程度,即我们需要的是它的相反值,通常叫它为可及率

\[accessibility = 1 - occlusion \in [0, 1] \]

在龙书11的项目AmbientOcclusion中,我们可以找到AmbientOcclusionApp::BuildVertexAmbientOcclusion函数,它负责为物体的每个顶点计算出间接光的可及率。由于与本章主旨不同,故不在这里贴出源码。它是在程序运行之初先对所有物体预先计算出顶点的遮蔽情况,物体每个顶点都会投射出固定数目的随机方向射线,然后与场景中的所有网格三角形做相交检测。这一切都是在CPU完成的。

如果你之前写过CPU光线追踪的程序的话,能明显感觉到产生一幅图所需要的时间非常的长。因为从物体表面一点可能会投射非常多的射线,并且这些射线还需要跟场景中的所有网格三角形做相交检测,如果不采用加速结构的话就是数以万计的射线要与数以万计的三角形同时做相交检测。在龙书11的所示例程中采用了八叉树这种特别的数据解来进行物体的空间划分以进行加速,这样一条射线就可能只需要做不到10次的逐渐精细的检测就可以快速判断出是否有三角形相交。

在经过几秒的漫长等待后,程序完成了物体的遮蔽预计算并开始渲染,下图跟前面的图相比起来可以说得到了极大的改善。该样例程序并没有使用任何光照,而是直接基于物体顶点的遮蔽属性进行着色。可以看到那些颜色比较深的地方通常都是模型的缝隙间,因为从它们投射出的光线更容易与其它几何体相交。

投射光线实现环境光遮蔽的方法适用于那些静态物体,即我们可以先给模型本身预先计算遮蔽值并保存到顶点上,又或者是通过一些工具直接生成环境光遮蔽图,即存有环境光遮蔽数据的纹理。然而,对于动态物体来说就不适用了,每次物体发生变化就要重新计算一次遮蔽数据明显非常不现实,也不能满足实时渲染的需求。接下来我们将会学到一种基于屏幕空间实时计算的环境光遮蔽技术。

屏幕空间环境光遮蔽(SSAO)

屏幕空间环境光遮蔽(Screen Space Ambient Occlusion)技术的策略是:在每一帧渲染过程中,将场景处在观察空间中的法向量和深度值渲染到额外的一个屏幕大小的纹理,然后将该纹理作为输入来估算每个像素点的环境光遮蔽程度。最终当前像素所接受的从某光源发出的环境光项为:

\[C_a = ambientAccess \cdot \mathbf{A_L}\otimes\mathbf{m_d} \]

法线和深度值的渲染

首先我们将场景物体渲染到屏幕大小、格式为DXGI_FORMAT_R16G16B16A16_FLOAT的法向量/深度值纹理贴图,其中RGB分量代表法向量,Alpha分量代表该点在屏幕空间中深度值。具体的HLSL代码如下:

// SSAO_NormalDepth_Object_VS.hlsl
#include "SSAO.hlsli"

// 生成观察空间的法向量和深度值的RTT的顶点着色器
VertexPosHVNormalVTex VS(VertexPosNormalTex vIn)
{
    VertexPosHVNormalVTex vOut;
    
    // 变换到观察空间
    vOut.PosV = mul(float4(vIn.PosL, 1.0f), g_WorldView).xyz;
    vOut.NormalV = mul(vIn.NormalL, (float3x3) g_WorldInvTransposeView);
    
    // 变换到裁剪空间
    vOut.PosH = mul(float4(vIn.PosL, 1.0f), g_WorldViewProj);
    
    vOut.Tex = vIn.Tex;
    
	return vOut;
}

// SSAO_NormalDepth_Instance_VS.hlsl
#include "SSAO.hlsli"

// 生成观察空间的法向量和深度值的RTT的顶点着色器
VertexPosHVNormalVTex VS(InstancePosNormalTex vIn)
{
    VertexPosHVNormalVTex vOut;
    
    vector posW = mul(float4(vIn.PosL, 1.0f), vIn.World);
    matrix viewProj = mul(g_View, g_Proj);
    matrix worldView = mul(vIn.World, g_View);
    matrix worldInvTransposeView = mul(vIn.WorldInvTranspose, g_View);
    
    // 变换到观察空间
    vOut.PosV = mul(float4(vIn.PosL, 1.0f), worldView).xyz;
    vOut.NormalV = mul(vIn.NormalL, (float3x3) worldInvTransposeView);
    
    // 变换到裁剪空间
    vOut.PosH = mul(posW, viewProj);
    
    vOut.Tex = vIn.Tex;
    
	return vOut;
}

// SSAO_NormalDepth_PS.hlsl
#include "SSAO.hlsli"

// 生成观察空间的法向量和深度值的RTT的像素着色器
float4 PS(VertexPosHVNormalVTex pIn, uniform bool alphaClip) : SV_TARGET
{
    // 将法向量给标准化
    pIn.NormalV = normalize(pIn.NormalV);
    
    if (alphaClip)
    {
        float4 g_TexColor = g_DiffuseMap.Sample(g_SamLinearWrap, pIn.Tex);
        
        clip(g_TexColor.a - 0.1f);
    }
    
    // 返回观察空间的法向量和深度值
    return float4(pIn.NormalV, pIn.PosV.z);
}

考虑到可能会通过实例化进行绘制,还需要额外配置实例化版本的顶点着色器。由于我们使用的是浮点型DXGI格式,写入任何浮点数据都是合理的(只要不超出16位浮点表示范围)。下面两幅图分别对应观察空间法向量/深度图的RGB部分和Alpha部分

环境光遮蔽的渲染

在绘制好观察空间法向量和深度纹理之后,我们就禁用深度缓冲区(我们不需要用到它),并在每个像素处调用SSAO像素着色器来绘制一个全屏四边形。这样像素着色器将运用法向量/深度纹理来为每个像素生成环境光可及率。最终生成的贴图叫SSAO图。尽管我们以全屏分辨率渲染法向量/深度图,但在绘制SSAO图时,出于性能的考虑,我们使用的是一半宽高的分辨率。以一半分辨率渲染并不会对质量有多大的影响,因为环境光遮蔽也是一种低频效果(low frequency effect,LFE)。

核心思想

p是当前我们正在处理的像素,我们根据从观察点到该像素在远平面内对应点的向量v以及法向量/深度缓冲区中存储的点p在观察空间中的深度值来重新构建出点p

q是以点p为中心的半球内的随机一点,点r对应的是从观察点到点q这一路径上的最近可视点。

如果\(|p_z-r_z|\)足够小,且r-pn之间的夹角小于90°,那么可以认为点r对点q产生遮蔽,故需要将其计入点p的遮蔽值。在本Demo中,我们使用了14个随机采样点,根据平均值法求得的遮蔽率来估算屏幕空间中的环境光遮蔽数据。

1. 重新构建待处理点在观察空间中的位置

当我们为绘制全屏四边形而对SSAO图中的每个像素调用SSAO的像素着色器时,我们可以在顶点着色器以某种方式输出视锥体远平面的四个角落点。龙书12的源码采用的是顶点着色阶段只使用SV_VertexID作为输入,并且提供NDC空间的顶点经过投影逆变换得到,但用于顶点着色器提供SV_VertexID的话会导致我们不能使用VS的图形调试器,故在此回避。

总而言之,目前的做法是在C++端生成视锥体远平面四个角点,然后通过常量缓冲区传入,并通过顶点输入传入视锥体远平面顶点数组的索引来获取。

// SSAORender.cpp
void SSAORender::BuildFrustumFarCorners(float fovY, float farZ)
{
	float aspect = (float)m_Width / (float)m_Height;

	float halfHeight = farZ * tanf(0.5f * fovY);
	float halfWidth = aspect * halfHeight;

	m_FrustumFarCorner[0] = XMFLOAT4(-halfWidth, -halfHeight, farZ, 0.0f);
	m_FrustumFarCorner[1] = XMFLOAT4(-halfWidth, +halfHeight, farZ, 0.0f);
	m_FrustumFarCorner[2] = XMFLOAT4(+halfWidth, +halfHeight, farZ, 0.0f);
	m_FrustumFarCorner[3] = XMFLOAT4(+halfWidth, -halfHeight, farZ, 0.0f);
}
cbuffer CBChangesEveryFrame : register(b1)
{
	// ...
    g_FrustumCorners[4];         // 视锥体远平面的4个端点
}

// 绘制SSAO图的顶点着色器
VertexOut VS(VertexIn vIn)
{
    VertexOut vOut;
    
    // 已经在NDC空间
    vOut.PosH = float4(vIn.PosL, 1.0f);
    
    // 我们用它的x分量来索引视锥体远平面的顶点数组
    vOut.ToFarPlane = g_FrustumCorners[vIn.ToFarPlaneIndex.x].xyz;
    
    vOut.Tex = vIn.Tex;
    
    return vOut;
}

现在,对于每个像素而言,我们得到了从观察点射向该像素直到远平面对应一点的向量ToFarPlane(亦即向量v),这些向量都是通过插值算出来的。然后我们对法向量/深度图进行采样来得到对应像素在观察空间中的法向量和深度值。重建屏幕空间座标p的思路为:已知采样出的观察空间的z值,它也正好是点p的z值;并且知道了原点到远平面的向量v。由于这条射线必然经过点p,故它们满足:

\[\mathbf{p}=\frac{p_z}{v_z}\mathbf{v} \]

因此就有:

// 绘制SSAO图的顶点着色器
float4 PS(VertexOut pIn, uniform int sampleCount) : SV_TARGET
{
    // p -- 我们要计算的环境光遮蔽目标点
    // n -- 顶点p的法向量
    // q -- 点p处所在半球内的随机一点
    // r -- 有可能遮挡点p的一点
    
    // 获取观察空间的法向量和当前像素的z座标
    float4 normalDepth = g_NormalDepthMap.SampleLevel(g_SamNormalDepth, pIn.Tex, 0.0f);
    
    float3 n = normalDepth.xyz;
    float pz = normalDepth.w;
    
    //
    // 重建观察空间座标 (x, y, z)
    // 寻找t使得能够满足 p = t * pIn.ToFarPlane
    // p.z = t * pIn.ToFarPlane.z
    // t = p.z / pIn.ToFarPlane.z
    //
    float3 p = (pz / pIn.ToFarPlane.z) * pIn.ToFarPlane;
    
    // ...
}

2. 生成随机采样点

这一步模拟的是向半球随机投射光线的过程。我们以点p为中心,在指定的遮蔽半径内随机地从点p的前侧部分采集N个点,并将其中的任意一点记为q。遮蔽半径是一项影响艺术效果的参数,它控制着我们采集的随机样点相对于点p的距离。而选择仅采集点p前侧部分的点,就相当于在以光线投射的方式执行环境光遮蔽时,就只需在半球内进行投射而不必在完整的球体内投射而已。

接下来的问题是如何来生成随机样点。一种解决方案是,我们可以生成随机向量并将它们存放于一个纹理图中,再在纹理图的N个不同位置获取N个随机向量。

在C++中,生成随机向量纹理由下面的方法实现:

HRESULT SSAORender::BuildRandomVectorTexture(ID3D11Device* device)
{
	CD3D11_TEXTURE2D_DESC texDesc(DXGI_FORMAT_R8G8B8A8_UNORM, 256, 256, 1, 1, 
		D3D11_BIND_SHADER_RESOURCE, D3D11_USAGE_IMMUTABLE);
	
	D3D11_SUBRESOURCE_DATA initData = {};
	std::vector<XMCOLOR> randomVectors(256 * 256);

	// 初始化随机数数据
	std::mt19937 randEngine;
	randEngine.seed(std::random_device()());
	std::uniform_real_distribution<float> randF(0.0f, 1.0f);
	for (int i = 0; i < 256 * 256; ++i)
	{
		randomVectors[i] = XMCOLOR(randF(randEngine), randF(randEngine), randF(randEngine), 0.0f);
	}
	initData.pSysMem = randomVectors.data();
	initData.SysMemPitch = 256 * sizeof(XMCOLOR);

	HRESULT hr;
	ComPtr<ID3D11Texture2D> tex;
	hr = device->CreateTexture2D(&texDesc, &initData, tex.GetAddressOf());
	if (FAILED(hr))
		return hr;

	hr = device->CreateShaderResourceView(tex.Get(), nullptr, m_pRandomVectorSRV.GetAddressOf());
	return hr;
}

然而,由于整个计算过程都是随机的,所以我们并不能保证采集的向量必然是均匀分布,也就是说,会有全部向量趋于同向的风险,这样一来,遮蔽率的估算结果必然有失偏颇。为了解决这个问题,我们将采用下列技巧。在我们实现的方法之中一共使用了N=14个采样点,并以下列C++代码生成14个均匀分布的向量。

void SSAORender::BuildOffsetVectors()
{
	// 从14个均匀分布的向量开始。我们选择立方体的8个角点,并沿着立方体的每个面选取中心点
	// 我们总是让这些点以相对另一边的形式交替出现。这种办法可以在我们选择少于14个采样点
	// 时仍然能够让向量均匀散开

	// 8个立方体角点向量
	m_Offsets[0] = XMFLOAT4(+1.0f, +1.0f, +1.0f, 0.0f);
	m_Offsets[1] = XMFLOAT4(-1.0f, -1.0f, -1.0f, 0.0f);

	m_Offsets[2] = XMFLOAT4(-1.0f, +1.0f, +1.0f, 0.0f);
	m_Offsets[3] = XMFLOAT4(+1.0f, -1.0f, -1.0f, 0.0f);

	m_Offsets[4] = XMFLOAT4(+1.0f, +1.0f, -1.0f, 0.0f);
	m_Offsets[5] = XMFLOAT4(-1.0f, -1.0f, +1.0f, 0.0f);

	m_Offsets[6] = XMFLOAT4(-1.0f, +1.0f, -1.0f, 0.0f);
	m_Offsets[7] = XMFLOAT4(+1.0f, -1.0f, +1.0f, 0.0f);

	// 6个面中心点向量
	m_Offsets[8] = XMFLOAT4(-1.0f, 0.0f, 0.0f, 0.0f);
	m_Offsets[9] = XMFLOAT4(+1.0f, 0.0f, 0.0f, 0.0f);

	m_Offsets[10] = XMFLOAT4(0.0f, -1.0f, 0.0f, 0.0f);
	m_Offsets[11] = XMFLOAT4(0.0f, +1.0f, 0.0f, 0.0f);

	m_Offsets[12] = XMFLOAT4(0.0f, 0.0f, -1.0f, 0.0f);
	m_Offsets[13] = XMFLOAT4(0.0f, 0.0f, +1.0f, 0.0f);


	// 初始化随机数数据
	std::mt19937 randEngine;
	randEngine.seed(std::random_device()());
	std::uniform_real_distribution<float> randF(0.25f, 1.0f);
	for (int i = 0; i < 14; ++i)
	{
		// 创建长度范围在[0.25, 1.0]内的随机长度的向量
		float s = randF(randEngine);

		XMVECTOR v = s * XMVector4Normalize(XMLoadFloat4(&m_Offsets[i]));

		XMStoreFloat4(&m_Offsets[i], v);
	}
}

在从随机向量贴图中采样之后,我们用它来对14个均匀分布的向量进行反射。其最终结果就是获得了14个均匀分布的随机向量。然后因为我们需要的是对半球进行采样,所以我们只需要将位于半球外的向量进行翻转即可。

// 在以p为中心的半球内,根据法线n对p周围的点进行采样
for (int i = 0; i < sampleCount; ++i)
{
    // 偏移向量都是固定且均匀分布的(所以我们采用的偏移向量不会在同一方向上扎堆)。
    // 如果我们将这些偏移向量关联于一个随机向量进行反射,则得到的必定为一组均匀分布
    // 的随机偏移向量
    float3 offset = reflect(g_OffsetVectors[i].xyz, randVec);
        
    // 如果偏移向量位于(p, n)定义的平面之后,将其翻转
    float flip = sign(dot(offset, n));
    
    // ...
}

3. 生成潜在的遮蔽点

现在我们拥有了在点p周围的随机采样点q。但是我们不清楚该点所处的位置是空无一物,还是处于实心物体,因此我们不能直接用它来测试是否遮蔽了点p。为了寻找潜在的遮蔽点,我们需要来自法向量/深度贴图中的深度信息。接下来我们对点q进行投影,并得到投影纹理座标,从而对贴图进行采样来获取沿着点q发出的射线,到达最近可视像素点r的深度值\(r_z\)。我们一样能够用前面的方式重新构建点r在观察空间中的位置,它们满足:

\[\mathbf{r}=\frac{r_z}{q_z}\mathbf{q} \]

因此根据每个随机采样点q所生产的点r即为潜在的遮蔽点

4. 进行遮蔽测试

现在我们获得了潜在的遮蔽点r,接下来就可以进行遮蔽测试,以估算它是否会遮蔽点p。该测试基于下面两种值:

  1. 观察空间中点p与点r的深度距离为\(|p_z-r_z|\)。随着距离的增长,遮蔽值将按比例线性减小,因为遮蔽点与目标点的距离越远,其遮蔽的效果就越弱。如果该距离超过某个指定的最大距离,那么点r将完全不会遮挡点p。而且,如果此距离过小,我们将认为点p与点q位于同一平面上,故点q此时也不会遮挡点p
  2. 法向量n与向量r-p的夹角的测定方式为\(max(\mathbf{n}\cdot(\frac{\mathbf{r-p}}{\Vert \mathbf{r-p} \Vert}), 0)\).这是为了防止自相交情况的发生

如果点r与点p位于同一平面内,就可以满足第一个条件,即距离\(|p_z-r_z|\)足够小以至于点r遮蔽了点q。然而,从上图可以看出,两者在同一平面内的时候,点r并没有遮蔽点p。通过计算\(max(\mathbf{n}\cdot(\frac{\mathbf{r-p}}{\Vert \mathbf{r-p} \Vert}), 0)\)作为因子相乘遮蔽值可以防止对此情况的误判

5. 完成计算过程

在对每个采样点的遮蔽数据相加后,还要通过除以采样的次数来计算遮蔽率。接着,我们会计算环境光的可及率,并对它进行幂运算以提高对比度(contrast)。当然,我们也能够按需求适当增加一些数值来提高光照强度,以此为环境光图(ambient map)添加亮度。除此之外,我们还可以尝试不同的对比值和亮度值。

occlusionSum /= g_SampleCount;

float access = 1.0f - occlusionSum;

// 增强SSAO图的对比度,是的SSAO图的效果更加明显
return saturate(pow(access, 4.0f));

完整HLSL实现

// SSAO.hlsli

// ...
Texture2D g_NormalDepthMap : register(t1);
Texture2D g_RandomVecMap : register(t2);
// ...

// ...
SamplerState g_SamNormalDepth : register(s1);
SamplerState g_SamRandomVec : register(s2);
// ...

// ...

cbuffer CBChangesOnResize : register(b2)
{
	// ...
    
    //
    // 用于SSAO
    //
    matrix g_ViewToTexSpace;    // Proj * Texture
    float4 g_FrustumCorners[4]; // 视锥体远平面的4个端点
}

cbuffer CBChangesRarely : register(b3)
{
    // 14个方向均匀分布但长度随机的向量
    float4 g_OffsetVectors[14]; 
    
    // 观察空间下的座标
    float g_OcclusionRadius = 0.5f;
    float g_OcclusionFadeStart = 0.2f;
    float g_OcclusionFadeEnd = 2.0f;
    float g_SurfaceEpsilon = 0.05f;
    
    // ...
};

//
// 用于SSAO
//
struct VertexIn
{
    float3 PosL : POSITION;
    float3 ToFarPlaneIndex : NORMAL; // 仅使用x分量来进行对视锥体远平面顶点的索引
    float2 Tex : TEXCOORD;
};

struct VertexOut
{
    float4 PosH : SV_POSITION;
    float3 ToFarPlane : TEXCOORD0; // 远平面顶点座标
    float2 Tex : TEXCOORD1;
};

其中g_SamNormalDepthg_SamRandomVec使用的是下面创建的采样器:

D3D11_SAMPLER_DESC samplerDesc;
ZeroMemory(&samplerDesc, sizeof samplerDesc);

// 用于法向量和深度的采样器
samplerDesc.Filter = D3D11_FILTER_MIN_MAG_LINEAR_MIP_POINT;
samplerDesc.AddressU = samplerDesc.AddressV = samplerDesc.AddressW = D3D11_TEXTURE_ADDRESS_BORDER;
samplerDesc.ComparisonFunc = D3D11_COMPARISON_NEVER;
samplerDesc.BorderColor[3] = 1e5f;	// 设置非常大的深度值 (Normal, depthZ) = (0, 0, 0, 1e5f)
samplerDesc.MinLOD = 0.0f;
samplerDesc.MaxLOD = D3D11_FLOAT32_MAX;
HR(device->CreateSamplerState(&samplerDesc, pImpl->m_pSamNormalDepth.GetAddressOf()));
pImpl->m_pEffectHelper->SetSamplerStateByName("g_SamNormalDepth", pImpl->m_pSamNormalDepth.Get());

// 用于随机向量的采样器
samplerDesc.AddressU = samplerDesc.AddressV = samplerDesc.AddressW = D3D11_TEXTURE_ADDRESS_WRAP;
samplerDesc.BorderColor[3] = 0.0f;
HR(device->CreateSamplerState(&samplerDesc, pImpl->m_pSamRandomVec.GetAddressOf()));
pImpl->m_pEffectHelper->SetSamplerStateByName("g_SamRandomVec", pImpl->m_pSamRandomVec.Get());
// SSAO_VS.hlsl
#include "SSAO.hlsli"

// 绘制SSAO图的顶点着色器
VertexOut VS(VertexIn vIn)
{
    VertexOut vOut;
    
    // 已经在NDC空间
    vOut.PosH = float4(vIn.PosL, 1.0f);
    
    // 我们用它的x分量来索引视锥体远平面的顶点数组
    vOut.ToFarPlane = g_FrustumCorners[vIn.ToFarPlaneIndex.x].xyz;
    
    vOut.Tex = vIn.Tex;
    
    return vOut;
}

// SSAO_PS.hlsl
#include "SSAO.hlsli"

// 给定点r和p的深度差,计算出采样点q对点p的遮蔽程度
float OcclusionFunction(float distZ)
{
    //
    // 如果depth(q)在depth(p)之后(超出半球范围),那点q不能遮蔽点p。此外,如果depth(q)和depth(p)过于接近,
    // 我们也认为点q不能遮蔽点p,因为depth(p)-depth(r)需要超过用户假定的Epsilon值才能认为点q可以遮蔽点p
    //
    // 我们用下面的函数来确定遮蔽程度
    //
    //    /|\ Occlusion
    // 1.0 |      ---------------\
    //     |      |             |  \
    //     |                         \
    //     |      |             |      \
    //     |                             \
    //     |      |             |          \
    //     |                                 \
    // ----|------|-------------|-------------|-------> zv
    //     0     Eps          zStart         zEnd
    float occlusion = 0.0f;
    if (distZ > g_SurfaceEpsilon)
    {
        float fadeLength = g_OcclusionFadeEnd - g_OcclusionFadeStart;
        // 当distZ由g_OcclusionFadeStart逐渐趋向于g_OcclusionFadeEnd,遮蔽值由1线性减小至0
        occlusion = saturate((g_OcclusionFadeEnd - distZ) / fadeLength);
    }
    return occlusion;
}


// 绘制SSAO图的顶点着色器
float4 PS(VertexOut pIn, uniform int sampleCount) : SV_TARGET
{
    // p -- 我们要计算的环境光遮蔽目标点
    // n -- 顶点p的法向量
    // q -- 点p处所在半球内的随机一点
    // r -- 有可能遮挡点p的一点
    
    // 获取观察空间的法向量和当前像素的z座标
    float4 normalDepth = g_NormalDepthMap.SampleLevel(g_SamNormalDepth, pIn.Tex, 0.0f);
    
    float3 n = normalDepth.xyz;
    float pz = normalDepth.w;
    
    //
    // 重建观察空间座标 (x, y, z)
    // 寻找t使得能够满足 p = t * pIn.ToFarPlane
    // p.z = t * pIn.ToFarPlane.z
    // t = p.z / pIn.ToFarPlane.z
    //
    float3 p = (pz / pIn.ToFarPlane.z) * pIn.ToFarPlane;
    
    // 获取随机向量并从[0, 1]^3映射到[-1, 1]^3
    float3 randVec = g_RandomVecMap.SampleLevel(g_SamRandomVec, 4.0f * pIn.Tex, 0.0f).xyz;
    randVec = 2.0f * randVec - 1.0f;
    
    float occlusionSum = 0.0f;
    
    // 在以p为中心的半球内,根据法线n对p周围的点进行采样
    for (int i = 0; i < sampleCount; ++i)
    {
        // 偏移向量都是固定且均匀分布的(所以我们采用的偏移向量不会在同一方向上扎堆)。
        // 如果我们将这些偏移向量关联于一个随机向量进行反射,则得到的必定为一组均匀分布
        // 的随机偏移向量
        float3 offset = reflect(g_OffsetVectors[i].xyz, randVec);
        
        // 如果偏移向量位于(p, n)定义的平面之后,将其翻转
        float flip = sign(dot(offset, n));
        
        // 在点p处于遮蔽半径的半球范围内进行采样
        float3 q = p + flip * g_OcclusionRadius * offset;
    
        // 将q进行投影,得到投影纹理座标
        float4 projQ = mul(float4(q, 1.0f), g_ViewToTexSpace);
        projQ /= projQ.w;
        
        // 找到眼睛观察点q方向所能观察到的最近点r所处的深度值(有可能点r不存在,此时观察到
        // 的是远平面上一点)。为此,我们需要查看此点在深度图中的深度值
        float rz = g_NormalDepthMap.SampleLevel(g_SamNormalDepth, projQ.xy, 0.0f).w;
        
        // 重建点r在观察空间中的座标 r = (rx, ry, rz)
        // 我们知道点r位于眼睛到点q的射线上,故有r = t * q
        // r.z = t * q.z ==> t = t.z / q.z
        float3 r = (rz / q.z) * q;
        
        // 测试点r是否遮蔽p
        //   - 点积dot(n, normalize(r - p))度量遮蔽点r到平面(p, n)前侧的距离。越接近于
        //     此平面的前侧,我们就给它设定越大的遮蔽权重。同时,这也能防止位于倾斜面
        //     (p, n)上一点r的自阴影所产生出错误的遮蔽值(通过设置g_SurfaceEpsilon),这
        //     是因为在以观察点的视角来看,它们有着不同的深度值,但事实上,位于倾斜面
        //     (p, n)上的点r却没有遮挡目标点p
        //   - 遮蔽权重的大小取决于遮蔽点与其目标点之间的距离。如果遮蔽点r离目标点p过
        //     远,则认为点r不会遮挡点p
        
        float distZ = p.z - r.z;
        float dp = max(dot(n, normalize(r - p)), 0.0f);
        float occlusion = dp * OcclusionFunction(distZ);
        
        occlusionSum += occlusion;
    }
    
    occlusionSum /= sampleCount;
    
    float access = 1.0f - occlusionSum;
    
    // 增强SSAO图的对比度,是的SSAO图的效果更加明显
    return saturate(pow(access, 4.0f));
}

模糊过程(双边模糊)

下图展示了我们目前生成的SSAO图的效果。其中的噪点是由于随机采样点过少导致的。但通过采集足够多的样点来屏蔽噪点的做法,在实时渲染的前提下并不切实际。对此,常用的解决方案是采用边缘保留的模糊(edge preserving blur)的过滤方式来使得SSAO图的过渡更为平滑。这里我们使用的是双边模糊,即bilateral blur。如果使用的过滤方法为非边缘保留的模糊,那么随着物体边缘的明显划分转为平滑的渐变,会使得场景中的物体难以界定。这种保留边缘的模糊算法与第30章中实现的模糊方法类似,唯一的区别在于需要添加一个条件语句,以令边缘不受模糊处理(要使用法线/深度贴图来检测边缘)。

// SSAO.hlsli
// ...
Texture2D g_NormalDepthMap : register(t1);
// ...
Texture2D g_InputImage : register(t3);

// ...
SamplerState g_SamBlur : register(s3); // MIG_MAG_LINEAR_MIP_POINT CLAMP

cbuffer CBChangesRarely : register(b3)
{
    // ...
    
    //
    // 用于SSAO_Blur
    //
    float4 g_BlurWeights[3] =
    {
        float4(0.05f, 0.05f, 0.1f, 0.1f),
        float4(0.1f, 0.2f, 0.1f, 0.1f),
        float4(0.1f, 0.05f, 0.05f, 0.0f)
    };
    
    int g_BlurRadius = 5;
    int3 g_Pad;
}

//
// 用于SSAO_Blur
//
struct VertexPosNormalTex
{
    float3 PosL : POSITION;
    float3 NormalL : NORMAL;
    float2 Tex : TEXCOORD;
};

struct VertexPosHTex
{
    float4 PosH : SV_POSITION;
    float2 Tex : TEXCOORD;
};
// SSAO_Blur_VS.hlsl
#include "SSAO.hlsli"

// 绘制SSAO图的顶点着色器
VertexPosHTex VS(VertexPosNormalTex vIn)
{
    VertexPosHTex vOut;
    
    // 已经在NDC空间
    vOut.PosH = float4(vIn.PosL, 1.0f);
    
    vOut.Tex = vIn.Tex;
    
    return vOut;
}

// SSAO_Blur_PS.hlsl
#include "SSAO.hlsli"

// 双边滤波
float4 PS(VertexPosHTex pIn, uniform bool horizontalBlur) : SV_Target
{
    // 解包到浮点数组
    float blurWeights[12] = (float[12]) g_BlurWeights;
    
    float2 texOffset;
    if (horizontalBlur)
    {
        texOffset = float2(1.0f / g_InputImage.Length.x, 0.0f);
    }
    else
    {
        texOffset = float2(0.0f, 1.0f / g_InputImage.Length.y);
    }
    
    // 总是把中心值加进去计算
    float4 color = blurWeights[g_BlurRadius] * g_InputImage.SampleLevel(g_SamBlur, pIn.Tex, 0.0f);
    float totalWeight = blurWeights[g_BlurRadius];
    
    float4 centerNormalDepth = g_NormalDepthMap.SampleLevel(g_SamBlur, pIn.Tex, 0.0f);
    // 分拆出观察空间的法向量和深度
    float3 centerNormal = centerNormalDepth.xyz;
    float centerDepth = centerNormalDepth.w;
    
    for (float i = -g_BlurRadius; i <= g_BlurRadius; ++i)
    {
        // 我们已经将中心值加进去了
        if (i == 0)
            continue;
        
        float2 tex = pIn.Tex + i * texOffset;
        
        float4 neighborNormalDepth = g_NormalDepthMap.SampleLevel(g_SamBlur, tex, 0.0f);
        // 分拆出法向量和深度
        float3 neighborNormal = neighborNormalDepth.xyz;
        float neighborDepth = neighborNormalDepth.w;
        
        //
        // 如果中心值和相邻值的深度或法向量相差太大,我们就认为当前采样点处于边缘区域,
        // 因此不考虑加入当前相邻值
        //
        
        if (dot(neighborNormal, centerNormal) >= 0.8f && abs(neighborDepth - centerDepth) <= 0.2f)
        {
            float weight = blurWeights[i + g_BlurRadius];
            
            // 将相邻像素加入进行模糊
            color += weight * g_InputImage.SampleLevel(g_SamBlur, tex, 0.0f);
            totalWeight += weight;
        }
        
    }

    // 通过让总权值变为1来补偿丢弃的采样像素
    return color / totalWeight;
}

经过了4次双边滤波的模糊处理后,得到的SSAO图如下:

使用环境光遮蔽图

到现在我们就已经构造出了环境光遮蔽图,最后一步便是将其应用到场景当中。我们采用如下策略:在场景渲染到后备缓冲区时,我们要把环境光图作为着色器的输入。接下来再以摄像机的视角生成投影纹理座标,对SSAO图进行采样,并将它应用到光照方程的环境光项。

在顶点着色器中,为了省下传一个投影纹理矩阵,采用下面的形式计算:

// 从NDC座标[-1, 1]^2变换到纹理空间座标[0, 1]^2
// u = 0.5x + 0.5
// v = -0.5y + 0.5
// ((xw, yw, zw, w) + (w, w, 0, 0)) * (0.5, -0.5, 1, 1) = ((0.5x + 0.5)w, (-0.5y + 0.5)w, zw, w)
//                                                      = (uw, vw, zw, w)
//                                                      =>  (u, v, z, 1)
vOut.SSAOPosH = (vOut.PosH + float4(vOut.PosH.ww, 0.0f, 0.0f)) * float4(0.5f, -0.5f, 1.0f, 1.0f);

而像素着色器则这样修改:

// 完成纹理投影变换并对SSAO图采样
pIn.SSAOPosH /= pIn.SSAOPosH.w;
float ambientAccess = g_SSAOMap.SampleLevel(g_Sam, pIn.SSAOPosH.xy, 0.0f).r;

[unroll]
for (i = 0; i < 5; ++i)
{
    ComputeDirectionalLight(g_Material, g_DirLight[i], pIn.NormalW, toEyeW, A, D, S);
    ambient += ambientAccess * A;	// 此处乘上可及率
    diffuse += shadow[i] * D;
    spec += shadow[i] * S;
}

下面两幅图展示了SSAO图应用后的效果对比。因为上一章的光照中环境光所占的比重并不是很大,因此在这一章我们将光照调整到让环境光所占的比重增大许多,以此让SSAO效果的反差更为显著。当物体处于阴影之中时,SSAO的优点尤其明显,能够更加凸显出3D立体感。

开启SSAO(上)和未开启SSAO(下)的对比,仔细观察圆柱底部、球的底部、房屋。

在渲染观察空间中场景法线/深度的同时,我们也在写入NDC深度到绑定的深度/模板缓冲区。因此,以SSAO图第二次渲染场景时,应当将深度检测的比较方法改为"EQUALS"。由于只有距离观察点最近的可视像素才能通过这项深度比较检测,所以这种检测方法就可以有效防止第二次渲染过程中的重复绘制操作。而且,在第二次渲染过程中也无须向深度缓冲区执行写操作。

D3D11_DEPTH_STENCIL_DESC dsDesc;
ZeroMemory(&dsDesc, sizeof dsDesc);
// 仅允许深度值一致的像素进行写入的深度/模板状态
// 没必要写入深度
dsDesc.DepthEnable = true;
dsDesc.DepthWriteMask = D3D11_DEPTH_WRITE_MASK_ZERO;
dsDesc.DepthFunc = D3D11_COMPARISON_EQUAL;

HR(device->CreateDepthStencilState(&dsDesc, DSSEqual.GetAddressOf()));



// BasicEffect.cpp
void BasicEffect::SetSSAOEnabled(bool enabled)
{
	pImpl->m_pEffectHelper->GetConstantBufferVariable("g_EnableSSAO")->SetSInt(enabled);
	// 我们在绘制SSAO法向量/深度图的时候也已经写入了主要的深度/模板贴图,
	// 所以我们可以直接使用深度值相等的测试,这样可以避免在当前的一趟渲染中
	// 出现任何的重复写入当前像素的情况,只有距离最近的像素才会通过深度比较测试
	pImpl->m_pEffectHelper->GetEffectPass("BasicObject")->SetDepthStencilState((enabled ? RenderStates::DSSEqual.Get() : nullptr), 0);
	pImpl->m_pEffectHelper->GetEffectPass("BasicInstance")->SetDepthStencilState((enabled ? RenderStates::DSSEqual.Get() : nullptr), 0);
	pImpl->m_pEffectHelper->GetEffectPass("NormalMapObject")->SetDepthStencilState((enabled ? RenderStates::DSSEqual.Get() : nullptr), 0);
	pImpl->m_pEffectHelper->GetEffectPass("NormalMapInstance")->SetDepthStencilState((enabled ? RenderStates::DSSEqual.Get() : nullptr), 0);
}

实现细节问题

在实现过程中遇到了一系列的问题,在此进行总结。

法向量的变换

对法向量进行世界变换通常是使用世界逆变换的转置矩阵,而且在HLSL中也仅仅是使用它的3x3部分:

vOut.NormalW = mul(vIn.NormalL, (float3x3) g_WorldInvTranspose);

这样做当然一点问题都没有,但问题是在本例中还需要将法向量变换到观察空间,所使用的矩阵是\(\mathbf{(W^{-1})^{T} V}\)的3x3部分:

vOut.NormalV = mul(vIn.NormalL, (float3x3) worldInvTransposeView);

如果在计算\({(W^{-1}})^{T}\)之前不抹除掉世界矩阵的平移分量的话,经过逆变换再转置后矩阵的第四列前三行的值很可能就是非0值,然后再乘上观察矩阵(观察矩阵的第四行前三列的值也可能是非0值)就会对3x3的部分产生影响,导致错误的法向量变换结果。

为此,我们需要使用下面的函数来进行世界矩阵的求逆再转置:

// ------------------------------
// InverseTranspose函数
// ------------------------------
inline DirectX::XMMATRIX XM_CALLCONV InverseTranspose(DirectX::FXMMATRIX M)
{
	using namespace DirectX;

	// 世界矩阵的逆的转置仅针对法向量,我们也不需要世界矩阵的平移分量
	// 而且不去掉的话,后续再乘上观察矩阵之类的就会产生错误的变换结果
	XMMATRIX A = M;
	A.r[3] = g_XMIdentityR3;

	return XMMatrixTranspose(XMMatrixInverse(nullptr, A));
}

关闭多重采样

在渲染法向量/深度RTV时,如果我们仍然使用开启4倍msaa的深度/模板缓冲区,那就也要要求法向量/深度RTV的采样等级和质量与其一致。因此在这一章我们选择将MSAA给关闭。只需要去D3DApp中将m_Enable4xMsaa设为false即可。

计算过程不同导致深度值比较不相等

在绘制法向量/深度缓冲区和最终的场景绘制都需要计算NDC深度值,如果使用的计算过程不完全一致,如:

// BasicEffect
vOut.PosH = mul(vIn.PosL, g_WorldViewProj);

// SSAO_NormalDepth
vOut.PosH = mul(vOut.PosV, g_Proj);

计算过程的不一致会导致算出来的深度值很可能会产生误差,然后导致出现下面这样的花屏效果:

SSAO的瑕疵

SSAO也并不是没有瑕疵的,因为它只针对屏幕空间进行操作,只要我们站的位置和视角刁钻一些,比如这里我们低头往下看,并且没有看到上面的石球,那么石球的上半部分无法对石柱顶部产生遮蔽,导致遮蔽效果大幅削弱。

练习题

  1. 修改SSAO演示程序,尝试用高斯模糊取代边缘保留模糊。哪种方法更好一些?

  2. 能否用计算着色器实现SSAO?

  3. 下图展示的是我们不进行自相交检测所生成的SSAO图。尝试修改本演示程序,去掉其中的相交检测来欣赏。

DirectX11 With Windows SDK完整目录

Github项目源码

欢迎加入QQ群: 727623616 可以一起探讨DX11,以及有什么问题也可以在这里汇报。

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