Epic Gamesが配布している、Unreal Engineの様々な機能のサンプルをまとめたプロジェクト「機能別サンプル(Content Examples)」の中に、UE4.26では、Niagara AdvancedというMapがあります。

そこでは、Niagaraの新機能やそれを使った応用例など、とても参考になるサンプルが多く配置されていて、実装内容を見てみるとたくさんの学びがあります。

それらのサンプルについて、いくつかの記事に分けて解説を行っていきます。

ちなみに、機能別サンプルはEpic Games Launcherのラーニングタブからダウンロードできます。また、UEのバージョン毎に内容が違う可能性があるので注意してください。

TLDR;)

  • Position Based Dynamicsは、位置によって計算する物理演算(衝突判定など)だよ
  • Position Based Dynamicsを使うとParticle同士のコリジョンがとれるようになるよ

はじめに

この記事では、Position Based Dynamics(PBD)についての解説をしていきます。これは、新機能というよりは、4.26から新しく追加された機能群、Simulation Stage, Particle Attribute Reader, Neighbor Grid3Dを活用してつくられているものです。ただ、PBDのための基本機能はモジュール化されて使えるようになっています。

ですので、それらの機能をまだ知らない人は、以下の記事を先に読むことをおすすめします。

【UE4.26】Niagara Advanced 解説基礎編~Simulation Stage~

【UE4.26】Niagara Advanced 解説基礎編~Particle Attribute Reader~

【UE4.26】Niagara Advanced 解説基礎編~Neighbor Grid3D~

今回は、Niagara Advancedの「3.6 Position Based Dynamics」というサンプルの解説をしていきますが、そもそもPBDとは何かという話なので、その解説からしていきます。

Position Based Dynamics とは

PBDとは、簡単に言うと、「位置ベースの物理演算」です。

普通の物理演算は、力→加速度→速度→位置のように物理法則に従って計算していくのに対し、PBDは位置を元に一定の制約条件(例えば物体が重ならないなど)から位置の調整→速度と計算していきます。

詳しくは、以下の記事などを参照してみてください。
[CEDEC 2014]剛体から流体まで,セガのプログラマーが語る「位置ベース物理シミュレーション」の最前線

さて、NiagaraではNeighbor Grid 3DとParticle Attribute Readerを使って、近くにあるParticleの位置情報などを取得し、事前に設定したParticleのRadiusから、Particle同士が重なっているかを計算することで、重ならないように調整することができます。

それを、Simulation Stageを使って繰り返し処理をすることで、より精度の高い調整を行います。

これの何がすごいかというと、今までできなかったParticle同士のコリジョンを達成できるということです。

違いを見れば一目瞭然ですね。左の方は、中央に引き寄せられながらもパーティクル同士が重ならないようになっています。

ちなみに、Epicが作ったデモリールの中のポップコーンマンも、このPBDの技術が使われています。

では、さっそくサンプルの解説に入りましょう。

3.6 Position Based Dynamics の解説

このサンプルは、複数の球が中心に向かって引き寄せられますが、他の球とちゃんと衝突しているような挙動をします。それがPBDによって実現されています。

中身を見ていきます。

まずNeighbor Grid3Dの初期化ですが、System UpdateでInitialize Neighbor Gridモジュールが使われています。

そこそこ大きい4×4×4のグリッドが設定されています。

Emitterはこのようになっていて、実際にPBDの処理を行っているのは、Particle CollisionsというSimulation Stageの中のPBD Particle Collisionというモジュールです。

順を追って説明をしていきたいと思います。

Emitter Update

まず、Spawn Burst Instantaneousで10個のパーティクルを同時にSpawnしています。

そして、Particle Attribute Readerが初期化されています。Emitter Nameには自分のEmitter名を設定します。これで別のParticleの情報が読めるようになりました。

Particle Spawn

Initialize ParticleでMassをランダムに設定した上で、Calculate Size and Rotational Inertia by Massで、Meshのスケール(と回転慣性)をMassの値と各種設定値を元に設定します。(大きさは、重さと密度から求められるので、Calculate Size and Rotational Inertia by Massは、それを設定するモジュールです)

これで、ランダムに設定した重さを元に、少しMeshの大きさをバラつかせています。

そして、Sphere Locationで一定の大きさの球内のランダムな位置に出現させています。

Particle Update

CollisionRadius(float)、Unyielding(bool)、Previous Position(Vector)の3つのAttributeが初期化されています。これらは、後のPBD Particle Collisionで使われるAttributeです。

