Unreal Engine 5のマテリアルにおけるLWC対応をHLSLから確認

# 概要

UE5のマテリアルではUE4の感覚でワールド座標値を使うとLWC(Large World Coordinate)が使われ、パフォーマンスの低下を起こす事があります。これは、LWCが必要となるオープンワールドのようなタイプのゲーム**以外**もあてはまるので注意が必要です。

本稿では、マテリアルから生成されるHLSLを確認しつつ、その仕組を解き明かしたいと思います。エンジンはUE5.6を使います。

実践的なマテリアルにおけるLWCの扱い方は、エピックさんが解説してくれています。

https://dev.epicgames.com/community/learning/tutorials/DdzL/unreal-engine-fortnite-efficient-materials-for-large-worlds

HLSL上でのLWCの実装については、以下の記事で解説されています。(※日本語翻訳は古いまま。2025/06/22時点英語のみ)

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

マテリアルがどのようにHLSL化されるかについては、以前記事にしたのでこちらもよろしければご覧ください。

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

# 補足：UE5のレンダリングでfloatの精度が問題にならない理由

前提として、UE5が事実上無限の広さを扱いつつレンダリングで浮動小数点数の精度が保てるのは２つの理由によります。

* CPUがdoubleを使う
* GPUがTranslated World Space座標系を使う

CPUが扱うFVectorやFMatrixはUE4とは異なり、UE5ではdoubleを使います。doubleは意図的に破綻させない限り事実上無限の広さを扱えます。一方GPUはdoubleではなくfloatを使いますが、カメラ位置を原点とするTranslated World Space座標系を主に使います。GPU上の頂点変換はワールド座標系を介さず、ローカル座標系から直接Translated World Space座標系に変換します。どちらも座標系の原点付近で計算を行うため、意図的に破綻させない限りfloatが起因で精度が問題になることはまずありません。

ただし、GPUでも部分的にワールド座標系を扱いたい時があり、その仕組みとしてLWCが用意されています。ただし、LWCによって上のレンダリングの計算精度が担保されているわけではなく、LWCはどちらかというとマテリアルやシェーダー向けのサポートライブラリと言ったほうが実態に近いです。

# World Position OffsetはLWCではない

マテリアルで座標値を扱う事を考える時、まず目に付くのはWorld Position Offsetではないでしょうか。

World Position Offset入力ピンの型はfloat3です。また、World Position OffsetはTranslated World Space座標系で頂点座標に対して加算されるので、LWCは不要、精度も問題ありません。

Translated World Space座標系は、カメラ座標を原点としつつも、カメラの回転は適用されていない座標系です。よって、ワールド座標系とXYZの３軸の方向は同じであるため、考え方としてはシンプルにWorld Position Offsetはその名が示唆する通りワールド座標系内で演算されるとみなしても間違いではないです。（マテリアル作成者はTranslated World Space座標系の存在を意識する必要がない）

以上のことは、各Vertex Factory(LocalVertexFactory.ush等)に定義されているVertexFactoryGetWorldPosition関数を見ることで確認できます。VertexFactoryGetWorldPositionはTranslated World Space座標系で頂点座標を返し、以後の各パス(BasePassVertexShader.usf等)でもTranslated World Space座標系が座標値の計算で使用されます。

# マテリアル内のLWCの使用は、マテリアル内だけで完結し、波及しない

UE5のシェーダーにおける座標系の扱いとWorld Position Offsetの実装を確認したところで、重要な事実が浮かび上がってきました。それは、マテリアル外のシェーダーコードにおいても、LWCはごく限られた箇所を除いて使用されていないということです。また、マテリアル作成者は、自ら意図した場合を除いてマテリアル外のシェーダーコードとのやりとりのためにLWCの使用を強制される事はありません。つまり、LWCを使うかどうかの判断はマテリアル内で完結しており、マテリアル作成者に委ねられています。

残った問題は、マテリアル作成者がワールド座標系をマテリアル内で扱いたい場合どうするかです。これは、以下の二択があります。

* 作りやすさを優先し、負荷を承知でLWCを使う
* パフォーマンスを優先し、LWCの使用を極力回避する

オープンワールドを作るわけではないので気にしない、という選択肢が無いのがシビアです。

