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

在C++中,我們還需要設置下面的渲染狀態用於粒子的渲染:

m_pRainEffect->SetDepthStencilState(RenderStates::DSSNoDepthWrite.Get(), 0);

C++代碼實現

在本章中,與粒子系統直接相關的類爲ParticleEffectParticleRedner類。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);
    
    // ...
}

演示

下面的動圖演示了火焰和雨水的粒子系統效果:

練習題

  1. 實現一個爆炸的粒子系統。發射器粒子產生N個隨機方向的外殼粒子。在經過一個短暫時間後,每個外殼粒子應當爆炸產生M個粒子。每個外殼不需要在同一個時間發生爆炸——通過隨機性賦上不同的爆炸倒計時。對M和N進行測試直到你得到不錯的結果。注意發射器在產生所有的外殼粒子後,將其摧毀使得不要產生更多的外殼。
  2. 實現一個噴泉的粒子系統。這些粒子應當從某個點產生,並沿着圓錐體範圍內的隨機方向向上發射。最終重力會使得它們掉落到地面。注意:給粒子一個足夠高的初速度來讓它們克服重力。

DirectX11 With Windows SDK完整目錄

Github項目源碼

歡迎加入QQ羣: 727623616 可以一起探討DX11,以及有什麼問題也可以在這裏彙報。

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