Collision Radiusは、Calculate Particle RadiusというDynamic Inputが使われています。中身を見ればわかりますが、そんなに複雑なことはしていなく、Method for Calculating Particle Radiusで設定したフラグを元に、Mesh Dimensionsで設定した値とParticleのスケールAttributeからRadiusを計算します。

今回は、Minimum Axis、Mesh Dimensions(2.0, 2.0, 2.0)なので、2.0 / 2 * Scaleの値がCollisionRadiusにはいります。

Previous Positionは、Solve Forces and Velocityモジュールを追加すると設定されるAttributeをそのまま使います。

Unyieldingはfalseに設定しています。

後の設定は、大したことはしていません。Point Attraction Forceで中心に引き寄せる動きを作っています。

ここのCollisionは、普通に他のObjectとの衝突を有効化するためのものなので、PBDとは関係ありません。

一応簡単に解説しておくと、このサンプルではGPU Simなので、衝突判定の方法として、以下の3つが使えます。

GPU Depth Bufferは、Scene Depthの情報を元に衝突判定をします。

GPU Distance Filedsは、Mesh Distance Fieldsの情報を元に衝突判定をします。Mesh Distance Fieldsは、ワールドの空間の各座標(ボクセル)から最も近いMeshまでの距離を格納した3D Textureです。これに関しては色々深い話なので、調べてみると面白いかもしてません。

Analytical Planesは、ユーザーが設定した無限平面とのみ衝突判定をします。

最後に、DynamicMaterialParameterが設定されていますが、これは使っていないです。

Populate Grid

このSimulation Stageで、各ParticleのExecution IndexをNeighbor Grid3Dに保存しています。

ここで使われている、Populate Neighbor Gridは既存モジュールです。Neighbor Grid3Dを解説した以前の記事では、この辺の処理はScratch Padモジュールで実装されていましたが、専用モジュールとしても既に存在していました。

ただ、Initialize Neighbor Grid3Dモジュールで設定されるAttributeを入力値に使っているので、Initialize Neighbor Grid3Dモジュールを使わない場合は、その辺の値を自分で用意してあげる必要があります。

中身は同じなので、これについての解説は割愛します。

Particle Collisions

いよいよ本丸の登場です。

まず、繰り返し回数は4回を指定しています。一回の処理では、他のParticleとの重なりを調整した結果、別のParticleと重なってしまう可能性があります。それを複数回繰り返すことで、より精度の良い調整になっていきます。もちろんパフォーマンスとのトレードオフになります。

そして、PBD処理を行っているPBD Particle Collisionですが、これも既存モジュールです。ただし、Experimentalなのもあって、Library Onlyのチェックを外さないと検索結果に出てこないので注意してください。

入力値は以下の通りです。

この後中身を見ていきますが、まあ正直まだ開発途中って感じです。

事実、KinectFrictionとStaticFrictionはまだ機能していませんので、値を変更しても変化は起きないです。

RelaxationAmountは、値を大きくするとPBDの効果が弱まり、ヌルっと衝突する感じになります。基本は1.0で大丈夫です。

Simulateのフラグは、PBDを有効にするか否かです。

後の項目は、決められている値なので変更する必要はありません。

中身を見ていきますが、HLSLで実装されています。

最終的には、以下のように値が更新されています。が、このサンプルでは、PositionとVelocityしか使っていません。

HLSLの中を見る前に、まず前提として押さえておきたい事を説明します。

PBDをどのように実現しているかというと、Neighbor Grid3Dで定義されたグリッド内で、自分が所属するセルと隣接しているセルの3×3×3=27(自分のセルも含む)個のセルに所属するParticleを総当たりで検索し、事前に設定したCollisionRadiusを元に、それらのParticleと重なっているかを調べます。

そして重なっている場合は、重なり部分の長さを求め、相手と自分のMassの値を元に重みづけをした距離だけ、そのParticleと逆方向に位置をズラすします。また、その方向にVelocityも更新します。

この処理を一フレームに、今回は4回繰り返すことで、PBDを実現しています。

また、もう一つUnyieldingというフラグがあります。直訳すると弾力に欠けるや屈しないなどの意味になりますが、このフラグがtrueなParticleとfalseなParticleに重なりがあった場合は、trueな方のParticleは動かずに、flaseの方だけが位置を調整するという処理が入っています。(正確には、UnyeildingMassPercentageを設定することで、どのくらい動かないかを調整できます)

これで、Particle毎に衝突時の挙動を少し変化させることが可能です。ただ、このサンプルでは、すべてfalseになっているので、Particle毎の違いはないです。