いずれを採用するにしても、どのようなシェーダコードが生成されるかを知っておくと適切な判断の手助けになるので、次の項で調査をしてみます。

# LWCの値同士の演算は基本的にLWCを返す

次のような２種類のマテリアルを組んだとします。コード生成結果を見るのが目的で、演算内容に深い意味はありません。両者の違いは、ActorPositionがAbsoluteかCamera Relativeかのみです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66160/c046a9c0-c1ea-485c-93bf-d8e2fc3a5ef1.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66160/549e336f-88be-47b0-91e7-38cdb34cc6f5.png)

前者はFWSVector3という、LWC型同士の演算が生成されており、最後のfracの結果だけはfloat3を返していました。後者は型がfloat3のみになりました。


```HLSL:前者
 	FWSVector3 Local1 = WSAdd(GetActorWorldPosition(Parameters), ((MaterialFloat3)2.34559989f));
	FWSVector3 Local2 = WSDivide(Local1, ((MaterialFloat3)12.34500027f));
	MaterialFloat3 Local3 = WSFracDemote(MakeWSVector(WSPromote(MakeWSVector(WSPromote(WSGetY(Local2)),WSPromote(WSGetY(Local2)))),WSPromote(WSGetX(Local2))));
```

```HLSL:後者
	MaterialFloat3 Local1 = (GetActorTranslatedWorldPosition(Parameters) + ((MaterialFloat3)2.34559989f));
	MaterialFloat3 Local2 = (Local1 / ((MaterialFloat3)12.34500027f));
	MaterialFloat3 Local3 = frac(MaterialFloat3(MaterialFloat2(Local2.g,Local2.g),Local2.r));
```

LWC型はノードをまたいで伝染していくことがわかりましたが、ノードの見た目は変わらないので、それがLWCの演算であるかは注意深く見る必要があります。

:::note info
Camera-Relative World SpaceはTranslated World Spaceの別名です。
:::

:::note info
MaterialFloat3は、float3の別名と思ってOKです。昔のモバイル向けピクセルシェーダーではhalf3に置き換えられていました。
:::

# FLWC~か、FDF~か
LWCには二種類の構造体郡・関数群が存在しており、ぱっと見ではマテリアルはどちらを採用しているかがわかりにくいです。

結論から言うと、マテリアルはFLWC~から開始するタイル採用シリーズを使っています。

二種類とは、以下を指します。

* FDFScalar, FDFVector3, FDFMatrixなどのdouble float採用シリーズ(DoubleFloat.ush)
* FLWCScalar, FLWCVector3, FLWCMatrixなどのタイル採用シリーズ(LargeWorldCoordinates.ush)

そして、上二種をマクロで切り替えて使う、FWSScalar, FWSVector3, FWSMatrixというシリーズ(WorldSpaceMath.ush) があります。

マテリアルのLWSはFWSシリーズで記述され、WorldSpaceMath.ushをMaterialTemplate.ushからincludeしています。C++を確認する限り、WSVECTOR_IS_TILEOFFSETが1になっており、FLWC~シリーズでコンパイルされます。

https://dev.epicgames.com/community/learning/tutorials/DdzL/unreal-engine-fortnite-efficient-materials-for-large-worlds の記述を見る限りでも、"materials and material functions still use the pre-5.4 format"の一文により、マテリアルはタイル採用シリーズを使っていると読めます。

# float型へのキャストを利用した負荷軽減

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66160/2de8d6ac-05cb-4c1b-b876-2dc290a07df1.png)


「オープンワールドを作るわけではないので気にしない、という選択肢が無い」と書きましたが、LWC型をfloat型にキャストすることで後続の演算を軽くすることが可能です。とはいえ、それ専用のノードを見つけられなかったため、Customノードでやってみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66160/30179c76-4d90-4ba7-a8db-a6d8bddeec75.png)

```HLSL:Custom
return LWCToFloat(LWCPosition);
```

