一、环境光遮蔽(AO)
由于性能的限制,实时光照模型往往会忽略间接光因素(即场景中其他物体所反弹的光线)。但在现实生活中,大部分光照其实是间接光。在第7章里面的光照方程里面引入了环境光项:
其中颜色AL表示的是从某光源发出,经过环境光反射而照射到物体表面的间接光总量。漫反射md则是物体表面根据漫反射率将入射光反射回的总量。这种方式的计算只是一种简化,并非真正的物理计算,它直接假定物体表面任意一点接收到的光照都是相同的,并且都能以相同的反射系数最终反射到我们眼睛。下图展示了如果仅采用环境光项来绘制模型的情况,物体将会被同一种单色所渲染:
当然,这种环境光项是不真实的,我们对其还有一些改良的余地。
环境光遮蔽(Ambient Occlusion)技术的主体思路如下图所示,表面上一点p所接收到的间接光总量,与照射到p为中心的半球的入射光量成正比。
一种估算点p受遮蔽程度的方法是采用光线投射法。我们随机投射出一些光线,使得它们传过以点p为中心的半球,并检测这些光线与网格相交的情况。或者说我们以点p作为射线的起点,随机地在半球范围选择一个方向进行投射。
如果投射了N条光线,有h条与网格相交,那么点p的遮蔽率大致为:
并且仅当光线与网格的交点q与点p之间的距离小于某个阈值d时才会认为该光线产生遮蔽。这是因为若交点p与点p距离过远时就说明这个方向上照射到点p的光不会受到物体的遮挡。
遮蔽因子用来测量该点受到遮蔽的程度(有多少光线不能到达该点)。计算该值出来,是为了知道该点能够接受光照的程度,即我们需要的是它的相反值,通常叫它为可及率:
在龙书11的项目AmbientOcclusion中,我们可以找到AmbientOcclusionApp::BuildVertexAmbientOcclusion函数,它负责为物体的每个顶点计算出间接光的可及率。由于与本章主旨不同,故不在这里贴出源码。它是在程序运行之初先对所有物体预先计算出顶点的遮蔽情况,物体每个顶点都会投射出固定数目的随机方向射线,然后与场景中的所有网格三角形做相交检测。这一切都是在CPU完成的。
如果你之前写过CPU光线追踪的程序的话,能明显感觉到产生一幅图所需要的时间非常的长。因为从物体表面一点可能会投射非常多的射线,并且这些射线还需要跟场景中的所有网格三角形做相交检测,如果不采用加速结构的话就是数以万计的射线要与数以万计的三角形同时做相交检测。在龙书11的所示例程中采用了八叉树这种特别的数据解来进行物体的空间划分以进行加速,这样一条射线就可能只需要做不到10次的逐渐精细的检测就可以快速判断出是否有三角形相交。
在经过几秒的漫长等待后,程序完成了物体的遮蔽预计算并开始渲染,下图跟前面的图相比起来可以说得到了极大的改善。该样例程序并没有使用任何光照,而是直接基于物体顶点的遮蔽属性进行着色。可以看到那些颜色比较深的地方通常都是模型的缝隙间,因为从它们投射出的光线更容易与其它几何体相交。
投射光线实现环境光遮蔽的方法适用于那些静态物体,即我们可以先给模型本身预先计算遮蔽值并保存到顶点上,又或者是通过一些工具直接生成环境光遮蔽图,即存有环境光遮蔽数据的纹理。然而,对于动态物体来说就不适用了,每次物体发生变化就要重新计算一次遮蔽数据明显非常不现实,也不能满足实时渲染的需求。接下来我们将会学到一种基于屏幕空间实时计算的环境光遮蔽技术。
二、屏幕空间环境光遮蔽(SSAO)
屏幕空间环境光遮蔽(Screen Space Ambient Occlusion)技术的策略是:在每一帧渲染过程中,将场景处在观察空间中的法向量和深度值渲染到额外的一个屏幕大小的纹理,然后将该纹理作为输入来估算每个像素点的环境光遮蔽程度。最终当前像素所接受的从某光源发出的环境光项为:
2.1 法线和深度值的渲染
首先我们将场景物体渲染到屏幕大小、格式为DXGI_FORMAT_R16G16B16A16_FLOAT的法向量/深度值纹理贴图,其中RGB分量代表法向量,Alpha分量代表该点在屏幕空间中深度值,一般情况下我们在出路延迟渲染的时候再GBuffer中也会对应存储相关信息。具体的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_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部分:
2.2 SSAO原理
在绘制好观察空间法向量和深度纹理之后,我们就禁用深度缓冲区(我们不需要用到它),并在每个像素处调用SSAO像素着色器来绘制一个全屏四边形。这样像素着色器将运用法向量/深度纹理来为每个像素生成环境光可及率。最终生成的贴图叫SSAO图。尽管我们以全屏分辨率渲染法向量/深度图,但在绘制SSAO图时,出于性能的考虑,我们使用的是一半宽高的分辨率。以一半分辨率渲染并不会对质量有多大的影响,因为环境光遮蔽也是一种低频效果(low frequency effect,LFE)。
2.1.1 核心思想
- 点p是当前我们正在处理的像素,我们根据从观察点到该像素在远平面内对应点的向量v以及法向量/深度缓冲区中存储的点p在观察空间中的深度值来重新构建出点p。
- 点q是以点p为中心的半球内的随机一点,点r对应的是从观察点到点q这一路径上的最近可视点。
- 如果|pz−rz|足够小,且r-p与n之间的夹角小于90°,那么可以认为点r对点q产生遮蔽,故需要将其计入点p的遮蔽值。在本Demo中,我们使用了14个随机采样点,根据平均值法求得的遮蔽率来估算屏幕空间中的环境光遮蔽数据。
2.3 SSAO实现过程
2.3.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);
}
buffer 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;
// ...
}
2.3.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));
// ...
}
2.3.3 生成潜在的遮蔽点
现在我们拥有了在点p周围的随机采样点q。但是我们不清楚该点所处的位置是空无一物,还是处于实心物体,因此我们不能直接用它来测试是否遮蔽了点p。为了寻找潜在的遮蔽点,我们需要来自法向量/深度贴图中的深度信息。接下来我们对点q进行投影,并得到投影纹理坐标,从而对贴图进行采样来获取沿着点q发出的射线,到达最近可视像素点r的深度值rz。我们一样能够用前面的方式重新构建点r在观察空间中的位置,它们满足:
因此根据每个随机采样点q所生产的点r即为潜在的遮蔽点。
2.3.4 遮蔽测试
现在我们获得了潜在的遮蔽点r,接下来就可以进行遮蔽测试,以估算它是否会遮蔽点p。该测试基于下面两种值:
- 观察空间中点p与点r的深度距离为|pz−rz|。随着距离的增长,遮蔽值将按比例线性减小,因为遮蔽点与目标点的距离越远,其遮蔽的效果就越弱。如果该距离超过某个指定的最大距离,那么点r将完全不会遮挡点p。而且,如果此距离过小,我们将认为点p与点q位于同一平面上,故点q此时也不会遮挡点p。
- 法向量n与向量r-p的夹角的测定方式为max(n⋅((r−p)/∥r−p∥),0)。这是为了防止自相交情况的发生。
如果点r与点p位于同一平面内,就可以满足第一个条件,即距离|pz−rz|足够小以至于点r遮蔽了点q。然而,从上图可以看出,两者在同一平面内的时候,点r并没有遮蔽点p。通过计算*max(n⋅((r−p)/∥r−p∥),0)*作为因子相乘遮蔽值可以防止对此情况的误判
2.3.5 SSAO图生成
在对每个采样点的遮蔽数据相加后,还要通过除以采样的次数来计算遮蔽率。接着,我们会计算环境光的可及率,并对它进行幂运算以提高对比度(contrast)。当然,我们也能够按需求适当增加一些数值来提高光照强度,以此为环境光图(ambient map)添加亮度。除此之外,我们还可以尝试不同的对比值和亮度值。
occlusionSum /= g_SampleCount;
float access = 1.0f - occlusionSum;
// 增强SSAO图的对比度,是的SSAO图的效果更加明显
return saturate(pow(access, 4.0f));
2.3.6 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_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));
}
2.4 SSAO优化
2.4.1 模糊过程(双边模糊)
下图展示了我们目前生成的SSAO图的效果。其中的噪点是由于随机采样点过少导致的。但通过采集足够多的样点来屏蔽噪点的做法,在实时渲染的前提下并不切实际。对此,常用的解决方案是采用边缘保留的模糊(edge preserving blur)的过滤方式来使得SSAO图的过渡更为平滑。这里我们使用的是双边模糊,即bilateral blur。如果使用的过滤方法为非边缘保留的模糊,那么随着物体边缘的明显划分转为平滑的渐变,会使得场景中的物体难以界定。这种保留边缘的模糊算法要使用法线/深度贴图来检测边缘。.
2.4.2 模糊处理HLSL代码
// 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图如下:
2.5 使用环境光遮蔽图
到现在我们就已经构造出了环境光遮蔽图,最后一步便是将其应用到场景当中。我们采用如下策略:在场景渲染到后备缓冲区时,我们要把环境光图作为着色器的输入。接下来再以摄像机的视角生成投影纹理坐标,对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图应用后的效果对比:
tip: 在渲染观察空间中场景法线/深度的同时,我们也在写入NDC深度到绑定的深度/模板缓冲区。因此,以SSAO图第二次渲染场景时,应当将深度检测的比较方法改为"EQUALS"。由于只有距离观察点最近的可视像素才能通过这项深度比较检测,所以这种检测方法就可以有效防止第二次渲染过程中的重复绘制操作。而且,在第二次渲染过程中也无须向深度缓冲区执行写操作。
三、粒子系统(Particle System)
3.1 粒子的表示
粒子是一种非常小的对象,通常在数学上可以表示为一个点。因此我们在D3D中可以考虑使用图元类型D3D11_PRIMITIVE_TOPOLOGY_POINTLIST来将一系列点传入。然而,点图元仅仅会被光栅化为单个像素。考虑到粒子可以有不同的大小,并且甚至需要将整个纹理都贴到这些粒子上,我们将采用前面公告板的绘制策略,即在顶点着色器输出顶点,然后在几何着色器将其变成一个四边形并朝向摄像机。此外需要注意的是,这些粒子的y轴是与摄像机的y轴是对齐的。
如果我们知道世界坐标系的上方向轴向量j,公告板中心位置C,以及摄像机位置E,这样我们可以描述公告板在世界坐标系下的局部坐标轴(即粒子的世界变换)是怎样的:
粒子的属性如下:
struct Particle
{
XMFLOAT3 InitialPos;
XMFLOAT3 InitialVel;
XMFLOAT2 Size;
float Age;
unsigned int Type;
};
注意:
我们不需要将顶点变成四边形。例如,使用LineList来渲染雨看起来工作的也挺不错,但而我们可以用不同的几何着色器来将点变成线。通常情况下,在我们的系统中,每个粒子系统拥有自己的一套特效和着色器集合。
3.2 粒子运动
我们将会让粒子以物理上的真实方式进行运动。为了简便,我们将限制粒子的加速度为恒定常数。例如,让加速度取决于重力,又或者是纯粹的风力。此外,我们不对粒子做任何的碰撞检测。
设p(t)为粒子在t时刻的位置,它的运动轨迹为一条光滑曲线。它在t时刻的瞬时速度为:
同样,粒子在t时刻的瞬时加速度为:
学过高数的话下面的公式不难理解:
连续函数f(t)的不定积分得到的函数有无限个,即C可以为任意常数,这些函数求导后可以还原回f(t)
通过对速度求不定积分就可以得到位置函数,对加速度求则得到的是速度函数:
现在设加速度a(t)是一个恒定大小,方向,不随时间变化的函数,并且我们知道t=0时刻的初始位置p0和初始速度v0。因此速度函数可以写作:
而t=0时刻满足
故速度函数为:
继续积分并代入p(0),我们可以求出位置函数:
由此可以看出,粒子的运动轨迹p(t) (t>=0)完全取决于初始位置、初始速度和恒定加速度。只要知道了这些参数,我们就可以画出它的运动轨迹了。因为它是关于t的二次函数,故它的运动轨迹一般为抛物线。
若令a=(0, -9.8, 0),则物体的运动可以看做仅仅受到了重力的影响。
注意:
你也可以选择不使用上面导出的函数。如果你已经知道了粒子的运动轨迹函数p(t),你也可以直接用到你的程序当中。比如椭圆的参数方程等。
3.3 随机性
在一个粒子系统中,我们想要让粒子的表现相似,但不是让他们都一样。这就意味着我们需要给粒子系统加上随机性。例如,如果我们在模拟雨滴,我们想让它从不同的地方降下来,以及稍微不同的下落角度、稍微不同的降落速度。
当然,如果只是在C++中生成随机数还是一件比较简单的事情的。但我们还需要在着色器代码中使用随机数,而我们没有着色器能够直接使用的随机数生成器。所以我们的做法是创建一个1D纹理,里面每个元素是float4(使用DXGI_FORMAT_R32G32B32A32_FLOAT)。然后我们使用区间[-1, 1]的随机4D向量来填满纹理,采样的时候则使用wrap寻址模式即可。着色器通过对该纹理采样来获取随机数。这里有许多对随机纹理进行采样的方法。如果每个粒子拥有不同的x坐标,我们可以使用x坐标来作为纹理坐标来获取随机数。然而,如果每个粒子的x坐标都相同,它们就会获得相同的随机数。另一种方式是,我们可以使用当前的游戏时间值作为纹理坐标。这样,不同时间生成的粒子将会获得不同的随机值。这也意味着同一时间生成的粒子将会获得相同的随机值。这样如果粒子系统就不应该在同一时间生成多个粒子了。因此,我们可以考虑把两者结合起来,当系统在同一时刻生成许多粒子的时候,我们可以添加一个不同的纹理坐标偏移值给游戏时间。这样就可以尽可能确保同一时间内产生的不同粒子在进行采样的时候能获得不同的随机数。例如,当我们循环20次来创建出20个粒子的时候,我们可以使用循环的索引再乘上某个特定值作为纹理坐标的偏移,然后再进行采样。现在我们就能够拿到20个不同的随机数了。
下面的代码用来生成随机数1D纹理:
需要注意的是,对于随机数纹理,我们只有一个mipmap等级。所以我们得使用SampleLevel的采样方法来限制采样mipmap等级为0。
下面的函数用于获得一个随机的单位向量:
float3 RandUnitVec3(float offset)
{
// 使用游戏时间加上偏移值来从随机纹理采样
float u = (g_GameTime + offset);
// 分量均在[-1,1]
float3 v = g_RandomVecMap.SampleLevel(g_SamLinear, u, 0).xyz;
// 标准化向量
return normalize(v);
}
3.4 混合与粒子系统
粒子系统通常以某些混合形式来绘制。对于火焰和法术释放的效果,我们想要让处于颗粒位置的颜色强度变量,那我们可以使用加法混合的形式。虽然我们可以只是将源颜色与目标颜色相加起来,但是粒子通常情况下是透明的,我们需要给源粒子颜色乘上它的alpha值。因此混合参数为:
SrcBlend = SRC_ALPHA;
DestBlend = ONE;
BlendOp = ADD;
即混合等式为:
换句话说,源粒子给最终的混合颜色产生的贡献程度是由它的不透明度所决定的:粒子越不透明,贡献的颜色值越多。另一种办法是我们可以在纹理中预先乘上它的不透明度(由alpha通道描述),以便于稀释它的纹理颜色。这种情况下的混合参数为:
SrcBlend = ONE;
DestBlend = ONE;
BlendOp = ADD;
加法混合还有一个很好的效果,那就是可以使区域的亮度与那里的粒子浓度成正比。浓度高的区域会显得格外明亮,这通常也是我们想要的
而对于烟雾来说,加法混合是行不通的,因为加入一堆重叠的烟雾粒子的颜色,最终会使得烟雾的颜色变亮,甚至变白。使用减法混合的话效果会更好一些(D3D11_BLEND_OP_REV_SUBTRACT),烟雾粒子会从目标色中减去一部分颜色。通过这种方式,可以使高浓度的烟雾粒子区域会变得更加灰黑。虽然这样做对黑烟的效果很好,但是对浅灰烟、蒸汽的效果表现不佳。烟雾的另一种可能的混合方式是使用透明度混合,我们只需要将烟雾粒子视作半透明物体,使用透明度混合来渲染它们。但透明度混合的主要问题是将系统中的粒子按照相对于眼睛的前后顺序进行排序,这种做法非常昂贵且不实际。考虑到粒子系统的随机性,这条规则有时候可以打破,这并不会产生比较显著的渲染问题。注意到如果场景中有许多粒子系统,这些系统应该按照从后到前的顺序进行排序;但我们也不想对系统内的粒子进行排序。
3.5 基于GPU的粒子系统
3.5.1 粒子系统特效
现在,我们可以将粒子的生成、变化、摧毁和绘制过程完全写在HLSL文件上,而不同的粒子系统的这些过程各有各的不同之处。比如说:
- 摧毁条件不同:我们可能要在雨水打中地面的时候将它摧毁,然而对于火焰粒子来说它是在几秒钟后被摧毁
- 变化过程不同:烟雾粒子可能随着时间推移变得暗淡,对于雨水粒子来说并不是这样的。同样地,烟雾粒子随时间推移逐渐扩散,而雨水大多是往下掉落的。
- 绘制过程不同:线图元通常在模拟雨水的时候效果良好,但火焰/烟雾粒子更多使用的是公告板的四边形。
- 生成条件不同:雨水和烟雾的初始位置和速度的设置方式明显也是不同的。
但这样做好处是可以让C++代码的工作量尽可能地减到最小。
3.5.2 ParticleEffect类
按照惯例,粒子系统分为了ParticleEffect和ParticleRender两个部分。其中ParticleEffect对粒子系统的HLSL实现有所约束,它可以读取一套HLSL文件并负责数据的传入。
class ParticleEffect : public IEffect
{
public:
ParticleEffect();
virtual ~ParticleEffect() override;
ParticleEffect(ParticleEffect&& moveFrom) noexcept;
ParticleEffect& operator=(ParticleEffect&& moveFrom) noexcept;
// 初始化所需资源
// 若effectPath为HLSL/Fire
// 则会寻找文件:
// - HLSL/Fire_SO_VS.hlsl
// - HLSL/Fire_SO_GS.hlsl
// - HLSL/Fire_VS.hlsl
// - HLSL/Fire_GS.hlsl
// - HLSL/Fire_PS.hlsl
bool Init(ID3D11Device* device, const std::wstring& effectPath);
// 产生新粒子到顶点缓冲区
void SetRenderToVertexBuffer(ID3D11DeviceContext* deviceContext);
// 绘制粒子系统
void SetRenderDefault(ID3D11DeviceContext* deviceContext);
void XM_CALLCONV SetViewProjMatrix(DirectX::FXMMATRIX VP);
void SetEyePos(const DirectX::XMFLOAT3& eyePos);
void SetGameTime(float t);
void SetTimeStep(float step);
void SetEmitDir(const DirectX::XMFLOAT3& dir);
void SetEmitPos(const DirectX::XMFLOAT3& pos);
void SetEmitInterval(float t);
void SetAliveTime(float t);
void SetTextureArray(ID3D11ShaderResourceView* textureArray);
void SetTextureRandom(ID3D11ShaderResourceView* textureRandom);
void SetBlendState(ID3D11BlendState* blendState, const FLOAT blendFactor[4], UINT sampleMask);
void SetDepthStencilState(ID3D11DepthStencilState* depthStencilState, UINT stencilRef);
void SetDebugObjectName(const std::string& name);
//
// IEffect
//
// 应用常量缓冲区和纹理资源的变更
void Apply(ID3D11DeviceContext* deviceContext) override;
private:
class Impl;
std::unique_ptr<Impl> pImpl;
};
其中用户需要手动设置的有渲染时的混合状态、深度/模板状态,以及ViewProj和EyePos。其余可以交给接下来要讲的ParticleRender类来完成。
3.5.3 ParticleRender类
该类代表一个粒子系统的实例,用户需要设置与该系统相关的参数、使用的纹理等属性:
class ParticleRender
{
public:
template<class T>
using ComPtr = Microsoft::WRL::ComPtr<T>;
ParticleRender() = default;
~ParticleRender() = default;
// 不允许拷贝,允许移动
ParticleRender(const ParticleRender&) = delete;
ParticleRender& operator=(const ParticleRender&) = delete;
ParticleRender(ParticleRender&&) = default;
ParticleRender& operator=(ParticleRender&&) = default;
// 自从该系统被重置以来所经过的时间
float GetAge() const;
void SetEmitPos(const DirectX::XMFLOAT3& emitPos);
void SetEmitDir(const DirectX::XMFLOAT3& emitDir);
void SetEmitInterval(float t);
void SetAliveTime(float t);
HRESULT Init(ID3D11Device* device, UINT maxParticles);
void SetTextureArraySRV(ID3D11ShaderResourceView* textureArraySRV);
void SetRandomTexSRV(ID3D11ShaderResourceView* randomTexSRV);
void Reset();
void Update(float dt, float gameTime);
void Draw(ID3D11DeviceContext* deviceContext, ParticleEffect& effect, const Camera& camera);
void SetDebugObjectName(const std::string& name);
private:
UINT m_MaxParticles = 0;
bool m_FirstRun = true;
float m_GameTime = 0.0f;
float m_TimeStep = 0.0f;
float m_Age = 0.0f;
DirectX::XMFLOAT3 m_EmitPos = {};
DirectX::XMFLOAT3 m_EmitDir = {};
float m_EmitInterval = 0.0f;
float m_AliveTime = 0.0f;
ComPtr<ID3D11Buffer> m_pInitVB;
ComPtr<ID3D11Buffer> m_pDrawVB;
ComPtr<ID3D11Buffer> m_pStreamOutVB;
ComPtr<ID3D11ShaderResourceView> m_pTextureArraySRV;
ComPtr<ID3D11ShaderResourceView> m_pRandomTexSRV;
};
注意:粒子系统使用一个纹理数组来对粒子进行贴图,因为我们可能不想让所有的粒子看起来都是一样的。例如,为了实现一个烟雾的粒子系统,我们可能想要使用几种烟雾纹理来添加变化,图元ID在像素着色器中可以用来对纹理数组进行索引。
3.5.4 发射器粒子
因为几何着色器负责创建/摧毁粒子,我们需要一个特别的发射器粒子。发射器粒子本身可以绘制出来,也可以不被绘制。假如你想让你的发射器粒子不能被看见,那么在绘制时的几何着色器的阶段你就可以不要将它输出。发射器粒子与当前粒子系统中的其它粒子的行为有所不同,因为它可以产生其它粒子。例如,一个发射器粒子可能会记录累计经过的时间,并且到达一个特定时间点的时候,它就会发射一个新的粒子。此外,通过限制哪些粒子可以发射其它粒子,它让我们对粒子的发射方式有了一定的控制。比如说现在我们只有一个发射器粒子,我们可以很方便地控制每一帧所生产的粒子数目。流输出几何着色器应当总是输出至少一个发射器粒子,因为如果粒子系统丢掉了所有的发射器,粒子系统终究会消亡;但对于某些粒子系统来说,让它最终消亡也许是一种理想的结果。
在本章中,我们将只使用一个发射器粒子。但如果需要的话,当前粒子系统的框架也可以进行扩展。
3.5.5 起始顶点缓冲区
在我们的粒子系统中,有一个比较特别的起始顶点缓冲区,它仅仅包含了一个发射器粒子,而我们用这个顶点缓冲区来启动粒子系统。发射器粒子将会开始不停地产生其它粒子。需要注意的是起始顶点缓冲区仅仅绘制一次(除了系统被重置以外)。当粒子系统经发射器粒子启动后,我们就可以使用两个流输出顶点缓冲区来进行后续绘制。
起始顶点缓冲区在系统被重置的时候也是有用的,我们可以使用下面的代码来重启粒子系统:
void ParticleRender::Reset()
{
m_FirstRun = true;
m_Age = 0.0f;
}
3.5.6 更新/绘制过程
绘制过程如下:
- 通过流输出几何着色器阶段来更新当前帧的粒子
- 使用更新好的粒子进行渲染
void ParticleRender::Draw(ID3D11DeviceContext* deviceContext, ParticleEffect& effect, const Camera& camera)
{
effect.SetGameTime(m_GameTime);
effect.SetTimeStep(m_TimeStep);
effect.SetEmitPos(m_EmitPos);
effect.SetEmitDir(m_EmitDir);
effect.SetEmitInterval(m_EmitInterval);
effect.SetAliveTime(m_AliveTime);
effect.SetTextureArray(m_pTextureArraySRV.Get());
effect.SetTextureRandom(m_pRandomTexSRV.Get());
// ******************
// 流输出
//
effect.SetRenderToVertexBuffer(deviceContext);
UINT strides[1] = { sizeof(VertexParticle) };
UINT offsets[1] = { 0 };
// 如果是第一次运行,使用初始顶点缓冲区
// 否则,使用存有当前所有粒子的顶点缓冲区
if (m_FirstRun)
deviceContext->IASetVertexBuffers(0, 1, m_pInitVB.GetAddressOf(), strides, offsets);
else
deviceContext->IASetVertexBuffers(0, 1, m_pDrawVB.GetAddressOf(), strides, offsets);
// 经过流输出写入到顶点缓冲区
deviceContext->SOSetTargets(1, m_pStreamOutVB.GetAddressOf(), offsets);
effect.Apply(deviceContext);
if (m_FirstRun)
{
deviceContext->Draw(1, 0);
m_FirstRun = false;
}
else
{
deviceContext->DrawAuto();
}
// 解除缓冲区绑定
ID3D11Buffer* nullBuffers[1] = { nullptr };
deviceContext->SOSetTargets(1, nullBuffers, offsets);
// 进行顶点缓冲区的Ping-Pong交换
m_pDrawVB.Swap(m_pStreamOutVB);
// ******************
// 使用流输出顶点绘制粒子
//
effect.SetRenderDefault(deviceContext);
deviceContext->IASetVertexBuffers(0, 1, m_pDrawVB.GetAddressOf(), strides, offsets);
effect.Apply(deviceContext);
deviceContext->DrawAuto();
}
3.6 案例
3.6.1 火焰
火焰粒子虽然是沿着指定方向发射,但给定了随机的初速度来火焰四散,并产生火球。
// Fire.hlsli
cbuffer CBChangesEveryFrame : register(b0)
{
matrix g_ViewProj;
float3 g_EyePosW;
float g_GameTime;
float g_TimeStep;
float3 g_EmitDirW;
float3 g_EmitPosW;
float g_EmitInterval;
float g_AliveTime;
}
cbuffer CBFixed : register(b1)
{
// 用于加速粒子运动的加速度
float3 g_AccelW = float3(0.0f, 7.8f, 0.0f);
// 纹理坐标
float2 g_QuadTex[4] =
{
float2(0.0f, 1.0f),
float2(1.0f, 1.0f),
float2(0.0f, 0.0f),
float2(1.0f, 0.0f)
};
}
// 用于贴图到粒子上的纹理数组
Texture2DArray g_TexArray : register(t0);
// 用于在着色器中生成随机数的纹理
Texture1D g_RandomTex : register(t1);
// 采样器
SamplerState g_SamLinear : register(s0);
float3 RandUnitVec3(float offset)
{
// 使用游戏时间加上偏移值来采样随机纹理
float u = (g_GameTime + offset);
// 采样值在[-1,1]
float3 v = g_RandomTex.SampleLevel(g_SamLinear, u, 0).xyz;
// 投影到单位球
return normalize(v);
}
#define PT_EMITTER 0
#define PT_FLARE 1
struct VertexParticle
{
float3 InitialPosW : POSITION;
float3 InitialVelW : VELOCITY;
float2 SizeW : SIZE;
float Age : AGE;
uint Type : TYPE;
};
// 绘制输出
struct VertexOut
{
float3 PosW : POSITION;
float2 SizeW : SIZE;
float4 Color : COLOR;
uint Type : TYPE;
};
struct GeoOut
{
float4 PosH : SV_Position;
float4 Color : COLOR;
float2 Tex : TEXCOORD;
};
// Fire_SO_VS.hlsl
#include "Fire.hlsli"
VertexParticle VS(VertexParticle vIn)
{
return vIn;
}
// Fire_SO_GS.hlsl
#include "Fire.hlsli"
[maxvertexcount(2)]
void GS(point VertexParticle gIn[1], inout PointStream<VertexParticle> output)
{
gIn[0].Age += g_TimeStep;
if (gIn[0].Type == PT_EMITTER)
{
// 是否到时间发射新的粒子
if (gIn[0].Age > g_EmitInterval)
{
float3 vRandom = RandUnitVec3(0.0f);
vRandom.x *= 0.5f;
vRandom.z *= 0.5f;
VertexParticle p;
p.InitialPosW = g_EmitPosW.xyz;
p.InitialVelW = 4.0f * vRandom;
p.SizeW = float2(3.0f, 3.0f);
p.Age = 0.0f;
p.Type = PT_FLARE;
output.Append(p);
// 重置时间准备下一次发射
gIn[0].Age = 0.0f;
}
// 总是保留发射器
output.Append(gIn[0]);
}
else
{
// 用于限制粒子数目产生的特定条件,对于不同的粒子系统限制也有所变化
if (gIn[0].Age <= g_AliveTime)
output.Append(gIn[0]);
}
}
// Fire_VS.hlsl
#include "Fire.hlsli"
VertexOut VS(VertexParticle vIn)
{
VertexOut vOut;
float t = vIn.Age;
// 恒定加速度等式
vOut.PosW = 0.5f * t * t * g_AccelW + t * vIn.InitialVelW + vIn.InitialPosW;
// 颜色随着时间褪去
float opacity = 1.0f - smoothstep(0.0f, 1.0f, t / 1.0f);
vOut.Color = float4(1.0f, 1.0f, 1.0f, opacity);
vOut.SizeW = vIn.SizeW;
vOut.Type = vIn.Type;
return vOut;
}
// Fire_GS.hlsl
#include "Fire.hlsli"
[maxvertexcount(4)]
void GS(point VertexOut gIn[1], inout TriangleStream<GeoOut> output)
{
// 不要绘制用于产生粒子的顶点
if (gIn[0].Type != PT_EMITTER)
{
//
// 计算该粒子的世界矩阵让公告板朝向摄像机
//
float3 look = normalize(g_EyePosW.xyz - gIn[0].PosW);
float3 right = normalize(cross(float3(0.0f, 1.0f, 0.0f), look));
float3 up = cross(look, right);
//
// 计算出处于世界空间的四边形
//
float halfWidth = 0.5f * gIn[0].SizeW.x;
float halfHeight = 0.5f * gIn[0].SizeW.y;
float4 v[4];
v[0] = float4(gIn[0].PosW + halfWidth * right - halfHeight * up, 1.0f);
v[1] = float4(gIn[0].PosW + halfWidth * right + halfHeight * up, 1.0f);
v[2] = float4(gIn[0].PosW - halfWidth * right - halfHeight * up, 1.0f);
v[3] = float4(gIn[0].PosW - halfWidth * right + halfHeight * up, 1.0f);
//
// 将四边形顶点从世界空间变换到齐次裁减空间
//
GeoOut gOut;
[unroll]
for (int i = 0; i < 4; ++i)
{
gOut.PosH = mul(v[i], g_ViewProj);
gOut.Tex = g_QuadTex[i];
gOut.Color = gIn[0].Color;
output.Append(gOut);
}
}
}
// Fire_PS.hlsl
#include "Fire.hlsli"
float4 PS(GeoOut pIn) : SV_Target
{
return g_TexArray.Sample(g_SamLinear, float3(pIn.Tex, 0.0f)) * pIn.Color;
}
在C++中,我们还需要设置下面两个渲染状态用于粒子的渲染:
m_pFireEffect->SetBlendState(RenderStates::BSAlphaWeightedAdditive.Get(), nullptr, 0xFFFFFFFF);
m_pFireEffect->SetDepthStencilState(RenderStates::DSSNoDepthWrite.Get(), 0);
3.6.2 雨水
雨水粒子系统也是由一系列的HLSL文件所组成。它的形式和火焰粒子系统有所相似,但在生成/摧毁/渲染的规则上有所不同。例如,我们的雨水加速度是向下的,并带有小幅度的倾斜角,然而火焰的加速度是向上的。此外,雨水粒子系统最终产生的绘制图元是线,而不是四边形;并且雨水的产生位置与摄像机位置有联系,它总是在摄像机的上方周围(移动的时候在上方偏前)产生雨水粒子,这样就不需要在整个世界产生雨水了。这样就可以造成一种当前正在下雨的假象(当然移动起来的话就会感觉有些假,雨水量减少了)。需要注意该系统并没有使用任何的混合状态。
// Rain.hlsli
cbuffer CBChangesEveryFrame : register(b0)
{
matrix g_ViewProj;
float3 g_EyePosW;
float g_GameTime;
float g_TimeStep;
float3 g_EmitDirW;
float3 g_EmitPosW;
float g_EmitInterval;
float g_AliveTime;
}
cbuffer CBFixed : register(b1)
{
// 用于加速粒子运动的加速度
float3 g_AccelW = float3(-1.0f, -9.8f, 0.0f);
}
// 用于贴图到粒子上的纹理数组
Texture2DArray g_TexArray : register(t0);
// 用于在着色器中生成随机数的纹理
Texture1D g_RandomTex : register(t1);
// 采样器
SamplerState g_SamLinear : register(s0);
float3 RandUnitVec3(float offset)
{
// 使用游戏时间加上偏移值来采样随机纹理
float u = (g_GameTime + offset);
// 采样值在[-1,1]
float3 v = g_RandomTex.SampleLevel(g_SamLinear, u, 0).xyz;
// 投影到单位球
return normalize(v);
}
float3 RandVec3(float offset)
{
// 使用游戏时间加上偏移值来采样随机纹理
float u = (g_GameTime + offset);
// 采样值在[-1,1]
float3 v = g_RandomTex.SampleLevel(g_SamLinear, u, 0).xyz;
return v;
}
#define PT_EMITTER 0
#define PT_FLARE 1
struct VertexParticle
{
float3 InitialPosW : POSITION;
float3 InitialVelW : VELOCITY;
float2 SizeW : SIZE;
float Age : AGE;
uint Type : TYPE;
};
// 绘制输出
struct VertexOut
{
float3 PosW : POSITION;
uint Type : TYPE;
};
struct GeoOut
{
float4 PosH : SV_Position;
float2 Tex : TEXCOORD;
};
// Rain_SO_VS.hlsl
#include "Rain.hlsli"
VertexParticle VS(VertexParticle vIn)
{
return vIn;
}
// Rain_SO_GS.hlsl
#include "Rain.hlsli"
[maxvertexcount(6)]
void GS(point VertexParticle gIn[1], inout PointStream<VertexParticle> output)
{
gIn[0].Age += g_TimeStep;
if (gIn[0].Type == PT_EMITTER)
{
// 是否到时间发射新的粒子
if (gIn[0].Age > g_EmitInterval)
{
[unroll]
for (int i = 0; i < 5; ++i)
{
// 在摄像机上方的区域让雨滴降落
float3 vRandom = 30.0f * RandVec3((float)i / 5.0f);
vRandom.y = 20.0f;
VertexParticle p;
p.InitialPosW = g_EmitPosW.xyz + vRandom;
p.InitialVelW = float3(0.0f, 0.0f, 0.0f);
p.SizeW = float2(1.0f, 1.0f);
p.Age = 0.0f;
p.Type = PT_FLARE;
output.Append(p);
}
// 重置时间准备下一次发射
gIn[0].Age = 0.0f;
}
// 总是保留发射器
output.Append(gIn[0]);
}
else
{
// 用于限制粒子数目产生的特定条件,对于不同的粒子系统限制也有所变化
if (gIn[0].Age <= g_AliveTime)
output.Append(gIn[0]);
}
}
// Rain_VS.hlsl
#include "Rain.hlsli"
VertexOut VS(VertexParticle vIn)
{
VertexOut vOut;
float t = vIn.Age;
// 恒定加速度等式
vOut.PosW = 0.5f * t * t * g_AccelW + t * vIn.InitialVelW + vIn.InitialPosW;
vOut.Type = vIn.Type;
return vOut;
}
// Rain_GS.hlsl
#include "Rain.hlsli"
[maxvertexcount(6)]
void GS(point VertexOut gIn[1], inout LineStream<GeoOut> output)
{
// 不要绘制用于产生粒子的顶点
if (gIn[0].Type != PT_EMITTER)
{
// 使线段沿着一个加速度方向倾斜
float3 p0 = gIn[0].PosW;
float3 p1 = gIn[0].PosW + 0.07f * g_AccelW;
GeoOut v0;
v0.PosH = mul(float4(p0, 1.0f), g_ViewProj);
v0.Tex = float2(0.0f, 0.0f);
output.Append(v0);
GeoOut v1;
v1.PosH = mul(float4(p1, 1.0f), g_ViewProj);
v1.Tex = float2(0.0f, 0.0f);
output.Append(v1);
}
}
// Rain_PS.hlsl
#include "Rain.hlsli"
float4 PS(GeoOut pIn) : SV_Target
{
return g_TexArray.Sample(g_SamLinear, float3(pIn.Tex, 0.0f));
}
在C++中,我们还需要设置下面的渲染状态用于粒子的渲染:
m_pRainEffect->SetDepthStencilState(RenderStates::DSSNoDepthWrite.Get(), 0);
3.7 C++代码实现
在本部分中,与粒子系统直接相关的类为ParticleEffect和ParticleRedner类。GameApp类承担了实现过程。
首先是初始化关于粒子系统和特效的部分:
bool GameApp::InitResource()
{
// ...
// ******************
// 初始化特效
//
// ...
m_pFireEffect->SetBlendState(RenderStates::BSAlphaWeightedAdditive.Get(), nullptr, 0xFFFFFFFF);
m_pFireEffect->SetDepthStencilState(RenderStates::DSSNoDepthWrite.Get(), 0);
m_pRainEffect->SetDepthStencilState(RenderStates::DSSNoDepthWrite.Get(), 0);
// ...
// ******************
// 初始化粒子系统
//
ComPtr<ID3D11ShaderResourceView> pFlareSRV, pRainSRV, pRandomSRV;
HR(CreateTexture2DArrayFromFile(m_pd3dDevice.Get(), m_pd3dImmediateContext.Get(),
std::vector<std::wstring>{ L"..\\Texture\\flare0.dds" }, nullptr, pFlareSRV.GetAddressOf()));
HR(CreateRandomTexture1D(m_pd3dDevice.Get(), nullptr, pRandomSRV.GetAddressOf()));
m_pFire->Init(m_pd3dDevice.Get(), 500);
m_pFire->SetTextureArraySRV(pFlareSRV.Get());
m_pFire->SetRandomTexSRV(pRandomSRV.Get());
m_pFire->SetEmitPos(XMFLOAT3(0.0f, -1.0f, 0.0f));
m_pFire->SetEmitDir(XMFLOAT3(0.0f, 1.0f, 0.0f));
m_pFire->SetEmitInterval(0.005f);
m_pFire->SetAliveTime(1.0f);
HR(CreateTexture2DArrayFromFile(m_pd3dDevice.Get(), m_pd3dImmediateContext.Get(),
std::vector<std::wstring>{ L"..\\Texture\\raindrop.dds" }, nullptr, pRainSRV.GetAddressOf()));
HR(CreateRandomTexture1D(m_pd3dDevice.Get(), nullptr, pRandomSRV.ReleaseAndGetAddressOf()));
m_pRain->Init(m_pd3dDevice.Get(), 10000);
m_pRain->SetTextureArraySRV(pRainSRV.Get());
m_pRain->SetRandomTexSRV(pRandomSRV.Get());
m_pRain->SetEmitDir(XMFLOAT3(0.0f, -1.0f, 0.0f));
m_pRain->SetEmitInterval(0.0015f);
m_pRain->SetAliveTime(3.0f);
// ...
}
然后是更新部分,角色移动时会在角色头顶上再往前一些的地方为中心点的范围来产生粒子:
void GameApp::UpdateScene(float dt)
{
// ...
// ******************
// 粒子系统
//
if (m_KeyboardTracker.IsKeyPressed(Keyboard::R))
{
m_pFire->Reset();
m_pRain->Reset();
}
m_pFire->Update(dt, m_Timer.TotalTime());
m_pRain->Update(dt, m_Timer.TotalTime());
m_pFireEffect->SetViewProjMatrix(m_pCamera->GetViewProjXM());
m_pFireEffect->SetEyePos(m_pCamera->GetPosition());
static XMFLOAT3 lastCameraPos = m_pCamera->GetPosition();
XMFLOAT3 cameraPos = m_pCamera->GetPosition();
XMVECTOR cameraPosVec = XMLoadFloat3(&cameraPos);
XMVECTOR lastCameraPosVec = XMLoadFloat3(&lastCameraPos);
XMFLOAT3 emitPos;
XMStoreFloat3(&emitPos, cameraPosVec + 3.0f * (cameraPosVec - lastCameraPosVec));
m_pRainEffect->SetViewProjMatrix(m_pCamera->GetViewProjXM());
m_pRainEffect->SetEyePos(m_pCamera->GetPosition());
m_pRain->SetEmitPos(emitPos);
lastCameraPos = m_pCamera->GetPosition();
}
最后是绘制,由于粒子可能是透明物体,并且本例中不写入深度值,要在画完天空盒之后才来绘制粒子系统:
void GameApp::DrawScene()
{
assert(m_pd3dImmediateContext);
assert(m_pSwapChain);
m_pd3dImmediateContext->ClearRenderTargetView(m_pRenderTargetView.Get(), reinterpret_cast<const float*>(&Colors::Silver));
m_pd3dImmediateContext->ClearDepthStencilView(m_pDepthStencilView.Get(), D3D11_CLEAR_DEPTH | D3D11_CLEAR_STENCIL, 1.0f, 0);
// ******************
// 正常绘制场景
//
// 统计实际绘制的物体数目
std::vector<Transform> acceptedData;
// 默认视锥体裁剪
acceptedData = Collision::FrustumCulling(m_InstancedData, m_Trees.GetLocalBoundingBox(),
m_pCamera->GetViewXM(), m_pCamera->GetProjXM());
// 默认硬件实例化绘制
m_pBasicEffect->SetRenderDefault(m_pd3dImmediateContext.Get(), BasicEffect::RenderInstance);
m_Trees.DrawInstanced(m_pd3dImmediateContext.Get(), m_pBasicEffect.get(), acceptedData);
// 绘制地面
m_pBasicEffect->SetRenderDefault(m_pd3dImmediateContext.Get(), BasicEffect::RenderObject);
m_Ground.Draw(m_pd3dImmediateContext.Get(), m_pBasicEffect.get());
// 绘制天空盒
m_pSkyEffect->SetRenderDefault(m_pd3dImmediateContext.Get());
m_pGrassCube->Draw(m_pd3dImmediateContext.Get(), *m_pSkyEffect, *m_pCamera);
// ******************
// 粒子系统留在最后绘制
//
m_pFire->Draw(m_pd3dImmediateContext.Get(), *m_pFireEffect, *m_pCamera);
m_pRain->Draw(m_pd3dImmediateContext.Get(), *m_pRainEffect, *m_pCamera);
// ...
}