e.blog

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

カールノイズを使ったパーティクル表現

f:id:edo_m18:20180726190953p:plain

概要

以前、カールノイズについてふたつの記事を書きました。
カールノイズの「流体表現」についてと「衝突判定」についてです。

edom18.hateblo.jp

edom18.hateblo.jp

今回はこれを発展させて、上記のカールノイズを使った具体的なパーティクル表現について書きたいと思います。
実際に動いている様子はこんな感じです↓

移動量に応じて自動でエミットするタイプ↓

モデルの頂点位置からエミットするタイプ↓

上の例では移動距離に応じて発生させるパーティクルと、メッシュの頂点部分にパーティクルを発生させる2パターンを実装しました。

なお、今回のサンプルはGithubにアップしてあります。

github.com

ちなみに、後者の「頂点部分にパーティクル」というのは、ドラクエ11リレミトの表現をイメージしてみました↓


【ドラクエ11】目指せ最速クリア!ぶっ続けでプレイして世界を救う配信!【ネタバレ注意】

前回の実装ではパーティクルはすべて常にアップデートされていた

前の実装ではすべてのパーティクルが生きていて常に更新がかかる実装になっていました。
以前に投稿した動画はこんな感じです↓

見てもらうと分かりますがパーティクルが発生し続けていて「任意のタイミングで追加する」というケースがなかったわけですね。
つまりパーティクルは常に発生し続けライフタイムが0になったらまた新しく生成し直す、というサイクルで実装していました。

具体的には、ライフタイムがゼロになったらライフタイムを回復させ、位置を初期位置(ランダム性あり)として更新していました。

なので毎フレームパーティクルのデータを更新すればよく、休止中のパーティクルを起こす必要もなければ状態を管理する必要もなかったわけです。
しかし移動距離で発生させたりなど、「任意のタイミングで追加」する必要がある場合は「活動中」と「休止中」のパーティクルを管理し、追加の場合は休止中のパーティクルに対して処理を実行する必要が出てきます。

連続してパーティクルをエミットする

冒頭で紹介したパーティクル表現では(Unity標準のパーティクルシステムでも同様の機能がありますが)「移動した距離に応じてエミットする」という実装になっています。
つまり、前に発生させたパーティクルは維持しつつ、新しく追加でパーティクルを発生させる必要がある、というわけです。

パーティクルをプールして管理する

今回の実装ではパーティクルの状態を管理し、必要であれば追加でエミットする必要があることは書きました。
ではそれをどうやったら実現できるのでしょうか。

先に結論を書いてしまうと、休止しているパーティクルのIDを保持するバッファを用意し、休止になったパーティクルのIDはプールへ戻し、追加の必要が出た場合はそのプールからIDを取り出し利用する、という仕組みを作ります。

この実装にあたっては、凹みTipsの以下の記事を参考にさせていただきました。

tips.hecomi.com

まずはザッと今回新しく追加した処理を見てみます。

プールの状態を初期化するInitカーネル

Initカーネルで行っているのは、用意されたパーティクルバッファ分の初期化です。
処理はシンプルに、全パーティクルの非活性化およびそのIDのプールへの保存です。

////////////////////////////////////////////////////////////
///
/// 初期化処理のカーネル関数
///
[numthreads(8, 1, 1)]
void Init(uint id : SV_DispatchThreadID)
{
    _Particles[id].active = false;
    _DeadList.Append(id);
}

上記の_DeadList.Append(id);がプールへIDを保存している箇所ですね。
_DeadListの名前の通り、非活性化状態のパーティクルのIDを保存しているバッファです。

さて、このAppendを実行しているバッファはなんでしょうか。答えは以下です。

Append Buffer、Consume Bufferを利用してプールを管理する

Append BufferConsume Bufferはそれぞれ、追加・取り出し可能なLIFO(Last In First Out)コンテナです。(つまり、最後に入れた要素が最初に取り出されるタイプのコンテナです)

MSDNのドキュメントは以下です。

docs.microsoft.com

docs.microsoft.com

AppendStructuredBufferのドキュメントから引用すると、

Output buffer that appears as a stream the shader may append to. Only structured buffers can take T types that are structures.

シェーダから追加することができるストリームとしてのバッファ、とあります。
このバッファに対して、寿命が来たパーティクルをバッファに入れてあげることで死活管理ができるというわけです。

ちなみにシェーダ内では以下のように定義しています。

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

さて、ふたつのバッファを定義しているのでふたつのバッファを用意する必要があるのか、と思われるかもしれませんが、インターフェースが違うだけでバッファとしての実態は同じものを利用します。

