1. 程式人生 > 實用技巧 >DirectX11——粒子系統

DirectX11——粒子系統

DirectX11 粒子系統

前言

粒子系統表示三維計算機圖形學中模擬一些特定的模糊現象的技術,而這些現象用其它傳統的渲染技術難以實現真實感的物理運動規律。(百度百科)

我們經常使用粒子系統來模擬火焰、爆炸、煙霧等現象,在Unity3D中就有一套成熟粒子系統以供遊戲開發者使用。最近在學習使用DirectX11中的著色器反射來構建粒子系統渲染框架,並嘗試使用這一套框架實現爆炸和噴泉特效。

本人學習的DirectX11是基於Windows SDK實現的,本次粒子系統的所有前置知識以及粒子系統的實現均來自DirectX11 with Windows SDK教程:https://www.cnblogs.com/X-Jun/p/9028764.html

本次粒子系統的框架也使用了https://github.com/MKXJun/DirectX11-With-Windows-SDK中第35個專案中的ParticleEffect框架ParticleRender

涵蓋知識

粒子系統的實現屬於教程中高階部分的內容,需要的知識比較全面,具體請看思維導圖:

總結一下:著色器的書寫與C++中建立著色器、流輸出階段、利用著色器反射修改緩衝區資料、混合狀態和深度/模板測試、利用幾何著色器實現公告板效果。

通過粒子系統的實現學習,可以學習到:

  • 利用幾何著色器和流輸出階段高效產生和儲存粒子

  • 使用物理知識來模擬物理運動規律

  • 鍛鍊設計渲染框架的能力

粒子系統

粒子系統需要較多的屬性,不同的粒子系統會擁有不同的屬性,在通用的粒子系統中,我們定義以下屬性:

1、發射位置

2、發射方向

3、發射間隔

4、粒子存活時間

5、系統時長

6、系統步長

7、最大粒子數目

8、觀察位置和觀察矩陣

這些屬性應該不難理解,觀察位置和觀察矩陣在其他渲染系統中也是必備的。

粒子

粒子本身也有不少的屬性,通用的粒子屬性如下:

struct ParticleVertex
{
    XMFLOAT3 initialPos;   // 初始位置
    XMFLOAT3 initialVel;   // 初始速度
    XMFLOAT3 size;         // 粒子大小
    float
age; // 粒子年齡 uint type; // 粒子型別 }

粒子型別包括發射器粒子普通發射粒子,發射器粒子會根據自身年齡(解釋為年齡不太合適,因為它會不斷地迴圈置零)來判斷是否發射新粒子,發射新粒子後重新積累時間,又一次達到系統的發射間隔時發射新粒子。在幾何著色器中,發射粒子總是被輸出到頂點緩衝區,如果沒控制好粒子最大數量、粒子發射間隔和粒子存活時間,緩衝區無法發容納射器粒子,粒子系統就會消失(沒有粒子)。

隨機數值

我們可以在C++使用random來產生隨機數,但在HLSL中並沒有產生隨機數的方法,因此我們需要自己定義一些產生隨機數的方法。通常情況下,對一個Texture1D資源進行不同位置的取樣可以達到獲得隨機數的效果。建立一個1D紋理,裡面每個元素是float4DXGI_FORMAT_R32G32B32A32_FLOAT),然後我們使用區間[-1, 1]的隨機4D向量來填滿紋理,取樣的時候則使用wrap定址模式即可。取樣的結果也是在[-1, 1],可根據需要通過計算使之落在指定範圍。

下面是獲得隨機單位向量的方法:

float3 RandUnitVec3(float offset)
{
    // 使用遊戲時間加上偏移值來從隨機紋理取樣
    float u = (g_GameTime + offset);
    // 分量均在[-1,1]
    float3 v = g_RandomVecMap.SampleLevel(g_SamLinear, u, 0).xyz;
    // 標準化向量
    return normalize(v);
}
 

接下來的爆炸噴泉特效的簡單模擬,在這裡只講解主要的步驟。

噴泉

噴泉系統:粒子從某個點產生,並沿著圓錐體範圍內的隨機方向向上發射,最終重力會使得它們掉落到地面。

重力的模擬比較簡單,在Fountain.hlsli中定義重力加速度G,在計算粒子位置時使用物理勻加速直線運動位移公式:x = 1/2 * a * t * t + v * t計算位移,加上初始位置即可。

// Fountain.hlsli
...
cbuffer CBFixed : register(b1)
{
    // 重力加速度
    float3 g_G = float3(0.0f, -9.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)
    };
}
...
​
// Fountain_VS.hlsl
VertexOut VS(VertexParticle vIn)
{
    VertexOut vOut;
    
    float t = vIn.Age;
    
    // 恆定加速度等式
    vOut.PosW = 0.5f * t * t * g_G + 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;
}
​

在隨機數部分已經提及,我們定義了產生隨機單位向量的方法:

float3 RandUnitVec3(float offset)
{
    // 使用遊戲時間加上偏移值來從隨機紋理取樣
    float u = (g_GameTime + offset);
    // 分量均在[-1,1]
    float3 v = g_RandomVecMap.SampleLevel(g_SamLinear, u, 0).xyz;
    // 標準化向量
    return normalize(v);
}

在產生新粒子我們可以使用以下計算使向量集中在圓錐區域:

