Edited at
Unity 2Day 10

AppendStructuredBuffer/ConsumeStructuredBufferを使ったGPUパーティクルのサンプル

More than 1 year has passed since last update.

この記事は、Unity 2 Advent Calendar 2016 の10日目の記事です。

9日目の記事は@Marimoiroさんの Unityでテスト用にインスペクタ上にメソッドを実行してくれるボタンを作ろう でした。

↓動画(クリックでYoutubeへ)

今回は、AppendStructuredBuffer/ConsumeStructuredBufferを使ったGPUパーティクルのサンプルを作ってみました。

ComputeBuffer.CopyCountは、Unity5.3以前ではバグっていて正常にカウントが取得できなかった為、AppendStructuredBuffer/ConsumeStructuredBufferを使ったGPUパーティクルが作れませんでした。

5.4になってからようやくバグが修正されました。

開発環境は、Unity5.5、Windows10です。

今のところUnityのComputeShaderはMacでは動きません。

今回作ったGPUパーティクルのサンプルのプロジェクトはこちらにアップしております。

https://github.com/kaiware007/UnityGPUParticleSample


ComputeShaderとは

GPUを使って描画以外に汎用的な処理をさせることができるプログラムです。

GPUを使うメリットは、大量のデータを並列で高速に処理できるところにあります。

ComputeShaderの詳細は、下記の記事が非常に分かりやすかったです。

Unity : ComputeShader のシンプルなサンプル(1)


AppendStructuredBuffer/ConsumeStructuredBufferとは

ComputeBufferとは、ざっくりいうとGPU上に置けるデータの配列です。

いろんな型が指定でき、intやfloatだけじゃなく自作の構造体も指定できます。

通常のComputeBufferは、RWStructuredBuffer(RWはReadWriteの略)などとして使う事が多いと思います。

AppendStructuredBufferは、Append()関数で末尾にデータを追加できます。逆に言うと追加しか出来ません。

ConsumeStructuredBufferは、Consume()関数で末尾からデータを取り出すことが出来ます。逆に言うと取り出すことしか出来ません。

同じComputeBufferをAppendStructuredBufferとConsumeStructuredBufferで指定することで、LIFOとして使うことが出来ます。


今回のGPUパーティクルの仕組み

今回のGPUパーティクルは、前述のAppendStructuredBufferとConsumeStructuredBufferを使ってパーティクルの生死を管理しています。

無効になったパーティクルのインデックスをAppendStructuredBufferに追加、パーティクルを発生させるときはConsumeStreucturedBufferから取得して使います。

画面上でマウスをクリックした位置にパーティクルを生成されます。

パーティクルは、一度に256個ずつ放出されます。

放出されたパーティクルは、移動しながら暗くなって行き、10秒で完全に見えなくなって消えます。


C#側コード説明


パーティクルデータの構造体定義

同一のものをComputeShaderやレンダリング用のShaderにも定義します。

    struct ParticleData

{
public bool isActive; // 有効フラグ
public Vector3 position; // 座標
public Vector3 velocity; // 加速度
public Color color; // 色
public float duration; // 生存時間
public float scale; // サイズ
}


各種初期化、パーティクルデータのComputeBuffer等を初期化

    /// <summary>

/// 初期化
/// </summary>
void Initialize()
{
// パーティクル数上限の計算、ComputeShaderのスレッド数の倍数にしている
particleNum = (particleMax / THREAD_NUM_X) * THREAD_NUM_X;

// 一度に生成するパーティクル数の計算
emitNum = (emitMax / THREAD_NUM_X) * THREAD_NUM_X;
Debug.Log("particleNum " + particleNum + " emitNum " + emitNum + " THREAD_NUM_X " + THREAD_NUM_X);

// ComputeBufferの初期化、配列数はパーティクル数分
particleBuffer = new ComputeBuffer(particleNum, Marshal.SizeOf(typeof(ParticleData)), ComputeBufferType.Default);

// AppendStructuredBuffer と ConsumeStreucturedBuffer共用のComputeBufferの初期化
particlePoolBuffer = new ComputeBuffer(particleNum, Marshal.SizeOf(typeof(int)), ComputeBufferType.Append);
particlePoolBuffer.SetCounterValue(0);

// パーティクル数の数を取得するためのComputeBufferの初期化
particleCountBuffer = new ComputeBuffer(4, Marshal.SizeOf(typeof(int)), ComputeBufferType.IndirectArguments);
particleCounts = new int[]{ 0, 1, 0, 0 };
particleCountBuffer.SetData(particleCounts);

// ComputeShaderのカーネル(関数)番号を取得
initKernel = cs.FindKernel("Init");
emitKernel = cs.FindKernel("Emit");
updateKernel = cs.FindKernel("Update");

Debug.Log("initKernel " + initKernel + " emitKernel " + emitKernel + " updateKernel " + updateKernel);

InitParticle();
}

void Start () {
Initialize();
}

