DirectX11 With Windows SDK–35 粒子系统

前言

在这一章中,我们主要关注的是如何模拟一系列粒子,并控制它们运动。这些粒子的行为都是类似的,但它们也带有一定的随机性。这一堆粒子的几何我们叫它为粒子系统,它可以被用于模拟一些比较现象,如:火焰、雨、烟雾、爆炸、法术效果等。

在这一章开始之前,你需要先学过如下章节:

章节
11 混合状态
15 几何着色器初探
16 流输出阶段
17 利用几何着色器实现公告板效果

学习目标

  1. 熟悉如何利用几何着色器和流输出阶段来高效存储、渲染粒子
  2. 了解我们如何利用基本的物理概念来让我们的粒子能够以物理上的真实方式来运动
  3. 设计一个灵活的粒子系统框架使得我们可以方便地创建新的自定义粒子系统

DirectX11 With Windows SDK完整目录

Github项目源码

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

粒子的表示

粒子是一种非常小的对象,通常在数学上可以表示为一个点。因此我们在D3D中可以考虑使用图元类型D3D11_PRIMITIVE_TOPOLOGY_POINTLIST来将一系列点传入。然而,点图元仅仅会被光栅化为单个像素。考虑到粒子可以有不同的大小,并且甚至需要将整个纹理都贴到这些粒子上,我们将采用前面公告板的绘制策略,即在顶点着色器输出顶点,然后在几何着色器将其变成一个四边形并朝向摄像机。此外需要注意的是,这些粒子的y轴是与摄像机的y轴是对齐的。

如果我们知道世界坐标系的上方向轴向量j,公告板中心位置C,以及摄像机位置E,这样我们可以描述公告板在世界坐标系下的局部坐标轴(即粒子的世界变换)是怎样的:

\[\mathbf{w}=\frac{\mathbf{E-C}}{\|\mathbf{E-C}\|}\\
\mathbf{u}=\frac{\mathbf{j\times w}}{\|\mathbf{j\times w}\|}\\
\mathbf{v}=\mathbf{w\times u}\\
\mathbf{W}=\begin{bmatrix}
u_x & u_y & u_z & 0\\
v_x & v_y & v_z & 0\\
w_x & w_y & w_z & 0\\
C_x & C_y & C_z & 1\\
\end{bmatrix}
\]

粒子的属性如下:

struct Particle
{
    XMFLOAT3 InitialPos;
    XMFLOAT3 InitialVel;
    XMFLOAT2 Size;
    float Age;
    unsigned int Type;
};

注意:我们不需要将顶点变成四边形。例如,使用LineList来渲染雨看起来工作的也挺不错,但而我们可以用不同的几何着色器来将点变成线。通常情况下,在我们的系统中,每个粒子系统拥有自己的一套特效和着色器集合。

粒子运动

我们将会让粒子以物理上的真实方式进行运动。为了简便,我们将限制粒子的加速度为恒定常数。例如,让加速度取决于重力,又或者是纯粹的风力。此外,我们不对粒子做任何的碰撞检测。

p(t)为粒子在t时刻的位置,它的运动轨迹为一条光滑曲线。它在t时刻的瞬时速度为:

\[\mathbf{v}(t)=\mathbf{p’}(t)
\]

同样,粒子在t时刻的瞬时加速度为:

\[\mathbf{a}(t)=\mathbf{v’}(t)=\mathbf{p”}(t)
\]

学过高数的话下面的公式不难理解:

\[\int\mathbf{f(t)}dt=\mathbf{F}(t) + C\\
[\mathbf{F}(t)+C]’=\mathbf{f}(t)
\]

连续函数f(t)的不定积分得到的函数有无限个,即C可以为任意常数,这些函数求导后可以还原回f(t)

通过对速度求不定积分就可以得到位置函数,对加速度求则得到的是速度函数:

\[\mathbf{p}(t)=\int\mathbf{v}(t)dt\\
\mathbf{v}(t)=\int\mathbf{a}(t)dt\\
\]

现在设加速度a(t)是一个恒定大小,方向,不随时间变化的函数,并且我们知道t=0时刻的初始位置p0和初始速度v0。因此速度函数可以写作:

\[\mathbf{v}(t)=\int\mathbf{a}(t)dt=t\cdot\mathbf{a} + \mathbf{c}
\]

而t=0时刻满足

\[\mathbf{v}(0)=\mathbf{c}=\mathbf{v_0}
\]

故速度函数为:

\[\mathbf{v}(t) =t\cdot\mathbf{a} + \mathbf{v_0}
\]

继续积分并代入p(0),我们可以求出位置函数:

\[\mathbf{p}(t) = \frac{1}{2}t^2\mathbf{a}+t\mathbf{v_0}+\mathbf{p_0}
\]

换句话说,粒子的运动轨迹p(t) (t>=0)完全取决于初始位置、初始速度和恒定加速度。只要知道了这些参数,我们就可以画出它的运动轨迹了。因为它是关于t的二次函数,故它的运动轨迹一般为抛物线。

若令a=(0, -9.8, 0),则物体的运动可以看做仅仅受到了重力的影响。

注意:你也可以选择不使用上面导出的函数。如果你已经知道了粒子的运动轨迹函数p(t),你也可以直接用到你的程序当中。比如椭圆的参数方程等。

随机性

在一个粒子系统中,我们想要让粒子的表现相似,但不是让他们都一样。这就意味着我们需要给粒子系统加上随机性。例如,如果我们在模拟余地,我们想让它从不同的地方降下来,以及稍微不同的下落角度、稍微不同的降落速度。

当然,如果只是在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);
}

混合与粒子系统

粒子系统通常以某些混合形式来绘制。对于火焰和法术释放的效果,我们想要让处于颗粒位置的颜色强度变量,那我们可以使用加法混合的形式。虽然我们可以只是将源颜色与目标颜色相加起来,但是粒子通常情况下是透明的,我们需要给源粒子颜色乘上它的alpha值。因此混合参数为:

SrcBlend = SRC_ALPHA;
DestBlend = ONE;
BlendOp = ADD;

即混合等式为:

\[\mathbf{C}=a_s\cdot\mathbf{C_{src}} + \mathbf{C_{dst}}
\]

换句话说,源粒子给最终的混合颜色产生的贡献程度是由它的不透明度所决定的:粒子越不透明,贡献的颜色值越多。另一种办法是我们可以在纹理中预先乘上它的不透明度(由alpha通道描述),以便于稀释它的纹理颜色。这种情况下的混合参数为:

SrcBlend = ONE;
DestBlend = ONE;
BlendOp = ADD;

加法混合还有一个很好的效果,那就是可以使区域的亮度与那里的粒子浓度成正比。浓度高的区域会显得格外明亮,这通常也是我们想要的

而对于烟雾来说,加法混合是行不通的,因为加入一堆重叠的烟雾粒子的颜色,最终会使得烟雾的颜色变亮,甚至变白。使用减法混合的话效果会更好一些(D3D11_BLEND_OP_REV_SUBTRACT),烟雾粒子会从目标色中减去一部分颜色。通过这种方式,可以使高浓度的烟雾粒子区域会变得更加灰黑。虽然这样做对黑烟的效果很好,但是对浅灰烟、蒸汽的效果表现不佳。烟雾的另一种可能的混合方式是使用透明度混合,我们只需要将烟雾粒子视作半透明物体,使用透明度混合来渲染它们。但透明度混合的主要问题是将系统中的粒子按照相对于眼睛的前后顺序进行排序,这种做法非常昂贵且不实际。考虑到粒子系统的随机性,这条规则有时候可以打破,这并不会产生比较显著的渲染问题。注意到如果场景中有许多粒子系统,这些系统应该按照从后到前的顺序进行排序;但我们也不想对系统内的粒子进行排序。

基于GPU的粒子系统

粒子系统一般随着时间的推移会产生和消灭粒子。一种看起来比较合理的方式就是使用动态顶点缓冲区并在CPU跟踪粒子的生成和消灭,顶点缓冲区装有当前存活或者刚生成的粒子。但是,我们从前面的章节已经知道一个独立的、仅以流输出阶段为输出的一趟渲染就可以在GPU完全控制粒子的生成和摧毁。这种做法是非常高效的,因为它不需要从CPU上传数据给GPU,并且它将粒子生成/摧毁的工作从CPU搬移到了GPU,然后可以减少CPU的工作量了。