float3 vRandom = RandUnitVec3(offset);
vRandom.x *= 0.5f;  // 可根據圓錐的範圍乘上特定值使向量集中在指定區域
vRandom.z *= 0.5f;
vRandom.y = sqrt(1 - vRandom.x * vRandom.x - vRandom.z * vRandom.z); // 落在單位圓上

在模擬噴泉中發現,一幀發射一個粒子的效果仍然很差,需要使用迴圈來產生更多的粒子。

注意:幾何著色器的每次呼叫最多隻能處理1024個標量

// Fountain_SO_GS.hlsl
#include " Fountain.hlsli"
​
[maxvertexcount(4)]
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)
        {
        
            // for迴圈產生更多粒子,以三個粒子為例
            for (int i = 0; i < 3; i++)
            {
                float3 vRandom = RandUnitVec3(g_GameTime * i);  // 不同的偏移量進行採集
                vRandom.x *= 0.5f;  // 可根據圓錐的範圍乘上特定值使向量集中在指定區域
                vRandom.z *= 0.5f;
                vRandom.y = sqrt(1 - vRandom.x * vRandom.x - vRandom.z * vRandom.z);// 落在單位圓上
            
                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]);
    }
}
​

爆炸

發射器粒子產生N個隨機方向的外殼粒子。在經過一個短暫時間後,每個外殼粒子應當爆炸產生M個粒子。每個外殼不需要在同一個時間發生爆炸——通過隨機性賦上不同的爆炸倒計時。

在這裡為了方便觀察將N和M設為10,儲存發射器粒子觀察隨機性。

與噴泉相比爆炸系統更加複雜,發射器會發射兩種粒子,故我們需要新增第三種粒子:外殼粒子PT_SHELL。外殼粒子會隨機時間爆炸,故我們需要獲得一個隨機數來定義爆炸時間。我們先定義一個方法來獲得[-1, 1]間的隨機數。

float RandNum(float offset)
{
    // 使用遊戲時間加上偏移值來從隨機紋理取樣
    float u = (g_GameTime + offset);
    // 在[-1,1]
    float v = g_RandomVecMap.SampleLevel(g_SamLinear, u, 0).x;
   
    return v;
}

再通過以下計算使其落在粒子的最大存活時間內:

float Randomtime = (RandNum(i * g_GameTime) + 1) * 0.5f * g_AliveTime;

外殼粒子的初始年齡(Age)就是粒子存活時間減去獲得的隨機爆炸時間:

p.Age = g_AliveTime - Randomtime;

這樣就可以在外殼粒子生命週期結束時產生新的普通粒子。

完整的Explosion_SO_GS如下:

#include "Explosion.hlsli"
​
[maxvertexcount(11)]
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)
        {
            uint ShellParticleCount = 10;
            
            [unroll]
            for (int i = 0; i < ShellParticleCount; i++)
            {
                float3 vRandom = RandUnitVec3(i * g_GameTime);
                float Randomtime = (RandNum(i * g_GameTime) + 1) * 0.5f * g_AliveTime;
                
                VertexParticle p;
                p.InitialPosW = g_EmitPosW.xyz;
                p.InitialVelW = 5.0f * vRandom;
                p.SizeW = float2(2.0f, 2.0f);
                p.Age = g_AliveTime - Randomtime;
                p.Type = PT_SHELL;
            
                output.Append(p);
                
            }
                      
            // 重置時間準備下一次發射
            gIn[0].Age = 0.0f;
        }
        
        // 總是保留髮射器
        output.Append(gIn[0]);
    }
    else if (gIn[0].Type == PT_SHELL)
    {
        if (gIn[0].Age > g_AliveTime)
        {
            uint FlareParticleCount = 10;
                
            [unroll]
            for (int i = 0; i < FlareParticleCount; i++)
            {
                float3 vRandom = RandUnitVec3(i * g_GameTime * 0.9f);
                
                VertexParticle p;
                p.InitialPosW = gIn[0].InitialPosW;
                p.InitialVelW = 10.0f * vRandom;
                p.SizeW = float2(1.0f, 1.0f);
                p.Age = 0;
                p.Type = PT_FLARE;
            
                output.Append(p);
                
            }
        }
        else
        {
            gIn[0].InitialPosW = gIn[0].InitialPosW + g_TimeStep * gIn[0].InitialVelW;
            gIn[0].InitialVelW = gIn[0].InitialVelW * (1 + g_AccelW  * g_TimeStep);
            
            output.Append(gIn[0]);
        }
    }
    else
    {
        // 用於限制粒子數目產生的特定條件,對於不同的粒子系統限制也有所變化
        if (gIn[0].Age <= g_AliveTime)
        {
            gIn[0].InitialPosW = gIn[0].InitialPosW + g_TimeStep * gIn[0].InitialVelW;
            gIn[0].InitialVelW = gIn[0].InitialVelW * (1 + g_AccelW * g_TimeStep);
            
            output.Append(gIn[0]);
        }
    }
}

你會發現在這個系統內粒子的位置是不斷更新的,在不改動渲染框架的前提下,我將粒子屬性中InitialPosWInitialVelW重新定義為當前位置和當前速度, 將原本在VS中計算的粒子位置遷移到SO_GS中,並將新的位置和速度資訊儲存起來,以便在外殼粒子”爆炸“時可以拿到它的位置資訊。更好的處理方法是修改粒子的屬性,添加當前位置,當然,這樣的處理得修改ParticleEffect

在位置和速度計算中,g_AccelW是粒子運動中受到的空氣阻力產生的加速度,忽略重力影響。

演示

下面的動圖演示噴泉和爆炸效果: