始めに
ComputeShaderについて調べてみますと「GPUパーティクル」という単語を何度か目にしました。
今回はこのGPUパーティクルについてまとめようと思います。
こちらのサイトを参考に実装を行っています。
また、今回の内容は以下のリポジトリにあります。
GPUパーティクルの処理の流れ
パーティクルを見えないようにしてスタックに入れる
始めにパーティクルをすべて用意して、大きさを 0 にしたり透明にして見えないようにします。(今回は大きさを 0 にしています)
次に各パーティクルのインデックスを1つのスタックに保存します。
スタックから要素を取り出してパーティクルを生成する
パーティクルを生成する際はスタックからパーティクルのインデックスを取得します。
取得されたインデックスのパーティクルは大きさを戻す、不透明にする(今回は大きさを戻す)ことでパーティクルが生成されたように見せます。
また、生成されたパーティクルには「使用している」フラグを立てます。
生成されたパーティクルの更新を行う
パーティクルの更新を行う際は「使用している」フラグが立っているもののみを更新します。
パーティクルの更新では速度計算、座標計算、大きさの調整、寿命の減算などを行います。
寿命が尽きたパーティクルをスタックに戻す
パーティクルの更新で寿命が尽きたパーティクルは再び見えないようにして、「使用しているフラグ」を降ろします。
また、そのパーティクルのインデックスをスタックに追加して再び使えるようにします。
Append/ConsumeStructuredBufferについて
上の解説ではスタックと解説しましたが、ComputeShaderにスタックはありません。
スタックの代わりにAppendStructuredBuffer、ConsumeStructuredBufferを使用します。
- AppendStructuredBuffer : 要素の追加のみが出来る
- ConsumeStructuredBuffer : 要素の取り出しのみが出来る
このAppendStructuredBufferとConsumeStructuredBufferが1つのComputeBufferを参照することで疑似的にスタックの機能を実現することが出来ます。
ComputeBufferに値を追加する際はAppendStructuredBuffer.Append()を使い、値を取り出す際はConsumeStructuredBuffer.Consume()を使います。
実装
C#プログラム
using System.Collections;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using UnityEngine;
using UnityEngine.Assertions;
using UnityEngine.Rendering;
public class TestGPGPUParticle : MonoBehaviour {
#region DrawMeshInstancedIndirect_Parameters
[Header ("DrawMeshInstancedIndirectのパラメータ")]
[SerializeField]
private Mesh m_mesh;
[SerializeField]
private Material m_material;
[SerializeField]
private Bounds m_bounds;
[SerializeField]
private ShadowCastingMode m_shadowCastingMode;
[SerializeField]
private bool m_receiveShadows;
#endregion
#region GPUParticle_Parameters
[Header ("GPUパーティクルのパラメータ")]
[Space (20)]
[SerializeField]
private int m_instanceCount;
[SerializeField]
private int m_emitCount;
[SerializeField]
private float m_lifeTime;
[SerializeField]
private float m_force;
[SerializeField]
private float m_forceAngle;
[SerializeField]
private float m_gravity;
[SerializeField]
private float m_scale;
#endregion
#region ComputeShader_Parameters
private ComputeBuffer m_argsBuffer;
[SerializeField]
private ComputeShader m_gpuParticleCalculator;
private ComputeBuffer m_particlesBuffer;
private ComputeBuffer m_particlePoolBuffer;
private ComputeBuffer m_particlePoolCountBuffer;
private int[] m_poolCount = { 0, 0, 0, 0 };
private int m_updateKernel, m_emitKernel;
private Vector3Int m_updateGroupSize, m_emitGroupSize;
#endregion
#region Struct_Particle
private struct Particle {
public Vector3 position;
public Vector3 velocity;
public Vector3 angle;
public float duration;
public float scale;
public bool isActive;
}
#endregion
// Start is called before the first frame update
void Start () {
InitializeArgsBuffer ();
InitializeGPUParticle ();
}
// Update is called once per frame
void Update () {
m_gpuParticleCalculator.SetFloat ("Time", Time.time);
m_gpuParticleCalculator.SetFloat ("deltaTime", Time.deltaTime);
if (Input.GetMouseButton (0)) {
EmitParticles ();
}
UpdateParticles ();
}
void LateUpdate () {
Graphics.DrawMeshInstancedIndirect (
m_mesh,
0,
m_material,
m_bounds,
m_argsBuffer,
0,
null,
m_shadowCastingMode,
m_receiveShadows
);
}
private void EmitParticles () {
int poolCount = GetParticlePoolCount ();
// 未使用のパーティクルが一度に生成する個数より低い場合は
// パーティクルを生成しない
if (poolCount < m_emitCount)
return;
m_gpuParticleCalculator.Dispatch (m_emitKernel, m_emitGroupSize.x, m_emitGroupSize.y, m_emitGroupSize.z);
}
private int GetParticlePoolCount () {
// ComputeShader内のDeadListに入っているデータの個数を取得する
ComputeBuffer.CopyCount (m_particlePoolBuffer, m_particlePoolCountBuffer, 0);
m_particlePoolCountBuffer.GetData (m_poolCount);
int restPool = m_poolCount[0];
Debug.Log ("restPool = " + restPool);
return restPool;
}
private void UpdateParticles () {
m_gpuParticleCalculator.Dispatch (m_updateKernel, m_updateGroupSize.x, m_updateGroupSize.y, m_updateGroupSize.z);
}
private void InitializeArgsBuffer () {
Assert.IsNotNull (m_mesh, "メッシュが設定されていません");
Assert.IsNotNull (m_material, "マテリアルが設定されていません");
var args = new uint[] { 0, 0, 0, 0, 0 };
args[0] = m_mesh.GetIndexCount (0);
args[1] = (uint) m_instanceCount;
m_argsBuffer = new ComputeBuffer (1, 4 * args.Length, ComputeBufferType.IndirectArguments);
m_argsBuffer.SetData (args);
}
private void InitializeGPUParticle () {
Assert.IsFalse (m_instanceCount < m_emitCount, "一度に出すパーティクルの個数がパーティクルの総数以上になっています");
// インスタンスの個数、一度に出すパーティクルの個数は2の累乗に設定(計算しやすくするため)
m_instanceCount = Mathf.ClosestPowerOfTwo (m_instanceCount);
m_emitCount = Mathf.ClosestPowerOfTwo (m_emitCount);
InitializeComputeBuffers ();
InitializeParticlePool ();
m_gpuParticleCalculator.SetFloat ("lifeTime", m_lifeTime);
m_gpuParticleCalculator.SetFloat ("force", m_force);
m_gpuParticleCalculator.SetFloat ("forceAngle", m_forceAngle);
m_gpuParticleCalculator.SetFloat ("scale", m_scale);
m_gpuParticleCalculator.SetFloat ("gravity", m_gravity);
m_updateKernel = m_gpuParticleCalculator.FindKernel ("UpdateParticles");
uint x, y, z;
m_gpuParticleCalculator.GetKernelThreadGroupSizes (m_updateKernel, out x, out y, out z);
m_updateGroupSize = new Vector3Int (m_instanceCount / (int) x, (int) y, (int) z);
m_gpuParticleCalculator.SetBuffer (m_updateKernel, "particles", m_particlesBuffer);
m_gpuParticleCalculator.SetBuffer (m_updateKernel, "deadList", m_particlePoolBuffer);
m_emitKernel = m_gpuParticleCalculator.FindKernel ("EmitParticles");
m_gpuParticleCalculator.GetKernelThreadGroupSizes (m_emitKernel, out x, out y, out z);
m_emitGroupSize = new Vector3Int (m_emitCount / (int) x, (int) y, (int) z);
m_gpuParticleCalculator.SetBuffer (m_emitKernel, "particles", m_particlesBuffer);
m_gpuParticleCalculator.SetBuffer (m_emitKernel, "particlePool", m_particlePoolBuffer);
}
private void InitializeComputeBuffers () {
m_particlesBuffer = new ComputeBuffer (m_instanceCount, Marshal.SizeOf (typeof (Particle)));
m_material.SetBuffer ("_ParticleBuffer", m_particlesBuffer);
m_particlePoolBuffer = new ComputeBuffer (m_instanceCount, Marshal.SizeOf (typeof (int)), ComputeBufferType.Append);
// Append/Consumeの追加削除位置を0に設定する
m_particlePoolBuffer.SetCounterValue (0);
// パーティクルの個数を求める際に使用する
m_particlePoolCountBuffer = new ComputeBuffer (4, Marshal.SizeOf (typeof (int)), ComputeBufferType.IndirectArguments);
m_particlePoolCountBuffer.SetData (m_poolCount);
}
private void InitializeParticlePool () {
int initializeParticlesKernel = m_gpuParticleCalculator.FindKernel ("InitializeParticles");
m_gpuParticleCalculator.GetKernelThreadGroupSizes (initializeParticlesKernel, out uint x, out uint y, out uint z);
m_gpuParticleCalculator.SetBuffer (initializeParticlesKernel, "deadList", m_particlePoolBuffer);
m_gpuParticleCalculator.SetBuffer (initializeParticlesKernel, "particles", m_particlesBuffer);
m_gpuParticleCalculator.Dispatch (initializeParticlesKernel, m_instanceCount / (int) x, 1, 1);
}
private void OnDisable () {
m_particlesBuffer?.Release ();
m_particlePoolBuffer = null;
m_particlePoolBuffer?.Release ();
m_particlePoolBuffer = null;
m_particlePoolCountBuffer?.Release ();
m_particlePoolCountBuffer = null;
m_argsBuffer?.Release ();
m_argsBuffer = null;
}
}
ComputeShader
#pragma kernel InitializeParticles
#pragma kernel EmitParticles
#pragma kernel UpdateParticles
#define Deg2Rad 0.0174532924
#define PI 3.14159274
struct Particle {
float3 position;
float3 velocity;
float3 angle;
float duration;
float scale;
bool isActive;
};
RWStructuredBuffer<Particle> particles;
AppendStructuredBuffer<uint> deadList;
ConsumeStructuredBuffer<uint> particlePool;
float lifeTime;
float force;
float forceAngle;
float gravity;
float scale;
float deltaTime;
float Time;
// 乱数生成
inline float rnd(float2 p){
return frac(sin(dot(p ,float2(12.9898, 78.233))) * 43758.5453);
}
float4x4 eulerAnglesToRotationMatrix(float3 angles) {
float cx = cos(angles.x); float sx = sin(angles.x);
float cy = cos(angles.z); float sy = sin(angles.z);
float cz = cos(angles.y); float sz = sin(angles.y);
return float4x4(
cz*cy + sz*sx*sy, -cz*sy + sz*sx*cy, sz*cx, 0,
sy*cx, cy*cx, -sx, 0,
-sz*cy + cz*sx*sy, sy*sz + cz*sx*cy, cz*cx, 0,
0, 0, 0, 1);
}
float3 CalcAngle(float id) {
float x = rnd(float2(deltaTime + id*3.951, deltaTime-2*id));
float y = rnd(float2(Time+id*1.23, Time+id*3.14));
float z = rnd(float2(deltaTime+Time+id*0.987, Time/deltaTime+id*3.23));
return float3(x*180.0, y*180.0, z*180.0);
}
// 上方向からangle以下傾けた速度を返す
float3 CalcVelocity(float angle, float id) {
float4 vel = float4(0, force, 0, 1);
float angleY = rnd(float2(Time + id*2.978, Time - deltaTime + id*1.098))*2*PI;
float angleX = rnd(float2(Time + deltaTime - id*2.131, Time + id*4.521))*angle*Deg2Rad;
vel = mul(eulerAnglesToRotationMatrix(float3(angleX, angleY, 0)), vel);
return float3(vel.x, vel.y, vel.z);
}
[numthreads(8, 1, 1)]
void InitializeParticles(uint id : SV_DISPATCHTHREADID) {
particles[id.x].isActive = false;
deadList.Append(id.x);
}
[numthreads(8, 1, 1)]
void EmitParticles() {
uint id = particlePool.Consume();
particles[id].isActive = true;
particles[id].position = float3(0, 0, 0);
particles[id].velocity = CalcVelocity(forceAngle, id);
particles[id].angle = CalcAngle(id);
particles[id].duration = lifeTime;
particles[id].scale = scale;
}
[numthreads(8, 1, 1)]
void UpdateParticles(uint id : SV_DISPATCHTHREADID) {
if(particles[id.x].isActive) {
particles[id.x].velocity -= float3(0, gravity * deltaTime, 0);
particles[id.x].position += particles[id.x].velocity * deltaTime;
particles[id.x].duration = max(0, particles[id.x].duration - deltaTime);
particles[id.x].scale = lerp(scale, 0, 1 - particles[id.x].duration/lifeTime);
if(particles[id.x].duration <= 0) {
particles[id.x].isActive = false;
deadList.Append(id.x);
}
}
}
Shader
Shader "Custom/GPUParticleShader"
{
Properties
{
_Color ("Color", Color) = (1,1,1,1)
_MainTex ("Albedo (RGB)", 2D) = "white" {}
_Glossiness ("Smoothness", Range(0,1)) = 0.5
_Metallic ("Metallic", Range(0,1)) = 0.0
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 200
CGPROGRAM
// Physically based Standard lighting model, and enable shadows on all light types
#pragma surface surf Standard addshadow fullforwardshadows
#pragma multi_compile_instancing
#pragma instancing_options procedural:setup
// Use shader model 3.0 target, to get nicer looking lighting
#pragma target 3.0
sampler2D _MainTex;
struct Input
{
float2 uv_MainTex;
};
struct Particle {
float3 position;
float3 velocity;
float3 angle;
float duration;
float scale;
bool isActive;
};
#ifdef UNITY_PROCEDURAL_INSTANCING_ENABLED
StructuredBuffer<Particle> _ParticleBuffer;
#endif
#define Deg2Rad 0.0174532924
float4x4 eulerAnglesToRottationMatrix(float3 angles) {
float cx = cos(angles.x * Deg2Rad); float sx = sin(angles.x * Deg2Rad);
float cy = cos(angles.z * Deg2Rad); float sy = sin(angles.z * Deg2Rad);
float cz = cos(angles.y * Deg2Rad); float sz = sin(angles.y * Deg2Rad);
return float4x4(
cz*cy + sz*sx*sy, -cz*sy + sz*sx*cy, sz*cx, 0,
sy*cx, cy*cx, -sx, 0,
-sz*cy + cz*sx*sy, sy*sz + cz*sx*cy, cz*cx, 0,
0, 0, 0, 1);
}
void setup() {
#ifdef UNITY_PROCEDURAL_INSTANCING_ENABLED
float3 position = _ParticleBuffer[unity_InstanceID].position;
float3 angle = _ParticleBuffer[unity_InstanceID].angle;
float scale = _ParticleBuffer[unity_InstanceID].scale;
// LifeTime == 1の場合、生成されて消えるまで180度回転する
angle += (1 - _ParticleBuffer[unity_InstanceID].duration) * 180;
// スケーリング
unity_ObjectToWorld._11_21_31_41 = float4(scale, 0, 0, 0);
unity_ObjectToWorld._12_22_32_42 = float4(0, scale, 0, 0);
unity_ObjectToWorld._13_23_33_43 = float4(0, 0, scale, 0);
// 回転
unity_ObjectToWorld = mul(eulerAnglesToRottationMatrix(angle), unity_ObjectToWorld);
// 座標
unity_ObjectToWorld._14_24_34_44 = float4(position, 1);
// モデル行列を求める(間違っているかも. . .)
// 参考:https://qiita.com/yuji_yasuhara/items/8d63455d1d277af4c270
unity_WorldToObject = unity_ObjectToWorld;
unity_WorldToObject._14_24_34 *= -1;
unity_WorldToObject._11_12_13 = unity_ObjectToWorld._11_21_31;
unity_WorldToObject._21_22_23 = unity_ObjectToWorld._12_22_32;
unity_WorldToObject._31_32_33 = unity_ObjectToWorld._13_23_33;
unity_WorldToObject._11_12_13 /= scale * scale;
unity_WorldToObject._21_22_23 /= scale * scale;
unity_WorldToObject._31_32_33 /= scale * scale;
#endif
}
half _Glossiness;
half _Metallic;
fixed4 _Color;
// Add instancing support for this shader. You need to check 'Enable Instancing' on materials that use the shader.
// See https://docs.unity3d.com/Manual/GPUInstancing.html for more information about instancing.
// #pragma instancing_options assumeuniformscaling
UNITY_INSTANCING_BUFFER_START(Props)
// put more per-instance properties here
UNITY_INSTANCING_BUFFER_END(Props)
void surf (Input IN, inout SurfaceOutputStandard o)
{
// Albedo comes from a texture tinted by color
fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color;
o.Albedo = c.rgb;
// Metallic and smoothness come from slider variables
o.Metallic = _Metallic;
o.Smoothness = _Glossiness;
o.Alpha = c.a;
}
ENDCG
}
FallBack "Diffuse"
}
シェーダーは過去のブログの内容をほぼそのまま持ってきました。
解説
基本的に最初にお話しした"GPUパーティクルの処理の流れ"に沿っていると思います。
パーティクルの初期化
パーティクルのインデックスを保存するComputeBuffer "m_particlePoolBuffer" を生成します。
Append/Consumeを行うComputeBufferはコンストラクタで ComputeBufferType.Append を設定しなくてはいけません。
またSetCounterValue(0)を指定することで要素の追加を0から始めるよう設定します。
m_particlePoolBuffer = new ComputeBuffer (m_instanceCount, Marshal.SizeOf (typeof (int)), ComputeBufferType.Append); // Append/Consumeの追加削除位置を0に設定する m_particlePoolBuffer.SetCounterValue (0);
ComputeBufferの初期化の次にパーティクルのデータが入ったComputeBufferと先ほどのm_particlePoolBufferをComputeShaderに渡して初期化処理を実行します。
ここで、m_particlePoolBufferはComputeShader内のAppendStructuredBuffer "deadList" から参照されます。
private void InitializeParticlePool () { int initializeParticlesKernel = m_gpuParticleCalculator.FindKernel ("InitializeParticles"); m_gpuParticleCalculator.GetKernelThreadGroupSizes (initializeParticlesKernel, out uint x, out uint y, out uint z); m_gpuParticleCalculator.SetBuffer (initializeParticlesKernel, "deadList", m_particlePoolBuffer); m_gpuParticleCalculator.SetBuffer (initializeParticlesKernel, "particles", m_particlesBuffer); m_gpuParticleCalculator.Dispatch (initializeParticlesKernel, m_instanceCount / (int) x, 1, 1); }
初期化処理では、「使用している」フラグを表す isActive をfalseに設定し未使用のパーティクルのインデックスをdeadList.Appendを利用してm_particlePoolBufferに追加します。
[numthreads(8, 1, 1)] void InitializeParticles(uint id : SV_DISPATCHTHREADID) { particles[id.x].isActive = false; deadList.Append(id.x); }
パーティクルの生成
パーティクルを生成する前に未使用のパーティクルがいくつあるか確認します。もし未使用の個数が一度に生成する個数より少ない場合は、パーティクルの生成を行いません。
個数が十分であればパーティクルの生成を行います。
また、一度に生成するパーティクルの個数はComputeShaderのグループサイズ x スレッドサイズとなります。
例えば、グループサイズ(16, 1, 1)、スレッドサイズ(8, 1, 1)とすると (16 x 1 x 1) x (8 x 1 x 1) = 128となり一度に128個のパーティクルを生成します。
private void EmitParticles () { int poolCount = GetParticlePoolCount (); // 未使用のパーティクルが一度に生成する個数より低い場合は // パーティクルを生成しない if (poolCount < m_emitCount) return; m_gpuParticleCalculator.Dispatch (m_emitKernel, m_emitGroupSize.x, m_emitGroupSize.y, m_emitGroupSize.z); }
未使用のパーティクルの個数を求める関数は次のようになります。
ここで使用されている "m_particlePoolCountBuffer" は ComputeBufferType.IndirectArguments として生成されたComputeBufferとなります。 ComputeBuffer.CopyCountを使用してm_particlePoolBufferに入っているデータの個数を取得してm_particlPoolCountBufferの始めに保存します。
後は取得した個数を先ほどの呼び出し元に返します。
// パーティクルの個数を求める際に使用する m_particlePoolCountBuffer = new ComputeBuffer (4, Marshal.SizeOf (typeof (int)), ComputeBufferType.IndirectArguments); m_particlePoolCountBuffer.SetData (m_poolCount); . . . . . . . . . . . . . . . . . . . . . . private int GetParticlePoolCount () { // ComputeShader内のDeadListに入っているデータの個数を取得する ComputeBuffer.CopyCount (m_particlePoolBuffer, m_particlePoolCountBuffer, 0); m_particlePoolCountBuffer.GetData (m_poolCount); int restPool = m_poolCount[0]; return restPool; }
ComputeShader内でのパーティクル生成処理は次のようになります。
particlePoolはm_particlePoolBufferを参照しており、particlePool.Consumeで保存されているパーティクルのインデックスを取得します。
次に、パーティクルが使用されいていることを示すisActiveをtrueにして各パラメータの初期化を行います。
[numthreads(8, 1, 1)] void EmitParticles() { uint id = particlePool.Consume(); particles[id].isActive = true; particles[id].position = float3(0, 0, 0); particles[id].velocity = CalcVelocity(forceAngle, id); particles[id].angle = CalcAngle(id); particles[id].duration = lifeTime; particles[id].scale = scale; }
パーティクルの更新
パーティクルの更新処理は毎フレーム行います。
パーティクルの更新をする際、最初にisActiveを確認してそのパーティクルが生きているかを確認します。 そうでないものには更新処理を行いません。
更新処理では速度、座標、大きさ、そして寿命の更新を行います。
この時寿命が尽きたら、初期化時と同様にdeadList.Appendでm_particlePoolBufferにインデックスを保存し、isActiveをfalseに設定します。
また、寿命が尽きたパーティクルは大きさの計算で自動的に大きさ 0 となり、見えなくなります。
[numthreads(8, 1, 1)] void UpdateParticles(uint id : SV_DISPATCHTHREADID) { if(particles[id.x].isActive) { particles[id.x].velocity -= float3(0, gravity * deltaTime, 0); particles[id.x].position += particles[id.x].velocity * deltaTime; particles[id.x].duration = max(0, particles[id.x].duration - deltaTime); particles[id.x].scale = lerp(scale, 0, 1 - particles[id.x].duration/lifeTime); if(particles[id.x].duration <= 0) { particles[id.x].isActive = false; deadList.Append(id.x); } } }
生成時の初速について
パーティクルの初速はUnityのパーティクルのShape Coneを模して作成しました。
パーティクルの初速は上方向から 0 ~ angle 傾けた速度ベクトルとなります。
これはx軸に対して0 ~ angle度傾けて、次に0 ~ 360度y軸に対して回転させることで求めることが出来ます。
また、ベクトルの回転はshaderでも使用している回転行列を使用しています。
// 上方向からangle以下傾けた速度を返す float3 CalcVelocity(float angle, float id) { float4 vel = float4(0, force, 0, 1); float angleY = rnd(float2(Time + id*2.978, Time - deltaTime + id*1.098))*2*PI; float angleX = rnd(float2(Time + deltaTime - id*2.131, Time + id*4.521))*angle*Deg2Rad; vel = mul(eulerAnglesToRotationMatrix(float3(angleX, angleY, 0)), vel); return float3(vel.x, vel.y, vel.z); }
結果
見栄え的にはあまり良くないかもしれませんが、たくさんのcubeを出すパーティクルが出来ました。
参考
今回の内容はこちらのサイトを参考にして勉強させていただきました。
乱数についてはこちらのサイトも参考にしています。
以下Unity公式マニュアルです。