因为性能的限制,实时光照模型每每会忽略间接光因素(即场景中其余物体所反弹的光线)。但在现实生活中,大部分光照实际上是间接光。在第7章里面的光照方程里面引入了环境光项:html
其中颜色\(\mathbf{A_L}\)表示的是从某光源发出,通过环境光反射而照射到物体表面的间接光总量。漫反射\(\mathbf{m_d}\)则是物体表面根据漫反射率将入射光反射回的总量。这种方式的计算只是一种简化,并不是真正的物理计算,它直接假定物体表面任意一点接收到的光照都是相同的,而且都能以相同的反射系数最终反射到咱们眼睛。下图展现了若是仅采用环境光项来绘制模型的状况,物体将会被同一种单色所渲染:git
固然,这种环境光项是不真实的,咱们对其还有一些改良的余地。github
学习目标:算法
DirectX11 With Windows SDK完整目录数组
Github项目源码dom
欢迎加入QQ群: 727623616 能够一块儿探讨DX11,以及有什么问题也能够在这里汇报。函数
环境光遮蔽技术的主体思路以下图所示,表面上一点p所接收到的间接光总量,与照射到p为中心的半球的入射光量成正比。工具
一种估算点p受遮蔽程度的方法是采用光线投射法。咱们随机投射出一些光线,使得它们传过以点p为中心的半球,并检测这些光线与网格相交的状况。或者说咱们以点p做为射线的起点,随机地在半球范围选择一个方向进行投射。性能
若是投射了N条光线,有h条与网格相交,那么点p的遮蔽率大体为:学习
而且仅当光线与网格的交点q与点p之间的距离小于某个阈值d时才会认为该光线产生遮蔽。这是由于若交点p与点p距离过远时就说明这个方向上照射到点p的光不会受到物体的遮挡。
遮蔽因子用来测量该点受到遮蔽的程度(有多少光线不能到达该点)。计算该值出来,是为了知道该点可以接受光照的程度,即咱们须要的是它的相反值,一般叫它为可及率:
在龙书11的项目AmbientOcclusion中,咱们能够找到AmbientOcclusionApp::BuildVertexAmbientOcclusion
函数,它负责为物体的每一个顶点计算出间接光的可及率。因为与本章主旨不一样,故不在这里贴出源码。它是在程序运行之初先对全部物体预先计算出顶点的遮蔽状况,物体每一个顶点都会投射出固定数目的随机方向射线,而后与场景中的全部网格三角形作相交检测。这一切都是在CPU完成的。
若是你以前写过CPU光线追踪的程序的话,能明显感受到产生一幅图所须要的时间很是的长。由于从物体表面一点可能会投射很是多的射线,而且这些射线还须要跟场景中的全部网格三角形作相交检测,若是不采用加速结构的话就是数以万计的射线要与数以万计的三角形同时作相交检测。在龙书11的所示例程中采用了八叉树这种特别的数据解来进行物体的空间划分以进行加速,这样一条射线就可能只须要作不到10次的逐渐精细的检测就能够快速判断出是否有三角形相交。
在通过几秒的漫长等待后,程序完成了物体的遮蔽预计算并开始渲染,下图跟前面的图相比起来能够说获得了极大的改善。该样例程序并无使用任何光照,而是直接基于物体顶点的遮蔽属性进行着色。能够看到那些颜色比较深的地方一般都是模型的缝隙间,由于从它们投射出的光线更容易与其它几何体相交。
投射光线实现环境光遮蔽的方法适用于那些静态物体,即咱们能够先给模型自己预先计算遮蔽值并保存到顶点上,又或者是经过一些工具直接生成环境光遮蔽图,即存有环境光遮蔽数据的纹理。然而,对于动态物体来讲就不适用了,每次物体发生变化就要从新计算一次遮蔽数据明显很是不现实,也不能知足实时渲染的需求。接下来咱们将会学到一种基于屏幕空间实时计算的环境光遮蔽技术。
屏幕空间环境光遮蔽(Screen Space Ambient Occlusion)技术的策略是:在每一帧渲染过程当中,将场景处在观察空间中的法向量和深度值渲染到额外的一个屏幕大小的纹理,而后将该纹理做为输入来估算每一个像素点的环境光遮蔽程度。最终当前像素所接受的从某光源发出的环境光项为:
首先咱们将场景物体渲染到屏幕大小、格式为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-p与n之间的夹角小于90°,那么能够认为点r对点q产生遮蔽,故须要将其计入点p的遮蔽值。在本Demo中,咱们使用了14个随机采样点,根据平均值法求得的遮蔽率来估算屏幕空间中的环境光遮蔽数据。
当咱们为绘制全屏四边形而对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,故它们知足:
所以就有:
// 绘制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; // ... }
这一步模拟的是向半球随机投射光线的过程。咱们以点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)); // ... }
如今咱们拥有了在点p周围的随机采样点q。可是咱们不清楚该点所处的位置是空无一物,仍是处于实心物体,所以咱们不能直接用它来测试是否遮蔽了点p。为了寻找潜在的遮蔽点,咱们须要来自法向量/深度贴图中的深度信息。接下来咱们对点q进行投影,并获得投影纹理坐标,从而对贴图进行采样来获取沿着点q发出的射线,到达最近可视像素点r的深度值\(r_z\)。咱们同样可以用前面的方式从新构建点r在观察空间中的位置,它们知足:
所以根据每一个随机采样点q所生产的点r即为潜在的遮蔽点
如今咱们得到了潜在的遮蔽点r,接下来就能够进行遮蔽测试,以估算它是否会遮蔽点p。该测试基于下面两种值:
若是点r与点p位于同一平面内,就能够知足第一个条件,即距离\(|p_z-r_z|\)足够小以致于点r遮蔽了点q。然而,从上图能够看出,二者在同一平面内的时候,点r并无遮蔽点p。经过计算\(max(\mathbf{n}\cdot(\frac{\mathbf{r-p}}{\Vert \mathbf{r-p} \Vert}), 0)\)做为因子相乘遮蔽值能够防止对此状况的误判
在对每一个采样点的遮蔽数据相加后,还要经过除以采样的次数来计算遮蔽率。接着,咱们会计算环境光的可及率,并对它进行幂运算以提升对比度(contrast)。固然,咱们也可以按需求适当增长一些数值来提升光照强度,以此为环境光图(ambient map)添加亮度。除此以外,咱们还能够尝试不一样的对比值和亮度值。
occlusionSum /= g_SampleCount; float access = 1.0f - occlusionSum; // 加强SSAO图的对比度,是的SSAO图的效果更加明显 return saturate(pow(access, 4.0f));
// 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_SamNormalDepth
和g_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演示程序,尝试用高斯模糊取代边缘保留模糊。哪一种方法更好一些?
可否用计算着色器实现SSAO?
下图展现的是咱们不进行自相交检测所生成的SSAO图。尝试修改本演示程序,去掉其中的相交检测来欣赏。
DirectX11 With Windows SDK完整目录
欢迎加入QQ群: 727623616 能够一块儿探讨DX11,以及有什么问题也能够在这里汇报。