Position型の疑問
Unreal Engine 5になり、NiagaraにPosition型が導入されました。
Unreal Engine 5のLWC(Large World Coordinate)対応の一環と読めますが、既存のVector型とはどう異なるのでしょうか。
特にエンジニアとして気になるのは、Position型を使う事に伴うパフォーマンス低下や精度低下はないのかかと思うので、そこを中心に見てみました。
尚、検証に使ったUnreal Engineのバージョンは5.3.2です。
Unreal Engine 5ではLWCの導入に伴い、C++やBlueprintのVector型は倍精度浮動小数点数に変更されました。しかし、倍精度が使われるのはCPUが処理する領域に限定されるようです。先に答えを書くと、NiagaraのPosition型やVector型は単精度(float3)が使われ続けます。ただし、属するタイルがNiagara Systemのインスタンス毎に保持されており、LWCの中で正しい位置に描画がされるようになっています。
Position型の定義
まず、Position型の定義を確認します。
上の公式の解説を見ると、Position型はエミッタのLocal Spaceにチェックを入れているかどうかで解釈が変わります。デフォルトではLocal Spaceチェックは入っておらず、そのエミッタをWorld Space Emitterと呼びます。Local Spaceにチェックを入れると、そのエミッタはLocal Space Emitterと呼びます。
Local Space Emitterの場合は、Position型の原点はNiagara Systemのインスタンスの場所とされており、それはNiagara Componentの配置されている場所になります。一方、World Space Emitterの場合は、Position型の原点はNiagara Systemのインスタンスが属するタイルの原点になります。
Local SpaceへのチェックがPositionの解釈にどう影響するか簡単な実験をしてみます。Vortex Originを0,0,0としたVortex Velocityモジュールを入れました。これで、Positionの原点を中心にパーティクルが回転するはずです。
Local Space Emitterにすると、Niagara Actor(Niagara Component)を中心にパーティクルが回転しました。
World Space Emitterにすると、ワールド原点にいるマネキンを中心にパーティクルが回転しました。
World Space Emitterの時は、Niagara Componentの中心はSimulation Position, もしくは、
というアトリビュートでPositionの原点からNiagara Componentの距離が管理されており、エミッタやパーティクルがどこに配置されてもPositionの原点の位置に関わらず意図通り実行できるようになっています。また、Local Space Emitterの場合、SimulationPositionは0,0,0が入っているものと思われます。
VFX制作者にとってのLocal Space EmitterとWorld Space Emitterの違いは
gameDev Outpostさんの解説がわかりやすいです。
https://www.youtube.com/watch?v=KEJx7ZX25gY
Position型もVector型もfloat3
出力されたHLSLを見てみると、Position型もVector型もfloat3として定義されていました。諸々考えると納得です。時に万単位のパーティクルを処理する事を考えると倍精度を使わない事はパフォーマンス面で合理的であり、純粋な視覚効果であるNiagaraは基本的にカメラ付近でのみ実行されるため、倍精度がそもそも不要と思えます。
以下では、MyPositionはPosition型、MyVectorはVector型で宣言しています。
/*0102*/ struct FParamMap0_Particles
/*0103*/ {
/*0104*/ float Age;
/*0105*/ float Lifetime;
/*0106*/ float Mass;
/*0107*/ float MaterialRandom;
/*0108*/ float3 MyPosition;
/*0109*/ float3 MyVector;
/*0110*/ float NormalizedAge;
/*0111*/ float3 Position;
/*0112*/ FParamMap0_Particles_Previous Previous;
/*0113*/ float RibbonUVDistance;
/*0114*/ int UniqueID;
/*0115*/ };
上のコードは、適当なScratch Pad Moduleを作り、適当に値を宣言して生成しました。
PositionとVectorを相互変換するとどんなコードが生成されるか
Position型からVector型に変換するそれらしき名前のノードが2つありますが、中身は全くの別物なので要注意です。それぞれを使用してMyVectorにPositionを代入するコードを見てみます。
まず、Position -> Vector ノードを使用した時は、以下のようにfloat3同士を直接代入するのと同等のコードが出力されました。
/*0282*/ void ScratchModule_Emitter_Func_(inout FSimulationContext Context)
/*0283*/ {
/*0284*/ float3 Output1;
/*0285*/ Output1.x = Context.Map.Particles.Position.x;
/*0286*/ Output1.y = Context.Map.Particles.Position.y;
/*0287*/ Output1.z = Context.Map.Particles.Position.z;
/*0288*/ Context.Map.Particles.MyVector = Output1;
/*0289*/ }
一方Position to Vectorを使用した時は、その見た目に反し比較的大掛かりなコードが生成されました。しかしやっていることは簡単で、Context.Map.Particles.Positionに対してContext.Map.Engine.Owner.LWCTileで与えられたタイル座標をオフセットしてContext.Map.Particles.MyVectorに代入していることがわかります。
/*0284*/ void NiagaraScript_280_Particle_Func_(float4 In_FLOAT4_VAR, int In_TargetChannel, out float Out_NewOutput, inout FSimulationContext Context)
/*0285*/ {
/*0286*/ float X;
/*0287*/ float Y;
/*0288*/ float Z;
/*0289*/ float W;
/*0290*/ X = In_FLOAT4_VAR.x;
/*0291*/ Y = In_FLOAT4_VAR.y;
/*0292*/ Z = In_FLOAT4_VAR.z;
/*0293*/ W = In_FLOAT4_VAR.w;
/*0294*/ float Constant10 = (0.0);
/*0295*/ float NiagaraFloat_SelectResult = Constant10;
/*0296*/ NiagaraFloat_SelectResult = X;
/*0297*/ if(In_TargetChannel == 1)
/*0298*/ {
/*0299*/ NiagaraFloat_SelectResult = Y;
/*0300*/ }
/*0301*/ if(In_TargetChannel == 2)
/*0302*/ {
/*0303*/ NiagaraFloat_SelectResult = Z;
/*0304*/ }
/*0305*/ if(In_TargetChannel == 3)
/*0306*/ {
/*0307*/ NiagaraFloat_SelectResult = W;
/*0308*/ }
/*0309*/ Out_NewOutput = NiagaraFloat_SelectResult;
/*0310*/ }
/*0311*/
/*0312*/ void NiagaraScript_279_Particle_Func_(float3 In_Position, float4x4 In_Engine_Owner_SystemLocalToWorld, float4 In_Engine_Owner_LWCTile, out float3 Out_Vector, inout FSimulationContext Context)
/*0313*/ {
/*0314*/ float3 Output1;
/*0315*/ Output1.x = Context.Map.Engine.Owner.LWCTile.x;
/*0316*/ Output1.y = Context.Map.Engine.Owner.LWCTile.y;
/*0317*/ Output1.z = Context.Map.Engine.Owner.LWCTile.z;
/*0318*/ int Constant9 = 3;
/*0319*/ float NiagaraScript_280_Particle_Func_Output_NewOutput;
/*0320*/ NiagaraScript_280_Particle_Func_(Context.Map.Engine.Owner.LWCTile, Constant9, NiagaraScript_280_Particle_Func_Output_NewOutput, Context);
/*0321*/ float3 Result10 = Output1 * NiagaraScript_280_Particle_Func_Output_NewOutput;
/*0322*/ float3 Output11;
/*0323*/ Output11.x = In_Position.x;
/*0324*/ Output11.y = In_Position.y;
/*0325*/ Output11.z = In_Position.z;
/*0326*/ float3 Result11 = Result10 + Output11;
/*0327*/ Out_Vector = Result11;
/*0328*/ }
/*0329*/
/*0330*/ void ScratchModule_Emitter_Func_(inout FSimulationContext Context)
/*0331*/ {
/*0332*/ float4x4 Constant7 = float4x4(1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1);
/*0333*/ float4 Constant8 = float4(0,0,0,0);
/*0334*/ float3 NiagaraScript_279_Particle_Func_Output_Vector;
/*0335*/ NiagaraScript_279_Particle_Func_(Context.Map.Particles.Position, Constant7, Constant8, NiagaraScript_279_Particle_Func_Output_Vector, Context);
/*0336*/ Context.Map.Particles.MyVector = NiagaraScript_279_Particle_Func_Output_Vector;
/*0337*/ }
Position to Vectorの実装をみるとより意図が明確にわかります。Positionをワールド座標系に変換しているようです。まず、属するLWCのタイルの座標が加算されます。更にLocal Space Emitterの場合はPosition型の原点の行列を掛けています。
逆の変換を見てみます。Vector -> Positionノードは予想通り、float3同士を代入するのと同等のコードを生成しました。
/*0281*/ void ScratchModule_01_Emitter_Func_(inout FSimulationContext Context)
/*0282*/ {
/*0283*/ float3 Output1;
/*0284*/ Output1.x = Context.Map.Particles.MyVector.x;
/*0285*/ Output1.y = Context.Map.Particles.MyVector.y;
/*0286*/ Output1.z = Context.Map.Particles.MyVector.z;
/*0287*/ Context.Map.Particles.Position = Output1;
/*0288*/ }
Vector to Positionという、Position to Vectorの逆の変換を行う関数があり、属するLWCのタイルの座標を減算するHLSLが生成されました。
/*0281*/ void NiagaraScript_64_Particle_Func_(float4 In_FLOAT4_VAR, int In_TargetChannel, out float Out_NewOutput, inout FSimulationContext Context)
/*0282*/ {
/*0283*/ float X;
/*0284*/ float Y;
/*0285*/ float Z;
/*0286*/ float W;
/*0287*/ X = In_FLOAT4_VAR.x;
/*0288*/ Y = In_FLOAT4_VAR.y;
/*0289*/ Z = In_FLOAT4_VAR.z;
/*0290*/ W = In_FLOAT4_VAR.w;
/*0291*/ float Constant10 = (0.0);
/*0292*/ float NiagaraFloat_SelectResult = Constant10;
/*0293*/ NiagaraFloat_SelectResult = X;
/*0294*/ if(In_TargetChannel == 1)
/*0295*/ {
/*0296*/ NiagaraFloat_SelectResult = Y;
/*0297*/ }
/*0298*/ if(In_TargetChannel == 2)
/*0299*/ {
/*0300*/ NiagaraFloat_SelectResult = Z;
/*0301*/ }
/*0302*/ if(In_TargetChannel == 3)
/*0303*/ {
/*0304*/ NiagaraFloat_SelectResult = W;
/*0305*/ }
/*0306*/ Out_NewOutput = NiagaraFloat_SelectResult;
/*0307*/ }
/*0308*/
/*0309*/ void NiagaraScript_63_Particle_Func_(float3 In_VECTOR_VAR, float4x4 In_Engine_Owner_SystemWorldToLocal, float4 In_Engine_Owner_LWCTile, out float3 Out_Position, inout FSimulationContext Context)
/*0310*/ {
/*0311*/ float3 Output1;
/*0312*/ Output1.x = Context.Map.Engine.Owner.LWCTile.x;
/*0313*/ Output1.y = Context.Map.Engine.Owner.LWCTile.y;
/*0314*/ Output1.z = Context.Map.Engine.Owner.LWCTile.z;
/*0315*/ int Constant9 = 3;
/*0316*/ float NiagaraScript_64_Particle_Func_Output_NewOutput;
/*0317*/ NiagaraScript_64_Particle_Func_(Context.Map.Engine.Owner.LWCTile, Constant9, NiagaraScript_64_Particle_Func_Output_NewOutput, Context);
/*0318*/ float3 Result10 = Output1 * NiagaraScript_64_Particle_Func_Output_NewOutput;
/*0319*/ float3 Result11 = In_VECTOR_VAR - Result10;
/*0320*/ float3 NiagaraPosition;
/*0321*/ NiagaraPosition.x = Result11.x;
/*0322*/ NiagaraPosition.y = Result11.y;
/*0323*/ NiagaraPosition.z = Result11.z;
/*0324*/ Out_Position = NiagaraPosition;
/*0325*/ }
/*0326*/
/*0327*/ void ScratchModule_01_Emitter_Func_(inout FSimulationContext Context)
/*0328*/ {
/*0329*/ float4x4 Constant7 = float4x4(1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1);
/*0330*/ float4 Constant8 = float4(0,0,0,0);
/*0331*/ float3 NiagaraScript_63_Particle_Func_Output_Position;
/*0332*/ NiagaraScript_63_Particle_Func_(Context.Map.Particles.MyVector, Constant7, Constant8, NiagaraScript_63_Particle_Func_Output_Position, Context);
/*0333*/ Context.Map.Particles.Position = NiagaraScript_63_Particle_Func_Output_Position;
/*0334*/ }
Vector to Positionの実装を見ると、ワールド座標系であるVectorを属するタイルからの相対座標であるPositionに変換しています。
Vector to Positionノードも、Vector -> Positionノードとは全く異なる処理を行っているので要注意です。
LWC関連コード
生成されたHLSLを見ると、
Context.Map.Engine.Owner.LWCTile
という、float4の変数が存在します。この変数は、Position to VectorまたはVector to Positionを使わない限りは生成されたHLSL内で利用されている形跡はありません。
Position to Vector等から生成されたHLSLの計算内容から推測するに、LWCTileのxyzはタイルのインデックス、wはタイル1枚あたりのサイズが入っているようです。
LWCTileはおそらくレンダラで使うのが主であり、VFX制作者はその存在を意識する必要はほぼなさそうです。
Vector to Position 及び、Position to Vector について
大抵のケースで使う必要はなく、むしろ間違って使われる事を危惧してしまいます。
考えられる実用的用途としては、
- エンジニアがデバッグの用途で使う
- LWCを使うゲームにおいてワールド座標を直打ち ※ただし精度低下には注意
などには使えそうです。
しかし、一方で以下のデメリットがあります。
- NiagaraのVectorは単精度なので、ワールド座標をVectorに扱わせた瞬間から精度低下が起こる
- VFX制作者が(Niagara内で)ワールド座標系やLWCを意識せずともよい事のメリットを壊してしまう
そして、その取り違えやすいノード名が気になりました。
Vector -> Position や、Position -> Vector と名前がとても似ています。
LWCTileがワールド原点にあるときはVector -> Position や Position -> Vectorと同じ結果が得られてしまうので、うっかり取り違えて気づかない、なんてことも起こりがちである気がします。
まとめ
新設されたPosition型は、Worldの任意地点を原点とした座標として明確に意味が定義され、Vector型とは区別されました。HLSLではVector型と同等のfloat3であり、相互に変換が可能で、変換の際に誤差が発生することもありません。
VFX制作者は、Vector to Positionノード等で意識的にワールド座標を使わない限りLWCを意識する必要はありません。LWC導入に伴って計算精度やパフォーマンスなどを追加で気にする事もありません。Position型がLWC対応に必須要素であったかというと、そういうものでもなさそうです。
Position型が明確な定義を持ち、可読性が上がったと言えます。つまり、Position型の登場は「学習コストが上がる怖いもの」ではなく、「可読性が上がる嬉しいもの」でした。
追記1:Position原点をワールド原点以外に乗せる検証
ワールド原点以外にある別のタイルがPosition原点になる様子を観察してみます。タイルサイズはLargeWorldRenderPosition.hに定義されており、約21キロメートルです。
static constexpr double UE_LWC_RENDER_TILE_SIZE = 2097152.0;
目印のマネキンを2097152,0,0へ、Niagara Systemも同じX座標でYをマネキンから少々ずらして配置してみました。エミッタはWorld Position Emitterです。マネキンを中心に回転しています。
同じ条件で、目印のマネキンを2095000,0,0に、Niagara Systemも同じX座標でYを少々ずらして置きました。依然として2097152,0,0を中心に回転しています。どうやら最も近いタイル原点がPositionの原点として採択されるようです。
追記2:Convert Vector to Positionについて
Position型のモジュールの引数にVector型を指定したい時、Convert Vector to PositionというDynamic Inputを利用してPosition型に変換できます。
Convert Vector to Positionの第2引数で第1引数のVectorにワールド座標からの変換を入れるかどうかを選択できます。
Passthrough as Non Large World Positionを選択するとVector -> Position相当、Interpret as a Large World Position VectorでVector to Position相当のHLSLが生成されます。
Convert Vector to Positionの主な用途はUpdate Mesh Reproduction Spriteが出力するMesh PositionをPosition型の引数として使う時かと思います。この変数はその名に反してVector型です。どちらかというとConvert Vector to Positionを型変換に使う状況が何か間違っており、よってUpdate Mesh Reproduction Spriteのほうが修正されるべきと思うのですが、現時点の最新版であるUE5.4でもMesh PositionはVector型のままのようです。