e.blog

主にUnity/UE周りのことについてまとめていきます

ComputeShaderで動かす様々な形に変化するパーティクルシステム

概要

今回は1/7〜1/10にかけて開催されたCES2020でMESONが展示した『PORTAL』の中で、メインの演出となったパーティクルシステムについて書きたいと思います。

このパーティクルシステムの特徴は、指定したモデルグループの頂点位置にパーティクルをまとわせるというものです。パーティクルの色は対象モデルの頂点位置と同じ色になるように調整されています。

以下の動画で、どんな動きをするかを見ることができます。

youtu.be

今回の記事の内容はGitHubにもアップしてあるので実際の動作を見たい方はそちらもご覧ください。

github.com

サンプルプロジェクトを実行すると以下のようなシーンを見ることができます。
(※ 動画内のモデルはUnity Asset Storeのものなのでリポジトリ内には含まれません。モデルは各自でご用意ください


Transform particle system demo

常にシーンに存在しているパーティクルが設定に応じて様々な形に変化します。
メッシュの頂点に張り付いたり、任意の形状になったり、テクスチャの絵の形になったり。

今回はこれを達成するために工夫した点についてまとめます。


Table of Contents


処理フロー

まずはざっくり概観を。

  1. 対象となるモデル(メッシュ)の情報を集める
  2. それをグルーピングする(すべての情報を配列にまとめる)
  3. 初期化用データのためのインデックスバッファを用意する
  4. データをComputeShaderに送る
  5. 必要に応じてComputeShaderでターゲット位置を更新
  6. GPUインスタンシングでまとめて描画する

方針

変化しないデータをひとつにまとめる

最初、モデル(メッシュ)のデータをターゲットごとに都度取り出して処理しようとしたところ単純に負荷が高すぎてうまくいきませんでした。
特に、プロジェクトでは1モデルあたり10,000〜20,000頂点くらいあり、さらにそれを5、6体分グルーピングする必要があったためそれなりの負荷になってしまいました。

ゲーミングPCのような高性能なPCでさえもプチフリーズをするくらいには負荷が高かったです。

そこでメモリアクセスを効率よくするため、またコピーを最適化するため頂点情報をまとめてひとつの配列にしSystem.Array.Copyを利用することで対応しました。(同様にUVなどの値も別途配列にまとめる)

つまり、メモリ効率を意識してすべての情報を配列にまとめるということをしました。
テクスチャもTexture2DArrayという仕組みで配列にまとめてシェーダに送ります。

頂点データも変化しないデータとしてまとめてしまっているのは対象モデルがSkinnedMeshRendererを持っておらずアニメーションしない前提のためです。

アニメーションする場合は毎フレーム頂点位置を更新してやる必要があるため負荷が高いかもしれません。(時間があったらSkinnedMeshRendererにも対応してみたいと思います)

初期化用データを用意してパーティクルのターゲット変更をCompute Shaderで行う

各パーティクルそれぞれは常になにかしらのターゲット位置を持っています。(ランタイムで位置を計算する場合もあります)
またターゲットは、冒頭の動画のように途中で切り替えることができるようになっています。

しかしながら、パーティクル数は膨大になることが多くそれをCPUで切り替えるには重すぎる可能性があります。
そこで本パーティクルシステムでは初期化(ターゲットの変更処理)もGPUで行っています。

イメージ的には、ターゲットが変わった際にグループ単位でごっそり内容を入れ替えてしまうイメージです。

Indexバッファを用いて初期化用データ配列にアクセスする

前述のターゲット変更の仕組みですが、ここにも少し工夫があります。
パーティクルの更新処理としてはスレッドIDを配列の添字にしてアクセスすることが多いと思いますが、今回のシステムではシーンに存在しているパーティクル数とターゲットの数が異なる場合がほとんどです。

つまりパーティクルの数と初期化用データの数が異なるということです。
当然、配列の数が異なるということは同一の添字を利用することはできません。

例えば冒頭の動画を見てもらうと分かるように、移動対象がメッシュのモデルの頂点だけではなく、テクスチャのピクセルなどにもなりえます。

そこで採用したのが『インデックスバッファ』です。

イメージ的には通常のレンダリングパイプラインで利用されるインデックスバッファと同様です。

各パーティクルを初期化する際、InitData構造体の配列を用いてパーティクルの情報を書き換えるようにしているのですが、どのInitData構造体にアクセスすればいいのかはスレッドIDからでは判断できません。
そこでインデックスバッファの出番、というわけです。

IndexBuffer配列はパーティクル数と同じになっていますが、格納されているのは対象となるInitData配列のインデックスです。
このインデックス番号を利用して初期化対象のデータを特定し、初期化を行うというフローになっています。

初期化時のカーネル関数を見てみるとイメージが掴めるかと思います。

[numthreads(THREAD_NUM, 1, 1)]
void SetupParticlesImmediately(uint id : SV_DispatchThreadID)
{
    TransformParticle p = _Particles[id];

    uint idx = _IndexBuffer[id];

    float4x4 mat = _MatrixData[_InitDataList[idx].targetId];

    p.isActive = _InitDataList[idx].isActive;
    p.position = mul(mat, float4(_InitDataList[idx].targetPosition, 1.0)).xyz;
    p.targetPosition = p.position;
    p.uv = _InitDataList[idx].uv;
    p.targetId = _InitDataList[idx].targetId;
    p.scale = _InitDataList[idx].scale;
    p.horizontal = _InitDataList[idx].horizontal;
    p.velocity = _InitDataList[idx].velocity;

    _Particles[id] = p;
}

上記のuint idx = _IndexBuffer[id];の部分がインデックスバッファからインデックスを取得している箇所ですね。

解説

全体を概観できたところで、コードを交えながらどう実装したかを解説していきたいと思います。

Compute Shaderの利用

今回のパーティクルシステムではCompute Shaderを利用しています。

主なCompute Shaderのカーネルは2種類

まずはCompute Shaderについてです。
そもそもCompute Shaderってなんぞ? って人は前に書いた記事も参考にしてみてください。

edom18.hateblo.jp

edom18.hateblo.jp

今回、パーティクルの計算のために用意したのは主に2種類のカーネルです。
後述しますが、それぞれに複数のカーネルがあります。

種類は以下。

  1. パーティクルをターゲットに動かすための更新用カーネル
  2. パーティクルのターゲット自体を変更するための初期化カーネル

の2つです。

構造体

カーネルの説明に入る前に、今回のパーティクルシステムで利用している構造体を整理しておきます。
利用している構造体は以下の2つ。

ひとつめがパーティクル用でふたつめが初期化用です。

public struct TransformParticle
{
    public int isActive;
    public int targetId;
    public Vector2 uv;

    public Vector3 targetPosition;

    public float speed;
    public Vector3 position;

    public int useTexture;
    public float scale;

    public Vector4 velocity;

    public Vector3 horizontal;
}

public struct InitData
{
    public int isActive;
    public Vector3 targetPosition;

    public int targetId;
    public float scale;

    public Vector4 velocity;

    public Vector2 uv;
    public Vector3 horizontal;
}

初期化用のほうは、初期化したい内容だけを定義した構造体になっています。
ターゲット位置とターゲットのID(モデル行列やテクスチャアクセスに利用)、UV値、スケール、そしてアクティブ状態の内容を保持します。

初期化用カーネル

初期化用カーネルでは前述の通り、すでにバインド済みのパーティクルバッファを初期化用構造体によって初期化します。
また前述のように、対象のInitData配列にアクセスするためのインデックスバッファも利用します。

なお、初期化用のカーネルはふたつあります。

ひとつは、ターゲットを更新し、そのターゲットにスムーズに遷移するための初期化(SetupParticles)。
そしてもうひとつは『即座に』パーティクルの状態を変更するための初期化(SetupParticlesImmediately)です。

ひとつめがメインの初期化処理ですが、表現によっては即座にパーティクルを指定の位置に移動させたい場合があります。
その場合に利用するのが2番目のカーネルというわけです。

具体的なコードは以下。

[numthreads(THREAD_NUM, 1, 1)]
void SetupParticles(uint id : SV_DispatchThreadID)
{
    TransformParticle p = _Particles[id];

    uint idx = _IndexBuffer[id];

    float4x4 mat = _MatrixData[_InitDataList[idx].targetId];

    p.isActive = _InitDataList[idx].isActive;
    p.targetPosition = mul(mat, float4(_InitDataList[idx].targetPosition, 1.0)).xyz;
    p.uv = _InitDataList[idx].uv;
    p.targetId = _InitDataList[idx].targetId;
    p.scale = _InitDataList[idx].scale;
    p.horizontal = _InitDataList[idx].horizontal;
    p.velocity = _InitDataList[idx].velocity;

    _Particles[id] = p;
}

[numthreads(THREAD_NUM, 1, 1)]
void SetupParticlesImmediately(uint id : SV_DispatchThreadID)
{
    TransformParticle p = _Particles[id];

    uint idx = _IndexBuffer[id];

    float4x4 mat = _MatrixData[_InitDataList[idx].targetId];

    p.isActive = _InitDataList[idx].isActive;
    p.position = mul(mat, float4(_InitDataList[idx].targetPosition, 1.0)).xyz;
    p.targetPosition = p.position;
    p.uv = _InitDataList[idx].uv;
    p.targetId = _InitDataList[idx].targetId;
    p.scale = _InitDataList[idx].scale;
    p.horizontal = _InitDataList[idx].horizontal;
    p.velocity = _InitDataList[idx].velocity;

    _Particles[id] = p;
}

実は違いは一箇所だけです。p.positionを設定するかしないかです。
位置更新用のカーネルでは、呼び出されるごとに位置を更新するわけですが、ターゲットが存在するパーティクルの場合は今の位置から徐々にp.targetPositionに移動する、という実装になっています。

つまりは、p.positionが即座に変更されることで、見た目上即時、位置が更新されたように見えている、というわけですね。

さて肝心の処理ですが、基本的にはInitData構造体によって渡されたデータをコピーしているだけです。

初期化処理の中でのポイントは_MatrixData部分です。
targetIdによって計算中のパーティクルがどのモデルに属しているのかを判別しており、該当のモデル行列を取り出してターゲット位置に掛けることで実際のモデルの頂点位置を計算しています。

ちなみに感のいい方なら気づいたかもしれませんが、テクスチャのピクセルに変形する場合はモデル行列は必要ありません。そのため、その場合にはただのMaterix4x4.identityを利用しています。

また同様に、レンダリング用シェーダでも対象テクスチャを判別するためにtargetIdが必要になるため、パーティクルのデータとして設定しています。
なお、なぜこれが必要になるのかはレンダリングの詳細のときに解説します。

更新用カーネル

これは特にむずかしいことはしていません。
コードを見てもらうと分かりますが、ターゲット位置と現在位置の差分距離をDeltaTimeによって徐々にターゲット位置に近づけるように計算しているだけです。
(ただ、パーティクルごとにアニメーションの差を生ませるために『速度』の項目がありますが、本質的にはターゲットに近づけていることには変わりありません)

[numthreads(THREAD_NUM, 1, 1)]
void UpdateAsTarget(uint id : SV_DispatchThreadID)
{
    TransformParticle p = _Particles[id];

    float3 delta = p.targetPosition - p.position;
    float3 pos = (delta + p.velocity.xyz * 0.2) * _DeltaTime * p.speed;

    const float k = 5.5;
    p.velocity.xyz -= k * p.velocity.xyz * _DeltaTime;

    p.position += pos;
    p.useTexture = 1;

    _Particles[id] = p;
}

特定の形状に移動させるカーネル

基本的には設定されたモデルの頂点にパーティクルが張り付く動作をしますが、パーティクルの移動先をランタイムに計算することで任意の形状に散らすこともできます。

サンプルの動画では拡散したりぐるぐる周ったりしているのが分かるかと思います。

該当のコードは以下の通り。

[numthreads(THREAD_NUM, 1, 1)]
void UpdateAsExplosion(uint id : SV_DispatchThreadID)
{
    TransformParticle p = _Particles[id];

    float3 pos = (p.velocity.xyz) * p.velocity.w * _DeltaTime;

    float s = sin(rand(id) + _Time) * 0.00003;
    p.velocity.xyz += s;
    float k = 2.0;
    p.velocity.xyz -= k * p.velocity.xyz * _DeltaTime;

    p.position += pos;

    p.useTexture = 0;

    _Particles[id] = p;
}

パーティクルシステムでは更新方法のカーネルを変えることでパーティクルの位置計算の仕方を変更しています。
つまり、カーネルを増やせば様々な形状の変化を与えることができるわけです。

なので例では四散するパーティクルの更新用カーネルを示しましたが、サンプルの動画ではぐるぐると周る演出もあります。
これは更新用カーネルを変えることによって実現しています。

この更新用カーネルを増やすことでさらに様々な表現を作ることが可能になっています。

C#コード

次にC#側のコードを見ていきましょう。

今回のシステムで使う主なクラスは以下の3つです。

  • TransformParticleSystem
  • ParticleTarget
  • ParticleTargetGroup

各クラスの概要は以下の通りです。

TransformParticleSystem

TransformParticleSystemが今回のコア部分です。このクラスでCompute Shaderへのデータ設定や各種データの取得、整理などを行っています。

ParticleTarget

ParticleTargetはターゲットとなるMeshの各種情報を取得するためのラッパークラスです。
各種情報とは、頂点やUV、テクスチャ情報などパーティクルシステムで利用する情報です。

この派生型のクラスとして、テクスチャのピクセル位置に移動させるようなクラスも用意されています。
ただ基本概念は一緒なので説明は割愛します。

ParticleTargetGroup

ParticleTargetGroupParticleTargetをグループ化するためのクラスです。
今回のプロジェクトの要件では複数のモデルをひとつにまとめて扱う必要があったためこのグループクラスを定義しています。

このグループクラスでは、子どもに持つParticleTargetから頂点などの情報を取得しそれらをひとつにまとめる処理を担当しています。
最終的にこのグループ単位でパーティクルシステムにデータが渡され利用されます。

C#側では主にバッファの設定とCompute Shaderへの計算命令の実行(Dispatch)を行います。

バッファ周りの仕組みや概要については以前の記事を参考にしてください。

edom18.hateblo.jp

データの設定

今回のポイントはデータの変更処理にあります。

特にマトリクスの変更とターゲット位置の変更がメインです。
ということでマトリクスの変更処理部分を見てみます。

private void UpdateMatrices(ParticleTargetGroup group)
{
    group.UpdateMatrices();

    System.Array.Copy(group.MatrixData, 0, _matrixData, 0, group.MatrixData.Length);

    _matrixBuffer.SetData(_matrixData);
}

// ParticleTargetGroup.UpdateMatrices処理
public void UpdateMatrices()
{
    for (int i = 0; i < _targets.Length; i++)
    {
        _matrixData[i] = _targets[i].WorldMatrix; // localToWorldMatrixを返すプロパティ
    }
}

グルーピングされているターゲットのlocalToWorldMatrixを配列に詰めているだけです。

続いてターゲット位置の変更。

public void UpdateInitData(InitData[] updateData)
{
    if (updateData.Length > _initDataList.Length)
    {
        Debug.LogError("Init data list size is not enough to use.");
    }

    int len = updateData.Length > _initDataList.Length ? _initDataList.Length : updateData.Length;

    System.Array.Copy(updateData, _initDataList, len);

    _initDataListBuffer.SetData(_initDataList);
}

このInitDataが更新用データ構造体で、パーティクルひとつの更新情報になります。
このInitData配列を、各グループクラスが起動時にまとめあげて保持しているわけです。

そして変更のタイミングに応じて、すでに生成済みのInitData配列を渡すことで高速にターゲットを変更しているわけです。
なのでここをランタイムで生成して渡すことで完全に任意の位置にパーティクルを飛ばすことも可能になっています。

そしてこのあとに初期化用カーネルを呼び出してパーティクルの更新を行っています。

呼び出し部分は以下。

private void UpdateAllBuffers(int kernelId)
{
    SetBuffer(kernelId, _propertyDef.ParticleBufferID, _particleBuffer);
    SetBuffer(kernelId, _propertyDef.InitDataListID, _initDataListBuffer);
    SetBuffer(kernelId, _propertyDef.MatrixDataID, _matrixBuffer);
    SetBuffer(kernelId, _propertyDef.IndexBufferID, _indexBuffer);
}

private void Dispatch(int kernelId)
{
    _computeShader.Dispatch(kernelId, _maxCount / THREAD_NUM, 1, 1);
}

それぞれ必要なバッファを対象カーネルにセットし、そのあとでカーネルを起動します。
呼び出すカーネルは前述した初期化用カーネルです。

これを呼び出すことでパーティクルデータのバッファの内容が書き換わり、以後の位置更新ループによって別のターゲットへパーティクルが近づいていくことになります。

パーティクルのレンダリングについて

本パーティクルシステムの大まかな仕組みは当初から変わっていません。
しかしモック段階では問題にならなかったものも、実際のコンテンツに組み込むことで問題が出てくることが多々あります。

今回も同じように問題が発生しました。

問題というのは、モック段階ではパーティクルの移動のみに焦点を当てて実装を行っていたのですが、いざ組み込みを開始すると、パーティクルの移動先の対象モデルと同じ色にしないとなりません。
(でないとモデル形状になるだけの単色のパーティクルの塊になってしまう)

当然、対象モデルはテクスチャを持っており、頂点ごとに色など持っていません。
つまり、対象頂点位置の色をテクスチャから引っ張ってくる必要があるわけです。

しかし、パーティクルに利用するシェーダは当然ひとつだけで、GPUに転送できるデータにも限りがあります。
CPUのように柔軟にテクスチャ情報を取り出すわけにはいきません。
なにより、転送できるテクスチャの数にも限りがある上に、複数のテクスチャを配列以外でアクセスするには問題が多すぎました。

そこで、最初の『方針』のところでも書いたように、対象モデルのテクスチャをTexture2DArrayにまとめることで配列にし、かつ『どのテクスチャのどこをフェッチするか』という情報をパーティクル自体に持たせる必要があります。

ということを念頭に置いて、どうしているかのコード断片を抜き出すと以下のようになります。

// 頂点シェーダ
TransformParticle p = _Particles[id];

v2f o;

// 中略

o.uv.xy = p.uv.xy;
o.uv.z = p.targetId;

// フラグメントシェーダ
col = UNITY_SAMPLE_TEX2DARRAY(_Textures, i.uv);

Texture2DArrayでは最初の2要素(x, y)が通常のUV値として利用され、3要素目(z)が配列の添字として利用されます。
なのでそれを頂点シェーダからフラグメントシェーダへ転送し、UNITY_SAMPLE_TEX2DARRAYマクロを利用して対象の色をフェッチしている、というわけです。

正直、これが想定通りうまく行ったときは内心飛び跳ねていました。
移動自体は問題なく動いたものの、色味をつけるところで躓いていたので。

この仕組み自体はWindows, Mac OSX, iOS, Android, MagicLeapすべてで問題なく動いているので汎用的に使える仕組みだと思います。

なお、パーティクルを画面に表示するフローについては以前書いた以下の記事を参照ください。このパーティクルシステムを実装したあとに備忘録として書いた記事なのでほぼ内容はそのままです。

edom18.hateblo.jp

プロジェクトでのハマりポイント

最初のモック段階では問題なく動いていたものの、実際のプロジェクトでは画面になにも表示されないという問題が発生しました。

おそらく理由はメモリ不足。
ターゲットとなるモデルを複数指定していたのですが、同じモデルなのにも関わらず複数のグループに分割したために必要となるデータの重複が発生し、特にTexture2DArrayにまとめたテクスチャのデータ量が増えすぎたためだと思います。

なので参考にアップしてあるGitHubのコードではグループ化の最適化を行っています。
具体的には、サブグループという概念を取り入れてグループデータはひとつにまとめ、サブグループ単位でパーティクル位置を指定できるようにしました。

ただ今回の解説からは脱線するので説明は割愛します。