赤がUnyielding=trueで、青がfalseです。UnyeildingMassPercentageは1.0

それらを踏まえて、HLSLの中身を見ていきましょう。

// 出力用の変数を初期化
OutPosition = Position;
OutVelocity = Velocity;
CollidesOut = CollidesIn;
NumberOfNeighbors = 0;
displayCount = 0.0;

//AveragedCenterLocation = float3 (0.0,0.0,0.0);

#if GPU_SIMULATION

// 自分含め隣接する27個のセルにアクセスするためのインデックス差分を定義
const int3 IndexOffsets [ 27 ] = 
{
	int3(-1,-1,-1),
	int3(-1,-1, 0),
	int3(-1,-1, 1),
	int3(-1, 0,-1),
	int3(-1, 0, 0),
	int3(-1, 0, 1),
	int3(-1, 1,-1),
	int3(-1, 1, 0),
	int3(-1, 1, 1),

	int3(0,-1,-1),
	int3(0,-1, 0),
	int3(0,-1, 1),
	int3(0, 0,-1),
	int3(0, 0, 0),
	int3(0, 0, 1),
	int3(0, 1,-1),
	int3(0, 1, 0),
	int3(0, 1, 1),

	int3(1,-1,-1),
	int3(1,-1, 0),
	int3(1,-1, 1),
	int3(1, 0,-1),
	int3(1, 0, 0),
	int3(1, 0, 1),
	int3(1, 1,-1),
	int3(1, 1, 0),
	int3(1, 1, 1),
};

// ポジションをグリッド空間の座標に変換
float3 UnitPos;
myNeighborGrid.SimulationToUnit(Position, SimulationToUnit, UnitPos);

// 自分が所属するセルのインデックスを取得
int3 Index;
myNeighborGrid.UnitToIndex(UnitPos, Index.x,Index.y,Index.z);

// 途中で使う変数を定義
float3 FinalOffsetVector = {0,0,0};
uint ConstraintCount = 0;
float TotalMassPercentage =  1.0;

// グリッドのセル数を取得
int3 NumCells;
myNeighborGrid.GetNumCells(NumCells.x, NumCells.y, NumCells.z);

// 各セルに保存できる最大数を取得
int MaxNeighborsPerCell;
myNeighborGrid.MaxNeighborsPerCell(MaxNeighborsPerCell);