CPU(C#)側での処理は以下のようになっています。

private ComputeBuffer _particlePoolBuffer;
// ---------------------
_particlePoolBuffer = new ComputeBuffer(_particleNumLimit, sizeof(int), ComputeBufferType.Append);
// ---------------------
_computeShader.SetBuffer(_curlnoiseKernel, _deadListId, _particlePoolBuffer);
// ---------------------
_computeShader.SetBuffer(_emitKernel, _particlePoolId, _particlePoolBuffer);

まず、ComputeBuffer型の_particlePoolBufferを定義し、タイプをComputeBufferType.Appendとしてバッファを生成しています。

その後SetBufferによってバッファをセットしていますが、見て分かる通り実際に渡しているバッファの参照はどちらも同じ_particlePoolBufferです。

つまり、追加する場合はAppendStructuredBufferとして参照を渡し、取り出すときはConsumeStructuredBufferとして参照を渡す、というわけですね。

これで、寿命が尽きたパーティクルはバッファ(リスト)に戻され、必要になったときに、非活性化しているパーティクルのIDを「取り出して」値を設定することで、今回のように追加でエミットできるようになる、というわけです。

パーティクルを追加でエミットする

さて、仕組みが分かったところで実際にエミットしているところを見てみましょう。

////////////////////////////////////////////////////////////
///
/// パーティクルをエミットさせるカーネル関数
///
[numthreads(8, 1, 1)]
void Emit()
{
    uint id = _ParticlePool.Consume();

    float2 seed = float2(id + 1, id + 2);
    float3 randomPosition = rand3(seed);

    Particle p = _Particles[id];

    p.active = true;
    p.position = _Position + (randomPosition * 0.05);
    p.velocity = _Velocity;
    p.color = _Color;
    p.scale = _Scale;
    p.baseScale = _BaseScale;
    p.time = 0;
    p.lifeTime = randRange(seed + 1, _MinLifeTime, _MaxLifeTime);
    p.delay = _Delay;

    _Particles[id] = p;
}

冒頭のuint id = _ParticlePool.Consume();がIDを取り出しているところですね。
こうして、非活性化しているパーティクルのIDを取り出し、取り出したパーティクルに対して新しくライフタイムや色、位置などを設定することで無事、該当のパーティクルが動き出す、というわけです。

パーティクルの死活管理

さて、パーティクル生成の最後に実際にパーティクルをアップデートしている箇所を見てみます。
前回の実装ではライフタイムがゼロになった場合はまた新しいライフタイムを即座に設定し、すぐに復活させていました。

が、今回は「非活性化状態リスト」に追加する必要があります。
該当の処理は以下になります。

////////////////////////////////////////////////////////////
///
/// カールノイズのカーネル関数
///
[numthreads(8, 1, 1)]
void CurlNoiseMain(uint id : SV_DispatchThreadID)
{
    // ... 前略

    if (p.active)
    {
        // ... 中略

        if (p.time >= p.lifeTime)
        {
            p.active = false;
            _DeadList.Append(id);
            p.scale = 0;
        }
    }

    // ... 後略
}

ライフタイムがゼロ以下になった場合は_DeadList.Append(id);として、非活性化リストに戻しているのが分かるかと思います。

これで晴れて、パーティクルの死活管理ができる、というわけです。

パーティクルの残り数を考慮する

以上でパーティクルを追加でエミットすることが可能になりました。
しかし今のままだと少し問題が残っています。

というのは、パーティクルの残数を管理する必要があることです。
パーティクルの計算にはComputeBufferによって渡されたパーティクル情報を元に計算を行います。
このとき、初期化のタイミングでパーティクル数を規定します。(バッファサイズを明示する必要があります)
サンプルでは30000という数字でバッファを生成しています。

つまり、(当然ですが)このサイズを超えるパーティクルは生成することができません。
しかし、今回の追加エミットの処理ではその上限を超えてパーティクルの追加を要求することができてしまいます。

当然、バッファサイズを超えた数をリクエストしてもプールには休止中のIDがなく、結果生成されることがありません。
自分が遭遇した問題としては、上限を超えてリクエストを行ってしまうとプールに戻るパーティクルがなくなり、結果的にパーティクルが再生されない、という問題がありました。
しかもその状態が発生してしまうと以後、まったくパーティクルが発生しなくなる、という問題に遭遇しました。

なので「上限を超えないように」エミットを調整しないとならない、というわけです。

現在のプールに残っているIDの数を取得する

ではどうするのか。
結論から先に書いてしまうと、プールに残っているIDの数を取得して調整を行います。

プールのカウントを取得するにはComputeBuffer.CopyCountを利用します。
取得、確認しているコードは以下です。

_particleArgsBuffer = new ComputeBuffer(1, sizeof(int), ComputeBufferType.IndirectArguments);
_particleArgs = new int[] { 0 };

// --------------------

_particleArgsBuffer.SetData(_particleArgs);
ComputeBuffer.CopyCount(_particlePoolBuffer, _particleArgsBuffer, 0);

_particleArgsBuffer.GetData(_particleArgs);

return _particleArgs[0];

最終的に_particleArgsint型の配列に結果が格納されます。
今回のサンプルでは30000のパーティクルを生成しているので、まったくの未使用状態だと_particleArgs[0] == 30000となります。

MSDNのドキュメント↓
ComputeBuffer.CopyCount - Unity スクリプトリファレンス

あとは、ここで取得した数と実際にエミットしたい数とを比較すればバッファ以上のパーティクルを生成してしまうのを防ぐことができるようになります。