THREAD_NUM_XはComputeShaderのスレッド数と同じ値です。

// ComputeShaderのスレッド数

const int THREAD_NUM_X = 16;

InitParticle()の中身は下記です。

ComputeShader側で初期化しています。

    /// <summary>

/// パーティクルの初期化
/// </summary>
void InitParticle()
{
cs.SetBuffer(initKernel, "_Particles", particleBuffer);
cs.SetBuffer(initKernel, "_DeadList", particlePoolBuffer);
cs.Dispatch(initKernel, particleNum / THREAD_NUM_X, 1, 1);
}


パーティクルの更新処理

    /// <summary>

/// パーティクルの更新
/// </summary>
void UpdateParticle()
{
cs.SetFloat("_DT", Time.deltaTime);
cs.SetFloat("_LifeTime", lifeTime);
cs.SetFloat("_Gravity", gravity);
cs.SetBuffer(updateKernel, "_Particles", particleBuffer);
cs.SetBuffer(updateKernel, "_DeadList", particlePoolBuffer);

cs.Dispatch(updateKernel, particleNum / THREAD_NUM_X, 1, 1);
}

/// <summary>
/// パーティクルの発生
/// THREAD_NUM_X分発生
/// </summary>
/// <param name="position"></param>
void EmitParticle(Vector3 position)
{
// ConsumeStructuredBuffer内のパーティクル数の残数を取得する
particleCountBuffer.SetData(particleCounts);
ComputeBuffer.CopyCount(particlePoolBuffer, particleCountBuffer, 0);
particleCountBuffer.GetData(particleCounts);

particlePoolNum = particleCounts[0];

if (particleCounts[0] < emitNum) return; // 残数がemitNum未満なら発生させない

cs.SetVector("_EmitPosition", position);
cs.SetFloat("_VelocityMax", velocityMax);
cs.SetFloat("_LifeTime", lifeTime);
cs.SetFloat("_ScaleMin", scaleMin);
cs.SetFloat("_ScaleMax", scaleMax);
cs.SetFloat("_Sai", sai);
cs.SetFloat("_Val", val);
cs.SetFloat("_Time", Time.time);
cs.SetBuffer(emitKernel, "_ParticlePool", particlePoolBuffer);
cs.SetBuffer(emitKernel, "_Particles", particleBuffer);

cs.Dispatch(emitKernel, emitNum / THREAD_NUM_X, 1, 1); // emitNumの数だけ発生
}

// Update is called once per frame
void Update () {
// マウスボタンがクリックされたらカーソルの位置からパーティクルを生成する
if (Input.GetMouseButton(0))
{
Vector3 mpos = Input.mousePosition;
mpos.z = 10;
Vector3 pos = camera.ScreenToWorldPoint(mpos);
EmitParticle(pos);
}
UpdateParticle();
}


終了時のComputeBufferの解放

終了時にComputeBufferを明示的に解放しないとリークします。

    /// <summary>

/// ComputeBufferの解放
/// </summary>
void ReleaseBuffer() {
if (particlePoolBuffer != null)
{
particlePoolBuffer.Release();
}
if (particleBuffer != null)
{
particleBuffer.Release();
}
if(particleCountBuffer != null)
{
particleCountBuffer.Release();
}
}

void OnDestroy()
{
ReleaseBuffer();
}


レンダリング処理

    void OnRenderObject()

{
material.SetBuffer("_Particles", particleBuffer);
material.SetPass(0);

Graphics.DrawProcedural(MeshTopology.Points, particleNum);
}


ComputeShader側コード説明


カーネル(関数)の定義

#pragma kernel Init

#pragma kernel Emit
#pragma kernel Update

ComputeBufer.FindKernelで検索されるカーネルIDは上述の順番0,1,2という感じで返ってきます。


パーティクルデータの構造体

C# のコードと同じ構造になっています。

Vector3がfloat3、Colorがfloat4など微妙に型が変わってますがデータのバイト数的には同じです。

 struct ParticleData

{
bool isActive; // 有効フラグ
float3 position; // 座標
float3 velocity; // 加速度
float4 color; // 色
float duration; // 生存時間
float scale; // サイズ
};


ComputeBufferの定義

Particlesが、パーティクルデータの構造体配列になっています。

_DeadListと_ParticlePoolは、それぞれAppend~とConsume~のバッファですが、同一のComputeBufferを参照しています。

uintなのは、
Particlesのインデックスを保持するためです。

RWStructuredBuffer<ParticleData> _Particles;

AppendStructuredBuffer<uint> _DeadList;
ConsumeStructuredBuffer<uint> _ParticlePool;


初期化

THREAD_NUM_Xはnumthreadsの値として使ってます。C#側でも同じ値にしています。

numthreadsの数分並列に処理が走るため、パーティクルの総数をTHREAD_NUM_Xの倍数にすることで処理されない余りが発生しないようにしています。

#define THREAD_NUM_X 16

