この記事は、Unreal Engine (UE) Advent Calendar 2022 カレンダー 3 の25日目の記事です。
はじめに
今回は、UE5のMatrix Demoに出てくる、エージェントがCube上にDissolveして死亡する表現を再現してみようと思います。
実は、実装方法自体は、The Matrix Awakens: Creating the Vehicles and VFX | Tech Talk | State of Unreal 2022で解説されているので、これを元に再現したいと思います。
また、ここで説明する内容を実装したNiagaraのデータと2つのサンプルが含まれたものを以下で提供しているので、実際に動くデータがすぐに欲しい方は、確認してみてください。
https://heyyohanashima.gumroad.com/l/mwyfs
環境
Unreal Engine 5.1.0
実装の解説
概要
仕組みを簡単に説明してしまうと、以下のような内容になります。
- 対象のメッシュを二つ重ねる
- 片方のメッシュにStencil Valueを与える
- 対象のメッシュをDissolveで消す。その際、Stencil Valueが入っている方を少し遅らせてDissolveさせる
- Niagaraで、Particleを対象のスクリーンスペースでのバウンディングボックスに発生させる
- 各Particleのスクリーン座標のStencil Valueを参照して、該当の値が入っているものだけ生かす
仕組み自体は一緒なので、まずは以下のようなシンプルな例での実装を、具体的に中身を見ながら解説します。
MeshのDissolveとStencil Value
対象はBP化し、元のMeshの子に、Stencil Value用のダミーメッシュを置いています。
また、Z-fightingを防ぐため、ダミーの方は少し小さくします(マテリアルで法線の逆方向に少しオフセットしてます)。
そして、Render CustomDepth Passをオンにし、CustomDepth Stencil Valueに今回は2を入れました(Niagaraでこの値を使いますが、値自体は何でもいいです)。
また、元のMeshと被っている所にStencil Valueは書き込まれないようにしたいので、元のMeshの方もRender CustomDepth Passはオンにします。
Dissolve自体は、今回の趣旨から離れるので詳しくは解説しません。
簡潔に言うと、Materialで指定位置からの距離を元に閾値で消えるようにして、そこにNoiseとエッジのEmissiveを追加している感じです。
そして、ダミーの方だけ少し遅れてDissolveさせるようにします。
Stencil Valueだけを可視化すると、以下のように遅れた部分がStencil Valueに書き込まれているのが分かります。
後は、このStencil ValueでParticleのフィルターをしますが、その前に、対象Meshのスクリーンスペースバウンディングボックス上にParticleを発生させる処理を解説します
Niagaraで対象Meshのスクリーンスペースバウンディングボックス上にParticleを発生させる
※対象のスクリーンスペースでのバウンディングボックス計算周りは、NiagaraのScreenSpace⇔WorldPosition座標変換系の処理がなぜか自分の想定通りの挙動をしなかったので、少し遠回りな自己流の実装しています。自分の理解不足かUE5のバグかわかりませんが、もっとよい方法があるかもしれないので、あくまで一例として捉えてもらえると 🙂
Niagara Eimitterの全体像は以下です。※GBufferを読むので、GPU Simである必要があります。
この中で、今回の表現の処理を担っているのは、赤枠で囲ったところで、Niagara Module Script化しています。
その中で、スクリーンスペースバウンディングボックスの処理をしているのは、NMS Set Clip Space Bounding Box
と NMS Get Position WSBy Clip Space BB
の2つです。それぞれ見ていきましょう。
NMS Set Clip Space Bounding Box の解説
ここでは、対象となるMeshのワールドスペースの3次元のバウンディングボックスから、スクリーンスペースの2次元のバウンディングボックスを計算します。
そのためには、まず対象となるMeshのバウンディングボックス情報をNiagaraに送る必要があります
まずは、Niagaraのユーザーパラメータを作成します。
- BoxExtent:Meshのバウンディングボックスの大きさ(中心から各面への距離なのでボックスの半分のサイズ)
- Center:Meshのバウンディングボックスの中心
そして、BPから該当の情報をNiagaraに対して毎フレーム送ります。
以下が、NMS Set Clip Space Bounding Box
の中身で、BPから送られてきたバウンディングボックス情報は、CenterWS
と HalfExtentWS
の入力値として使います。
スクリーンスペースのバウンディングボックスの計算はHLSLで書いています。(正確には、なぜかスクリーンスペースだと上手くいかなかったので、クリップスペースで計算しています)
方針は、3次元のバウンディングボックスの8頂点のスクリーンスペース上の座標を出し、x,yの最小-最大を、スクリーンスペースのバウンディングボックスとしています(この方法は必ずしも正確な計算とは言えませんが、だいたい問題ない感じに見えるのでOKとします)
// アウトプット
ClipExtentXY = float2(0,0);
ClipCenter = float4(0,0,0,0);
#if GPU_SIMULATION
// 計算の簡易化のため、各頂点の方向を配列にしておく
float3 dir[8] =
{
float3(1,1,1),
float3(1,1,-1),
float3(1,-1,1),
float3(-1,1,1),
float3(1,-1,-1),
float3(-1,1,-1),
float3(-1,-1,1),
float3(-1,-1,-1)
};
// クリップスペースx,yの最小最大用変数
float minX = 10000;
float maxX = -10000;
float minY = 10000;
float maxY = -10000;
for (int i = 0; i < 8; i++) {
float3 target = Center + HalfExtent * dir[i]; // バウンディングボックスの各頂点のワールド座標を計算
float4 sample = float4(target, 1);
float4 clip = mul(sample, View.WorldToClip); // ワールドスペースからクリップスペースに変換
minX = min(minX, clip.x);
maxX = max(maxX, clip.x);
minY = min(minY, clip.y);
maxY = max(maxY, clip.y);
};
// クリップスペースのバウンディングボックスの大きさと中心を計算してアウトプットに格納
ClipExtentXY = float2(maxX - minX, maxY - minY);
ClipCenter = mul(float4(Center, 1), View.WorldToClip);
#endif
このモジュールのアウトプットは、以下の2つです。
- ClipExtentXY:クリップスペースのバウンディングボックスの大きさ(Vector2)
- ClipCenter:クリップスペースのバウンディングボックスの中心(Vector4:対象Meshの中心の深度情報z、座標変換に使うwの値も含むため)
この情報を使って、次のモジュールでParticleの発生位置を制御します。
※UE5.4では、View.WorldToClipの代わりに、View.TranslatedWorldToClipを使ってください。
NMS Get Position WSBy Clip Space BB の解説
このモジュールでやっていることは、求めたスクリーンスペースのバウンディングボックスから各Particle毎に任意の点を選択し、ワールド座標に変換し直して、その位置にParticleを移動させます。
このモジュールのインプットは以下です。
- ClipCenter:
NMS Set Clip Space Bounding Box
で求めたクリップスペースのバウンディングボックスの中心 - ClipExtentXY:
NMS Set Clip Space Bounding Box
で求めたクリップスペースのバウンディングボックスの大きさ - NumXY:任意の点を求める際のバウンディングボックスの分割数
- Write to Position:求めたポジションを実際にParticleに適用するかのフラグ
NumXYを変えると以下のように、Particleの整列の密度が変わります。
まず各Particleに、分割数の総数(X=60,Y=160なら9600)からランダムな値を与えます。
その値を使い、スクリーンスペースのバウンディングボックス上の点を計算し、ワールド座標に変換する処理をHLSLで書いています。
// アウトプット
PositionWS = float3(0,0,0);
#if GPU_SIMULATION
float2 start = Center.xy - ExtentXY / 2.0; // クリップスペースの右上の座標を計算
float2 size = ExtentXY / (NumXY - float2(1,1)); // 分割数を元に、分割点の距離を計算
// 各Particleに与えたランダムな通し番号から、分割点のインデックスを計算
int indexX = fmod(Index, int(NumXY.x));
int temp = Index / int(NumXY.x);
int indexY = fmod(temp, int(NumXY.y));
// 分割点のインデックスからクリップスペースの座標を計算
float2 clipXY = start + size * float2(indexX,indexY);
float4 clipPos = float4(clipXY, Center.zw);
// 対象メッシュ中心のクリップスペースzwを利用してワールドスペースに変換
PositionWS = mul(clipPos, View.ClipToWorld).xyz;
#endif
※UE5.4では、View.ClipToWorldの代わりに、View.ClipToTranslatedWorldを使ってください。
ParticleをStencil Valueでフィルターする
ここまでで、対象Meshのスクリーンスペースのバウンディングボックス上にParticleを発生させることができたので、後は、そのスクリーンスペースの座標からGBufferのStencil Valueを読みフィルター処理をすればOKです。
その処理は、NMS Filter by Stencil Value
というモジュールで行っています。
中身はシンプルに、ワールドポジションからスクリーンUVに変換しStencil Valueを読み、インプットで与えたStencil Valueと一致していれば生かすという処理をしているだけです。処理のノードもNiagaraに標準で備わっています。
※今までの処理でも World Position to Screen UV
を使えば良くねと思うかもしれませんが、この辺が想定通りの挙動にならず、やむなくクリップスペースを使うことになりました。UEのEidotorのスクリーン周りの計算は少し特殊らしく(実際に表示されている範囲ではなくEditorウィンドウ全体をスクリーンとして計算している的な情報を聞いたことがあります。またUE5で変更も多く入ってそう…)、この辺はちゃんとエンジンコードを追った上で理解しないといけないかもしれません。
後は、ここで作ったAliveのAttributeで、Killするかしないかを判定するだけです。
エッジ(Stencil Value=2)の所だけParticleが残っていることがわかります。
CustomDepthを使ってParticleをMeshに引っ付ける
現状は、Meshのバウンディングボックスの中心の深度を基準に、カメラに垂直にParticleが配置されるようになっています。このままでも、今回のケースでは大きく見た目に問題がなかったですが、より正確にやるために、Particleをちゃんと対象のMeshに引っ付けます。
やり方は、Stencil Value同様に、Custom Depthの値をGBufferから読み、Meshの深度情報をもとにParticleを引っ付けます。
この辺りの解説は、以下で書いたので今回は割愛します。
【UE4.26】Niagara Advanced 解説基礎編~Sample GBuffer Attributes~
おまけでScene Colorもサンプルする
同様の手順でGBufferからScene Colorを読んで、ParticleのColorに反映させます。これで、MeshのMaterial側のDissolveの色を変えるだけで、Particleの色も変わってくれます
Matrix Demoっぽいやつ
以上で、実装の解説自体は終わりましたが、一応Matrix Demoに少し寄せたものもお見せします。
とはいえ、本家とMeshも違うし、Animationもないし、時間もない、のでまあ作例の一つとして見てもらえればと思います。
仕組みは全く一緒で、ParticleをCubeにしてCollisionを与えるようにしたぐらいの違いです。
おわりに
Matrix Demoを見た時、カッコいい表現だなと思いCity Sampleを意気揚々と開くも、この表現のデータは見つからず…
解説動画が出て仕組みを知って、なるほど!と思い、早速実装してみました。
MeshのDissolveに合わせてParticleを出すのは、普通にMeshからParticleを出してもできないことはなく、仕事も含め実際に色々使ったりしてますが、
マテリアル側のDissolve処理をNiagaraでもやらなければならなかったり、MeshのVertexの密度によってParticleの出方が偏ったりと制御が色々大変だったので、スクリーンスペースでやる方法を知り、目から鱗でした!
NiagaraでGBufferを読むのはまだ、Experimentalな機能ですが、今の所大きな不具合に遭遇していないので、映像用途とかなら、仕事でも全然つかえるんじゃないかと思います!
Niagaraって、やっぱいいですね!