```HLSL:生成されたHLSL
    MaterialFloat3 Local1 = CustomExpression0(Parameters,WSDemote(GetActorWorldPosition(Parameters)),GetActorWorldPosition(Parameters));
	MaterialFloat3 Local2 = (Local1 + ((MaterialFloat3)2.34559989f));
	MaterialFloat3 Local3 = (Local2 / ((MaterialFloat3)12.34500027f));
	MaterialFloat3 Local4 = frac(MaterialFloat3(MaterialFloat2(Local3.g,Local3.g),Local3.r));
...
MaterialFloat3 CustomExpression0(FMaterialPixelParameters Parameters,float3 LWCPosition, FWSVector3 LWCLWCPosition)
{
return LWCToFloat(LWCPosition);
}
```
CustomExpression0経由後はfloat3が計算に使われるようになりました。当然ながら、ワールド原点から離れると精度の問題が発生する可能性があることに留意します。


# LWCの演算による精度低下に注意する

LWCの演算は注意点があります。

* タイル値が小数になることがある
* 演算、特に乗除算を繰り返すと精度が低下するかもしれない

FLWCScalarは以下のように定義されています。

```HLSL:LargeWorldCoordinates.ush
struct FLWCScalar
{
	float Tile;
	float Offset;
};
```

タイルサイズは以下のように定義されています。
```C++:LargeWorldRenderPosition.h
inline constexpr double UE_LWC_RENDER_TILE_SIZE = 2097152.0;
```
FLWCScalar等のタイル部の値1あたり、2097152cm(約21km)を意味します。つまり、Tile * UE_LWC_RENDER_TILE_SIZE + Offset が実際の距離になります。

LWCAddはFLWCScalar同士の足し算で、以下のように定義されています。
```HLSL:LWCOperations.ush
FLWCType LWCAdd(FLWCType Lhs, FLWCType Rhs) { return LWCConstructor(LWCGetTile(Lhs) + LWCGetTile(Rhs), Lhs.Offset + Rhs.Offset); }
```
Tile同士、Offset同士を足しています。これはわかります。ただし、Offset同士の足し算でタイル1つ分の長さに達したとして、自動的にTileが繰り上がることはないことがわかります。

LWCMultiplyはFLWCScalar同士の掛け算で、以下のように定義されています。
```HLSL:LWCOperations.ush
FLWCType LWCMultiply(FLWCType Lhs, FLWCType Rhs)
{
	return LWCConstructor(LWCGetTile(Lhs) * (LWCGetTile(Rhs) * UE_LWC_RENDER_TILE_SIZE + Rhs.Offset) + LWCGetTile(Rhs) * Lhs.Offset, Lhs.Offset * Rhs.Offset);
}
```
にわかには理解できませんでしたが、GitHub Copilotが教えてくれました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66160/eb980051-10b0-4193-a0c1-6cb936a2fea3.png)


LWCConstructorの第一引数がTile、第二引数がOffsetになります。Offset同士の乗算結果がOffsetとなり、タイル値を係数に含む領域の乗算結果がTileに入ります。

TileとOffsetを掛けた値がTileに入ることにより、Tileに小数が入り得ることがわかります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66160/e06d889c-41d6-4c08-8476-e1ece86436f3.png)

また、Offset同士の乗算結果がOffsetに代入されている点も注意です。大きなOffset値同士の掛け算が続くと精度低下が起こることが予想されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66160/1cf323d4-d97b-4494-9af2-70c79f393c3d.png)


上のLWCMultiplyは一例です。LWCMultiplyはオーバーロードして定義されており、LWC型とfloat型を引数に取るLWCMultiplyでも同じ問題が起こります。

LWCNormalizeTileという、タイル部を整数化する関数もあるようですが、計算過程で自動的に挿入されることはなさそうです。

まとめると、LWCは広域を高精度で表現する能力があるが、演算を繰り返すと精度が下がっていくことがあり、特に乗除算は注意が必要、となります。

# まとめ

* パフォーマンスのためにLWCの使用を最低限にする。マテリアルノードをつなぐ白線がfloat型かLWC型かを意識すること。
* LWCは万能ではない。LWCはdoubleと同等ではなく、演算を繰り返すと特定条件で誤差が蓄積するなどの欠点がある。
* 生成されるHLSLを確認するとLWCの動作原理を知ることができる。
* GitHub CopilotはUnreal Shaderについても教えてくれる。