[numthreads(THREAD_NUM_X, 1, 1)]
void Init (uint3 id : SV_DispatchThreadID)
{
uint no = id.x;

_Particles[no].isActive = false;
_DeadList.Append(no); // 未使用リスト(AppendStructuredBuffer)の末尾に追加
}


パーティクルの発生

[numthreads(THREAD_NUM_X, 1, 1)]

void Emit ()
{
uint no = _ParticlePool.Consume(); // 未使用リスト(ConsumeStructuredBuffer)の末尾から未使用パーティクルのインデックスを取得

float2 seed = float2(no + _Time, no + 1.583 + _Time);
float speed = rnd(seed) * _VelocityMax;
float scale = (rnd(seed + 3) - 0.5) * 2.0 * (_ScaleMax - _ScaleMin) + _ScaleMin;
float h = rnd(seed + 5); // color

_Particles[no].isActive = true; // 有効にする
_Particles[no].position = _EmitPosition;
_Particles[no].velocity = (rnd3(seed + 3.15)) * speed;
_Particles[no].color = float4(hsv_to_rgb(float3(h, _Sai, _Val)),1);
_Particles[no].duration = _LifeTime;
_Particles[no].scale = scale;
}


パーティクルの更新

[numthreads(THREAD_NUM_X, 1, 1)]

void Update (uint3 id : SV_DispatchThreadID)
{
uint no = id.x;

// 有効フラグが立っているものだけ処理
if(_Particles[no].isActive) {
_Particles[no].velocity.y -= _Gravity * _DT;
_Particles[no].position += _Particles[no].velocity * _DT;
_Particles[no].duration -= _DT;
_Particles[no].color.a = max(_Particles[no].duration / _LifeTime, 0);
if(_Particles[no].duration <= 0) {
_Particles[no].isActive = false;
_DeadList.Append(no); // 寿命が付きたら未使用リストに追加
}
}

}


描画用のShader処理コード説明


各種定義

レンダリング用のShaderでComputeBufferを使うときは、

#pragma target 5.0

を定義します。

こちらでもパーティクルデータの構造体の定義が必要です。

面倒くさい場合は.cgincなどでまとめてincludeするといいかも?

struct ParticleData

{
bool isActive; // 有効フラグ
float3 position; // 座標
float3 velocity; // 加速度
float4 color; // 色
float duration; // 生存時間
float scale; // サイズ
};

こちらのパーティクルデータは、Readのみなので通常のStreucturedBufferとして定義しています。

StructuredBuffer<ParticleData> _Particles;


頂点シェーダ

ComputeBufferのパーティクルデータの数が頂点数として頂点シェーダーに流れてきます。

頂点シェーダでは、SV_VertexIDから頂点インデックスを受け取るようにしています。

それをパーティクルデータのインデックスとしてそのまま使っています。

有効フラグが立っていないパーティクルは、サイズを0にして描画処理は走っているけど実質見えないようにしています。

v2f vert (uint id : SV_VertexID)

{
v2f o;
o.pos = float4(_Particles[id].position, 1);
o.uv = float2(0,0);
o.col = _Particles[id].color;
o.scale = _Particles[id].isActive ? _Particles[id].scale : 0; // 有効出ないときはサイズを0にする

return o;
}


ジオメトリシェーダ

頂点シェーダからのデータはただの頂点なので、頂点座標の周囲に頂点を追加してビルボードを作成します。

// ジオメトリシェーダ

[maxvertexcount(4)]
void geom(point v2f input[1], inout TriangleStream<v2f> outStream)
{
v2f o;

// 全ての頂点で共通の値を計算しておく
float4 pos = input[0].pos;
float4 col = input[0].col;
o.scale = 0;

// 四角形になるように頂点を生産
for (int x = 0; x < 2; x++)
{
for (int y = 0; y < 2; y++)
{
// ビルボード用の行列
float4x4 billboardMatrix = UNITY_MATRIX_V;
billboardMatrix._m03 = billboardMatrix._m13 = billboardMatrix._m23 = billboardMatrix._m33 = 0;

// テクスチャ座標
float2 uv = float2(x, y);
o.uv = uv;

// 頂点位置を計算
o.pos = pos + mul(float4((uv * 2 - float2(1, 1)) * input[0].scale, 0, 1), billboardMatrix);
o.pos = mul(UNITY_MATRIX_VP, o.pos);

// 色
o.col = col;

// ストリームに頂点を追加
outStream.Append(o);
}
}

// トライアングルストリップを終了
outStream.RestartStrip();
}


フラグメントシェーダー

fixed4 frag (v2f i) : SV_Target

{
// sample the texture
fixed4 col = tex2D(_MainTex, i.uv) * i.col;
return col;
}


参考

Unity : ComputeShader のシンプルなサンプル(1)

[Unity]コンピュートシェーダ(GPGPU)で1万個のパーティクルを動かす

Unity で Compute Shader を使ったスクリーンスペース衝突有りの GPU パーティクルを作ってみた