Unreal Engine 5のNiagaraで採用されたPosition型とは何なのか

## Position型の疑問
Unreal Engine 5になり、NiagaraにPosition型が導入されました。

https://dev.epicgames.com/documentation/en-us/unreal-engine/large-world-coordinates-in-niagara-for-unreal-engine

Unreal Engine 5のLWC(Large World Coordinate)対応の一環と読めますが、既存のVector型とはどう異なるのでしょうか。

特にエンジニアとして気になるのは、Position型を使う事に伴うパフォーマンス低下や精度低下はないのかかと思うので、そこを中心に見てみました。

尚、検証に使ったUnreal Engineのバージョンは5.3.2です。

:::note info
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の原点を中心にパーティクルが回転するはずです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66160/acd35a2b-bf6d-2ef5-50f7-0306da9a2ce8.png)

Local Space Emitterにすると、Niagara Actor（Niagara Component）を中心にパーティクルが回転しました。
![Qiita-Local.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66160/688a4ba4-a37d-5237-0378-46947a559a63.gif)

World Space Emitterにすると、ワールド原点にいるマネキンを中心にパーティクルが回転しました。
![Qiita-World.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66160/f6a85648-178c-f084-b30d-b531d340fade.gif)

World Space Emitterの時は、Niagara Componentの中心はSimulation Position, もしくは、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66160/b80eb53b-46b9-6cce-a33d-821bf9e19dea.png)
というアトリビュートでPositionの原点からNiagara Componentの距離が管理されており、エミッタやパーティクルがどこに配置されてもPositionの原点の位置に関わらず意図通り実行できるようになっています。また、Local Space Emitterの場合、SimulationPositionは0,0,0が入っているものと思われます。

:::note info
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型で宣言しています。

```HLSL:HLSL
/*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を作り、適当に値を宣言して生成しました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66160/f0b62cde-7093-ee48-209d-62fc09805db2.png)

## PositionとVectorを相互変換するとどんなコードが生成されるか

Position型からVector型に変換するそれらしき名前のノードが２つありますが、中身は全くの別物なので要注意です。それぞれを使用してMyVectorにPositionを代入するコードを見てみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66160/c6d9bbed-2651-e5d5-7b46-1844f062853f.png)

まず、Position -> Vector ノードを使用した時は、以下のようにfloat3同士を直接代入するのと同等のコードが出力されました。

```HLSL:HLSL
/*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に代入していることがわかります。
```HLSL:HLSL
/*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型の原点の行列を掛けています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66160/09e42aa2-4b19-9c8a-3b35-0e544e510bd8.png)


逆の変換を見てみます。Vector -> Positionノードは予想通り、float3同士を代入するのと同等のコードを生成しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66160/176b1430-a95a-56c0-550e-9961c77f47d7.png)

```HLSL:HLSL
/*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が生成されました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66160/5d4b1843-8066-c157-328f-930db79c34ba.png)

```HLSL: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に変換しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66160/c242b0ac-ff09-ae2b-6de0-1a67d4553f84.png)

Vector to Positionノードも、Vector -> Positionノードとは全く異なる処理を行っているので要注意です。

## LWC関連コード

生成されたHLSLを見ると、
```HLSL:HLSL
Context.Map.Engine.Owner.LWCTile
```
という、float4の変数が存在します。この変数は、Position to VectorまたはVector to Positionを使わない限りは生成されたHLSL内で利用されている形跡はありません。

Position to Vector等から生成されたHLSLの計算内容から推測するに、LWCTileのxyzはタイルのインデックス、wはタイル１枚あたりのサイズが入っているようです。

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型の登場は「学習コストが上がる怖いもの」ではなく、「可読性が上がる嬉しいもの」でした。


## 追記１：Position原点をワールド原点以外に乗せる検証

ワールド原点以外にある別のタイルがPosition原点になる様子を観察してみます。タイルサイズはLargeWorldRenderPosition.hに定義されており、約21キロメートルです。

```C++:LargeWorldRenderPosition.h
static constexpr double UE_LWC_RENDER_TILE_SIZE = 2097152.0;
```

目印のマネキンを2097152,0,0へ、Niagara Systemも同じX座標でYをマネキンから少々ずらして配置してみました。エミッタはWorld Position Emitterです。マネキンを中心に回転しています。

![Qiita-2097152.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66160/a644373f-9299-2d02-9fb3-4a1541bce256.gif)

同じ条件で、目印のマネキンを2095000,0,0に、Niagara Systemも同じX座標でYを少々ずらして置きました。依然として2097152,0,0を中心に回転しています。どうやら最も近いタイル原点がPositionの原点として採択されるようです。

![Qiita-2095000.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66160/92c86fce-8f52-ba7d-13a4-5fff65b266bb.gif)



## 追記２：Convert Vector to Positionについて

Position型のモジュールの引数にVector型を指定したい時、Convert Vector to PositionというDynamic Inputを利用してPosition型に変換できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66160/b50b8f7f-5201-3201-8e33-16ef98e45aed.png)

Convert Vector to Positionの第2引数で第1引数のVectorにワールド座標からの変換を入れるかどうかを選択できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66160/6877eea7-b490-28af-bbfe-379a94d03bad.png)

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型のままのようです。

## 追記３：UE5.6のNiagara LWCタイル更新モード

UE5.6の新機能です。World Space EmitterがLWCのタイルを乗り換えてもパーティクルは維持されます。

タイルの座標差をPosition型の各変数に対しオフセットすることで各パーティクルは同じ場所に留まります。Position型がVector型と厳密に区別されたことにより、機能が実現しています。

https://qiita.com/yorung/items/c6742746a87b1e8cf5fe