// 27個のセルを検索
for (int xxx = 0; xxx < 27; ++xxx) 
{
    // 各セルにつき、MaxNeighborsPerCell回検索
    for (int i = 0; i < MaxNeighborsPerCell; ++i)
    {
        // セルに保存されているParticle配列のIndexを取得
        const int3 IndexToUse =Index + IndexOffsets[xxx];
        int NeighborLinearIndex;
        myNeighborGrid.NeighborGridIndexToLinear(IndexToUse.x, IndexToUse.y, IndexToUse.z, i, NeighborLinearIndex);

        // NeighborLinearIndexをもとに、ParticleのExecution Indexを取得
        int CurrNeighborIdx;
        myNeighborGrid.GetParticleNeighbor(NeighborLinearIndex, CurrNeighborIdx);


        // 取得したExecution Indexをもとに、そのPartilceのポジションを取得
        bool myBool; 
        float3 OtherPos;
        DirectReads.GetVectorByIndex<Attribute="Position">(CurrNeighborIdx, myBool, OtherPos);
        
        // 対象Particleと自身との距離と方向を計算
        const float3 vectorFromOtherToSelf = Position - OtherPos;
        const float dist = length(vectorFromOtherToSelf);
        const float3 CollisionNormal = vectorFromOtherToSelf / dist;

        // 対象ParticleのCollisionRadiusを取得
        float OtherRadius;
        DirectReads.GetFloatByIndex<Attribute="CollisionRadius">(CurrNeighborIdx, myBool, OtherRadius);

        // 重なりがあるかを計算(Overlapが0以上なら重なっている)
        float Overlap = (CollisionRadius + OtherRadius) - dist;

        // 正常な値かチェック
        if (IndexToUse.x >= 0 && IndexToUse.x < NumCells.x && 
            IndexToUse.y >= 0 && IndexToUse.y < NumCells.y && 
            IndexToUse.z >= 0 && IndexToUse.z < NumCells.z && 
            CurrNeighborIdx != InstanceIdx && CurrNeighborIdx != -1 && dist > 1e-5)
        {

            // 変数の初期化
            bool otherUnyeilding = false;
            TotalMassPercentage =  1.0;

            // 重なりがあるものだけ処理
            if ( Overlap > 1e-5)
            {
                NumberOfNeighbors+=1;
                displayCount = NumberOfNeighbors;

                // 対象ParticleのUnyieldingフラグを取得
                bool NeighborUnyieldResults;
                DirectReads.GetBoolByIndex<Attribute="Unyielding">(CurrNeighborIdx, myBool, NeighborUnyieldResults);

                CollidesOut = true;

                // 対象ParticleのMassを取得
                float OtherMass;
                DirectReads.GetFloatByIndex<Attribute="Mass">(CurrNeighborIdx, myBool, OtherMass);

                // 対象Particleと自身のMassをもとに、重みづけ用の係数を計算(これは後の計算で1-TotalMassPercentageとなるので、小さい方がより遠くに動く)
                TotalMassPercentage = Mass / (Mass + OtherMass); 

                // Unyieldingがどちらもtrueの場合は、何もしない
                if ( NeighborUnyieldResults && Unyielding ){ // both this particle and the other are unyeilding
                    TotalMassPercentage = TotalMassPercentage; 
                }
                // 対象ParticleのみUnyielding=trueの場合は、TotalMassPercentageをUnyeildingMassPercentageの分だけ0に寄せる
                else if ( NeighborUnyieldResults ) { // this particle yeilds but the other does not
                    TotalMassPercentage = lerp ( TotalMassPercentage, 0.0, UnyeildingMassPercentage);
                }
                // 自身のみUnyielding=trueの場合は、TotalMassPercentageをUnyeildingMassPercentageの分だけ1に寄せる
                else if (Unyielding) { // this particle is unyeilding but the other is
                    TotalMassPercentage = lerp ( TotalMassPercentage,1.0, UnyeildingMassPercentage);
                }

                // 対象Particleから遠ざかる方向の、重なりの距離に重みづけした大きさのベクトルを加算していく。
                FinalOffsetVector += (1.0 - TotalMassPercentage) * Overlap * CollisionNormal; 
                
                // この辺はモーメントとかを求めているっぽいが、現状は使ってない。Frictionとかが実装されるときに使われるかもしれない。
                float3 OtherPreviousPos;
                DirectReads.GetVectorByIndex<Attribute="Previous.Position">(CurrNeighborIdx, myBool, OtherPreviousPos);
                const float3 P0Velocity = Position - PreviousPosition;
                const float3 PNVelocity = OtherPos - OtherPreviousPos;
                const float3 P0RelativeMomentum = P0Velocity*Mass - PNVelocity*OtherMass;
                const float3 NormalBounceVelocity = (P0RelativeMomentum - dot(P0RelativeMomentum, CollisionNormal) * CollisionNormal)/Mass;
                const float NormalBounceVelocityMag = length(NormalBounceVelocity);
                
                // 何個のParticleと重なっていたかを記録
                ConstraintCount += 1;
            }
        }      
    }
}

// 重なりがあるかつIsMobile(=Simulateの入力値)がtureの時
if (ConstraintCount > 0 && IsMobile)
{
    // 合算した位置調整のベクトルを、その個数×RelaxationAmount(入力値)で割った値(RelaxationAmountでbiasをかけた平均値)を、現在のポジションに足す
    OutPosition += 1.*FinalOffsetVector/ (ConstraintCount * RelaxationAmount);

    // add friction support here(frictionが実装されそうですね)

    // 調整後のポジションと前フレームのポジションをもとに速度を更新
    OutVelocity = (OutPosition - PreviousPosition) * InvDt;

    // Unyeilding=tureな場合は、速度をなくす
    if (Unyielding) {
        //May want to make this return to the previous velocity
        OutVelocity = float3 (0,0,0);
    }
}

#endif

めちゃめちゃ読みづらいですね、、すいません、、。(そのうち調整します)
どこか別のEditorに貼り付けたら、読みやすくなると思いますm( . . )m

コメントにある程度解説は書いたので、それを読めばだいたいわかると思いますが、やはりまだ実装途中って感じなので、今後のバージョンでは内容が変わっているように思います。

おわりに

パーティクル同士のコリジョンが取れるようになったのは、素直にすごい進化だなと思いました。

パフォーマンスも、ポップコーンマンのデモで、この部分の処理時間は1ms以下と講演で言っていたので、ちゃんと調整すれば実用に耐え得りそうです。

PBDに関しては、別のサンプルもあるので、別の記事で紹介できたらなと思います。

One Comment

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です