前言

由于性能的限制,实时光照模型往往会忽略间接光因素(即场景中其他物体所反弹的光线)。但在现实生活中,大部分光照其实是间接光。在第7章里面的光照方程里面引入了环境光项:
C a = A L ⊗ m d C_a = \mathbf{A_L}\otimes\mathbf{m_d} Ca​=AL​⊗md​
其中颜色 A L \mathbf{A_L} AL​表示的是从某光源发出,经过环境光反射而照射到物体表面的间接光总量。漫反射 m d \mathbf{m_d} md​则是物体表面根据漫反射率将入射光反射回的总量。这种方式的计算只是一种简化,并非真正的物理计算,它直接假定物体表面任意一点接收到的光照都是相同的,并且都能以相同的反射系数最终反射到我们眼睛。下图展示了如果仅采用环境光项来绘制模型的情况,物体将会被同一种单色所渲染:

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

学习目标:

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

DirectX11 With Windows SDK完整目录

Github项目源码

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

投射光线实现环境光遮蔽

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

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

如果投射了N条光线,有h条与网格相交,那么点p的遮蔽率大致为:
o c c l u s i o n = h N ∈ [ 0 , 1 ] occlusion=\frac{h}{N} \in [0, 1] occlusion=Nh​∈[0,1]
并且仅当光线与网格的交点q与点p之间的距离小于某个阈值d时才会认为该光线产生遮蔽。这是因为若交点p与点p距离过远时就说明这个方向上照射到点p的光不会受到物体的遮挡。

遮蔽因子用来测量该点受到遮蔽的程度(有多少光线不能到达该点)。计算该值出来,是为了知道该点能够接受光照的程度,即我们需要的是它的相反值,通常叫它为可及率
a c c e s s i b i l i t y = 1 − o c c l u s i o n ∈ [ 0 , 1 ] accessibility = 1 - occlusion \in [0, 1] accessibility=1−occlusion∈[0,1]
在龙书11的项目AmbientOcclusion中,我们可以找到AmbientOcclusionApp::BuildVertexAmbientOcclusion函数,它负责为物体的每个顶点计算出间接光的可及率。由于与本章主旨不同,故不在这里贴出源码。它是在程序运行之初先对所有物体预先计算出顶点的遮蔽情况,物体每个顶点都会投射出固定数目的随机方向射线,然后与场景中的所有网格三角形做相交检测。这一切都是在CPU完成的。

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

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

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

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

屏幕空间环境光遮蔽(Screen Space Ambient Occlusion)技术的策略是:在每一帧渲染过程中,将场景处在观察空间中的法向量和深度值渲染到额外的一个屏幕大小的纹理,然后将该纹理作为输入来估算每个像素点的环境光遮蔽程度。最终当前像素所接受的从某光源发出的环境光项为:
C a = a m b i e n t A c c e s s ⋅ A L ⊗ m d C_a = ambientAccess \cdot \mathbf{A_L}\otimes\mathbf{m_d} Ca​=ambientAccess⋅AL​⊗md​

法线和深度值的渲染

首先我们将场景物体渲染到屏幕大小、格式为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 ∣ |p_z-r_z| ∣pz​−rz​∣足够小,且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,故它们满足:
p = p z v z v \mathbf{p}=\frac{p_z}{v_z}\mathbf{v} p=vz​pz​​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_z rz​。我们一样能够用前面的方式重新构建点r在观察空间中的位置,它们满足:
r = r z q z q \mathbf{r}=\frac{r_z}{q_z}\mathbf{q} r=qz​rz​​q
因此根据每个随机采样点q所生产的点r即为潜在的遮蔽点

4. 进行遮蔽测试

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

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