粒子系统特效

现在,我们可以将粒子的生成、变化、摧毁和绘制过程完全写在HLSL文件上,而不同的粒子系统的这些过程各有各的不同之处。比如说:

  1. 摧毁条件不同:我们可能要在雨水打中地面的时候将它摧毁,然而对于火焰粒子来说它是在几秒钟后被摧毁
  2. 变化过程不同:烟雾粒子可能随着时间推移变得暗淡,对于雨水粒子来说并不是这样的。同样地,烟雾粒子随时间推移逐渐扩散,而雨水大多是往下掉落的。
  3. 绘制过程不同:线图元通常在模拟雨水的时候效果良好,但火焰/烟雾粒子更多使用的是公告板的四边形。
  4. 生成条件不同:雨水和烟雾的初始位置和速度的设置方式明显也是不同的。

但这样做好处是可以让C++代码的工作量尽可能地减到最小。

ParticleEffect类

按照惯例,粒子系统分为了ParticleEffectParticleRender两个部分。其中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;
};

其中用户需要手动设置的有渲染时的混合状态、深度/模板状态,以及ViewProjEyePos。其余可以交给接下来要讲的ParticleRender类来完成。

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在像素着色器中可以用来对纹理数组进行索引。

发射器粒子

因为几何着色器负责创建/摧毁粒子,我们需要一个特别的发射器粒子。发射器粒子本身可以绘制出来,也可以不被绘制。假如你想让你的发射器粒子不能被看见,那么在绘制时的几何着色器的阶段你就可以不要将它输出。发射器粒子与当前粒子系统中的其它粒子的行为有所不同,因为它可以产生其它粒子。例如,一个发射器粒子可能会记录累计经过的时间,并且到达一个特定时间点的时候,它就会发射一个新的粒子。此外,通过限制哪些粒子可以发射其它粒子,它让我们对粒子的发射方式有了一定的控制。比如说现在我们只有一个发射器粒子,我们可以很方便地控制每一帧所生产的粒子数目。流输出几何着色器应当总是输出至少一个发射器粒子,因为如果粒子系统丢掉了所有的发射器,粒子系统终究会消亡;但对于某些粒子系统来说,让它最终消亡也许是一种理想的结果。

在本章中,我们将只使用一个发射器粒子。但如果需要的话,当前粒子系统的框架也可以进行扩展。

起始顶点缓冲区

在我们的粒子系统中,有一个比较特别的起始顶点缓冲区,它仅仅包含了一个发射器粒子,而我们用这个顶点缓冲区来启动粒子系统。发射器粒子将会开始不停地产生其它粒子。需要注意的是起始顶点缓冲区仅仅绘制一次(除了系统被重置以外)。当粒子系统经发射器粒子启动后,我们就可以使用两个流输出顶点缓冲区来进行后续绘制。

起始顶点缓冲区在系统被重置的时候也是有用的,我们可以使用下面的代码来重启粒子系统:

void ParticleRender::Reset()
{
    m_FirstRun = true;
    m_Age = 0.0f;
}

更新/绘制过程

绘制过程如下:

  1. 通过流输出几何着色器阶段来更新当前帧的粒子
  2. 使用更新好的粒子进行渲染
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();
}

火焰

火焰粒子虽然是沿着指定方向发射,但给定了随机的初速度来火焰四散,并产生火球。

// 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);

雨水

雨水粒子系统也是由一系列的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));
}

演示

下面的动图演示了火焰和雨水的粒子系统效果:

练习题

  1. 实现一个爆炸的粒子系统。发射器粒子产生N个随机方向的外壳粒子。在经过一个短暂时间后,每个外壳粒子应当爆炸产生M个粒子。每个外壳不需要在同一个时间发生爆炸——通过随机性赋上不同的爆炸倒计时。对M和N进行测试直到你得到不错的结果。注意发射器在产生所有的外壳粒子后,将其摧毁使得不要产生更多的外壳。
  2. 实现一个喷泉的粒子系统。这些粒子应当从某个点产生,并沿着圆锥体范围内的随机方向向上发射。最终重力会使得它们掉落到地面。注意:给粒子一个足够高的初速度来让它们克服重力。

DirectX11 With Windows SDK完整目录

Github项目源码

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