如果点r与点p位于同一平面内,就可以满足第一个条件,即距离 ∣ p z − r z ∣ |p_z-r_z| ∣pz​−rz​∣足够小以至于点r遮蔽了点q。然而,从上图可以看出,两者在同一平面内的时候,点r并没有遮蔽点p。通过计算 m a x ( n ⋅ ( r − p ∥ r − p ∥ ) , 0 ) max(\mathbf{n}\cdot(\frac{\mathbf{r-p}}{\Vert \mathbf{r-p} \Vert}), 0) max(n⋅(∥r−p∥r−p​),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 * Texturefloat4 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         zEndfloat occlusion = 0.0f;if (distZ > g_SurfaceEpsilon){float fadeLength = g_OcclusionFadeEnd - g_OcclusionFadeStart;// 当distZ由g_OcclusionFadeStart逐渐趋向于g_OcclusionFadeEnd,遮蔽值由1线性减小至0occlusion = 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]^3float3 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.zfloat3 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不会遮挡点pfloat 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 CLAMPcbuffer 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);
}

C++端代码实现

在本章中,与SSAO直接相关的类有BasicEffectSSAOEffectSSAORenderGameApp类承担了实现过程。

SSAORender类

SSAORender类负责用来生成SSAO图,将SSAOEffect的使用给封装了起来:

class SSAORender
{public:template<class T>using ComPtr = Microsoft::WRL::ComPtr<T>;SSAORender() = default;~SSAORender() = default;// 不允许拷贝,允许移动SSAORender(const SSAORender&) = delete;SSAORender& operator=(const SSAORender&) = delete;SSAORender(SSAORender&&) = default;SSAORender& operator=(SSAORender&&) = default;HRESULT InitResource(ID3D11Device* device,int width, int height, float fovY, float farZ);HRESULT OnResize(ID3D11Device* device,int width, int height, float fovY, float farZ);// 开始绘制场景到法向量/深度图// 缓存当前RTV、DSV和视口// 当我们渲染到法向量/深度图时,需要传递程序使用的深度缓冲区void Begin(ID3D11DeviceContext* deviceContext, ID3D11DepthStencilView* dsv, bool enableAlphaClip = false);// 完成最终的SSAO图绘制后进行恢复void End(ID3D11DeviceContext* deviceContext);// 设置SSAO图作为RTV,并绘制到一个全屏矩形以启用像素着色器来计算环境光遮蔽项// 尽管我们仍保持主深度缓冲区绑定到管线上,但要禁用深度缓冲区的读写,// 因为我们不需要深度缓冲区来计算SSAO图void ComputeSSAO(ID3D11DeviceContext* deviceContext, SSAOEffect& ssaoEffect, const Camera& camera);// 对SSAO图进行模糊,使得由于每个像素的采样次数较少而产生的噪点进行平滑处理// 这里使用边缘保留的模糊void BlurAmbientMap(ID3D11DeviceContext* deviceContext, SSAOEffect& ssaoEffect, int blurCount);// 获取SSAO图ID3D11ShaderResourceView* GetAmbientTexture();// 设置调试对象名void SetDebugObjectName(const std::string& name);private:void BuildFrustumFarCorners(float fovY, float farZ);void BuildOffsetVectors();HRESULT BuildTextureViews(ID3D11Device* device, int width, int height);HRESULT BuildFullScreenQuad(ID3D11Device* device);HRESULT BuildRandomVectorTexture(ID3D11Device* device);void BlurAmbientMap(ID3D11DeviceContext* deviceContext, SSAOEffect& ssaoEffect,ID3D11ShaderResourceView* inputSRV, ID3D11RenderTargetView* outputRTV, bool horzBlur);private:UINT m_Width = 0;UINT m_Height = 0;DirectX::XMFLOAT4 m_FrustumFarCorner[4] = {};DirectX::XMFLOAT4 m_Offsets[14] = {};D3D11_VIEWPORT m_AmbientMapViewPort = {};ComPtr<ID3D11ShaderResourceView> m_pRandomVectorSRV;ComPtr<ID3D11RenderTargetView> m_pNormalDepthRTV;ComPtr<ID3D11ShaderResourceView> m_pNormalDepthSRV;ComPtr<ID3D11Buffer> m_pScreenQuadVB;ComPtr<ID3D11Buffer> m_pScreenQuadIB;ComPtr<ID3D11RenderTargetView> m_pCachedRTV;ComPtr<ID3D11DepthStencilView> m_pCachedDSV;D3D11_VIEWPORT m_CachedViewPort = {};//// 在模糊期间使用两个纹理进行Ping-Pong交换//ComPtr<ID3D11RenderTargetView> m_pAmbientRTV0;ComPtr<ID3D11ShaderResourceView> m_pAmbientSRV0;ComPtr<ID3D11RenderTargetView> m_pAmbientRTV1;ComPtr<ID3D11ShaderResourceView> m_pAmbientSRV1;
};

这样在GameApp::DrawScene中绘制到SSAO图的过程可以简化为:

// ******************
// 绘制到SSAO图
//
if (m_EnableSSAO)
{m_pSSAOMap->Begin(m_pd3dImmediateContext.Get(), m_pDepthStencilView.Get());{// 绘制场景到法向量/深度缓冲区DrawScene(m_pSSAOEffect.get());// 计算环境光遮蔽值到SSAO图m_pSSAOMap->ComputeSSAO(m_pd3dImmediateContext.Get(), *m_pSSAOEffect, *m_pCamera);// 进行模糊m_pSSAOMap->BlurAmbientMap(m_pd3dImmediateContext.Get(), *m_pSSAOEffect, 4);}m_pSSAOMap->End(m_pd3dImmediateContext.Get());
}

实现细节问题

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

法向量的变换

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

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

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

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

如果在计算 ( W − 1 ) T {(W^{-1}})^{T} (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,以及有什么问题也可以在这里汇报。

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

  1. OpenGL SSAO屏幕空间环境光遮蔽的实例

    OpenGL SSAO屏幕空间环境光遮蔽 先上图,再解答. 完整主要的源代码 源代码剖析 先上图,再解答. 完整主要的源代码 #include <glad/glad.h> #include ...

  2. OpenGL进阶之SSAO屏幕空间环境光遮蔽

    参考: https://learnopenglcn.github.io/05%20Advanced%20Lighting/09%20SSAO/ 环境光照是我们加入场景总体光照中的一个固定光照常量,它被 ...

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

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

  4. 屏幕空间环境光遮蔽(SSAO)算法的实现

    SSAO SSAO介绍 之前写SSAO的时候最后一直没达到想要的效果,最近闲下来又重新写了下,才发现自己之前真的蠢- -!(位置和法线忘转到视口坐标).这里就好好整理一下这个算法,以免下次想拿起来又不 ...

  5. 【TA-霜狼_may-《百人计划》】图形4.2 SSAO算法 屏幕空间环境光遮蔽

    [TA-霜狼_may-<百人计划>]图形4.2 SSAO算法 屏幕空间环境光遮蔽 @[TOC]([TA-霜狼_may-<百人计划>]图形4.2 SSAO算法 屏幕空间环境光遮蔽 ...

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

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

  7. Vulkan_SSAO—屏幕空间环境光遮蔽

    屏幕空间环境光遮蔽 我们已经在前面的基础教程中简单介绍到了这部分内容:环境光照(Ambient Lighting).环境光照是我们加入场景总体光照中的一个固定光照常量,它被用来模拟光的散射(Scatt ...

  8. 【技美百人计划】图形 4.2 SSAO算法 屏幕空间环境光遮蔽(&HBAO)

    笔记 SSAO介绍 AO 环境光遮蔽,AmbientOcclusion.一种模拟光线到达物体的能力和粗略的全局方法. SSAO 屏幕环境光遮蔽,Screen Space Ambient Occlusi ...

  9. DirectX 11 学习笔记-Part2-4【Cubemap/贴图置换/阴影/屏幕空间环境光遮蔽】【原理篇】

    代码已上传至GitHub,这部分对应的项目为NormalDisplacementSsao.运行项目时可以使用数字键1-3切换渲染的shader,另外按下z键可以看到SSAO生成的遮罩贴图. 这部分内容 ...

最新文章

  1. idea怎么找到当前报错的行_科研论文如何想到不错的 idea?
  2. 她们,在字节跳动写代码
  3. arm-linux-gcc makefile,ARM-LINUX-GCC简易万能makefile
  4. 5.jsp中动态include与静态include的区别
  5. 小米11和小米10至尊版纪念版哪个好
  6. Hadoop学习:Map/Reduce初探与小Demo实现
  7. 对象 普通po转_厦门2020年转学怎么转?需要什么材料?你想知道的答案都在这!......
  8. vue.js的项目实战
  9. Unity3D_(游戏)卡牌03_选关界面
  10. python第五十二课--自定义异常类
  11. 证券公司八大业务最全梳理
  12. Vision Transformer模型/论文详解
  13. CMOS MIPI EOT 学习 基于Zynq高速串行CMOS接口的设计与实现
  14. Excel 两列合并为一列中间加空格
  15. 什么是spring框架?spring框架的好处?
  16. mongdb compass 可视化工具的使用
  17. 4、关于step的设置
  18. 红外避障模块c语言编写程序,红外避障模块(红外对管)51单片机驱动源程序
  19. 杰奇python采集器_极速杰奇采集器
  20. Tableau图表 • 堆积发散条形图(李克特量表)

热门文章

  1. 2009年6月9日,博客再次改进的通报!
  2. Spark入门知识--Day7
  3. Python成长之路【第九篇】:Python基础之面向对象
  4. latex中的字体族,\texttt{}是什么
  5. 三个一工程比较有意思的题汇总
  6. 分部声明一定不能指定不同的基类
  7. i++和++i 和i+1的区别
  8. 计算机 调 应用统计,山东工商学院统计学院2020年应用统计专硕调剂信息
  9. 王衠:爱游戏电视游戏平台将实现宽带支付
  10. 网络安全-终端防护设备